Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
858edab
Add `__contains__` to `Bounds`
llucax Jul 19, 2026
2fa80f8
Add `__bool__` and `is_bounded()` to `Bounds`
llucax Jul 19, 2026
b91f8dc
Add `BoundsSet`
llucax Jul 19, 2026
afe3cdb
Add `InvalidBoundsSet`
llucax Jul 19, 2026
813eb9b
Migrate `MetricSample.bounds` to `bounds_set`
llucax Jul 19, 2026
ab984c7
Add `InvalidBoundsSetError`
llucax Jul 19, 2026
6bdba6e
Add safe accessor `MetricSample.get_bounds_set()`
llucax Jul 19, 2026
1b2d856
Reject `NaN` in bounds membership tests
llucax Jul 19, 2026
ec64f69
Normalize the deprecated `MetricSample.bounds` property
llucax Jul 19, 2026
43ea977
Update release notes
llucax Jul 19, 2026
cd4ab26
Add `FloatInt` type alias
llucax Jul 20, 2026
558e20e
Use `FloatInt` in metric bounds
llucax Jul 20, 2026
f2c4f36
Use `FloatInt` in metric samples
llucax Jul 20, 2026
f7011dc
Use `FloatInt` in `Location`
llucax Jul 20, 2026
380314f
Use `FloatInt` in `PowerTransformer`
llucax Jul 20, 2026
c2395af
Update release notes
llucax Jul 20, 2026
fc2996b
Remove `None` from `MetricConnection.name`
llucax Jul 21, 2026
c8de59a
Add `CategorySpecificInfo`
llucax Jul 21, 2026
237495c
Store carried category specific info on every component
llucax Jul 21, 2026
4839813
Rework `ElectricalComponent.__str__`
llucax Jul 21, 2026
baa37c5
Store the declared category name on mismatched components
llucax Jul 21, 2026
eae39a5
Standardize the invalid marker in `MetricConnection.__str__`
llucax Jul 20, 2026
238b033
Add `MetricSample.__str__`
llucax Jul 20, 2026
e4d64f2
Use `str` instead of `repr` for values error messages
llucax Jul 20, 2026
515fed5
Update release notes
llucax Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,28 @@

* `frequenz.client.common.metrics.Bounds.__str__` now renders as `[lower,upper]` (no space after the comma) to match the compact format used by `Lifetime` and to compose cleanly with the `<invalid:...>` marker on `InvalidBounds`.

* Several `__str__` representations were standardized around the `<invalid:VALUE>` marker, so a `grep '<invalid:'` over logs finds every invariant violation regardless of which type produced it:

* `frequenz.client.common.metrics.MetricConnection.__str__` renders as `{name}:{category}` (with `name` possibly empty); known categories render as their member name, the unspecified category renders as `cat=<invalid:0>`, and unknown non-zero categories render as `cat=<int>`.
* `frequenz.client.common.metrics.MetricSample` gained a compact `__str__` (`metric=value`, plus `@connection` when a connection is set) instead of falling back to the dataclass `repr`.
* `UnrecognizedElectricalComponent`, `MismatchedCategoryElectricalComponent`, `UnrecognizedBattery`, `UnrecognizedEvCharger` and `UnrecognizedInverter` now expose their raw wire `category` / `type` in `__str__` (e.g. `CID1:comp1:Inverter:type=99`), instead of hiding it behind the class name alone. These values are merely unrecognized (forward-compatible), not invariant violations, so they use a plain `:field=value` detail rather than the `<invalid:...>` marker.

* `frequenz.client.common.metrics.MetricSample.bounds` is now deprecated; use `bounds_set` instead.

The field type changed from `list[Bounds]` to `BoundsSet | InvalidBoundsSet` (see New Features). Reads and construction remain backward compatible: passing the `bounds=` keyword argument still works (it builds a `BoundsSet` and emits a `DeprecationWarning`), and reading `MetricSample.bounds` still returns the valid `Bounds` as a `list` (also emitting a `DeprecationWarning`). The compatibility property returns only the valid, normalized bounds, so it may differ from the raw wire list when bounds overlapped or touched.

* `frequenz.client.common.metrics.proto.v1alpha8.metric_sample_from_proto_with_issues` no longer drops invalid bounds or reports them as a major issue.

