Source code for smartcer.core.community.community

"""
A community of nodes stepped together (docs/FORMULATION.md sections 7-10).

The community sends each battery action to its node, collects the node flows and computes shared
energy, incentive, welfare and the mean constraint penalty K_t. It is what the Gymnasium
environment wraps.
"""

from collections.abc import Sequence
from dataclasses import dataclass

import numpy as np
from numpy.typing import ArrayLike

from smartcer.core.community.incentives import IncentiveModel
from smartcer.core.community.shared_energy import shared_energy
from smartcer.core.community.welfare import WelfareBreakdown, welfare_breakdown
from smartcer.core.nodes import Node, NodeStep


[docs] @dataclass(frozen=True, slots=True) class CommunityStep: """ Record of one community step. Attributes: t: Step index. nodes: One record per node, in the community's node order. welfare: Welfare breakdown of the step. penalty: Mean constraint penalty over the nodes with a battery, K_t = Σ_{u ∈ U_b} k_u / ``|U_b|`` (0 without batteries). """ t: int nodes: tuple[NodeStep, ...] welfare: WelfareBreakdown penalty: float
[docs] class Community: """ Nodes, incentive model and time axis of a scenario. Attributes: name: Scenario name. nodes: Nodes in a fixed order. node_ids: Node identifiers in the same order. storage_node_ids: Identifiers of the nodes with a battery, in node order; this is the order of the actions. incentive: Incentive model. timestamps: Start time of each step, numpy datetime64 array. delta_t: Duration of a step (hours). """ def __init__( self, nodes: Sequence[Node], incentive: IncentiveModel, timestamps: np.ndarray, delta_t: float, name: str = '', ) -> None: """ Create a community. Args: nodes: Nodes, all with the same number of steps and time step. incentive: Incentive model with the same number of steps. timestamps: Start time of each step. delta_t: Duration of a step (hours). name: Scenario name. Raises: ValueError: If there are no nodes, node ids repeat, or steps and time steps disagree. """ if not nodes: raise ValueError('a community needs at least one node') node_ids = [node.node_id for node in nodes] if len(set(node_ids)) != len(node_ids): raise ValueError(f'node ids must be unique, got {node_ids}') n_steps = len(timestamps) for node in nodes: if node.n_steps != n_steps: raise ValueError(f'node {node.node_id!r} has {node.n_steps} steps, the community ' f'has {n_steps}') if node.delta_t != delta_t: raise ValueError(f'node {node.node_id!r} has delta_t {node.delta_t}, the ' f'community has {delta_t}') if incentive.n_steps != n_steps: raise ValueError(f'the incentive model has {incentive.n_steps} steps, the community ' f'has {n_steps}') self.name = name self.nodes = tuple(nodes) self.node_ids = tuple(node_ids) self.storage_node_ids = tuple(node.node_id for node in nodes if node.battery is not None) self.incentive = incentive self.timestamps = np.asarray(timestamps) self.delta_t = float(delta_t) self._action_index = {node_id: i for i, node_id in enumerate(self.storage_node_ids)} def __repr__(self) -> str: return (f'Community({self.name!r}, {len(self.nodes)} nodes, ' f'{len(self.storage_node_ids)} batteries, {self.n_steps} steps)') @property def n_steps(self) -> int: """Number of steps of the scenario.""" return len(self.timestamps)
[docs] def node(self, node_id: str) -> Node: """ Node by identifier. Args: node_id: Node identifier. Returns: Node: The node. Raises: KeyError: If the node does not exist. """ try: return self.nodes[self.node_ids.index(node_id)] except ValueError: raise KeyError(f'unknown node {node_id!r}; nodes: {list(self.node_ids)}') from None
[docs] def reset(self) -> None: """ Restore every battery's initial state of charge. """ for node in self.nodes: node.reset()
[docs] def step(self, t: int, actions: ArrayLike | None = None) -> CommunityStep: """ Apply one action per battery in step t and compute the community results. Args: t: Step index. actions: One action in [-1, 1] per battery, in `storage_node_ids` order; None leaves every battery idle. Returns: CommunityStep: Node records, welfare breakdown and mean penalty. Raises: ValueError: If the number of actions differs from the number of batteries. """ if actions is None: action_list = [0.0] * len(self.storage_node_ids) else: action_list = np.asarray(actions, dtype=np.float64).reshape(-1).tolist() if len(action_list) != len(self.storage_node_ids): raise ValueError(f'expected {len(self.storage_node_ids)} actions (one per battery, ' f'{list(self.storage_node_ids)}), got {len(action_list)}') records = [] for node in self.nodes: index = self._action_index.get(node.node_id) records.append(node.step(t, None if index is None else action_list[index])) export = np.array([r.export_kwh for r in records]) import_ = np.array([r.import_kwh for r in records]) shared = shared_energy(export, import_) welfare = welfare_breakdown( revenue=sum(r.revenue for r in records), cost=sum(r.cost for r in records), storage_cost=sum(r.storage_cost for r in records), incentive=self.incentive.compute(t, shared, export), shared_energy_kwh=shared, export_kwh=float(export.sum()), import_kwh=float(import_.sum()), ) penalties = [r.penalty for r, node in zip(records, self.nodes) if node.battery is not None] penalty = sum(penalties) / len(penalties) if penalties else 0.0 return CommunityStep(t=t, nodes=tuple(records), welfare=welfare, penalty=penalty)