Exemple #1
0
class Area:
    def __init__(self,
                 name: str = None,
                 children: List["Area"] = None,
                 uuid: str = None,
                 strategy: BaseStrategy = None,
                 appliance: BaseAppliance = None,
                 config: SimulationConfig = None,
                 budget_keeper=None,
                 balancing_spot_trade_ratio=ConstSettings.BalancingSettings.
                 SPOT_TRADE_RATIO,
                 event_list=[],
                 transfer_fee_pct: float = None,
                 transfer_fee_const: float = None,
                 external_connection_available=False):
        self.balancing_spot_trade_ratio = balancing_spot_trade_ratio
        self.active = False
        self.log = TaggedLogWrapper(log, name)
        self.current_tick = 0
        self.name = name
        self.uuid = uuid if uuid is not None else str(uuid4())
        self.slug = slugify(name, to_lower=True)
        self.parent = None
        self.children = children if children is not None else []
        for child in self.children:
            child.parent = self

        if (len(self.children) > 0) and (strategy is not None):
            raise AreaException("A leaf area can not have children.")
        self.strategy = strategy
        self.appliance = appliance
        self._config = config
        self.events = Events(event_list, self)
        self.budget_keeper = budget_keeper
        if budget_keeper:
            self.budget_keeper.area = self
        self._bc = None
        self._markets = None
        self.dispatcher = DispatcherFactory(self)()
        self.transfer_fee_pct = transfer_fee_pct
        self.transfer_fee_const = transfer_fee_const
        self.display_type = "Area" if self.strategy is None else self.strategy.__class__.__name__
        self._markets = AreaMarkets(self.log)
        self.stats = AreaStats(self._markets)
        self.redis_ext_conn = RedisAreaExternalConnection(self) \
            if external_connection_available is True else None

    def set_events(self, event_list):
        self.events = Events(event_list, self)

    def activate(self, bc=None):
        if bc:
            self._bc = bc
        for attr, kind in [(self.strategy, 'Strategy'),
                           (self.appliance, 'Appliance')]:
            if attr:
                if self.parent:
                    attr.area = self.parent
                    attr.owner = self
                else:
                    raise AreaException("{kind} {attr.__class__.__name__} "
                                        "on area {s} without parent!".format(
                                            kind=kind, attr=attr, s=self))

            if self.budget_keeper:
                self.budget_keeper.activate()
        if ConstSettings.IAASettings.AlternativePricing.PRICING_SCHEME != 0:
            self.transfer_fee_pct = 0
        elif self.transfer_fee_pct is None:
            self.transfer_fee_pct = self.config.iaa_fee
        if self.transfer_fee_const is None:
            self.transfer_fee_const = self.config.iaa_fee_const

        # Cycle markets without triggering it's own event chain.
        self._cycle_markets(_trigger_event=False)

        if not self.strategy and self.parent is not None:
            self.log.debug("No strategy. Using inter area agent.")
        self.log.debug('Activating area')
        self.active = True
        self.dispatcher.broadcast_activate()

    def deactivate(self):
        self._cycle_markets(deactivate=True)

    def _cycle_markets(self,
                       _trigger_event=True,
                       _market_cycle=False,
                       deactivate=False):
        """
        Remove markets for old time slots, add markets for new slots.
        Trigger `MARKET_CYCLE` event to allow child markets to also cycle.

        It's important for this to happen from top to bottom of the `Area` tree
        in order for the `InterAreaAgent`s to be connected correctly

        `_trigger_event` is used internally to avoid multiple event chains during
        initial area activation.
        """
        self.events.update_events(self.now)

        if self.redis_ext_conn:
            self.redis_ext_conn.market_cycle_event()

        if not self.children:
            # Since children trade in markets we only need to populate them if there are any
            return

        if self.budget_keeper and _market_cycle:
            self.budget_keeper.process_market_cycle()

        self.log.debug("Cycling markets")
        self._markets.rotate_markets(self.now, self.stats, self.dispatcher)
        if deactivate:
            return

        # Clear `current_market` cache
        self.__dict__.pop('current_market', None)

        # Markets range from one slot to market_count into the future
        changed = self._markets.create_future_markets(self.now, True, self)

        if ConstSettings.BalancingSettings.ENABLE_BALANCING_MARKET and \
                len(DeviceRegistry.REGISTRY.keys()) != 0:
            changed_balancing_market = self._markets.create_future_markets(
                self.now, False, self)
        else:
            changed_balancing_market = None

        # Force market cycle event in case this is the first market slot
        if (changed or len(self._markets.past_markets.keys())
                == 0) and _trigger_event:
            self.dispatcher.broadcast_market_cycle()

        # Force balancing_market cycle event in case this is the first market slot
        if (changed_balancing_market or len(self._markets.past_balancing_markets.keys()) == 0) \
                and _trigger_event and ConstSettings.BalancingSettings.ENABLE_BALANCING_MARKET:
            self.dispatcher.broadcast_balancing_market_cycle()

    def tick(self):
        if ConstSettings.IAASettings.MARKET_TYPE == 2 or \
                ConstSettings.IAASettings.MARKET_TYPE == 3:
            if ConstSettings.GeneralSettings.EVENT_DISPATCHING_VIA_REDIS:
                self.dispatcher.publish_market_clearing()
            else:
                for market in self.all_markets:
                    market.match_offers_bids()

        self.events.update_events(self.now)
        self.current_tick += 1
        if self._markets:
            for market in self._markets.markets.values():
                market.update_clock(self.current_tick)

    def tick_and_dispatch(self):
        if d3a.constants.DISPATCH_EVENTS_BOTTOM_TO_TOP:
            self.dispatcher.broadcast_tick()
            self.tick()
        else:
            self.tick()
            self.dispatcher.broadcast_tick()

    def __repr__(self):
        return "<Area '{s.name}' markets: {markets}>".format(
            s=self,
            markets=[
                t.format(TIME_FORMAT) for t in self._markets.markets.keys()
            ])

    @property
    def current_slot(self):
        return self.current_tick // self.config.ticks_per_slot

    @property
    def current_tick_in_slot(self):
        return self.current_tick % self.config.ticks_per_slot

    @property
    def config(self):
        if self._config:
            return self._config
        if self.parent:
            return self.parent.config
        return GlobalConfig

    @property
    def bc(self):
        if self._bc is not None:
            return self._bc
        if self.parent:
            return self.parent.bc
        return None

    @cached_property
    def child_by_slug(self):
        slug_map = {}
        areas = [self]
        while areas:
            for area in list(areas):
                slug_map[area.slug] = area
                areas.remove(area)
                areas.extend(area.children)
        return slug_map

    @property
    def now(self) -> DateTime:
        """
        Return the 'current time' as a `DateTime` object.
        Can be overridden in subclasses to change the meaning of 'now'.

        In this default implementation 'current time' is defined by the number of ticks that
        have passed.
        """
        return self.config.start_date.add(
            seconds=self.config.tick_length.seconds * self.current_tick)

    @property
    def all_markets(self):
        return [
            m for m in self._markets.markets.values()
            if is_market_in_simulation_duration(self.config, m)
        ]

    @property
    def past_markets(self):
        return list(self._markets.past_markets.values())

    def get_market(self, timeslot):
        return self._markets.markets[timeslot]

    def get_past_market(self, timeslot):
        return self._markets.past_markets[timeslot]

    def get_balancing_market(self, timeslot):
        return self._markets.balancing_markets[timeslot]

    @property
    def balancing_markets(self):
        return list(self._markets.balancing_markets.values())

    @property
    def past_balancing_markets(self):
        return list(self._markets.past_balancing_markets.values())

    @property
    def market_with_most_expensive_offer(self):
        # In case of a tie, max returns the first market occurrence in order to
        # satisfy the most recent market slot
        return max(
            self.all_markets,
            key=lambda m: m.sorted_offers[0].price / m.sorted_offers[0].energy)

    @property
    def next_market(self):
        """Returns the 'current' market (i.e. the one currently 'running')"""
        try:
            return list(self._markets.markets.values())[0]
        except IndexError:
            return None

    @property
    def current_market(self):
        """Returns the 'current' market (i.e. the one currently 'running')"""
        try:
            return list(self._markets.past_markets.values())[-1]
        except IndexError:
            return None

    @property
    def current_balancing_market(self):
        """Returns the 'current' balancing market (i.e. the one currently 'running')"""
        try:
            return list(self._markets.past_balancing_markets.values())[-1]
        except IndexError:
            return None

    def get_future_market_from_id(self, _id):
        try:
            return [m for m in self._markets.markets.values()
                    if m.id == _id][0]
        except IndexError:
            return None

    @property
    def last_past_market(self):
        try:
            return list(self._markets.past_markets.values())[-1]
        except IndexError:
            return None

    @cached_property
    def available_triggers(self):
        triggers = []
        if isinstance(self.strategy, TriggerMixin):
            triggers.extend(self.strategy.available_triggers)
        if isinstance(self.appliance, TriggerMixin):
            triggers.extend(self.appliance.available_triggers)
        return {t.name: t for t in triggers}

    def _fire_trigger(self, trigger_name, **params):
        for target in (self.strategy, self.appliance):
            if isinstance(target, TriggerMixin):
                for trigger in target.available_triggers:
                    if trigger.name == trigger_name:
                        return target.fire_trigger(trigger_name, **params)

    def update_config(self, **kwargs):
        if not self.config:
            return
        self.config.update_config_parameters(**kwargs)
        if self.strategy:
            self.strategy.read_config_event()
        for child in self.children:
            child.update_config(**kwargs)