Malformed bounds are now preserved in the returned `MetricSample.bounds_set` as an `InvalidBoundsSet` (validity is encoded in the type), so the previous "bounds for ... is invalid, ignoring these bounds" major issue is no longer produced.

* `float`-typed fields and accessors are now annotated with the new `FloatInt` (`float | int`) type alias (see New Features), to be honest about what PEP 484's numeric tower actually admits. These symbols are affected:

* `frequenz.client.common.metrics.AggregatedMetricValue`: the `avg`, `min`, `max` and `raw` fields.
* `frequenz.client.common.metrics.MetricSample`: the `value` field and the `as_single_value()` return type.
* `frequenz.client.common.metrics.Bounds`: the `lower` and `upper` fields (shared with the new `BaseBounds` / `InvalidBounds` hierarchy).

Runtime behavior is completely unchanged: these fields could always end up storing `int` values (`x: float = 1` is legal even under `mypy --strict`), the annotations just didn't admit it. Reads that assign to `float`-typed destinations or do plain arithmetic keep type-checking as before. However, code that pattern-matches these values with a bare `case float():` arm — a latent runtime crash, since `isinstance(1, float)` is `False` — will now be flagged as non-exhaustive by strict type checkers and should be widened to `case float() | int():`, and calling `float`-only methods (e.g. `hex()`) on them now requires an explicit `float(...)` conversion.

## New Features

* Added 4 new electrical component classes for categories that previously collapsed into `UnrecognizedElectricalComponent`:
Expand Down Expand Up @@ -91,6 +113,7 @@
* `frequenz.client.common.grid.DeliveryArea.get_code_type()`
* `frequenz.client.common.metrics.MetricConnection.get_category()`
* `frequenz.client.common.metrics.MetricSample.get_metric()`
* `frequenz.client.common.metrics.MetricSample.get_bounds_set()`
* `frequenz.client.common.microgrid.electrical_components.ElectricalComponent.get_metric_config_bounds()`

* Added new delivery-area class hierarchy:
Expand All @@ -105,6 +128,18 @@

* Added `frequenz.client.common.metrics.proto.v1alpha8.bounds_from_proto2` returning `Bounds | InvalidBounds`. This is the replacement for the now-deprecated `bounds_from_proto`.

* `frequenz.client.common.metrics.Bounds` gained containment check capabilities:

* `value in bounds` (`__contains__`) tests membership, inclusive on both ends, with a `None` bound meaning unbounded in that direction.
* `bool(bounds)` and `bounds.is_bounded()` report whether the bounds restrict anything; a fully unbounded `Bounds()` is falsy.

* Added a new bounds-set class hierarchy:

* `frequenz.client.common.metrics.BoundsSet` — a normalized union of `Bounds` with an efficient `value in bounds_set` membership test. Overlapping and touching bounds are merged on construction, and the empty set is the unbounded set (it contains every value and is falsy).
* `frequenz.client.common.metrics.InvalidBoundsSet` — a set built from bounds that included at least one `InvalidBounds`; it preserves all the raw bounds unmerged and provides no membership test.

* Added a new `frequenz.client.common.metrics.MetricSample.bounds_set` field, typed `BoundsSet | InvalidBoundsSet`, replacing the deprecated `bounds` list (see Upgrading). Malformed wire bounds are preserved as an `InvalidBoundsSet` instead of being dropped. Use `get_bounds_set()` to resolve it to a valid `BoundsSet` or a clear `InvalidBoundsSetError`.

* Added a new `frequenz.client.common.types.Location` type together with the `frequenz.client.common.types.proto.v1alpha8.location_from_proto` conversion function.

* Added a new `frequenz.client.common.microgrid.Microgrid` type, together with the `frequenz.client.common.microgrid.proto.v1alpha8.microgrid_from_proto` conversion function.
Expand All @@ -117,6 +152,11 @@

* Added a new `frequenz.client.common.microgrid.Microgrid` type with a raising `is_active()` method, together with the `frequenz.client.common.microgrid.proto.v1alpha8.microgrid_from_proto` conversion function.

* Added `frequenz.client.common.FloatInt`, a type alias for `float | int`.

