Skip to content

Commit fce9a16

Browse files
committed
Add the base Component class
This class will be used to combine classic inheritance (all components will derive from it), a mix-in to provide common functionality of all components, and also build "algebraic data types", so they can be used in `match` statements and easily do exhaustion checks to make sure all possible types of components are handled when needed. Note it doesn't make sense to make this a `abc.ABC` because there are no abstract methods. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 316fa15 commit fce9a16

File tree

6 files changed

+571
-0
lines changed

6 files changed

+571
-0
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""All classes and functions related to microgrid components."""
5+
6+
from ._category import ComponentCategory
7+
from ._component import Component
8+
from ._status import ComponentStatus
9+
10+
__all__ = [
11+
"Component",
12+
"ComponentCategory",
13+
"ComponentStatus",
14+
]
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""The component categories that can be used in a microgrid."""
5+
6+
import enum
7+
8+
from frequenz.api.common.v1.microgrid.components import components_pb2
9+
10+
11+
@enum.unique
12+
class ComponentCategory(enum.Enum):
13+
"""The known categories of components that can be present in a microgrid."""
14+
15+
UNSPECIFIED = components_pb2.COMPONENT_CATEGORY_UNSPECIFIED
16+
"""The component category is unspecified, probably due to an error in the message."""
17+
18+
GRID = components_pb2.COMPONENT_CATEGORY_GRID
19+
"""The point where the local microgrid is connected to the grid."""
20+
21+
METER = components_pb2.COMPONENT_CATEGORY_METER
22+
"""A meter, for measuring electrical metrics, e.g., current, voltage, etc."""
23+
24+
INVERTER = components_pb2.COMPONENT_CATEGORY_INVERTER
25+
"""An electricity generator, with batteries or solar energy."""
26+
27+
CONVERTER = components_pb2.COMPONENT_CATEGORY_CONVERTER
28+
"""A DC-DC converter."""
29+
30+
BATTERY = components_pb2.COMPONENT_CATEGORY_BATTERY
31+
"""A storage system for electrical energy, used by inverters."""
32+
33+
EV_CHARGER = components_pb2.COMPONENT_CATEGORY_EV_CHARGER
34+
"""A station for charging electrical vehicles."""
35+
36+
CRYPTO_MINER = components_pb2.COMPONENT_CATEGORY_CRYPTO_MINER
37+
"""A crypto miner."""
38+
39+
ELECTROLYZER = components_pb2.COMPONENT_CATEGORY_ELECTROLYZER
40+
"""An electrolyzer for converting water into hydrogen and oxygen."""
41+
42+
CHP = components_pb2.COMPONENT_CATEGORY_CHP
43+
"""A heat and power combustion plant (CHP stands for combined heat and power)."""
44+
45+
RELAY = components_pb2.COMPONENT_CATEGORY_RELAY
46+
"""A relay.
47+
48+
Relays generally have two states: open (connected) and closed (disconnected).
49+
They are generally placed in front of a component, e.g., an inverter, to
50+
control whether the component is connected to the grid or not.
51+
"""
52+
53+
PRECHARGER = components_pb2.COMPONENT_CATEGORY_PRECHARGER
54+
"""A precharge module.
55+
56+
Precharging involves gradually ramping up the DC voltage to prevent any
57+
potential damage to sensitive electrical components like capacitors.
58+
59+
While many inverters and batteries come equipped with in-built precharging
60+
mechanisms, some may lack this feature. In such cases, we need to use
61+
external precharging modules.
62+
"""
63+
64+
FUSE = components_pb2.COMPONENT_CATEGORY_FUSE
65+
"""A fuse."""
66+
67+
VOLTAGE_TRANSFORMER = components_pb2.COMPONENT_CATEGORY_VOLTAGE_TRANSFORMER
68+
"""A voltage transformer.
69+
70+
Voltage transformers are used to step up or step down the voltage, keeping
71+
the power somewhat constant by increasing or decreasing the current. If voltage is
72+
stepped up, current is stepped down, and vice versa.
73+
74+
Note:
75+
Voltage transformers have efficiency losses, so the output power is
76+
always less than the input power.
77+
"""
78+
79+
HVAC = components_pb2.COMPONENT_CATEGORY_HVAC
80+
"""A Heating, Ventilation, and Air Conditioning (HVAC) system."""
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Base component from which all other components inherit."""
5+
6+
import dataclasses
7+
import logging
8+
from collections.abc import Mapping
9+
from datetime import datetime, timezone
10+
from typing import Any, Self
11+
12+
from frequenz.client.common.microgrid import MicrogridId
13+
from frequenz.client.common.microgrid.components import ComponentId
14+
15+
from .._lifetime import Lifetime
16+
from ..metrics._bounds import Bounds
17+
from ..metrics._metric import Metric
18+
from ._category import ComponentCategory
19+
from ._status import ComponentStatus
20+
21+
_logger = logging.getLogger(__name__)
22+
23+
24+
@dataclasses.dataclass(frozen=True, kw_only=True)
25+
class Component: # pylint: disable=too-many-instance-attributes
26+
"""A base class for all components."""
27+
28+
id: ComponentId
29+
"""This component's ID."""
30+
31+
microgrid_id: MicrogridId
32+
"""The ID of the microgrid this component belongs to."""
33+
34+
category: ComponentCategory | int
35+
"""The category of this component.
36+
37+
Note:
38+
This should not be used normally, you should test if a component
39+
[`isinstance`][] of a concrete component class instead.
40+
41+
It is only provided for using with a newer version of the API where the client
42+
doesn't know about a new category yet (i.e. for use with
43+
[`UnrecognizedComponent`][frequenz.client.microgrid.component.UnrecognizedComponent])
44+
and in case some low level code needs to know the category of a component.
45+
"""
46+
47+
status: ComponentStatus | int = ComponentStatus.UNSPECIFIED
48+
"""The status of this component.
49+
50+
Tip:
51+
You can also use
52+
[`is_active_now()`][frequenz.client.microgrid.component.Component.is_active_now]
53+
or
54+
[`is_active_at()`][frequenz.client.microgrid.component.Component.is_active_at],
55+
which also checks if the component is operational.
56+
"""
57+
58+
name: str | None = None
59+
"""The name of this component."""
60+
61+
manufacturer: str | None = None
62+
"""The manufacturer of this component."""
63+
64+
model_name: str | None = None
65+
"""The model name of this component."""
66+
67+
operational_lifetime: Lifetime = dataclasses.field(default_factory=Lifetime)
68+
"""The operational lifetime of this component."""
69+
70+
rated_bounds: Mapping[Metric | int, Bounds] = dataclasses.field(
71+
default_factory=dict,
72+
# dict is not hashable, so we don't use this field to calculate the hash. This
73+
# shouldn't be a problem since it is very unlikely that two components with all
74+
# other attributes being equal would have different category specific metadata,
75+
# so hash collisions should be still very unlikely.
76+
hash=False,
77+
)
78+
"""List of rated bounds present for the component identified by Metric."""
79+
80+
category_specific_metadata: Mapping[str, Any] = dataclasses.field(
81+
default_factory=dict,
82+
# dict is not hashable, so we don't use this field to calculate the hash. This
83+
# shouldn't be a problem since it is very unlikely that two components with all
84+
# other attributes being equal would have different category specific metadata,
85+
# so hash collisions should be still very unlikely.
86+
hash=False,
87+
)
88+
"""The category specific metadata of this component.
89+
90+
Note:
91+
This should not be used normally, it is only useful when accessing a newer
92+
version of the API where the client doesn't know about the new metadata fields
93+
yet (i.e. for use with
94+
[`UnrecognizedComponent`][frequenz.client.microgrid.component.UnrecognizedComponent]).
95+
"""
96+
97+
def __new__(cls, *_: Any, **__: Any) -> Self:
98+
"""Prevent instantiation of this class."""
99+
if cls is Component:
100+
raise TypeError(f"Cannot instantiate {cls.__name__} directly")
101+
return super().__new__(cls)
102+
103+
def is_active_at(self, timestamp: datetime) -> bool:
104+
"""Check whether this component is active at a specific timestamp.
105+
106+
A component is considered active if it is in the active state and is
107+
operational at the given timestamp. The operational lifetime is used to
108+
determine whether the component is operational at the given timestamp.
109+
110+
If a component has an unspecified status, it is assumed to be active
111+
and a warning is logged.
112+
113+
Args:
114+
timestamp: The timestamp to check.
115+
116+
Returns:
117+
Whether this component is active at the given timestamp.
118+
"""
119+
if self.status is ComponentStatus.UNSPECIFIED:
120+
_logger.warning(
121+
"Component %s has an unspecified status. Assuming it is active.",
122+
self,
123+
)
124+
return self.operational_lifetime.is_operational_at(timestamp)
125+
126+
return (
127+
self.status is ComponentStatus.ACTIVE
128+
and self.operational_lifetime.is_operational_at(timestamp)
129+
)
130+
131+
def is_active_now(self) -> bool:
132+
"""Check whether this component is currently active.
133+
134+
A component is considered active if it is in the active state and is
135+
operational at the current time. The operational lifetime is used to
136+
determine whether the component is operational at the current time.
137+
138+
If a component has an unspecified status, it is assumed to be active
139+
and a warning is logged.
140+
141+
Returns:
142+
Whether this component is active at the current time.
143+
"""
144+
return self.is_active_at(datetime.now(timezone.utc))
145+
146+
@property
147+
def identity(self) -> tuple[ComponentId, MicrogridId]:
148+
"""The identity of this component.
149+
150+
This uses the component ID and microgrid ID to identify a component
151+
without considering the other attributes, so even if a component state
152+
changed, the identity remains the same.
153+
"""
154+
return (self.id, self.microgrid_id)
155+
156+
def __str__(self) -> str:
157+
"""Return a human-readable string representation of this instance."""
158+
name = f":{self.name}" if self.name else ""
159+
return f"{self.id}<{type(self).__name__}>{name}"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Status for a component."""
5+
6+
import enum
7+
8+
from frequenz.api.common.v1.microgrid.components import components_pb2
9+
10+
11+
@enum.unique
12+
class ComponentStatus(enum.Enum):
13+
"""The known statuses of a component."""
14+
15+
UNSPECIFIED = components_pb2.COMPONENT_STATUS_UNSPECIFIED
16+
"""The status is unspecified."""
17+
18+
ACTIVE = components_pb2.COMPONENT_STATUS_ACTIVE
19+
"""The component is active."""
20+
21+
INACTIVE = components_pb2.COMPONENT_STATUS_INACTIVE
22+
"""The component is inactive."""

tests/component/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Tests for components."""

0 commit comments

Comments
 (0)