Exemple #2
0
class Area:

    def __init__(self, name: str = None, children: List["Area"] = None,
                 uuid: str = None,
                 strategy: BaseStrategy = None,
                 config: SimulationConfig = None,
                 budget_keeper=None,
                 balancing_spot_trade_ratio=ConstSettings.BalancingSettings.SPOT_TRADE_RATIO,
                 event_list=[],
                 grid_fee_percentage: float = None,
                 grid_fee_constant: float = None,
                 external_connection_available: bool = False,
                 throughput: ThroughputParameters = ThroughputParameters()
                 ):
        validate_area(grid_fee_constant=grid_fee_constant,
                      grid_fee_percentage=grid_fee_percentage)
        self.balancing_spot_trade_ratio = balancing_spot_trade_ratio
        self.active = False
        self.log = TaggedLogWrapper(log, name)
        self.current_tick = 0
        self.__name = name
        self.throughput = throughput
        self.uuid = uuid if uuid is not None else str(uuid4())
        self.slug = slugify(name, to_lower=True)
        self.parent = None
        self.children = AreaChildrenList(self, children) if children is not None\
            else AreaChildrenList(self)
        for child in self.children:
            child.parent = self

        if (len(self.children) > 0) and (strategy is not None):
            raise AreaException("A leaf area can not have children.")
        self.strategy = strategy
        self._config = config
        self.events = Events(event_list, self)
        self.budget_keeper = budget_keeper
        if budget_keeper:
            self.budget_keeper.area = self
        self._bc = None
        self._markets = None
        self.dispatcher = DispatcherFactory(self)()
        self._set_grid_fees(grid_fee_constant, grid_fee_percentage)
        self.display_type = "Area" if self.strategy is None else self.strategy.__class__.__name__
        self._markets = AreaMarkets(self.log)
        self.stats = AreaStats(self._markets, self)
        log.debug("External connection %s for area %s", external_connection_available, self.name)
        self.redis_ext_conn = RedisMarketExternalConnection(self) \
            if external_connection_available and self.strategy is None else None
        self.should_update_child_strategies = False
        self.external_connection_available = external_connection_available

    @property
    def name(self):
        return self.__name

    @name.setter
    def name(self, new_name):
        if check_area_name_exists_in_parent_area(self.parent, new_name):
            raise AreaException("Area name should be unique inside the same Parent Area")

        self.__name = new_name

    def get_state(self):
        state = {}
        if self.strategy is not None:
            state = self.strategy.get_state()

        state.update(**{
            "current_tick": self.current_tick,
            "area_stats": self.stats.get_state()
        })
        return state

    def restore_state(self, saved_state):
        self.current_tick = saved_state["current_tick"]
        self.stats.restore_state(saved_state["area_stats"])
        if self.strategy is not None:
            self.strategy.restore_state(saved_state)

    def area_reconfigure_event(self, **kwargs):
        """Reconfigure the device properties at runtime using the provided arguments."""
        if self.strategy is not None:
            self.strategy.area_reconfigure_event(**kwargs)
            return True

        grid_fee_constant = (
            kwargs["grid_fee_constant"]
            if key_in_dict_and_not_none(kwargs, "grid_fee_constant")
            else self.grid_fee_constant)
        grid_fee_percentage = (
            kwargs["grid_fee_percentage"]
            if key_in_dict_and_not_none(kwargs, "grid_fee_percentage")
            else self.grid_fee_percentage)

        baseline_peak_energy_import_kWh = (
            kwargs["baseline_peak_energy_import_kWh"]
            if key_in_dict_and_not_none(
                kwargs, "baseline_peak_energy_import_kWh")
            else self.throughput.baseline_peak_energy_import_kWh)

        baseline_peak_energy_export_kWh = (
            kwargs["baseline_peak_energy_export_kWh"]
            if key_in_dict_and_not_none(
                kwargs, "baseline_peak_energy_export_kWh")
            else self.throughput.baseline_peak_energy_export_kWh)

        import_capacity_kVA = (
            kwargs["import_capacity_kVA"]
            if key_in_dict_and_not_none(kwargs, "import_capacity_kVA")
            else self.throughput.import_capacity_kVA)

        export_capacity_kVA = (
            kwargs["export_capacity_kVA"]
            if key_in_dict_and_not_none(kwargs, "export_capacity_kVA")
            else self.throughput.import_capacity_kVA)

        try:
            validate_area(grid_fee_constant=grid_fee_constant,
                          grid_fee_percentage=grid_fee_percentage)
            throughput = ThroughputParameters(
                            baseline_peak_energy_import_kWh=baseline_peak_energy_import_kWh,
                            baseline_peak_energy_export_kWh=baseline_peak_energy_export_kWh,
                            import_capacity_kVA=import_capacity_kVA,
                            export_capacity_kVA=export_capacity_kVA
                        )

        except Exception as ex:
            log.error(ex)
            return

        self._set_grid_fees(grid_fee_constant, grid_fee_percentage)
        self.throughput = throughput
        self._update_descendants_strategy_prices()

    def _update_descendants_strategy_prices(self):
        try:
            if self.strategy is not None:
                self.strategy.event_activate_price()
            for child in self.children:
                child._update_descendants_strategy_prices()
        except Exception:
            log.exception("area._update_descendants_strategy_prices failed.")
            return

    def _set_grid_fees(self, grid_fee_const, grid_fee_percentage):
        grid_fee_type = self.config.grid_fee_type \
            if self.config is not None \
            else ConstSettings.IAASettings.GRID_FEE_TYPE
        if grid_fee_type == 1:
            grid_fee_percentage = None
        elif grid_fee_type == 2:
            grid_fee_const = None
        self.grid_fee_constant = grid_fee_const
        self.grid_fee_percentage = grid_fee_percentage

    def get_path_to_root_fees(self):
        if self.parent is not None:
            grid_fee_constant = self.grid_fee_constant if self.grid_fee_constant else 0
            return grid_fee_constant + self.parent.get_path_to_root_fees()
        return self.grid_fee_constant if self.grid_fee_constant else 0

    def get_grid_fee(self):
        grid_fee_type = self.config.grid_fee_type \
            if self.config is not None \
            else ConstSettings.IAASettings.GRID_FEE_TYPE
        return self.grid_fee_constant if grid_fee_type == 1 else self.grid_fee_percentage

    def set_events(self, event_list):
        self.events = Events(event_list, self)

    def activate(self, bc=None, current_tick=None, simulation_id=None):
        if current_tick is not None:
            self.current_tick = current_tick

        self._bc = blockchain_interface_factory(bc, self.uuid, simulation_id)

        if self.strategy:
            if self.parent:
                self.strategy.area = self.parent
                self.strategy.owner = self
            else:
                raise AreaException(
                    f"Strategy {self.strategy.__class__.__name__} on area {self} without parent!"
                    )

        if self.budget_keeper:
            self.budget_keeper.activate()
        if ConstSettings.IAASettings.AlternativePricing.PRICING_SCHEME != 0:
            self._set_grid_fees(0, 0)

        # Cycle markets without triggering it's own event chain.
        self.cycle_markets(_trigger_event=False)

        if not self.strategy and self.parent is not None:
            self.log.debug("No strategy. Using inter area agent.")
        self.log.debug("Activating area")
        self.active = True
        self.dispatcher.broadcast_activate(bc=bc, current_tick=self.current_tick,
                                           simulation_id=simulation_id)
        if self.redis_ext_conn is not None:
            self.redis_ext_conn.sub_to_external_channels()

    def deactivate(self):
        self.cycle_markets(deactivate=True)
        if self.redis_ext_conn is not None:
            self.redis_ext_conn.deactivate()
        if self.strategy:
            self.strategy.deactivate()

    def cycle_markets(self, _trigger_event=True, _market_cycle=False, deactivate=False):
        """
        Remove markets for old time slots, add markets for new slots.
        Trigger `MARKET_CYCLE` event to allow child markets to also cycle.

        It's important for this to happen from top to bottom of the `Area` tree
        in order for the `InterAreaAgent`s to be connected correctly

        `_trigger_event` is used internally to avoid multiple event chains during
        initial area activation.
        """

        current_tick_in_slot = int(self.current_tick % self.config.ticks_per_slot)
        tick_at_the_slot_start = self.current_tick - current_tick_in_slot
        if tick_at_the_slot_start == 0:
            now_value = self.now
        else:
            datetime_at_the_slot_start = self.config.start_date.add(
                seconds=self.config.tick_length.seconds * tick_at_the_slot_start
            )

            now_value = datetime_at_the_slot_start

        self.events.update_events(now_value)

        if not self.children:
            # Since children trade in markets we only need to populate them if there are any
            return

        if self.budget_keeper and _market_cycle:
            self.budget_keeper.process_market_cycle()

        self.log.debug("Cycling markets")
        self._markets.rotate_markets(now_value)
        self.dispatcher._delete_past_agents(self.dispatcher._inter_area_agents)

        # area_market_stats have to updated when cycling market of each area:
        self.stats.update_area_market_stats()

        if deactivate:
            return

        if self.should_update_child_strategies is True:
            self._update_descendants_strategy_prices()
            self.should_update_child_strategies = False

        # Clear `current_market` cache
        self.__dict__.pop("current_market", None)

        # Markets range from one slot to market_count into the future
        changed = self._markets.create_future_markets(now_value, True, self)

        if ConstSettings.BalancingSettings.ENABLE_BALANCING_MARKET and \
                len(DeviceRegistry.REGISTRY.keys()) != 0:
            changed_balancing_market = self._markets.create_future_markets(now_value, False, self)
        else:
            changed_balancing_market = None

        # Force market cycle event in case this is the first market slot
        if (changed or len(self._markets.past_markets.keys()) == 0) and _trigger_event:
            self.dispatcher.broadcast_market_cycle()

        # Force balancing_market cycle event in case this is the first market slot
        if (changed_balancing_market or len(self._markets.past_balancing_markets.keys()) == 0) \
                and _trigger_event and ConstSettings.BalancingSettings.ENABLE_BALANCING_MARKET:
            self.dispatcher.broadcast_balancing_market_cycle()

    def publish_market_cycle_to_external_clients(self):
        if self.strategy and isinstance(self.strategy, ExternalMixin):
            self.strategy.publish_market_cycle()
        elif not self.strategy and self.external_connection_available:
            self.redis_ext_conn.publish_market_cycle()
        for child in self.children:
            child.publish_market_cycle_to_external_clients()

    def _consume_commands_from_aggregator(self):
        if self.redis_ext_conn is not None and self.redis_ext_conn.is_aggregator_controlled:
            (self.redis_ext_conn.aggregator.
             consume_all_area_commands(self.uuid,
                                       self.redis_ext_conn.trigger_aggregator_commands))
        elif (self.strategy
              and getattr(self.strategy, "is_aggregator_controlled", False)):
            (self.strategy.redis.aggregator.
             consume_all_area_commands(self.uuid,
                                       self.strategy.trigger_aggregator_commands))

    def tick(self):
        """Tick event handler.

        Invoke aggregator commands consumer, publishes market clearing, updates events,
        updates cached myco matcher markets and match trades recommendations.
        """
        self._consume_commands_from_aggregator()

        if (ConstSettings.IAASettings.MARKET_TYPE != SpotMarketTypeEnum.ONE_SIDED.value
                and not self.strategy):
            if ConstSettings.GeneralSettings.EVENT_DISPATCHING_VIA_REDIS:
                self.dispatcher.publish_market_clearing()
            else:
                self._update_myco_matcher()
                bid_offer_matcher.match_recommendations()

        self.events.update_events(self.now)

    def _update_myco_matcher(self) -> None:
        """Update the markets cache that the myco matcher will request"""
        bid_offer_matcher.update_area_uuid_markets_mapping(
            area_uuid_markets_mapping={
                self.uuid: {"markets": self.all_markets, "current_time": self.now}})

    def update_area_current_tick(self):
        self.current_tick += 1
        if self._markets:
            for market in self._markets.markets.values():
                market.update_clock(self.current_tick_in_slot)
        for child in self.children:
            child.update_area_current_tick()

    def tick_and_dispatch(self):
        """Invoke tick handler and broadcast the event to children."""
        if d3a.constants.DISPATCH_EVENTS_BOTTOM_TO_TOP:
            self.dispatcher.broadcast_tick()
            self.tick()
        else:
            self.tick()
            self.dispatcher.broadcast_tick()

    def __repr__(self):
        return "<Area '{s.name}' markets: {markets}>".format(
            s=self,
            markets=[t.format(d3a.constants.TIME_FORMAT) for t in self._markets.markets.keys()]
        )

    @property
    def current_slot(self):
        return self.current_tick // self.config.ticks_per_slot

    @property
    def current_tick_in_slot(self):
        return self.current_tick % self.config.ticks_per_slot

    @property
    def config(self):
        if self._config:
            return self._config
        if self.parent:
            return self.parent.config
        return GlobalConfig

    @property
    def bc(self):
        if self._bc is not None:
            return self._bc
        return None

    @cached_property
    def child_by_slug(self):
        slug_map = {}
        areas = [self]
        while areas:
            for area in list(areas):
                slug_map[area.slug] = area
                areas.remove(area)
                areas.extend(area.children)
        return slug_map

    @property
    def now(self) -> DateTime:
        """
        Return the "current time" as a "DateTime" object.
        Can be overridden in subclasses to change the meaning of "now".

        In this default implementation "current time" is defined by the number of ticks that
        have passed.
        """
        return self.config.start_date.add(
            seconds=self.config.tick_length.seconds * self.current_tick
        )

    @property
    def all_markets(self):
        return [m for m in self._markets.markets.values() if m.in_sim_duration]

    @property
    def past_markets(self):
        return list(self._markets.past_markets.values())

    def get_market(self, timeslot):
        return self._markets.markets[timeslot]

    def get_past_market(self, timeslot):
        return self._markets.past_markets[timeslot]

    def get_balancing_market(self, timeslot):
        return self._markets.balancing_markets[timeslot]

    @property
    def balancing_markets(self):
        return list(self._markets.balancing_markets.values())

    @property
    def past_balancing_markets(self):
        return list(self._markets.past_balancing_markets.values())

    @property
    def market_with_most_expensive_offer(self):
        # In case of a tie, max returns the first market occurrence in order to
        # satisfy the most recent market slot
        return max(self.all_markets,
                   key=lambda m: m.sorted_offers[0].energy_rate)

    @property
    def next_market(self):
        """Returns the "current" market (i.e. the one currently "running")"""
        try:
            return list(self._markets.markets.values())[0]
        except IndexError:
            return None

    @property
    def current_market(self):
        """Returns the "most recent past market" market
        (i.e. the one that has been finished last)"""
        try:
            return list(self._markets.past_markets.values())[-1]
        except IndexError:
            return None

    @property
    def current_balancing_market(self):
        """Returns the "current" balancing market (i.e. the one currently "running")"""
        try:
            return list(self._markets.past_balancing_markets.values())[-1]
        except IndexError:
            return None

    def get_future_market_from_id(self, _id):
        return self._markets.indexed_future_markets.get(_id, None)

    @property
    def last_past_market(self):
        try:
            return list(self._markets.past_markets.values())[-1]
        except IndexError:
            return None

    @cached_property
    def available_triggers(self):
        triggers = []
        if isinstance(self.strategy, TriggerMixin):
            triggers.extend(self.strategy.available_triggers)
        return {t.name: t for t in triggers}

    def update_config(self, **kwargs):
        if not self.config:
            return
        self.config.update_config_parameters(**kwargs)
        if self.strategy:
            self.strategy.read_config_event()
        for child in self.children:
            child.update_config(**kwargs)