PEP 484's numeric tower makes `int` assignable wherever `float` is annotated, even under `mypy --strict`, while at runtime `isinstance(1, float)` is `False` — so a plain `float` annotation silently admits values that crash `match … case float():` arms and `float`-only methods like `hex()`. The library now spells such annotations `FloatInt` instead of lying (see Upgrading); the alias docstring documents the trap in detail, including the inherent `bool ⊂ int` leak. New numeric fields (`Location` latitudes/longitudes, `PowerTransformer` voltages, bounds and bounds sets) use it as well. Values loaded from protobuf are unaffected in practice, as the wire always delivers real `float`s.

## Bug Fixes

* Fixed `EnumParityTest` so protobuf values whose Python member name exists with a different number fail parity checks instead of being treated as unmirrored protobuf values.
* Fixed potential unexpected exceptions due to type-checking accepting `int` for code annotated to only accept `float`. Fixes #250.
2 changes: 2 additions & 0 deletions src/frequenz/client/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
UnrecognizedEnumValueError,
UnspecifiedEnumValueError,
)
from ._float import FloatInt

__all__ = [
"ClientCommonError",
"FloatInt",
"InvalidAttributeError",
"MissingFieldError",
"UnrecognizedEnumValueError",
Expand Down
2 changes: 1 addition & 1 deletion src/frequenz/client/common/_exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def __init__(
(
message
if message is not None
else f"unrecognized enum value {value!r} for attribute {attr_name!r} in {instance}"
else f"unrecognized enum value {value} for attribute {attr_name!r} in {instance}"
),
)

Expand Down
50 changes: 50 additions & 0 deletions src/frequenz/client/common/_float.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# License: MIT
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH

"""Honest type alias for floating-point values."""

from typing import TypeAlias

FloatInt: TypeAlias = float | int
"""A `float` that may actually be an `int` at runtime.

[PEP 484's numeric tower](https://peps.python.org/pep-0484/#the-numeric-tower)
makes `int` assignable to any `float`-annotated parameter or field, so a plain
`float` annotation is a lie: type checkers (even `mypy --strict`) happily
accept `int` values, but `isinstance(1, float)` is `False` at runtime. That
breaks `match … case float():` arms (an `int` value falls through to
`assert_never()`), calls to `float`-only methods like `hex()`, and any other
code dispatching on the concrete runtime type.

This library instead annotates such values as `FloatInt`, making the
heterogeneity explicit: type checkers will push code reading these values to
handle both branches, typically by matching with `case float() | int():`. See
[issue #250](https://github.com/frequenz-floss/frequenz-client-common-python/issues/250)
for the full analysis and the alternatives that were rejected.

Danger:
`bool` is a subclass of `int`, so `True` and `False` also satisfy this
alias. This is inherent to Python's type system and not guarded against.

Example:
```python
from typing import assert_never

from frequenz.client.common import FloatInt


def describe(value: FloatInt | None) -> str:
match value:
case float() | int():
return f"number {value}"
case None:
return "nothing"
case unexpected:
assert_never(unexpected)


assert describe(1) == "number 1"
assert describe(1.5) == "number 1.5"
assert describe(None) == "nothing"
```
"""
2 changes: 1 addition & 1 deletion src/frequenz/client/common/grid/_delivery_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ def __init__(
"""The invalid delivery area instance that caused this error."""

message = (
f"invalid delivery area {delivery_area!r} for attribute {attr_name!r} in {instance}"
f"invalid delivery area {delivery_area} for attribute {attr_name!r} in {instance}"
if message is None
else message
)
Expand Down
13 changes: 12 additions & 1 deletion src/frequenz/client/common/metrics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@

"""Metrics definitions."""

from ._bounds import BaseBounds, Bounds, InvalidBounds, InvalidBoundsError
from ._bounds import (
BaseBounds,
Bounds,
BoundsSet,
InvalidBounds,
InvalidBoundsError,
InvalidBoundsSet,
InvalidBoundsSetError,
)
from ._metric import Metric
from ._sample import (
AggregatedMetricValue,
Expand All @@ -18,8 +26,11 @@
"AggregationMethod",
"BaseBounds",
"Bounds",
"BoundsSet",
"InvalidBounds",
"InvalidBoundsError",
"InvalidBoundsSet",
"InvalidBoundsSetError",
"Metric",
"MetricConnection",
"MetricConnectionCategory",
Expand Down
Loading