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)
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)