Skip to content

Commit ca626df

Browse files
authored
Read the operational mode from Python components (#99)
`frequenz-microgrid-component-graph` v0.6.1 adds an operational mode to graph components, but the trait default leaves every component `Unspecified` — the bindings have to read the mode off each Python component for it to have any effect. This builds against v0.6.1, translates the mode, and releases 0.5.1. ## Changes - Bump the crate from v0.6.0 to v0.6.1. - Translate `provides_telemetry()` / `accepts_control()` into one `OperationalMode` in `Component::try_new`, alongside the existing category translation. - Raise the `microgrid` extra's floor to `frequenz-client-microgrid >= 0.18.4`, the release the two methods arrived in. On 0.18.3 the mode is never readable and the new tests cannot run. - Write the release notes and bump the version to 0.5.1. ## Worth a look - **Both flags or nothing.** A mode is named only when both are known: `provides_telemetry() == false` fits `Inactive` and `ControlOnly` alike, so a half-known mode stays `Unspecified` rather than being guessed at. - **Missing accessors are tolerated.** The assets client has no equivalent methods, so their absence reads as unspecified instead of failing the graph — the same tolerance the category lookup already has. - **`getattr` and `call0` are separate steps** so a missing method reads as unspecified while an `AttributeError` from inside the method body propagates. `call_method0` cannot tell those apart, and swallowing the second would hide a caller's bug and leave the component measuring.
2 parents 5f3f590 + 13a5afc commit ca626df

6 files changed

Lines changed: 251 additions & 14 deletions

File tree

‎Cargo.lock‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "frequenz-microgrid-component-graph-python-bindings"
3-
version = "0.5.0"
3+
version = "0.5.1"
44
edition = "2024"
55

66
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -10,4 +10,4 @@ crate-type = ["cdylib"]
1010

1111
[dependencies]
1212
pyo3 = "0.29.0"
13-
frequenz-microgrid-component-graph = "0.6.0"
13+
frequenz-microgrid-component-graph = "0.6.2"

‎RELEASE_NOTES.md‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,14 @@
22

33
## Summary
44

5-
<!-- Here goes a general summary of what this release is about -->
5+
This release lets formulas take a component's operational mode into account. A component that provides no telemetry is not used as a measurement source. It is still used to classify the meter that measures it, and is measured through that meter instead.
66

77
## Upgrading
88

9-
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
9+
- The `microgrid` extra now needs `frequenz-client-microgrid >= 0.18.4`, up from `>= 0.18.3`. A component's operational mode is read from its `provides_telemetry()` and `accepts_control()` methods, and 0.18.3 has neither, so the feature below would do nothing there. If you pin the client yourself, move the pin to `>= 0.18.4, < 0.19`.
1010

1111
## New Features
1212

13-
<!-- Here goes the main new features and examples or instructions on how to use them -->
13+
- Formulas now take a component's operational mode into account. A component that provides no telemetry is not used as a measurement source. It is still used to classify the meter that measures it (e.g. as a PV meter or a CHP meter), so it can still be measured through that meter.
1414

15-
## Bug Fixes
16-
17-
<!-- Here goes notable bug fixes that are worth a special mention or explanation -->
15+
The mode is read from the component's `provides_telemetry()` and `accepts_control()` methods. A component that does not have both methods, or does not specify both values, is treated as providing telemetry and is used exactly as before. A component built from the microgrid API carries the mode the API reports for it, so formulas can change for a site that has an inactive or control-only component.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ email = "floss@frequenz.com"
3232

3333
[project.optional-dependencies]
3434
microgrid = [
35-
"frequenz-client-microgrid >= 0.18.3, < 0.19",
35+
"frequenz-client-microgrid >= 0.18.4, < 0.19",
3636
]
3737
assets = [
3838
"frequenz-client-assets >= 0.1.0, < 0.6",

‎src/component.rs‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@
33

44
use frequenz_microgrid_component_graph as cg;
55

6-
use pyo3::{prelude::*, types::PyAny};
6+
use pyo3::{
7+
exceptions::{PyAttributeError, PyValueError},
8+
prelude::*,
9+
types::PyAny,
10+
};
711

812
use crate::{category::category_from_python_component, utils::extract_int};
913

1014
/// A wrapper for the Python object representing a component.
1115
pub(crate) struct Component {
1216
pub(crate) component_id: u64,
1317
pub(crate) category: cg::ComponentCategory,
18+
pub(crate) operational_mode: cg::OperationalMode,
1419
pub(crate) object: Py<PyAny>,
1520
}
1621

@@ -22,16 +27,84 @@ impl cg::Node for Component {
2227
fn category(&self) -> cg::ComponentCategory {
2328
self.category
2429
}
30+
31+
fn operational_mode(&self) -> cg::OperationalMode {
32+
self.operational_mode
33+
}
34+
}
35+
36+
/// Reads a component's operational mode.
37+
///
38+
/// The Python side splits the mode into two flags, `provides_telemetry()`
39+
/// and `accepts_control()`, each of which raises `ValueError` when the mode
40+
/// is unspecified. Both flags together name one `OperationalMode`.
41+
///
42+
/// A mode is only named when both flags are known. One flag alone does
43+
/// not name a mode: `provides_telemetry() == false` fits both `Inactive`
44+
/// and `ControlOnly`. So a half-known mode is reported as `Unspecified`,
45+
/// which the graph treats as providing telemetry -- a component that says
46+
/// it has no telemetry but not whether it takes control keeps measuring.
47+
/// The API sends the two flags together or not at all, so this is a
48+
/// hand-built component, and reporting a mode it did not state would be
49+
/// a guess.
50+
fn operational_mode_from_python_component(
51+
object: &Bound<'_, PyAny>,
52+
) -> PyResult<cg::OperationalMode> {
53+
let (Some(telemetry), Some(control)) = (
54+
specified_flag(object, "provides_telemetry")?,
55+
specified_flag(object, "accepts_control")?,
56+
) else {
57+
return Ok(cg::OperationalMode::Unspecified);
58+
};
59+
60+
Ok(match (telemetry, control) {
61+
(true, true) => cg::OperationalMode::ControlAndTelemetry,
62+
(true, false) => cg::OperationalMode::TelemetryOnly,
63+
(false, true) => cg::OperationalMode::ControlOnly,
64+
(false, false) => cg::OperationalMode::Inactive,
65+
})
66+
}
67+
68+
/// Calls a no-argument boolean method and reads its answer, if it has one.
69+
///
70+
/// Two ways a component can have no answer, both giving `None`:
71+
///
72+
/// * The method is missing. Not every supported component type carries
73+
/// one: the methods arrived in `frequenz-client-microgrid` 0.18.4, and
74+
/// the assets client has no equivalent. Like the category lookup, the
75+
/// bindings keep working with a component that does not provide them.
76+
/// * The method is there and raises `ValueError`, which is how a
77+
/// component says its mode is unspecified.
78+
///
79+
/// Any other error is passed on. The method is looked up and called in
80+
/// two steps on purpose, so that only a missing method is read as
81+
/// unspecified: an `AttributeError` raised from inside the method body is
82+
/// the caller's own bug, and reporting that as an unspecified mode would
83+
/// hide it and leave the component measuring.
84+
fn specified_flag(object: &Bound<'_, PyAny>, name: &str) -> PyResult<Option<bool>> {
85+
let method = match object.getattr(name) {
86+
Ok(method) => method,
87+
Err(err) if err.is_instance_of::<PyAttributeError>(object.py()) => return Ok(None),
88+
Err(err) => return Err(err),
89+
};
90+
91+
match method.call0() {
92+
Ok(value) => value.extract().map(Some),
93+
Err(err) if err.is_instance_of::<PyValueError>(object.py()) => Ok(None),
94+
Err(err) => Err(err),
95+
}
2596
}
2697

2798
impl Component {
2899
pub(crate) fn try_new(py: Python<'_>, object: Bound<'_, PyAny>) -> PyResult<Self> {
29100
let component_id = extract_int(py, object.getattr("id")?)?;
30101
let category = category_from_python_component(py, &object)?;
102+
let operational_mode = operational_mode_from_python_component(&object)?;
31103

32104
Ok(Component {
33105
component_id,
34106
category,
107+
operational_mode,
35108
object: object.into(),
36109
})
37110
}

‎tests/test_microgrid_component_graph.py‎

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
"""Tests for the frequenz.microgrid_component_graph package."""
55

6-
from typing import Any
6+
from typing import Any, NoReturn
77

88
import pytest
99
from frequenz.client.common.microgrid import MicrogridId
@@ -479,3 +479,169 @@ def test_unspecified_component_type_is_rejected(
479479
ComponentConnection(source=ComponentId(2), destination=ComponentId(3)),
480480
},
481481
)
482+
483+
484+
def _pv_graph_with_modes(
485+
*, provides_telemetry: bool | None, accepts_control: bool | None
486+
) -> microgrid_component_graph.ComponentGraph[
487+
Component, ComponentConnection, ComponentId
488+
]:
489+
"""Build `Grid -> Meter -> SolarInverter`, with a mode on the inverter."""
490+
return microgrid_component_graph.ComponentGraph(
491+
components={
492+
GridConnectionPoint(
493+
id=ComponentId(1),
494+
microgrid_id=MicrogridId(1),
495+
rated_fuse_current=100,
496+
),
497+
Meter(id=ComponentId(2), microgrid_id=MicrogridId(1)),
498+
SolarInverter(
499+
id=ComponentId(3),
500+
microgrid_id=MicrogridId(1),
501+
_provides_telemetry=provides_telemetry,
502+
_accepts_control=accepts_control,
503+
),
504+
},
505+
connections={
506+
ComponentConnection(source=ComponentId(1), destination=ComponentId(2)),
507+
ComponentConnection(source=ComponentId(2), destination=ComponentId(3)),
508+
},
509+
)
510+
511+
512+
def test_operational_mode_default_is_unspecified() -> None:
513+
"""Test that a component with no operational mode still provides telemetry.
514+
515+
Both flags are `None` on a component built without them, which is the
516+
unspecified mode. It is treated as providing telemetry, so graphs that
517+
never set a mode keep their formulas.
518+
"""
519+
graph = _pv_graph_with_modes(provides_telemetry=None, accepts_control=None)
520+
assert graph.pv_formula(None) == "COALESCE(#3, #2, 0.0)"
521+
522+
523+
@pytest.mark.parametrize(
524+
"provides_telemetry, accepts_control",
525+
[
526+
pytest.param(True, True, id="control-and-telemetry"),
527+
pytest.param(True, False, id="telemetry-only"),
528+
],
529+
)
530+
def test_operational_mode_with_telemetry_is_a_source(
531+
provides_telemetry: bool, accepts_control: bool
532+
) -> None:
533+
"""Test that a mode providing telemetry keeps the component as a source."""
534+
graph = _pv_graph_with_modes(
535+
provides_telemetry=provides_telemetry, accepts_control=accepts_control
536+
)
537+
assert graph.pv_formula(None) == "COALESCE(#3, #2, 0.0)"
538+
539+
540+
@pytest.mark.parametrize(
541+
"provides_telemetry, accepts_control",
542+
[
543+
pytest.param(False, True, id="control-only"),
544+
pytest.param(False, False, id="inactive"),
545+
],
546+
)
547+
def test_operational_mode_without_telemetry_is_not_a_source(
548+
provides_telemetry: bool, accepts_control: bool
549+
) -> None:
550+
"""Test that a mode providing no telemetry drops the component as a source.
551+
552+
The inverter's own reading is gone; the meter above it measures it
553+
instead, and still counts as a PV meter because of it.
554+
"""
555+
graph = _pv_graph_with_modes(
556+
provides_telemetry=provides_telemetry, accepts_control=accepts_control
557+
)
558+
assert graph.pv_formula(None) == "COALESCE(#2, 0.0)"
559+
560+
561+
@pytest.mark.parametrize(
562+
"provides_telemetry, accepts_control",
563+
[
564+
pytest.param(False, None, id="control-unknown"),
565+
pytest.param(None, False, id="telemetry-unknown"),
566+
],
567+
)
568+
def test_operational_mode_half_known_is_unspecified(
569+
provides_telemetry: bool | None, accepts_control: bool | None
570+
) -> None:
571+
"""Test that a half-known mode is not guessed at.
572+
573+
A component can carry one flag without the other. Naming a mode from
574+
that would mean guessing the missing half, so the mode is unspecified
575+
and the component stays a measurement source -- even where the known
576+
flag is `_provides_telemetry=False`.
577+
"""
578+
graph = _pv_graph_with_modes(
579+
provides_telemetry=provides_telemetry, accepts_control=accepts_control
580+
)
581+
assert graph.pv_formula(None) == "COALESCE(#3, #2, 0.0)"
582+
583+
584+
def test_operational_mode_missing_accessors_is_unspecified(
585+
monkeypatch: pytest.MonkeyPatch,
586+
) -> None:
587+
"""Test that a component without the mode accessors is still accepted.
588+
589+
`provides_telemetry()` and `accepts_control()` arrived in
590+
frequenz-client-microgrid 0.18.4, and the assets client has no
591+
equivalent, so a supported component can carry neither. Such a
592+
component has an unspecified mode and stays a measurement source,
593+
rather than failing the graph. This mirrors the category lookup, which
594+
keeps working when a class is not present.
595+
596+
The flags below say "no telemetry", so the two paths give different
597+
formulas and this test can tell them apart: only removing the methods
598+
leaves `#3` in the formula. If the removal ever stopped matching, the
599+
mode would read as `Inactive`, `#3` would drop out, and the test would
600+
fail rather than pass while checking nothing.
601+
"""
602+
monkeypatch.delattr(Component, "provides_telemetry")
603+
monkeypatch.delattr(Component, "accepts_control")
604+
605+
graph = _pv_graph_with_modes(provides_telemetry=False, accepts_control=False)
606+
assert graph.pv_formula(None) == "COALESCE(#3, #2, 0.0)"
607+
608+
609+
def test_operational_mode_error_inside_accessor_is_not_hidden() -> None:
610+
"""Test that an `AttributeError` from inside an accessor is passed on.
611+
612+
A missing accessor means "mode unspecified". An accessor that is
613+
present but raises `AttributeError` from its own body is the caller's
614+
bug: reading that as an unspecified mode would hide it and leave the
615+
component measuring, which is the very thing the mode is meant to
616+
stop. The lookup and the call are therefore separate steps, so that a
617+
missing method reads as unspecified while an error out of the method
618+
body does not.
619+
"""
620+
621+
class BrokenMeter(Meter):
622+
"""A meter whose accessor raises, standing in for a caller bug."""
623+
624+
def provides_telemetry(self) -> NoReturn:
625+
"""Raise, as a buggy override would.
626+
627+
Raises:
628+
AttributeError: always.
629+
"""
630+
raise AttributeError("nested attribute missing")
631+
632+
with pytest.raises(AttributeError):
633+
microgrid_component_graph.ComponentGraph(
634+
components={
635+
GridConnectionPoint(
636+
id=ComponentId(1),
637+
microgrid_id=MicrogridId(1),
638+
rated_fuse_current=100,
639+
),
640+
BrokenMeter(id=ComponentId(2), microgrid_id=MicrogridId(1)),
641+
SolarInverter(id=ComponentId(3), microgrid_id=MicrogridId(1)),
642+
},
643+
connections={
644+
ComponentConnection(source=ComponentId(1), destination=ComponentId(2)),
645+
ComponentConnection(source=ComponentId(2), destination=ComponentId(3)),
646+
},
647+
)

0 commit comments

Comments
 (0)