From f9b8d8a020c1decb677225d292f19c0d29f01cc4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 9 Nov 2026 17:40:47 +0100 Subject: [PATCH 1/7] feat(FRBC): plan the declared leakage as part of the usage S2 states leakage as a rate per fill-level range. It was planned as a storage efficiency, which FlexMeasures takes as a share of the stock, so the storage leaked in proportion to how full it was, exact at one fill level only. The leakage rate of the range the storage starts in is now added to the usage, over the whole window, and sent with the schedule request. With a usage correction, the correction covers usage and leakage together, so an empty storage's leakage can be taken away like any other forced usage. The leakage sensor records that rate, in the fill-rate unit, and the site sensor is now called "leakage". It needs FlexMeasures v0.23.0, which takes a usage with the request; an older server is planned without leakage. As asked in the review of #283. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW Signed-off-by: F.N. Claessen --- CHANGELOG-S2.rst | 6 +- .../s2/control_types/FRBC/frbc_simple.py | 169 +++++++++--------- .../s2/script/websockets_server.py | 2 +- src/flexmeasures_client/s2/site.py | 5 +- tests/s2/test_example_end_to_end.py | 27 +-- tests/s2/test_frbc_declared_profiles.py | 45 ++--- tests/s2/test_frbc_element_selection.py | 84 ++------- tests/s2/test_frbc_soft_band.py | 12 +- tests/s2/test_frbc_usage_correction.py | 152 +++++++++++++--- 9 files changed, 288 insertions(+), 214 deletions(-) diff --git a/CHANGELOG-S2.rst b/CHANGELOG-S2.rst index bc69f54d..d7489317 100644 --- a/CHANGELOG-S2.rst +++ b/CHANGELOG-S2.rst @@ -20,11 +20,11 @@ Added - A fill-level target profile is recorded on the minima and maxima sensors, if both are given. It is recorded on the handler's ``resolution`` steps, starting from the step its start falls in. It is kept inside the declared fill-level range: a target past either end, such as a night setback below the range, is cut back to the range, and a target band wholly outside the range is recorded at its nearest end, with a warning. If one of the two bounds cannot be recorded, this is logged and the other is still recorded. - A usage forecast is recorded in ``/s``. A negative expected usage, which S2 allows, is recorded as stated. Without a usage correction (see below), a schedule request carries the latest forecast without its negative values, since FlexMeasures would plan them as a gain the storage cannot refuse. A FlexMeasures server before v0.23.0 only plans with what is recorded, so there a negative expected usage is recorded as zero, with a warning [`#286 `__]. - - A leakage behaviour is recorded as a storage efficiency, each time a schedule is requested (see Changed). + - A leakage behaviour is recorded as the leakage rate it states, in ``/s``, each time a schedule is requested (see Changed) [`#PRNUM `__]. [`#244 `__, `#259 `__, `#271 `__, `#274 `__] * ``FRBCSimple`` takes an ``energy_unit`` (default ``kWh``). The state of charge, the fill-level band, the fill rate and the usage forecast are recorded in this unit (the last two per second). Allowed units are ``J``, ``kJ``, ``MJ``, ``Wh``, ``kWh`` and ``MWh``; any other unit is refused when the handler is created. In a flex model, the state of charge is stated in ``kWh`` or ``MWh``, converted from the other units, because FlexMeasures accepts no others [`#244 `__, `#260 `__] -* ``FRBCSimple`` takes a ``fill_level_scale`` (default 1). Every fill level and fill rate sent to FlexMeasures is multiplied by it: the state of charge, the flex model, the fill-level band, the fill rate, the usage forecast and the storage efficiency. Translating a schedule into instructions keeps the resource manager's own units, because it matches fill levels against the operation modes, which are stated in those units. A scale of zero or below is refused. ``FRBCSimple`` also takes a ``power_unit`` (default ``W``), which an actuator's power is converted into and posted in [`#236 `__, `#244 `__, `#245 `__, `#252 `__] +* ``FRBCSimple`` takes a ``fill_level_scale`` (default 1). Every fill level and fill rate sent to FlexMeasures is multiplied by it: the state of charge, the flex model, the fill-level band, the fill rate, the usage forecast and the leakage. Translating a schedule into instructions keeps the resource manager's own units, because it matches fill levels against the operation modes, which are stated in those units. A scale of zero or below is refused. ``FRBCSimple`` also takes a ``power_unit`` (default ``W``), which an actuator's power is converted into and posted in [`#236 `__, `#244 `__, `#245 `__, `#252 `__] * ``FRBCSimple`` takes a ``hard_bound_factor`` (default 1.5). It sets how far above the declared fill-level range a schedule's hard upper bound lies. If a schedule starts from a state of charge above that bound, the bound is raised to include it. A factor below 1 is refused: FlexMeasures clips the band to the hard bounds, so a hard upper bound inside the declared range would narrow the band to something the resource manager never asked for [`#245 `__] * ``FRBCSimple`` can take the actuator's charging efficiency into account. Give it the new ``charging_efficiency_sensor_id``: it records there what the operation modes say about converting drawn power into stored energy, and requests a schedule with it as ``charging-efficiency``. For a heat pump, this ratio is its coefficient of performance. Without it, the scheduler assumes power converts into stored energy one to one, and plans to reach fill-level targets at a power the actuator cannot deliver. @@ -93,7 +93,7 @@ Changed - The hard bounds ``soc-min`` and ``soc-max`` are now zero and ``hard_bound_factor`` times the top of the range, raised to include a state of charge above it. In v0.9.0, the declared range itself was the hard bound. So a storage that really was outside it, for example floating above the top or left below the bottom, made the problem infeasible: there was no schedule at all, or one from a fallback scheduler. A storage reported below zero is planned from zero, with a warning, because FlexMeasures refuses a negative ``soc-min``. - FlexMeasures older than v0.26.0 cannot relax the band. Then the band is left out, and this is logged, so the storage is planned anywhere between the wide hard bounds. - The usage forecast is sent as ``soc-usage`` if its sensor is given and the resource manager provides a forecast (or has sent one anyway). - - The leakage behaviour is recorded as a ``storage-efficiency`` with each schedule request, over the window of the schedule. It uses the leakage at the starting fill level, as a share of that fill level (or of the middle of the range, if the storage starts below that). It is only named in the flex model if FlexMeasures confirmed storing it. FlexMeasures takes a storage efficiency as a share of the stock, whereas S2 states a rate, so the loss is exact only at that one fill level. + - The leakage behaviour is planned as part of the usage: the leakage rate of the fill-level range the storage starts in is added to ``soc-usage`` over the whole window, and sent with the request. S2 states leakage as a rate, so it is not planned as a ``storage-efficiency``, which FlexMeasures takes as a share of the stock, and which would make the storage leak in proportion to how full it is. A flat rate keeps draining an empty storage, as any forced usage does; with a usage correction, the plan can take that away. It needs FlexMeasures v0.23.0 or newer, which takes a usage with the request; an older server is planned without leakage, and this is logged [`#PRNUM `__]. [`#244 `__, `#245 `__, `#259 `__, `#271 `__, `#273 `__] * ``FRBCSimple`` posts the state of charge in its ``energy_unit`` (``kWh`` by default), and sets the flex model's ``soc-unit`` to match (to ``kWh`` if ``energy_unit`` is neither ``kWh`` nor ``MWh``). In v0.9.0, every state of charge and every schedule's ``soc-unit`` was labelled ``MWh``, whatever unit the resource manager reported in. On the example server's ``kWh`` sensor, a fill level of 10 was recorded as 10,000. A resource manager that reports in megawatt hours now needs ``energy_unit="MWh"`` [`#244 `__] diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index fff1cc88..fc330551 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -45,7 +45,6 @@ operation_mode_conversion_efficiency, ) from flexmeasures_client.s2.control_types.translations import ( - leakage_behaviour_to_storage_efficiency, translate_fill_level_target_profile, translate_usage_forecast_to_fm, ) @@ -111,6 +110,17 @@ def _merged(ranges: list[tuple[float, float]]) -> list[tuple[float, float]]: return merged +def _added_up(*usages: pd.Series | None) -> pd.Series | None: + """Return the sum of the usages given, per step, counting a step a usage does not cover as zero, or None if none is given.""" + given = [usage for usage in usages if usage is not None] + if not given: + return None + total = given[0] + for usage in given[1:]: + total = total.add(usage, fill_value=0) + return total + + def _without_negative_usage(usage_forecast): """Return a usage forecast in which each negative expected rate is replaced by zero. @@ -411,9 +421,9 @@ def __init__( soc_minima_sensor_id: int | None = None, soc_maxima_sensor_id: int | None = None, usage_forecast_sensor_id: int | None = None, - # The resolution of this sensor must equal the handler's resolution. - # A storage efficiency is what remains after one step, and this handler computes it for a step of its own resolution. - # FlexMeasures reads it as one step of the sensor's resolution. + # Also optional: a sensor to record the leakage rate on, in the fill-rate unit. + # With it, and FlexMeasures v0.23.0 or newer, the leakage the resource manager declares is planned as part of the usage. + # Without it, the storage is planned as if it lost nothing by itself. leakage_behaviour_sensor_id: int | None = None, # Also optional. # Without a sensor to record it on, no charging efficiency is declared. @@ -481,13 +491,13 @@ def __init__( f"soc_breach_price_factor has to be greater than zero, but {soc_breach_price_factor} was given." ) self._soc_breach_price_factor = soc_breach_price_factor - #: The charging and storage efficiencies that the last plan was asked for with, which the correction price depends on. + #: The charging efficiency that the last plan was asked for with, which the correction price depends on. self._planned_charging_efficiency: float | None = None - self._planned_storage_efficiency: float | None = None #: The currency of each price sensor, read once, since it is fixed when the sensor is set up. self._price_currency: dict[int, str] = {} self._charging_efficiency_recorded = None - self._storage_efficiency_recorded: tuple[datetime, float] | None = None + #: The window and rate of the leakage last recorded, so that the same one is not recorded again. + self._leakage_recorded: tuple[datetime, float] | None = None if resolution <= timedelta(0) or schedule_duration <= timedelta(0): raise ValueError( f"resolution and schedule_duration have to be positive, but {resolution} and {schedule_duration} were given." @@ -1379,7 +1389,7 @@ async def _plan( # FlexMeasures only accepts a state of charge in kWh or MWh. # So a fill level recorded in another energy unit is converted here, at the edge, instead of being refused. # The bounds used for translating the schedule below stay in the recorded unit. - # The usage and the leakage are read from sensors, which FlexMeasures converts itself. + # The usage and the leakage are given in fill-rate units, which FlexMeasures converts itself. # The band is converted here. soc_unit, to_soc_unit = self._soc_unit() flex_model: dict = { @@ -1417,10 +1427,8 @@ async def _plan( self._logger.info( "Planning without usage: the resource manager says it provides no usage forecast, and has sent none." ) - if await self._record_storage_efficiency(start, fill_level_at_start): - flex_model["storage-efficiency"] = { - "sensor": self._leakage_behaviour_sensor_id - } + # What the storage loses by itself, planned as part of the usage (see _with_usage_correction). + leakage = await self._planned_leakage(start, fill_level_at_start) # What the operation modes say about converting drawn power into stored energy. # Recorded first, since FlexMeasures reads it from the sensor named here and not from the flex model, # and named only where it was recorded. @@ -1485,7 +1493,9 @@ async def _plan( # Plan a correction of the usage forecast where one is configured and the server supports it. # Then an expected usage or gain that no plan can absorb is corrected towards zero, instead of making every plan infeasible. - corrected = await self._with_usage_correction(start, flex_model, flex_context) + corrected = await self._with_usage_correction( + start, flex_model, flex_context, leakage + ) request_model: dict | list[dict] = flex_model if corrected is not None: request_model, flex_context = corrected @@ -1799,7 +1809,11 @@ async def _bound_stored_prices( return total, None async def _with_usage_correction( - self, start: datetime, flex_model: dict, flex_context: dict + self, + start: datetime, + flex_model: dict, + flex_context: dict, + leakage: pd.Series | None = None, ) -> tuple[list[dict], dict] | None: """Return the request for a schedule with a correction of the usage forecast, or None to plan without one. @@ -1821,21 +1835,26 @@ async def _with_usage_correction( Where a correction is configured but cannot be planned, the forecast is sent without its negative values. Without a correction, it is sent the same way, from a server that takes a usage with the request on; an older server plans with what is recorded, which is then recorded without its negative values. + + The leakage, if planned (see _planned_leakage), is added to the forecast before any of this. + So the correction can also take away leakage that an empty storage cannot give. """ - if "soc-usage" not in flex_model: - return None + forecast_named = "soc-usage" in flex_model corrects = await self._corrects_usage() if not corrects and not await self._plans_usage_inline(): # This server plans with what is recorded, and no negative usage is recorded for it. + # No leakage is planned for it either (see _planned_leakage). return None - usage = self._aligned_usage() - if usage is None: + forecast = self._aligned_usage() if forecast_named else None + if forecast_named and forecast is None: # The sensor may hold negative usage recorded as stated, from before this handler started, # which a plan without a correction cannot absorb. A plan without usage can. self._logger.info( - "Planning without usage: no FRBC.UsageForecast has arrived since this handler started." + "Planning without a usage forecast: no FRBC.UsageForecast has arrived since this handler started." ) - del flex_model["soc-usage"] + usage = _added_up(forecast, leakage) + if usage is None: + flex_model.pop("soc-usage", None) return None if not corrects: if (usage < 0).any(): @@ -1891,16 +1910,12 @@ async def _with_usage_correction( if "charging-efficiency" in flex_model and self._planned_charging_efficiency else 1.0 ) - storage_efficiency = ( - self._planned_storage_efficiency - if "storage-efficiency" in flex_model and self._planned_storage_efficiency - else 1.0 - ) correction_price = _usage_correction_price( max_abs_price=max_abs_price, soc_breach_price=soc_breach_price, charging_efficiency=charging_efficiency, - storage_efficiency=storage_efficiency, + # The leakage is planned as a usage, which does not grow with the stock, so holding stock loses nothing extra. + storage_efficiency=1.0, steps=steps, step_hours=step_hours, other_prices_per_mw=other_prices_per_mw, @@ -1917,7 +1932,6 @@ async def _with_usage_correction( "soc-minima", "soc-maxima", "soc-usage", - "storage-efficiency", ) state_of_charge = {"sensor": self._soc_sensor_id} stock: dict = {"state-of-charge": state_of_charge} @@ -2051,62 +2065,55 @@ async def send_leakage_behaviour(self, leakage_behaviour: FRBCLeakageBehaviour): "Not recording an FRBC.LeakageBehaviour: no leakage-behaviour sensor is configured." ) - async def _record_storage_efficiency( + async def _planned_leakage( self, start: datetime, fill_level: float - ) -> bool: - """Record the share of its stock that the storage keeps per step, over the window a schedule covers, from the schedule's start. + ) -> pd.Series | None: + """Return the leakage to plan with, per step of the window a schedule covers, in the fill-rate unit, or None to plan without. + + S2 states leakage as a rate, per fill-level range, so it is planned as a usage, rather than as a share of the stock the storage keeps. + A share would make the storage leak in proportion to how full it is, which S2 does not say. + The rate is that of the range the schedule starts in, constant over the window, since the plan moves the fill level. + A flat rate keeps draining an empty storage, as any forced usage does, which a usage correction can take away. - :returns: whether the schedule may be planned against the sensor. It may not, if nothing is recorded on the sensor. + The rate is also recorded on the leakage sensor, over the same window, so that it can be seen next to the schedule. """ leakage_behaviour = self._latest(self._leakage_behaviour_history) - if ( - self._leakage_behaviour_sensor_id is None - or leakage_behaviour is None - or not self._system_description_history - ): - return False - # S2 gives leakage as a rate, but FlexMeasures takes a share of the stock. - # So one share matches the rate at only one fill level; at other fill levels, the loss scales with the stock. - # That fill level is where the schedule starts, as long as that is at least the middle of the storage's range. - # From a near-empty start, a share of the start level would let almost nothing remain per step. - # The loss would then be overstated many times over, as soon as the plan filled the storage. - # Below the middle, the loss is understated instead, by at most the ratio of the middle to the stock. - # That is the milder error. - storage_range = list(self._system_description_history.values())[ - -1 - ].storage.fill_level_range - middle = (storage_range.start_of_range + storage_range.end_of_range) / 2 - # The share that remains after one step of this handler's resolution. - # FlexMeasures reads it as one step of the leakage sensor's resolution, so the two resolutions must be equal. - efficiency = leakage_behaviour_to_storage_efficiency( - message=leakage_behaviour, - resolution=self._resolution, - fill_level_scale=self._fill_level_scale, - fill_level=fill_level, - share_of=max(fill_level, middle), - ) - # A percentage, as FlexMeasures takes it; the correction price takes the share. - self._planned_storage_efficiency = efficiency / 100 - if self._storage_efficiency_recorded == (start, efficiency): - return True - try: - await self._fm_client.post_sensor_data( - sensor_id=self._leakage_behaviour_sensor_id, - start=start, - prior=self.now(), - values=[efficiency], - unit="%", - duration=self._schedule_duration, - # As for the charging efficiency: the value is remembered below as recorded, so it must really be recorded. - confirm_ingestion=True, - ) - except REQUEST_FAILURES: - self._logger.exception( - "Not asking for a schedule against a storage efficiency: could not record %s %% on sensor %s for %s.", - efficiency, - self._leakage_behaviour_sensor_id, - start, + if self._leakage_behaviour_sensor_id is None or leakage_behaviour is None: + return None + if not await self._plans_usage_inline(): + # Only a usage given with the request can carry the leakage. + self._logger.info( + "Planning without leakage: FlexMeasures takes a usage with the request from v%s, and this server reports %s.", + INLINE_USAGE_SINCE, + self._fm_client.server_version, ) - return False - self._storage_efficiency_recorded = (start, efficiency) - return True + return None + element = element_for_fill_level(leakage_behaviour.elements, fill_level) + rate = element.leakage_rate * self._fill_level_scale + steps = int(self._schedule_duration / self._resolution) + leakage = pd.Series( + float(rate), + index=pd.date_range(start, periods=steps, freq=self._resolution), + ) + if self._leakage_recorded != (start, rate): + try: + await self._fm_client.post_sensor_data( + sensor_id=self._leakage_behaviour_sensor_id, + start=start, + prior=self.now(), + values=[rate], + unit=self.fill_rate_unit(), + duration=self._schedule_duration, + ) + except REQUEST_FAILURES: + # The leakage goes with the request, so the plan does not depend on it being recorded. + self._logger.exception( + "Could not record the leakage rate %s %s on sensor %s for %s; planning with it all the same.", + rate, + self.fill_rate_unit(), + self._leakage_behaviour_sensor_id, + start, + ) + else: + self._leakage_recorded = (start, rate) + return leakage diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 577f04c2..7a39d403 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -211,7 +211,7 @@ def fixed_site_handler(sensors: dict[str, dict]) -> FRBCSimple: soc_minima_sensor_id=sensors["soc minima"]["id"], soc_maxima_sensor_id=sensors["soc maxima"]["id"], usage_forecast_sensor_id=sensors["usage forecast"]["id"], - leakage_behaviour_sensor_id=sensors["storage efficiency"]["id"], + leakage_behaviour_sensor_id=sensors["leakage"]["id"], charging_efficiency_sensor_id=sensors["charging efficiency"]["id"], # Only where S2_CORRECT_USAGE asked for a correction (see SITE_SENSORS). usage_correction_sensor_id=sensors.get("usage correction", {}).get("id"), diff --git a/src/flexmeasures_client/s2/site.py b/src/flexmeasures_client/s2/site.py index cab0673e..0893d0a7 100644 --- a/src/flexmeasures_client/s2/site.py +++ b/src/flexmeasures_client/s2/site.py @@ -57,7 +57,8 @@ class SiteSensor: "soc minima": SiteSensor("kWh", "PT15M"), "soc maxima": SiteSensor("kWh", "PT15M"), "usage forecast": SiteSensor("kWh/s", "PT15M"), - "storage efficiency": SiteSensor("%", "PT15M"), + # The leakage rate the resource manager declares, as it is planned with: per second, like the usage forecast. + "leakage": SiteSensor("kWh/s", "PT15M"), "charging efficiency": SiteSensor("dimensionless", "PT15M"), } @@ -274,7 +275,7 @@ def sensor_id(sensor_name: str) -> int | None: soc_minima_sensor_id=sensor_id("soc minima"), soc_maxima_sensor_id=sensor_id("soc maxima"), usage_forecast_sensor_id=sensor_id("usage forecast"), - leakage_behaviour_sensor_id=sensor_id("storage efficiency"), + leakage_behaviour_sensor_id=sensor_id("leakage"), charging_efficiency_sensor_id=sensor_id("charging efficiency"), usage_correction_sensor_id=sensor_id("usage correction"), timezone=self._timezone, diff --git a/tests/s2/test_example_end_to_end.py b/tests/s2/test_example_end_to_end.py index 12a64aa3..1106d907 100644 --- a/tests/s2/test_example_end_to_end.py +++ b/tests/s2/test_example_end_to_end.py @@ -178,7 +178,7 @@ async def test_the_shipped_resource_manager_is_planned_for_and_instructed(): "soc minima", "soc maxima", "usage forecast", - "storage efficiency", + "leakage", "charging efficiency", ): assert sensors[name]["id"] in posted_to, name @@ -538,7 +538,7 @@ def test_the_site_sensors_are_what_the_cem_records_in(): server.SITE_SENSORS["state of charge"].unit, server.SITE_SENSORS["state of charge"].resolution, ) == ("kWh", "PT0M") - assert server.SITE_SENSORS["storage efficiency"].unit == "%" + assert server.SITE_SENSORS["leakage"].unit == "kWh/s" assert server.SITE_SENSORS["usage forecast"].unit == "kWh/s" @@ -561,18 +561,21 @@ async def test_the_demo_prices_are_seeded_again_while_the_server_runs(): assert fm.post_sensor_data.await_args.kwargs["sensor_id"] == 10 -def test_the_loss_is_a_share_of_the_fill_level_a_schedule_starts_from(): - """1.5 kWh a day from a 5 kWh buffer is a larger share of it than of a 12 kWh one.""" - from flexmeasures_client.s2.control_types.translations import ( - leakage_behaviour_to_storage_efficiency, +@pytest.mark.asyncio +async def test_the_loss_is_planned_at_the_rate_of_the_range_a_schedule_starts_in(): + """1.5 kWh a day once the buffer is warm, whatever its fill level within that range.""" + frbc = server.fixed_site_handler( + {name: {"id": i} for i, name in enumerate(server.SITE_SENSORS, start=1)} ) - + frbc._fm_client = AsyncMock() + frbc._fm_client.server_version_at_least = AsyncMock(return_value=True) leakage = leakage_behaviour(NOON) - at_five = leakage_behaviour_to_storage_efficiency( - leakage, resolution=timedelta(minutes=15), fill_level=7.0, share_of=5.0 - ) - rate = per_second(1.5 / 24) # the warm element, which applies at 7 - assert at_five == pytest.approx(100 * (1 - rate / 5.0) ** 900, rel=1e-12) + frbc._leakage_behaviour_history[str(leakage.message_id)] = leakage + + planned = await frbc._planned_leakage(NOON, 7.0) + + assert list(planned) == pytest.approx([per_second(1.5 / 24)] * len(planned)) + assert len(planned) > 0 @pytest.mark.asyncio diff --git a/tests/s2/test_frbc_declared_profiles.py b/tests/s2/test_frbc_declared_profiles.py index a1489358..d11492b7 100644 --- a/tests/s2/test_frbc_declared_profiles.py +++ b/tests/s2/test_frbc_declared_profiles.py @@ -40,7 +40,6 @@ ) from flexmeasures_client.client import FlexMeasuresClient -from flexmeasures_client.exceptions import JobTimeoutError from flexmeasures_client.s2.control_types.FRBC.frbc_simple import FRBCSimple from flexmeasures_client.s2.control_types.FRBC.utils import ( clip_fill_level_target_profile, @@ -264,7 +263,7 @@ async def test_usage_forecast_is_recorded_from_a_resolution_boundary( @pytest.mark.asyncio -async def test_leakage_behaviour_is_recorded_as_a_storage_efficiency( +async def test_leakage_behaviour_is_recorded_as_a_rate( system_description, ): """Recorded when a schedule is asked for, over the window it covers, rather than on arrival.""" @@ -291,11 +290,15 @@ async def test_leakage_behaviour_is_recorded_as_a_storage_efficiency( ] # nothing on arrival start = datetime(2024, 1, 1, 12, 15, tzinfo=timezone.utc) - assert await frbc._record_storage_efficiency(start, (RANGE_BOTTOM + RANGE_TOP) / 2) + planned = await frbc._planned_leakage(start, (RANGE_BOTTOM + RANGE_TOP) / 2) + # Planned as a usage over the window, at the rate S2 states, rather than as a share of the stock. + assert planned.index[0] == start + assert len(planned) == frbc._schedule_duration / frbc._resolution + assert set(planned) == {0.01} posted = _posted(frbc, LEAKAGE_SENSOR_ID) - assert posted["unit"] == "%" - assert len(posted["values"]) == 1 + assert posted["unit"] == frbc.fill_rate_unit() + assert posted["values"] == [0.01] assert posted["start"] == start assert posted["duration"] == frbc._schedule_duration @@ -617,7 +620,7 @@ async def test_an_actuator_s_fill_rate_is_posted_in_the_fill_rate_unit( @pytest.mark.asyncio -async def test_a_storage_efficiency_already_recorded_for_the_window_is_not_recorded_again( +async def test_a_leakage_already_recorded_for_the_window_is_not_recorded_again( system_description, ): frbc = _frbc(system_description, with_sensors=True) @@ -635,8 +638,8 @@ async def test_a_storage_efficiency_already_recorded_for_the_window_is_not_recor ) frbc._leakage_behaviour_history[str(leakage.message_id)] = leakage start = datetime(2024, 1, 1, 12, 15, tzinfo=timezone.utc) - assert await frbc._record_storage_efficiency(start, 50.0) - assert await frbc._record_storage_efficiency(start, 50.0) + assert await frbc._planned_leakage(start, 50.0) is not None + assert await frbc._planned_leakage(start, 50.0) is not None posts = [ c for c in frbc._fm_client.post_sensor_data.await_args_list @@ -646,10 +649,10 @@ async def test_a_storage_efficiency_already_recorded_for_the_window_is_not_recor @pytest.mark.asyncio -async def test_a_storage_efficiency_that_could_not_be_recorded_is_not_planned_against( +async def test_a_leakage_that_could_not_be_recorded_is_planned_all_the_same( system_description, caplog ): - """And it is tried again next time, rather than remembered as recorded.""" + """It goes with the request, so the plan does not need it recorded; it is tried again next time, rather than remembered as recorded.""" frbc = _frbc(system_description, with_sensors=True) leakage = FRBCLeakageBehaviour( message_id=get_unique_id(), @@ -666,15 +669,15 @@ async def test_a_storage_efficiency_that_could_not_be_recorded_is_not_planned_ag frbc._leakage_behaviour_history[str(leakage.message_id)] = leakage frbc._fm_client.post_sensor_data.side_effect = ConnectionError("unreachable") start = datetime(2024, 1, 1, 12, 15, tzinfo=timezone.utc) - assert await frbc._record_storage_efficiency(start, 50.0) is False - assert frbc._storage_efficiency_recorded is None - assert "could not record" in caplog.text + assert set(await frbc._planned_leakage(start, 50.0)) == {0.01} + assert frbc._leakage_recorded is None + assert "could not record" in caplog.text.lower() @pytest.mark.asyncio -async def test_a_storage_efficiency_whose_ingestion_is_unconfirmed_is_not_remembered( - system_description, -): +async def test_an_older_server_is_planned_without_leakage(system_description, caplog): + """Before FlexMeasures v0.23.0, a usage only comes from a sensor, so the leakage cannot go with the request.""" + caplog.set_level(logging.INFO) frbc = _frbc(system_description, with_sensors=True) leakage = FRBCLeakageBehaviour( message_id=get_unique_id(), @@ -689,13 +692,11 @@ async def test_a_storage_efficiency_whose_ingestion_is_unconfirmed_is_not_rememb ], ) frbc._leakage_behaviour_history[str(leakage.message_id)] = leakage - frbc._fm_client.post_sensor_data.side_effect = JobTimeoutError("still pending") + frbc._fm_client.server_version_at_least = AsyncMock(return_value=False) start = datetime(2024, 1, 1, 12, 15, tzinfo=timezone.utc) - assert await frbc._record_storage_efficiency(start, 50.0) is False - assert frbc._storage_efficiency_recorded is None - assert ( - frbc._fm_client.post_sensor_data.await_args.kwargs["confirm_ingestion"] is True - ) + assert await frbc._planned_leakage(start, 50.0) is None + assert _posted_sensor_ids(frbc) == [] + assert "Planning without leakage" in caplog.text @pytest.mark.asyncio diff --git a/tests/s2/test_frbc_element_selection.py b/tests/s2/test_frbc_element_selection.py index 992df47e..bde6f080 100644 --- a/tests/s2/test_frbc_element_selection.py +++ b/tests/s2/test_frbc_element_selection.py @@ -522,78 +522,30 @@ def test_the_storage_efficiency_at_a_fill_level_is_that_elements(): @pytest.mark.asyncio -async def test_the_handler_records_leakage_at_the_latest_fill_level(): +@pytest.mark.parametrize( + "fill_level, rate", + [ + (20.0, LEAKS_LITTLE.leakage_rate), + (50.0, LEAKS_MOST.leakage_rate), + (70.0, LEAKS_SOME.leakage_rate), + ], +) +async def test_the_handler_plans_and_records_the_leakage_of_the_range_it_starts_in( + fill_level, rate +): + """S2 states a rate per fill-level range, and the plan starts in one of them; not the leakiest, nor the first listed.""" leakage = _leakage(LEAKS_LITTLE, LEAKS_MOST, LEAKS_SOME) frbc = _frbc(_system_description(_mode(COLD, WARM)), leakage_behaviour_sensor_id=9) frbc._leakage_behaviour_history[str(leakage.message_id)] = leakage - await frbc._record_storage_efficiency( - datetime(2024, 1, 2, tzinfo=timezone.utc), 20.0 - ) - posted = [ - call.kwargs["values"] - for call in frbc._fm_client.post_sensor_data.await_args_list - if call.kwargs.get("sensor_id") == 9 - ] - assert posted == [ - [ - leakage_behaviour_to_storage_efficiency( - leakage, - resolution=timedelta(minutes=15), - fill_level=20.0, - share_of=max(20.0, _middle_of_the_range(frbc)), - ) - ] - ] - -def _middle_of_the_range(frbc) -> float: - storage_range = list(frbc._system_description_history.values())[ - -1 - ].storage.fill_level_range - return (storage_range.start_of_range + storage_range.end_of_range) / 2 - - -@pytest.mark.asyncio -async def test_a_near_empty_start_takes_the_loss_as_a_share_of_the_middle_of_the_range(): - """A share of a near-empty start would leave almost nothing per step, and overstate the loss once the plan fills up.""" - leakage = _leakage(LEAKS_LITTLE, LEAKS_MOST, LEAKS_SOME) - frbc = _frbc(_system_description(_mode(COLD, WARM)), leakage_behaviour_sensor_id=9) - frbc._leakage_behaviour_history[str(leakage.message_id)] = leakage - start = datetime(2024, 1, 2, tzinfo=timezone.utc) - await frbc._record_storage_efficiency(start, 0.01) - (posted,) = [ - call.kwargs["values"][0] - for call in frbc._fm_client.post_sensor_data.await_args_list - if call.kwargs.get("sensor_id") == 9 - ] - middle = _middle_of_the_range(frbc) - assert middle > 0.01 - assert posted == leakage_behaviour_to_storage_efficiency( - leakage, - resolution=timedelta(minutes=15), - fill_level=0.01, - share_of=middle, + planned = await frbc._planned_leakage( + datetime(2024, 1, 2, tzinfo=timezone.utc), fill_level ) - assert posted > 99 - -@pytest.mark.asyncio -async def test_a_start_above_the_middle_takes_the_loss_as_a_share_of_the_start(): - leakage = _leakage(LEAKS_LITTLE, LEAKS_MOST, LEAKS_SOME) - frbc = _frbc(_system_description(_mode(COLD, WARM)), leakage_behaviour_sensor_id=9) - frbc._leakage_behaviour_history[str(leakage.message_id)] = leakage - storage_range = list(frbc._system_description_history.values())[ - -1 - ].storage.fill_level_range - high = (storage_range.end_of_range + _middle_of_the_range(frbc)) / 2 - await frbc._record_storage_efficiency( - datetime(2024, 1, 2, tzinfo=timezone.utc), high - ) - (posted,) = [ - call.kwargs["values"][0] + assert set(planned) == {rate} + posted = [ + call.kwargs["values"] for call in frbc._fm_client.post_sensor_data.await_args_list if call.kwargs.get("sensor_id") == 9 ] - assert posted == leakage_behaviour_to_storage_efficiency( - leakage, resolution=timedelta(minutes=15), fill_level=high, share_of=high - ) + assert posted == [[rate]] diff --git a/tests/s2/test_frbc_soft_band.py b/tests/s2/test_frbc_soft_band.py index 37436b33..391c8215 100644 --- a/tests/s2/test_frbc_soft_band.py +++ b/tests/s2/test_frbc_soft_band.py @@ -206,7 +206,7 @@ async def test_the_usage_and_the_leakage_a_resource_manager_declares_are_planned frbc._leakage_behaviour_history[str(leakage.message_id)] = leakage frbc._usage_forecast_history["f"] = FRBCUsageForecast( message_id=get_unique_id(), - start_time="2024-01-01T00:00:00+00:00", + start_time=frbc.schedule_start(system_description.valid_from), elements=[ FRBCUsageForecastElement( duration=Duration.from_timedelta(timedelta(minutes=15)), @@ -217,9 +217,13 @@ async def test_the_usage_and_the_leakage_a_resource_manager_declares_are_planned flex_model, _ = await _asked_for(frbc, system_description) - # The forecast goes with the request. - assert [step["value"] for step in flex_model["soc-usage"][0]] == ["0.01 kWh/s"] - assert flex_model["storage-efficiency"] == {"sensor": LEAKAGE_SENSOR_ID} + # The forecast goes with the request, with the leakage added to it as a rate over the whole window, + # rather than as a share of the stock the storage keeps. + planned = [float(step["value"].split()[0]) for step in flex_model["soc-usage"][0]] + assert planned[0] == pytest.approx(0.01 + 0.001) + assert planned[1:] == pytest.approx([0.001] * (len(planned) - 1)) + assert len(planned) == frbc._schedule_duration / frbc._resolution + assert "storage-efficiency" not in flex_model @pytest.mark.asyncio diff --git a/tests/s2/test_frbc_usage_correction.py b/tests/s2/test_frbc_usage_correction.py index ab168a80..823a3fa0 100644 --- a/tests/s2/test_frbc_usage_correction.py +++ b/tests/s2/test_frbc_usage_correction.py @@ -465,24 +465,26 @@ def test_a_breach_price_factor_of_zero_is_refused(system_description): @pytest.mark.asyncio -async def test_the_correction_price_counts_the_efficiencies_the_schedule_is_asked_with( +async def test_the_correction_price_counts_the_charging_efficiency_and_not_the_leakage( system_description, ): - """Buying a unit of stock through a lossy actuator, or holding it while it leaks, makes it worth more.""" + """Buying a unit of stock through a lossy actuator makes it worth more. + + Leakage planned as a usage drains the same whatever the stock, so holding stock costs nothing extra. + """ frbc = _correcting( system_description, charging_efficiency_sensor_id=30, leakage_behaviour_sensor_id=31, ) frbc._usage_forecast_history["f"] = _forecast(0.01) + frbc._leakage_behaviour_history["l"] = _leakage(0.001) frbc._record_charging_efficiency = AsyncMock(return_value=True) - frbc._record_storage_efficiency = AsyncMock(return_value=True) frbc._planned_charging_efficiency = 0.8 - frbc._planned_storage_efficiency = 0.99 _, flex_context = await _request(frbc, system_description) - expected = _usage_correction_price(450.0, 4500.0, 0.8, 0.99, 48, 0.25) + expected = _usage_correction_price(450.0, 4500.0, 0.8, 1.0, 48, 0.25) assert flex_context["commodities"][1]["consumption-price"] == f"{expected} EUR/MWh" @@ -520,39 +522,143 @@ async def get_sensor(sensor_id, **_): @pytest.mark.asyncio -async def test_the_efficiencies_recorded_for_a_schedule_are_the_ones_it_is_priced_with( +async def test_the_charging_efficiency_recorded_for_a_schedule_is_the_one_it_is_priced_with( system_description, ): - """The correction price uses the very values recorded for the window: the charging efficiency, and the storage efficiency as a share.""" - from s2python.frbc import FRBCLeakageBehaviour, FRBCLeakageBehaviourElement + """The correction price uses the very value recorded for the window.""" + frbc = _correcting(system_description, charging_efficiency_sensor_id=30) - frbc = _correcting( - system_description, - charging_efficiency_sensor_id=30, - leakage_behaviour_sensor_id=31, + assert await frbc._record_charging_efficiency( + system_description, START.to_pydatetime(), 30.0 ) - leakage = FRBCLeakageBehaviour( + charging_posted = frbc._fm_client.post_sensor_data.await_args.kwargs["values"][0] + + assert frbc._planned_charging_efficiency == charging_posted + + +def _leakage(rate: float): + from s2python.frbc import FRBCLeakageBehaviour, FRBCLeakageBehaviourElement + + return FRBCLeakageBehaviour( message_id=get_unique_id(), valid_from=START.isoformat(), elements=[ FRBCLeakageBehaviourElement( fill_level_range=NumberRange(start_of_range=0, end_of_range=100), - leakage_rate=0.001, + leakage_rate=rate, ) ], ) - frbc._leakage_behaviour_history[str(leakage.message_id)] = leakage - assert await frbc._record_charging_efficiency( - system_description, START.to_pydatetime(), 30.0 + +def _values(series: list[dict]) -> list[float]: + return [float(step["value"].split()[0]) for step in series] + + +@pytest.mark.asyncio +async def test_the_correction_covers_the_forecast_and_the_leakage_together( + system_description, +): + """An empty storage cannot give its leakage either, so the correction may take that away too.""" + frbc = _correcting(system_description, leakage_behaviour_sensor_id=31) + frbc._usage_forecast_history["f"] = _forecast(0.01, -0.02) + frbc._leakage_behaviour_history["l"] = _leakage(0.001) + + flex_model, _ = await _request(frbc, system_description) + + _, stock, correction = flex_model + usage = _values(stock["soc-usage"][0]) + assert usage[:3] == pytest.approx([0.011, -0.019, 0.001]) + assert len(usage) == 48 # the whole window, beyond the forecast + assert "storage-efficiency" not in stock + # Each step may be moved towards zero by as much as the usage and the leakage there add up to. + up = { + s["start"]: float(s["value"].split()[0]) + for s in correction["consumption-capacity"] + } + down = { + s["start"]: float(s["value"].split()[0]) + for s in correction["production-capacity"] + } + assert up[START.isoformat()] == pytest.approx(0.011) + assert down[(START + STEP).isoformat()] == pytest.approx(0.019) + assert up[(START + 2 * STEP).isoformat()] == pytest.approx(0.001) + + +@pytest.mark.asyncio +async def test_leakage_alone_is_planned_and_corrected_without_a_forecast( + system_description, +): + frbc = _correcting(system_description, leakage_behaviour_sensor_id=31) + frbc._leakage_behaviour_history["l"] = _leakage(0.001) + + flex_model, _ = await _request(frbc, system_description) + + _, stock, correction = flex_model + assert _values(stock["soc-usage"][0]) == pytest.approx([0.001] * 48) + assert correction["sensor"] == CORRECTION_SENSOR_ID + + +@pytest.mark.asyncio +async def test_leakage_is_planned_without_a_usage_forecast_sensor(system_description): + """Leakage is what the storage loses by itself, whether or not a usage forecast is planned with.""" + frbc = _correcting( + system_description, + usage_forecast_sensor_id=None, + leakage_behaviour_sensor_id=31, ) - charging_posted = frbc._fm_client.post_sensor_data.await_args.kwargs["values"][0] - assert await frbc._record_storage_efficiency(START.to_pydatetime(), 30.0) - storage_posted = frbc._fm_client.post_sensor_data.await_args.kwargs["values"][0] + frbc._leakage_behaviour_history["l"] = _leakage(0.001) - assert frbc._planned_charging_efficiency == charging_posted - assert frbc._planned_storage_efficiency == pytest.approx(storage_posted / 100) - assert 0 < frbc._planned_storage_efficiency < 1 + flex_model, _ = await _request(frbc, system_description) + + _, stock, _ = flex_model + assert _values(stock["soc-usage"][0]) == pytest.approx([0.001] * 48) + + +@pytest.mark.asyncio +async def test_the_leakage_is_scaled_like_every_other_fill_rate(system_description): + frbc = _correcting( + system_description, leakage_behaviour_sensor_id=31, fill_level_scale=2 + ) + frbc._leakage_behaviour_history["l"] = _leakage(0.001) + + flex_model, _ = await _request(frbc, system_description) + + _, stock, _ = flex_model + assert _values(stock["soc-usage"][0]) == pytest.approx([0.002] * 48) + + +@pytest.mark.asyncio +async def test_no_leakage_is_planned_without_a_sensor_to_record_it_on( + system_description, +): + """The leakage sensor is what opts a handler in, as it was for the storage efficiency.""" + frbc = _correcting(system_description) + frbc._usage_forecast_history["f"] = _forecast(0.01) + frbc._leakage_behaviour_history["l"] = _leakage(0.001) + + flex_model, _ = await _request(frbc, system_description) + + _, stock, _ = flex_model + assert _values(stock["soc-usage"][0]) == pytest.approx([0.01]) + + +@pytest.mark.asyncio +async def test_without_a_correction_the_forecast_and_the_leakage_are_clipped_together( + system_description, +): + frbc = _frbc(system_description, leakage_behaviour_sensor_id=31) + frbc._fm_client.server_version_at_least = AsyncMock(return_value=True) + # Pin the window, so that it starts where the forecast does. + frbc.schedule_start = lambda valid_from: START.to_pydatetime() + frbc._usage_forecast_history["f"] = _forecast(0.01, -0.02) + frbc._leakage_behaviour_history["l"] = _leakage(0.001) + + flex_model, _ = await _request(frbc, system_description) + + usage = _values(flex_model["soc-usage"][0]) + assert usage[:3] == pytest.approx([0.011, 0.0, 0.001]) + assert "storage-efficiency" not in flex_model @pytest.mark.asyncio From 6cd7f404606b8fa233e037c99ce29f918756e6ba Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 9 Nov 2026 17:41:18 +0100 Subject: [PATCH 2/7] docs(changelog): link #289 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW Signed-off-by: F.N. Claessen --- CHANGELOG-S2.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG-S2.rst b/CHANGELOG-S2.rst index d7489317..2caf33a5 100644 --- a/CHANGELOG-S2.rst +++ b/CHANGELOG-S2.rst @@ -20,7 +20,7 @@ Added - A fill-level target profile is recorded on the minima and maxima sensors, if both are given. It is recorded on the handler's ``resolution`` steps, starting from the step its start falls in. It is kept inside the declared fill-level range: a target past either end, such as a night setback below the range, is cut back to the range, and a target band wholly outside the range is recorded at its nearest end, with a warning. If one of the two bounds cannot be recorded, this is logged and the other is still recorded. - A usage forecast is recorded in ``/s``. A negative expected usage, which S2 allows, is recorded as stated. Without a usage correction (see below), a schedule request carries the latest forecast without its negative values, since FlexMeasures would plan them as a gain the storage cannot refuse. A FlexMeasures server before v0.23.0 only plans with what is recorded, so there a negative expected usage is recorded as zero, with a warning [`#286 `__]. - - A leakage behaviour is recorded as the leakage rate it states, in ``/s``, each time a schedule is requested (see Changed) [`#PRNUM `__]. + - A leakage behaviour is recorded as the leakage rate it states, in ``/s``, each time a schedule is requested (see Changed) [`#289 `__]. [`#244 `__, `#259 `__, `#271 `__, `#274 `__] * ``FRBCSimple`` takes an ``energy_unit`` (default ``kWh``). The state of charge, the fill-level band, the fill rate and the usage forecast are recorded in this unit (the last two per second). Allowed units are ``J``, ``kJ``, ``MJ``, ``Wh``, ``kWh`` and ``MWh``; any other unit is refused when the handler is created. In a flex model, the state of charge is stated in ``kWh`` or ``MWh``, converted from the other units, because FlexMeasures accepts no others [`#244 `__, `#260 `__] @@ -93,7 +93,7 @@ Changed - The hard bounds ``soc-min`` and ``soc-max`` are now zero and ``hard_bound_factor`` times the top of the range, raised to include a state of charge above it. In v0.9.0, the declared range itself was the hard bound. So a storage that really was outside it, for example floating above the top or left below the bottom, made the problem infeasible: there was no schedule at all, or one from a fallback scheduler. A storage reported below zero is planned from zero, with a warning, because FlexMeasures refuses a negative ``soc-min``. - FlexMeasures older than v0.26.0 cannot relax the band. Then the band is left out, and this is logged, so the storage is planned anywhere between the wide hard bounds. - The usage forecast is sent as ``soc-usage`` if its sensor is given and the resource manager provides a forecast (or has sent one anyway). - - The leakage behaviour is planned as part of the usage: the leakage rate of the fill-level range the storage starts in is added to ``soc-usage`` over the whole window, and sent with the request. S2 states leakage as a rate, so it is not planned as a ``storage-efficiency``, which FlexMeasures takes as a share of the stock, and which would make the storage leak in proportion to how full it is. A flat rate keeps draining an empty storage, as any forced usage does; with a usage correction, the plan can take that away. It needs FlexMeasures v0.23.0 or newer, which takes a usage with the request; an older server is planned without leakage, and this is logged [`#PRNUM `__]. + - The leakage behaviour is planned as part of the usage: the leakage rate of the fill-level range the storage starts in is added to ``soc-usage`` over the whole window, and sent with the request. S2 states leakage as a rate, so it is not planned as a ``storage-efficiency``, which FlexMeasures takes as a share of the stock, and which would make the storage leak in proportion to how full it is. A flat rate keeps draining an empty storage, as any forced usage does; with a usage correction, the plan can take that away. It needs FlexMeasures v0.23.0 or newer, which takes a usage with the request; an older server is planned without leakage, and this is logged [`#289 `__]. [`#244 `__, `#245 `__, `#259 `__, `#271 `__, `#273 `__] * ``FRBCSimple`` posts the state of charge in its ``energy_unit`` (``kWh`` by default), and sets the flex model's ``soc-unit`` to match (to ``kWh`` if ``energy_unit`` is neither ``kWh`` nor ``MWh``). In v0.9.0, every state of charge and every schedule's ``soc-unit`` was labelled ``MWh``, whatever unit the resource manager reported in. On the example server's ``kWh`` sensor, a fill level of 10 was recorded as 10,000. A resource manager that reports in megawatt hours now needs ``energy_unit="MWh"`` [`#244 `__] From ca1bfb77093d6ca748a775ba00a21bbe491f3765 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 9 Nov 2026 19:27:49 +0100 Subject: [PATCH 3/7] feat(FRBC): record fill rates, usage and leakage per hour, in kW for kWh S2 states these rates per second, so in kWh/s a heat buffer's leakage of some tens of watts reads about 0.00001. They are now recorded in the power unit that goes with the energy unit (kW for kWh, W for J and Wh, MW for MJ and MWh), and the site's fill rate, usage forecast and leakage sensors are created in kW. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW Signed-off-by: F.N. Claessen --- CHANGELOG-S2.rst | 8 +-- .../s2/control_types/FRBC/frbc_simple.py | 61 +++++++++++-------- .../s2/control_types/FRBC/utils.py | 13 ++++ src/flexmeasures_client/s2/site.py | 10 +-- tests/s2/test_example_end_to_end.py | 10 +-- tests/s2/test_frbc_declared_profiles.py | 36 +++++++---- tests/s2/test_frbc_element_selection.py | 4 +- tests/s2/test_frbc_soft_band.py | 6 +- tests/s2/test_frbc_usage_correction.py | 54 ++++++++-------- 9 files changed, 120 insertions(+), 82 deletions(-) diff --git a/CHANGELOG-S2.rst b/CHANGELOG-S2.rst index 2caf33a5..cef63b82 100644 --- a/CHANGELOG-S2.rst +++ b/CHANGELOG-S2.rst @@ -19,11 +19,11 @@ Added * ``FRBCSimple`` can record what a resource manager declares about its storage, so it can be seen and planned with. Give it any of the new ``soc_minima_sensor_id``, ``soc_maxima_sensor_id``, ``usage_forecast_sensor_id`` and ``leakage_behaviour_sensor_id``; a declaration without a sensor is not recorded. - A fill-level target profile is recorded on the minima and maxima sensors, if both are given. It is recorded on the handler's ``resolution`` steps, starting from the step its start falls in. It is kept inside the declared fill-level range: a target past either end, such as a night setback below the range, is cut back to the range, and a target band wholly outside the range is recorded at its nearest end, with a warning. If one of the two bounds cannot be recorded, this is logged and the other is still recorded. - - A usage forecast is recorded in ``/s``. A negative expected usage, which S2 allows, is recorded as stated. Without a usage correction (see below), a schedule request carries the latest forecast without its negative values, since FlexMeasures would plan them as a gain the storage cannot refuse. A FlexMeasures server before v0.23.0 only plans with what is recorded, so there a negative expected usage is recorded as zero, with a warning [`#286 `__]. - - A leakage behaviour is recorded as the leakage rate it states, in ``/s``, each time a schedule is requested (see Changed) [`#289 `__]. + - A usage forecast is recorded in the fill-rate unit (see ``energy_unit`` below). A negative expected usage, which S2 allows, is recorded as stated. Without a usage correction (see below), a schedule request carries the latest forecast without its negative values, since FlexMeasures would plan them as a gain the storage cannot refuse. A FlexMeasures server before v0.23.0 only plans with what is recorded, so there a negative expected usage is recorded as zero, with a warning [`#286 `__]. + - A leakage behaviour is recorded as the leakage rate it states, in the fill-rate unit, each time a schedule is requested (see Changed) [`#289 `__]. [`#244 `__, `#259 `__, `#271 `__, `#274 `__] -* ``FRBCSimple`` takes an ``energy_unit`` (default ``kWh``). The state of charge, the fill-level band, the fill rate and the usage forecast are recorded in this unit (the last two per second). Allowed units are ``J``, ``kJ``, ``MJ``, ``Wh``, ``kWh`` and ``MWh``; any other unit is refused when the handler is created. In a flex model, the state of charge is stated in ``kWh`` or ``MWh``, converted from the other units, because FlexMeasures accepts no others [`#244 `__, `#260 `__] +* ``FRBCSimple`` takes an ``energy_unit`` (default ``kWh``). The state of charge and the fill-level band are recorded in this unit. Rates of fill level, which S2 states per second, are recorded per hour, in the power unit that goes with it: ``W`` for ``J`` and ``Wh``, ``kW`` for ``kJ`` and ``kWh``, and ``MW`` for ``MJ`` and ``MWh``. These are the fill rate, the usage forecast and the leakage. In ``kWh/s``, a heat buffer's leakage of some tens of watts would read about 0.00001 [`#289 `__]. Allowed units are ``J``, ``kJ``, ``MJ``, ``Wh``, ``kWh`` and ``MWh``; any other unit is refused when the handler is created. In a flex model, the state of charge is stated in ``kWh`` or ``MWh``, converted from the other units, because FlexMeasures accepts no others [`#244 `__, `#260 `__] * ``FRBCSimple`` takes a ``fill_level_scale`` (default 1). Every fill level and fill rate sent to FlexMeasures is multiplied by it: the state of charge, the flex model, the fill-level band, the fill rate, the usage forecast and the leakage. Translating a schedule into instructions keeps the resource manager's own units, because it matches fill levels against the operation modes, which are stated in those units. A scale of zero or below is refused. ``FRBCSimple`` also takes a ``power_unit`` (default ``W``), which an actuator's power is converted into and posted in [`#236 `__, `#244 `__, `#245 `__, `#252 `__] * ``FRBCSimple`` takes a ``hard_bound_factor`` (default 1.5). It sets how far above the declared fill-level range a schedule's hard upper bound lies. If a schedule starts from a state of charge above that bound, the bound is raised to include it. A factor below 1 is refused: FlexMeasures clips the band to the hard bounds, so a hard upper bound inside the declared range would narrow the band to something the resource manager never asked for [`#245 `__] * ``FRBCSimple`` can take the actuator's charging efficiency into account. Give it the new ``charging_efficiency_sensor_id``: it records there what the operation modes say about converting drawn power into stored energy, and requests a schedule with it as ``charging-efficiency``. For a heat pump, this ratio is its coefficient of performance. Without it, the scheduler assumes power converts into stored energy one to one, and plans to reach fill-level targets at a power the actuator cannot deliver. @@ -48,7 +48,7 @@ Added * An FRBC handler tracks the instruction batch the resource manager is working from, and can revoke instructions it no longer wants carried out. Set its ``send_revocations`` attribute to ``True``. When a new batch replaces that one, the handler revokes the old batch's pending instructions: those without a reported status, or reported as ``NEW`` or ``ACCEPTED``. This is off by default, because a resource manager does not have to honour revocations [`#237 `__] * New ``FRBC`` methods for handlers. ``filter_instruction_transitions()`` drops instructions that repeat the previous one. ``send_instruction_batch()`` sends a batch that replaces the previous one, unless a newer request has arrived in the meantime. ``system_description_fingerprint()`` tells a changed system description from a re-sent one [`#237 `__] * ``ControlTypeHandler.send_message()``. Handlers now use it to send messages, instead of putting them on the CEM's sending queue themselves [`#237 `__] -* ``FRBCSimple`` takes an optional ``fill_rate_sensor_id``, and records the actuator's fill rate there. The fill rate is recorded in ``/s``, not as a power: S2 states a fill rate as fill level per second, which for a heat buffer is not electricity at all [`#236 `__, `#244 `__] +* ``FRBCSimple`` takes an optional ``fill_rate_sensor_id``, and records the actuator's fill rate there. The fill rate is recorded in the fill-rate unit (see ``energy_unit``), not in ``power_unit``: a fill rate is what the actuator delivers into its buffer, which for a heat buffer is not electricity at all [`#236 `__, `#244 `__] * ``flexmeasures_client.s2.clock``, with a ``WallClock`` and a ``SimulatedClock``, and ``CEM(clock=...)``. The clock sets the time for the CEM and for every handler it registers. That includes their timers, and the time a recorded state of charge is known from [`#264 `__, `#268 `__] * ``CEM(accept_simulation_metadata=True)`` accepts messages from a co-simulation that are wrapped with the simulated time (``S2Wrapper``), and sets the CEM's clock from them. It is off by default, and then a wrapped message is refused as invalid. The example server turns it on with ``S2_ACCEPT_SIMULATION_METADATA`` [`#264 `__] * ``flexmeasures_client.s2.site``, with ``FRBC_SITE_SENSORS``, ``ensure_site`` and ``FRBCSiteConfigurator``. It gives each resource manager its own FlexMeasures site, named after it, with an FRBC handler connected to that site's sensors [`#265 `__] diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index fc330551..5d962332 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -35,6 +35,7 @@ ) from flexmeasures_client.s2.control_types.FRBC import FRBC, STORAGE_STATUS_REQUESTS from flexmeasures_client.s2.control_types.FRBC.utils import ( + FILL_RATE_UNIT_PER_ENERGY_UNIT, WATTS_PER_ENERGY_UNIT_PER_SECOND, clip_fill_level_target_profile, element_for_fill_level, @@ -170,7 +171,7 @@ def _usage_correction_limits( A step without a forecast allows no correction. :param usage_per_hour: expected usage per step, as a rate of fill level, indexed by the start of each step. - :param unit: the unit of that rate, such as kWh/s, which FlexMeasures converts like any power. + :param unit: the unit of that rate, such as kW (see fill_rate_unit). """ def segments(values: list[float]) -> list[dict]: @@ -551,18 +552,20 @@ def __init__( self._valid_from_shift = valid_from_shift def fill_rate_unit(self) -> str: - """The unit a rate of change of the fill level is recorded in. + """The unit a rate of change of the fill level is recorded in: the power unit that goes with the energy unit, such as kW for kWh. - S2 states a fill rate, and a usage forecast, as fill level per second, so this follows - whatever unit the fill level itself is recorded in. It is not the power unit: a fill rate - is what an actuator delivers into its buffer, which for a heat buffer is not electricity - at all, and which even for a battery differs from kilowatts by the seconds in an hour. + S2 states a fill rate, a usage forecast and a leakage as fill level per second. + Per second, a residential heat buffer's rates are tiny numbers in kWh/s, so they are recorded per hour, which for kWh is kW (see _per_second_in_fill_rate_unit). + It is not the power unit: a fill rate is what an actuator delivers into its buffer, which for a heat buffer is not electricity at all. - A method rather than a property, because S2 Handler's discover() walks dir(self) and - getattrs every name during __init__, before a subclass has assigned its own attributes. + A method rather than a property, because S2 Handler's discover() walks dir(self) and getattrs every name during __init__, before a subclass has assigned its own attributes. A property would be evaluated there and raise on the energy_unit it reads. """ - return f"{self.energy_unit}/s" + return FILL_RATE_UNIT_PER_ENERGY_UNIT[self.energy_unit][0] + + def _per_second_in_fill_rate_unit(self, rate): + """Return a rate of fill level per second, as S2 states it, in the fill-rate unit; a number or a series.""" + return rate * FILL_RATE_UNIT_PER_ENERGY_UNIT[self.energy_unit][1] def align_to_resolution(self, moment: datetime) -> datetime: """The last resolution boundary at or before that moment. @@ -1013,10 +1016,13 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): # useful quantity - what the actuator delivers into its buffer - it simply is not power. if self._fill_rate_sensor_id is not None: fill_rate = element.fill_rate - rate = ( - fill_rate.start_of_range - + (fill_rate.end_of_range - fill_rate.start_of_range) * factor - ) * self._fill_level_scale + rate = self._per_second_in_fill_rate_unit( + ( + fill_rate.start_of_range + + (fill_rate.end_of_range - fill_rate.start_of_range) * factor + ) + * self._fill_level_scale + ) # Catch failures, like for the power above, because this runs in a task that nobody awaits. try: await self._fm_client.post_sensor_data( @@ -1647,11 +1653,13 @@ def _aligned_usage(self) -> pd.Series | None: usage_forecast = self._latest(self._usage_forecast_history) if usage_forecast is None: return None - translated = translate_usage_forecast_to_fm( - usage_forecast, - self._resolution, - strategy="mean", - fill_level_scale=self._fill_level_scale, + translated = self._per_second_in_fill_rate_unit( + translate_usage_forecast_to_fm( + usage_forecast, + self._resolution, + strategy="mean", + fill_level_scale=self._fill_level_scale, + ) ) return pd.Series( translated.values, @@ -2013,11 +2021,13 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): ) return - translated = translate_usage_forecast_to_fm( - usage_forecast, - self._resolution, - strategy="mean", - fill_level_scale=self._fill_level_scale, + translated = self._per_second_in_fill_rate_unit( + translate_usage_forecast_to_fm( + usage_forecast, + self._resolution, + strategy="mean", + fill_level_scale=self._fill_level_scale, + ) ) # S2 allows a negative usage, a gain, so it is recorded as stated. # What to plan with is decided when a schedule is requested (see _with_usage_correction). @@ -2047,7 +2057,6 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): start=self.align_to_resolution(usage_forecast.start_time), prior=self.now(), values=translated.tolist(), - # translate_usage_forecast_to_fm returns fill level per second, scaled. unit=self.fill_rate_unit(), duration=pd.Timedelta(self._resolution) * len(translated), ) @@ -2089,7 +2098,9 @@ async def _planned_leakage( ) return None element = element_for_fill_level(leakage_behaviour.elements, fill_level) - rate = element.leakage_rate * self._fill_level_scale + rate = self._per_second_in_fill_rate_unit( + element.leakage_rate * self._fill_level_scale + ) steps = int(self._schedule_duration / self._resolution) leakage = pd.Series( float(rate), diff --git a/src/flexmeasures_client/s2/control_types/FRBC/utils.py b/src/flexmeasures_client/s2/control_types/FRBC/utils.py index 4e8fb54f..a9026aca 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/utils.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/utils.py @@ -451,6 +451,19 @@ def get_soc_min_max( } +#: The unit a fill rate is recorded in, for each unit a fill level can be recorded in, and how many of it one unit of energy per second is. +#: S2 states rates per second, which in kWh/s puts a heat buffer's leakage of some tens of watts near 1e-5, so they are recorded per hour instead. +#: A joule per second is a watt, and a kilowatt-hour per second is 3600 kilowatts. +FILL_RATE_UNIT_PER_ENERGY_UNIT: dict[str, tuple[str, float]] = { + "J": ("W", 1.0), + "kJ": ("kW", 1.0), + "MJ": ("MW", 1.0), + "Wh": ("W", 3600.0), + "kWh": ("kW", 3600.0), + "MWh": ("MW", 3600.0), +} + + def fill_rate_in_watts(fill_rate: float, energy_unit: str) -> float: """Convert a fill rate to watts, given the unit that the fill level is recorded in. diff --git a/src/flexmeasures_client/s2/site.py b/src/flexmeasures_client/s2/site.py index 0893d0a7..20e21d27 100644 --- a/src/flexmeasures_client/s2/site.py +++ b/src/flexmeasures_client/s2/site.py @@ -38,7 +38,7 @@ class SiteSensor: #: The sensors an FRBC site needs, by name. -#: Their units are the units the CEM records in: fill levels in kWh, and fill rates per second, as in S2. +#: Their units are the units the CEM records in: fill levels in kWh, and fill rates, usage and leakage in kW, which S2 states per second. #: The names are a contract with any reader of the site, such as a co-simulation. #: A co-simulation looks up "price", "production price", "power", "state of charge" and "measured-power" by name. FRBC_SITE_SENSORS: dict[str, SiteSensor] = { @@ -53,12 +53,12 @@ class SiteSensor: "measured-power": SiteSensor("kW", "PT15M", {"consumption_is_positive": True}), "state of charge": SiteSensor("kWh", "PT0M"), "RM discharge": SiteSensor("dimensionless", "PT15M"), - "fill rate": SiteSensor("kWh/s", "PT15M"), + "fill rate": SiteSensor("kW", "PT15M"), "soc minima": SiteSensor("kWh", "PT15M"), "soc maxima": SiteSensor("kWh", "PT15M"), - "usage forecast": SiteSensor("kWh/s", "PT15M"), - # The leakage rate the resource manager declares, as it is planned with: per second, like the usage forecast. - "leakage": SiteSensor("kWh/s", "PT15M"), + "usage forecast": SiteSensor("kW", "PT15M"), + # The leakage rate the resource manager declares, as it is planned with, in the same unit as the usage forecast. + "leakage": SiteSensor("kW", "PT15M"), "charging efficiency": SiteSensor("dimensionless", "PT15M"), } diff --git a/tests/s2/test_example_end_to_end.py b/tests/s2/test_example_end_to_end.py index 1106d907..a70d7369 100644 --- a/tests/s2/test_example_end_to_end.py +++ b/tests/s2/test_example_end_to_end.py @@ -326,7 +326,7 @@ async def test_the_site_gets_a_sensor_for_every_declaration_and_prices_from_toda made = {call.kwargs["name"]: call.kwargs for call in fm.add_sensor.await_args_list} assert "price" not in made assert made["power"]["attributes"] == {"consumption_is_positive": True} - assert made["fill rate"]["unit"] == "kWh/s" + assert made["fill rate"]["unit"] == "kW" assert made["soc minima"]["unit"] == "kWh" prices = fm.post_sensor_data.await_args.kwargs assert prices["sensor_id"] == 50 @@ -538,8 +538,8 @@ def test_the_site_sensors_are_what_the_cem_records_in(): server.SITE_SENSORS["state of charge"].unit, server.SITE_SENSORS["state of charge"].resolution, ) == ("kWh", "PT0M") - assert server.SITE_SENSORS["leakage"].unit == "kWh/s" - assert server.SITE_SENSORS["usage forecast"].unit == "kWh/s" + assert server.SITE_SENSORS["leakage"].unit == "kW" + assert server.SITE_SENSORS["usage forecast"].unit == "kW" @pytest.mark.asyncio @@ -574,7 +574,9 @@ async def test_the_loss_is_planned_at_the_rate_of_the_range_a_schedule_starts_in planned = await frbc._planned_leakage(NOON, 7.0) - assert list(planned) == pytest.approx([per_second(1.5 / 24)] * len(planned)) + assert list(planned) == pytest.approx( + [1.5 / 24] * len(planned) + ) # 1.5 kWh a day, in kW assert len(planned) > 0 diff --git a/tests/s2/test_frbc_declared_profiles.py b/tests/s2/test_frbc_declared_profiles.py index d11492b7..925007e0 100644 --- a/tests/s2/test_frbc_declared_profiles.py +++ b/tests/s2/test_frbc_declared_profiles.py @@ -259,7 +259,7 @@ async def test_usage_forecast_is_recorded_from_a_resolution_boundary( assert posted["start"] == frbc.align_to_resolution(usage_forecast.start_time) assert posted["start"].minute % 15 == 0 and posted["start"].second == 0 # A usage forecast is a rate of fill level, not a power. - assert posted["unit"] == frbc.fill_rate_unit() == "kWh/s" + assert posted["unit"] == frbc.fill_rate_unit() == "kW" @pytest.mark.asyncio @@ -295,10 +295,10 @@ async def test_leakage_behaviour_is_recorded_as_a_rate( # Planned as a usage over the window, at the rate S2 states, rather than as a share of the stock. assert planned.index[0] == start assert len(planned) == frbc._schedule_duration / frbc._resolution - assert set(planned) == {0.01} + assert set(planned) == {36.0} # 0.01 kWh/s, in kW posted = _posted(frbc, LEAKAGE_SENSOR_ID) assert posted["unit"] == frbc.fill_rate_unit() - assert posted["values"] == [0.01] + assert posted["values"] == [36.0] assert posted["start"] == start assert posted["duration"] == frbc._schedule_duration @@ -549,11 +549,10 @@ async def test_the_instruction_translation_stays_in_the_resource_managers_units( assert translate.call_args.kwargs["initial_fill_level"] == 30.0 -def test_a_fill_rate_is_not_recorded_as_a_power(): - """S2 states a fill rate as fill level per second, which is not the power unit. +def test_a_fill_rate_is_recorded_per_hour_of_the_energy_unit_whatever_the_power_unit(): + """S2 states a fill rate as fill level per second, which per second of kWh makes tiny numbers, so it is recorded in the power unit that goes with the energy unit. - For a heat buffer the quantity is not electricity at all, and even for a battery kWh/s - differs from kW by the number of seconds in an hour. + That is not the power unit, which says how an actuator's electric power is posted: for a heat buffer, a fill rate is not electricity at all. """ frbc = FRBCSimple( power_sensor_id=1, @@ -563,8 +562,9 @@ def test_a_fill_rate_is_not_recorded_as_a_power(): energy_unit="kWh", power_unit="W", ) - assert frbc.fill_rate_unit() == "kWh/s" + assert frbc.fill_rate_unit() == "kW" assert frbc.fill_rate_unit() != frbc.power_unit + assert frbc._per_second_in_fill_rate_unit(0.01) == pytest.approx(36.0) megawatt_hours = FRBCSimple( power_sensor_id=1, @@ -573,7 +573,19 @@ def test_a_fill_rate_is_not_recorded_as_a_power(): rm_discharge_sensor_id=4, energy_unit="MWh", ) - assert megawatt_hours.fill_rate_unit() == "MWh/s" + assert megawatt_hours.fill_rate_unit() == "MW" + assert megawatt_hours._per_second_in_fill_rate_unit(0.01) == pytest.approx(36.0) + + # A joule per second already is a watt. + joules = FRBCSimple( + power_sensor_id=1, + price_sensor_id=2, + soc_sensor_id=3, + rm_discharge_sensor_id=4, + energy_unit="kJ", + ) + assert joules.fill_rate_unit() == "kW" + assert joules._per_second_in_fill_rate_unit(0.01) == pytest.approx(0.01) @pytest.mark.asyncio @@ -612,9 +624,7 @@ async def test_an_actuator_s_fill_rate_is_posted_in_the_fill_rate_unit( ) ) - assert ( - _posted(frbc, fill_rate_sensor_id)["unit"] == frbc.fill_rate_unit() == "kWh/s" - ) + assert _posted(frbc, fill_rate_sensor_id)["unit"] == frbc.fill_rate_unit() == "kW" # The power sensor really does carry a power, and is untouched by this. assert _posted(frbc, 1)["unit"] == frbc.power_unit @@ -669,7 +679,7 @@ async def test_a_leakage_that_could_not_be_recorded_is_planned_all_the_same( frbc._leakage_behaviour_history[str(leakage.message_id)] = leakage frbc._fm_client.post_sensor_data.side_effect = ConnectionError("unreachable") start = datetime(2024, 1, 1, 12, 15, tzinfo=timezone.utc) - assert set(await frbc._planned_leakage(start, 50.0)) == {0.01} + assert set(await frbc._planned_leakage(start, 50.0)) == {36.0} assert frbc._leakage_recorded is None assert "could not record" in caplog.text.lower() diff --git a/tests/s2/test_frbc_element_selection.py b/tests/s2/test_frbc_element_selection.py index bde6f080..684920d7 100644 --- a/tests/s2/test_frbc_element_selection.py +++ b/tests/s2/test_frbc_element_selection.py @@ -542,10 +542,10 @@ async def test_the_handler_plans_and_records_the_leakage_of_the_range_it_starts_ datetime(2024, 1, 2, tzinfo=timezone.utc), fill_level ) - assert set(planned) == {rate} + assert set(planned) == {rate * 3600} # in kW posted = [ call.kwargs["values"] for call in frbc._fm_client.post_sensor_data.await_args_list if call.kwargs.get("sensor_id") == 9 ] - assert posted == [[rate]] + assert posted == [[rate * 3600]] diff --git a/tests/s2/test_frbc_soft_band.py b/tests/s2/test_frbc_soft_band.py index 391c8215..6c8135a3 100644 --- a/tests/s2/test_frbc_soft_band.py +++ b/tests/s2/test_frbc_soft_band.py @@ -220,8 +220,8 @@ async def test_the_usage_and_the_leakage_a_resource_manager_declares_are_planned # The forecast goes with the request, with the leakage added to it as a rate over the whole window, # rather than as a share of the stock the storage keeps. planned = [float(step["value"].split()[0]) for step in flex_model["soc-usage"][0]] - assert planned[0] == pytest.approx(0.01 + 0.001) - assert planned[1:] == pytest.approx([0.001] * (len(planned) - 1)) + assert planned[0] == pytest.approx((0.01 + 0.001) * 3600) # in kW + assert planned[1:] == pytest.approx([0.001 * 3600] * (len(planned) - 1)) assert len(planned) == frbc._schedule_duration / frbc._resolution assert "storage-efficiency" not in flex_model @@ -580,7 +580,7 @@ async def test_a_negative_usage_forecast_is_recorded_as_stated_and_said_so( ) await frbc.send_usage_forecast(forecast) posted = frbc._fm_client.post_sensor_data.await_args.kwargs - assert posted["values"] == pytest.approx([0.01, -0.02, 0.0, -0.005]) + assert posted["values"] == pytest.approx([36.0, -72.0, 0.0, -18.0]) # in kW assert "negative usage on 2 of 4 steps" in caplog.text diff --git a/tests/s2/test_frbc_usage_correction.py b/tests/s2/test_frbc_usage_correction.py index 823a3fa0..44f28dc4 100644 --- a/tests/s2/test_frbc_usage_correction.py +++ b/tests/s2/test_frbc_usage_correction.py @@ -234,17 +234,17 @@ async def test_a_schedule_is_asked_for_with_a_correction_on_the_storages_stock( assert "sensor" not in stock and "soc-minima" in stock # The usage planned is the forecast the limits come from, not whatever the sensor holds. assert [s["value"] for s in stock["soc-usage"][0]] == [ - "0.01 kWh/s", - "-0.02 kWh/s", + "36.0 kW", + "-72.0 kW", ] assert correction["sensor"] == CORRECTION_SENSOR_ID assert correction["commodity"] == USAGE_CORRECTION_COMMODITY assert correction["state-of-charge"] == {"sensor": SOC_SENSOR_ID} - # Usage of 0.01 kWh/s, then a gain of 0.02 kWh/s: each may only be moved towards zero. - assert correction["consumption-capacity"][0]["value"] == "0.01 kWh/s" - assert correction["consumption-capacity"][1]["value"] == "0.0 kWh/s" - assert correction["production-capacity"][0]["value"] == "0.0 kWh/s" - assert correction["production-capacity"][1]["value"] == "0.02 kWh/s" + # Usage of 0.01 kWh/s (36 kW), then a gain of 0.02 kWh/s (72 kW): each may only be moved towards zero. + assert correction["consumption-capacity"][0]["value"] == "36.0 kW" + assert correction["consumption-capacity"][1]["value"] == "0.0 kW" + assert correction["production-capacity"][0]["value"] == "0.0 kW" + assert correction["production-capacity"][1]["value"] == "72.0 kW" @pytest.mark.asyncio @@ -316,7 +316,7 @@ async def test_a_negative_usage_is_recorded_as_stated_where_it_is_corrected( await frbc.send_usage_forecast(_forecast(0.01, -0.02)) posted = frbc._fm_client.post_sensor_data.await_args.kwargs - assert posted["values"] == pytest.approx([0.01, -0.02]) + assert posted["values"] == pytest.approx([36.0, -72.0]) # in kW @pytest.mark.asyncio @@ -363,8 +363,8 @@ async def test_without_what_a_correction_needs_negative_usage_is_planned_as_none assert isinstance(flex_model, dict), why assert "commodities" not in flex_context assert [s["value"] for s in flex_model["soc-usage"][0]] == [ - "0.01 kWh/s", - "0.0 kWh/s", + "36.0 kW", + "0.0 kW", ] assert said in caplog.text @@ -382,8 +382,8 @@ async def test_a_server_before_the_correction_plans_without_one(system_descripti assert isinstance(flex_model, dict) # Planned without a correction: the forecast goes with the request, without its negative values. assert [step["value"] for step in flex_model["soc-usage"][0]] == [ - "0.01 kWh/s", - "0.0 kWh/s", + "36.0 kW", + "0.0 kW", ] @@ -400,8 +400,8 @@ async def test_without_a_correction_sensor_the_request_carries_the_clipped_forec assert isinstance(flex_model, dict) # Planned without a correction: the forecast goes with the request, without its negative values. assert [step["value"] for step in flex_model["soc-usage"][0]] == [ - "0.01 kWh/s", - "0.0 kWh/s", + "36.0 kW", + "0.0 kW", ] assert ( "commodities" not in flex_context @@ -456,7 +456,9 @@ async def test_a_negative_usage_is_recorded_as_stated_without_a_correction( await frbc.send_usage_forecast(_forecast(0.01, -0.02)) - assert frbc._fm_client.post_sensor_data.await_args.kwargs["values"] == [0.01, -0.02] + assert frbc._fm_client.post_sensor_data.await_args.kwargs[ + "values" + ] == pytest.approx([36.0, -72.0]) def test_a_breach_price_factor_of_zero_is_refused(system_description): @@ -568,7 +570,7 @@ async def test_the_correction_covers_the_forecast_and_the_leakage_together( _, stock, correction = flex_model usage = _values(stock["soc-usage"][0]) - assert usage[:3] == pytest.approx([0.011, -0.019, 0.001]) + assert usage[:3] == pytest.approx([39.6, -68.4, 3.6]) # in kW assert len(usage) == 48 # the whole window, beyond the forecast assert "storage-efficiency" not in stock # Each step may be moved towards zero by as much as the usage and the leakage there add up to. @@ -580,9 +582,9 @@ async def test_the_correction_covers_the_forecast_and_the_leakage_together( s["start"]: float(s["value"].split()[0]) for s in correction["production-capacity"] } - assert up[START.isoformat()] == pytest.approx(0.011) - assert down[(START + STEP).isoformat()] == pytest.approx(0.019) - assert up[(START + 2 * STEP).isoformat()] == pytest.approx(0.001) + assert up[START.isoformat()] == pytest.approx(39.6) + assert down[(START + STEP).isoformat()] == pytest.approx(68.4) + assert up[(START + 2 * STEP).isoformat()] == pytest.approx(3.6) @pytest.mark.asyncio @@ -595,7 +597,7 @@ async def test_leakage_alone_is_planned_and_corrected_without_a_forecast( flex_model, _ = await _request(frbc, system_description) _, stock, correction = flex_model - assert _values(stock["soc-usage"][0]) == pytest.approx([0.001] * 48) + assert _values(stock["soc-usage"][0]) == pytest.approx([3.6] * 48) assert correction["sensor"] == CORRECTION_SENSOR_ID @@ -612,7 +614,7 @@ async def test_leakage_is_planned_without_a_usage_forecast_sensor(system_descrip flex_model, _ = await _request(frbc, system_description) _, stock, _ = flex_model - assert _values(stock["soc-usage"][0]) == pytest.approx([0.001] * 48) + assert _values(stock["soc-usage"][0]) == pytest.approx([3.6] * 48) @pytest.mark.asyncio @@ -625,7 +627,7 @@ async def test_the_leakage_is_scaled_like_every_other_fill_rate(system_descripti flex_model, _ = await _request(frbc, system_description) _, stock, _ = flex_model - assert _values(stock["soc-usage"][0]) == pytest.approx([0.002] * 48) + assert _values(stock["soc-usage"][0]) == pytest.approx([7.2] * 48) @pytest.mark.asyncio @@ -640,7 +642,7 @@ async def test_no_leakage_is_planned_without_a_sensor_to_record_it_on( flex_model, _ = await _request(frbc, system_description) _, stock, _ = flex_model - assert _values(stock["soc-usage"][0]) == pytest.approx([0.01]) + assert _values(stock["soc-usage"][0]) == pytest.approx([36.0]) @pytest.mark.asyncio @@ -657,7 +659,7 @@ async def test_without_a_correction_the_forecast_and_the_leakage_are_clipped_tog flex_model, _ = await _request(frbc, system_description) usage = _values(flex_model["soc-usage"][0]) - assert usage[:3] == pytest.approx([0.011, 0.0, 0.001]) + assert usage[:3] == pytest.approx([39.6, 0.0, 3.6]) assert "storage-efficiency" not in flex_model @@ -692,8 +694,8 @@ async def version_at_least(version): assert isinstance(flex_model, dict) # Planned without a correction: the forecast goes with the request, without its negative values. assert [step["value"] for step in flex_model["soc-usage"][0]] == [ - "0.01 kWh/s", - "0.0 kWh/s", + "36.0 kW", + "0.0 kW", ] From 83fbf2388c1bb79dbc9ca30f9117ce33e241cb9f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 9 Nov 2026 19:31:35 +0100 Subject: [PATCH 4/7] feat(FRBC): take a fill_rate_unit, by default W Like the energy and power units, the unit that rates of fill level are recorded in is the deployment's to set: W (the default), kW or MW. Watts read a residential heat buffer's leakage of some tens of watts as such; the site's fill rate, usage forecast and leakage sensors are created in W. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW Signed-off-by: F.N. Claessen --- CHANGELOG-S2.rst | 9 +-- .../s2/control_types/FRBC/frbc_simple.py | 22 ++++-- .../s2/control_types/FRBC/utils.py | 14 +--- src/flexmeasures_client/s2/site.py | 8 +-- tests/s2/test_example_end_to_end.py | 10 +-- tests/s2/test_frbc_declared_profiles.py | 71 ++++++++++--------- tests/s2/test_frbc_element_selection.py | 4 +- tests/s2/test_frbc_soft_band.py | 6 +- tests/s2/test_frbc_usage_correction.py | 52 +++++++------- 9 files changed, 101 insertions(+), 95 deletions(-) diff --git a/CHANGELOG-S2.rst b/CHANGELOG-S2.rst index cef63b82..022df023 100644 --- a/CHANGELOG-S2.rst +++ b/CHANGELOG-S2.rst @@ -19,11 +19,12 @@ Added * ``FRBCSimple`` can record what a resource manager declares about its storage, so it can be seen and planned with. Give it any of the new ``soc_minima_sensor_id``, ``soc_maxima_sensor_id``, ``usage_forecast_sensor_id`` and ``leakage_behaviour_sensor_id``; a declaration without a sensor is not recorded. - A fill-level target profile is recorded on the minima and maxima sensors, if both are given. It is recorded on the handler's ``resolution`` steps, starting from the step its start falls in. It is kept inside the declared fill-level range: a target past either end, such as a night setback below the range, is cut back to the range, and a target band wholly outside the range is recorded at its nearest end, with a warning. If one of the two bounds cannot be recorded, this is logged and the other is still recorded. - - A usage forecast is recorded in the fill-rate unit (see ``energy_unit`` below). A negative expected usage, which S2 allows, is recorded as stated. Without a usage correction (see below), a schedule request carries the latest forecast without its negative values, since FlexMeasures would plan them as a gain the storage cannot refuse. A FlexMeasures server before v0.23.0 only plans with what is recorded, so there a negative expected usage is recorded as zero, with a warning [`#286 `__]. - - A leakage behaviour is recorded as the leakage rate it states, in the fill-rate unit, each time a schedule is requested (see Changed) [`#289 `__]. + - A usage forecast is recorded in ``fill_rate_unit`` (see below). A negative expected usage, which S2 allows, is recorded as stated. Without a usage correction (see below), a schedule request carries the latest forecast without its negative values, since FlexMeasures would plan them as a gain the storage cannot refuse. A FlexMeasures server before v0.23.0 only plans with what is recorded, so there a negative expected usage is recorded as zero, with a warning [`#286 `__]. + - A leakage behaviour is recorded as the leakage rate it states, in ``fill_rate_unit``, each time a schedule is requested (see Changed) [`#289 `__]. [`#244 `__, `#259 `__, `#271 `__, `#274 `__] -* ``FRBCSimple`` takes an ``energy_unit`` (default ``kWh``). The state of charge and the fill-level band are recorded in this unit. Rates of fill level, which S2 states per second, are recorded per hour, in the power unit that goes with it: ``W`` for ``J`` and ``Wh``, ``kW`` for ``kJ`` and ``kWh``, and ``MW`` for ``MJ`` and ``MWh``. These are the fill rate, the usage forecast and the leakage. In ``kWh/s``, a heat buffer's leakage of some tens of watts would read about 0.00001 [`#289 `__]. Allowed units are ``J``, ``kJ``, ``MJ``, ``Wh``, ``kWh`` and ``MWh``; any other unit is refused when the handler is created. In a flex model, the state of charge is stated in ``kWh`` or ``MWh``, converted from the other units, because FlexMeasures accepts no others [`#244 `__, `#260 `__] +* ``FRBCSimple`` takes an ``energy_unit`` (default ``kWh``). The state of charge and the fill-level band are recorded in this unit. Allowed units are ``J``, ``kJ``, ``MJ``, ``Wh``, ``kWh`` and ``MWh``; any other unit is refused when the handler is created. In a flex model, the state of charge is stated in ``kWh`` or ``MWh``, converted from the other units, because FlexMeasures accepts no others [`#244 `__, `#260 `__] +* ``FRBCSimple`` takes a ``fill_rate_unit`` (default ``W``; also ``kW`` or ``MW``). Rates of fill level, which S2 states per second, are recorded in it: the fill rate, the usage forecast and the leakage. In ``kWh/s``, a heat buffer's leakage of some tens of watts would read about 0.00001. Any other unit is refused when the handler is created [`#289 `__] * ``FRBCSimple`` takes a ``fill_level_scale`` (default 1). Every fill level and fill rate sent to FlexMeasures is multiplied by it: the state of charge, the flex model, the fill-level band, the fill rate, the usage forecast and the leakage. Translating a schedule into instructions keeps the resource manager's own units, because it matches fill levels against the operation modes, which are stated in those units. A scale of zero or below is refused. ``FRBCSimple`` also takes a ``power_unit`` (default ``W``), which an actuator's power is converted into and posted in [`#236 `__, `#244 `__, `#245 `__, `#252 `__] * ``FRBCSimple`` takes a ``hard_bound_factor`` (default 1.5). It sets how far above the declared fill-level range a schedule's hard upper bound lies. If a schedule starts from a state of charge above that bound, the bound is raised to include it. A factor below 1 is refused: FlexMeasures clips the band to the hard bounds, so a hard upper bound inside the declared range would narrow the band to something the resource manager never asked for [`#245 `__] * ``FRBCSimple`` can take the actuator's charging efficiency into account. Give it the new ``charging_efficiency_sensor_id``: it records there what the operation modes say about converting drawn power into stored energy, and requests a schedule with it as ``charging-efficiency``. For a heat pump, this ratio is its coefficient of performance. Without it, the scheduler assumes power converts into stored energy one to one, and plans to reach fill-level targets at a power the actuator cannot deliver. @@ -48,7 +49,7 @@ Added * An FRBC handler tracks the instruction batch the resource manager is working from, and can revoke instructions it no longer wants carried out. Set its ``send_revocations`` attribute to ``True``. When a new batch replaces that one, the handler revokes the old batch's pending instructions: those without a reported status, or reported as ``NEW`` or ``ACCEPTED``. This is off by default, because a resource manager does not have to honour revocations [`#237 `__] * New ``FRBC`` methods for handlers. ``filter_instruction_transitions()`` drops instructions that repeat the previous one. ``send_instruction_batch()`` sends a batch that replaces the previous one, unless a newer request has arrived in the meantime. ``system_description_fingerprint()`` tells a changed system description from a re-sent one [`#237 `__] * ``ControlTypeHandler.send_message()``. Handlers now use it to send messages, instead of putting them on the CEM's sending queue themselves [`#237 `__] -* ``FRBCSimple`` takes an optional ``fill_rate_sensor_id``, and records the actuator's fill rate there. The fill rate is recorded in the fill-rate unit (see ``energy_unit``), not in ``power_unit``: a fill rate is what the actuator delivers into its buffer, which for a heat buffer is not electricity at all [`#236 `__, `#244 `__] +* ``FRBCSimple`` takes an optional ``fill_rate_sensor_id``, and records the actuator's fill rate there. The fill rate is recorded in ``fill_rate_unit``, not in ``power_unit``: a fill rate is what the actuator delivers into its buffer, which for a heat buffer is not electricity at all [`#236 `__, `#244 `__] * ``flexmeasures_client.s2.clock``, with a ``WallClock`` and a ``SimulatedClock``, and ``CEM(clock=...)``. The clock sets the time for the CEM and for every handler it registers. That includes their timers, and the time a recorded state of charge is known from [`#264 `__, `#268 `__] * ``CEM(accept_simulation_metadata=True)`` accepts messages from a co-simulation that are wrapped with the simulated time (``S2Wrapper``), and sets the CEM's clock from them. It is off by default, and then a wrapped message is refused as invalid. The example server turns it on with ``S2_ACCEPT_SIMULATION_METADATA`` [`#264 `__] * ``flexmeasures_client.s2.site``, with ``FRBC_SITE_SENSORS``, ``ensure_site`` and ``FRBCSiteConfigurator``. It gives each resource manager its own FlexMeasures site, named after it, with an FRBC handler connected to that site's sensors [`#265 `__] diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py index 5d962332..dc9262fe 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -35,8 +35,8 @@ ) from flexmeasures_client.s2.control_types.FRBC import FRBC, STORAGE_STATUS_REQUESTS from flexmeasures_client.s2.control_types.FRBC.utils import ( - FILL_RATE_UNIT_PER_ENERGY_UNIT, WATTS_PER_ENERGY_UNIT_PER_SECOND, + WATTS_PER_FILL_RATE_UNIT, clip_fill_level_target_profile, element_for_fill_level, fm_schedule_to_instructions, @@ -465,6 +465,10 @@ def __init__( # The default is kilowatt-hours, because S2 is a residential protocol, and a home battery or heat buffer is of that order. # A deployment at another scale can set a different unit here. energy_unit: str = "kWh", + # The unit that rates of fill level are recorded in: the fill rate, the usage forecast and the leakage. + # S2 states them per second, which in kWh/s makes tiny numbers, so they are recorded in a power unit: W, kW or MW. + # The default is watts, in which a residential heat buffer's leakage of some tens of watts reads as such. + fill_rate_unit: str = "W", ) -> None: super().__init__(max_size) self._power_sensor_id = power_sensor_id @@ -546,26 +550,34 @@ def __init__( f"energy_unit has to be one of {', '.join(WATTS_PER_ENERGY_UNIT_PER_SECOND)}, but {energy_unit!r} was given." ) self.energy_unit = energy_unit + if fill_rate_unit not in WATTS_PER_FILL_RATE_UNIT: + raise ValueError( + f"fill_rate_unit has to be one of {', '.join(WATTS_PER_FILL_RATE_UNIT)}, but {fill_rate_unit!r} was given." + ) + self._fill_rate_unit = fill_rate_unit # delay the start of the schedule from the time `valid_from` # of the FRBC.SystemDescription. self._valid_from_shift = valid_from_shift def fill_rate_unit(self) -> str: - """The unit a rate of change of the fill level is recorded in: the power unit that goes with the energy unit, such as kW for kWh. + """The unit a rate of change of the fill level is recorded in: the fill_rate_unit given, by default W. S2 states a fill rate, a usage forecast and a leakage as fill level per second. - Per second, a residential heat buffer's rates are tiny numbers in kWh/s, so they are recorded per hour, which for kWh is kW (see _per_second_in_fill_rate_unit). + Per second, a residential heat buffer's rates are tiny numbers in kWh/s, so they are recorded in a power unit instead (see _per_second_in_fill_rate_unit). It is not the power unit: a fill rate is what an actuator delivers into its buffer, which for a heat buffer is not electricity at all. A method rather than a property, because S2 Handler's discover() walks dir(self) and getattrs every name during __init__, before a subclass has assigned its own attributes. A property would be evaluated there and raise on the energy_unit it reads. """ - return FILL_RATE_UNIT_PER_ENERGY_UNIT[self.energy_unit][0] + return self._fill_rate_unit def _per_second_in_fill_rate_unit(self, rate): """Return a rate of fill level per second, as S2 states it, in the fill-rate unit; a number or a series.""" - return rate * FILL_RATE_UNIT_PER_ENERGY_UNIT[self.energy_unit][1] + return rate * ( + WATTS_PER_ENERGY_UNIT_PER_SECOND[self.energy_unit] + / WATTS_PER_FILL_RATE_UNIT[self._fill_rate_unit] + ) def align_to_resolution(self, moment: datetime) -> datetime: """The last resolution boundary at or before that moment. diff --git a/src/flexmeasures_client/s2/control_types/FRBC/utils.py b/src/flexmeasures_client/s2/control_types/FRBC/utils.py index a9026aca..bfbbc6a6 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/utils.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/utils.py @@ -451,17 +451,9 @@ def get_soc_min_max( } -#: The unit a fill rate is recorded in, for each unit a fill level can be recorded in, and how many of it one unit of energy per second is. -#: S2 states rates per second, which in kWh/s puts a heat buffer's leakage of some tens of watts near 1e-5, so they are recorded per hour instead. -#: A joule per second is a watt, and a kilowatt-hour per second is 3600 kilowatts. -FILL_RATE_UNIT_PER_ENERGY_UNIT: dict[str, tuple[str, float]] = { - "J": ("W", 1.0), - "kJ": ("kW", 1.0), - "MJ": ("MW", 1.0), - "Wh": ("W", 3600.0), - "kWh": ("kW", 3600.0), - "MWh": ("MW", 3600.0), -} +#: The units a rate of fill level can be recorded in, and the number of watts in one of each. +#: S2 states rates per second, which in kWh/s puts a heat buffer's leakage of some tens of watts near 1e-5, so they are recorded in a power unit instead, by default W. +WATTS_PER_FILL_RATE_UNIT: dict[str, float] = {"W": 1.0, "kW": 1e3, "MW": 1e6} def fill_rate_in_watts(fill_rate: float, energy_unit: str) -> float: diff --git a/src/flexmeasures_client/s2/site.py b/src/flexmeasures_client/s2/site.py index 20e21d27..eee48425 100644 --- a/src/flexmeasures_client/s2/site.py +++ b/src/flexmeasures_client/s2/site.py @@ -38,7 +38,7 @@ class SiteSensor: #: The sensors an FRBC site needs, by name. -#: Their units are the units the CEM records in: fill levels in kWh, and fill rates, usage and leakage in kW, which S2 states per second. +#: Their units are the units the CEM records in: fill levels in kWh, and fill rates, usage and leakage in W, which S2 states per second. #: The names are a contract with any reader of the site, such as a co-simulation. #: A co-simulation looks up "price", "production price", "power", "state of charge" and "measured-power" by name. FRBC_SITE_SENSORS: dict[str, SiteSensor] = { @@ -53,12 +53,12 @@ class SiteSensor: "measured-power": SiteSensor("kW", "PT15M", {"consumption_is_positive": True}), "state of charge": SiteSensor("kWh", "PT0M"), "RM discharge": SiteSensor("dimensionless", "PT15M"), - "fill rate": SiteSensor("kW", "PT15M"), + "fill rate": SiteSensor("W", "PT15M"), "soc minima": SiteSensor("kWh", "PT15M"), "soc maxima": SiteSensor("kWh", "PT15M"), - "usage forecast": SiteSensor("kW", "PT15M"), + "usage forecast": SiteSensor("W", "PT15M"), # The leakage rate the resource manager declares, as it is planned with, in the same unit as the usage forecast. - "leakage": SiteSensor("kW", "PT15M"), + "leakage": SiteSensor("W", "PT15M"), "charging efficiency": SiteSensor("dimensionless", "PT15M"), } diff --git a/tests/s2/test_example_end_to_end.py b/tests/s2/test_example_end_to_end.py index a70d7369..1ea7e66f 100644 --- a/tests/s2/test_example_end_to_end.py +++ b/tests/s2/test_example_end_to_end.py @@ -326,7 +326,7 @@ async def test_the_site_gets_a_sensor_for_every_declaration_and_prices_from_toda made = {call.kwargs["name"]: call.kwargs for call in fm.add_sensor.await_args_list} assert "price" not in made assert made["power"]["attributes"] == {"consumption_is_positive": True} - assert made["fill rate"]["unit"] == "kW" + assert made["fill rate"]["unit"] == "W" assert made["soc minima"]["unit"] == "kWh" prices = fm.post_sensor_data.await_args.kwargs assert prices["sensor_id"] == 50 @@ -538,8 +538,8 @@ def test_the_site_sensors_are_what_the_cem_records_in(): server.SITE_SENSORS["state of charge"].unit, server.SITE_SENSORS["state of charge"].resolution, ) == ("kWh", "PT0M") - assert server.SITE_SENSORS["leakage"].unit == "kW" - assert server.SITE_SENSORS["usage forecast"].unit == "kW" + assert server.SITE_SENSORS["leakage"].unit == "W" + assert server.SITE_SENSORS["usage forecast"].unit == "W" @pytest.mark.asyncio @@ -575,8 +575,8 @@ async def test_the_loss_is_planned_at_the_rate_of_the_range_a_schedule_starts_in planned = await frbc._planned_leakage(NOON, 7.0) assert list(planned) == pytest.approx( - [1.5 / 24] * len(planned) - ) # 1.5 kWh a day, in kW + [1500 / 24] * len(planned) + ) # 1.5 kWh a day, in W assert len(planned) > 0 diff --git a/tests/s2/test_frbc_declared_profiles.py b/tests/s2/test_frbc_declared_profiles.py index 925007e0..f6801d69 100644 --- a/tests/s2/test_frbc_declared_profiles.py +++ b/tests/s2/test_frbc_declared_profiles.py @@ -259,7 +259,7 @@ async def test_usage_forecast_is_recorded_from_a_resolution_boundary( assert posted["start"] == frbc.align_to_resolution(usage_forecast.start_time) assert posted["start"].minute % 15 == 0 and posted["start"].second == 0 # A usage forecast is a rate of fill level, not a power. - assert posted["unit"] == frbc.fill_rate_unit() == "kW" + assert posted["unit"] == frbc.fill_rate_unit() == "W" @pytest.mark.asyncio @@ -295,10 +295,10 @@ async def test_leakage_behaviour_is_recorded_as_a_rate( # Planned as a usage over the window, at the rate S2 states, rather than as a share of the stock. assert planned.index[0] == start assert len(planned) == frbc._schedule_duration / frbc._resolution - assert set(planned) == {36.0} # 0.01 kWh/s, in kW + assert set(planned) == {36000.0} # 0.01 kWh/s, in W posted = _posted(frbc, LEAKAGE_SENSOR_ID) assert posted["unit"] == frbc.fill_rate_unit() - assert posted["values"] == [36.0] + assert posted["values"] == [36000.0] assert posted["start"] == start assert posted["duration"] == frbc._schedule_duration @@ -549,43 +549,44 @@ async def test_the_instruction_translation_stays_in_the_resource_managers_units( assert translate.call_args.kwargs["initial_fill_level"] == 30.0 -def test_a_fill_rate_is_recorded_per_hour_of_the_energy_unit_whatever_the_power_unit(): - """S2 states a fill rate as fill level per second, which per second of kWh makes tiny numbers, so it is recorded in the power unit that goes with the energy unit. - - That is not the power unit, which says how an actuator's electric power is posted: for a heat buffer, a fill rate is not electricity at all. - """ - frbc = FRBCSimple( +def _handler(**units) -> FRBCSimple: + return FRBCSimple( power_sensor_id=1, price_sensor_id=2, soc_sensor_id=3, rm_discharge_sensor_id=4, - energy_unit="kWh", - power_unit="W", + **units, ) - assert frbc.fill_rate_unit() == "kW" - assert frbc.fill_rate_unit() != frbc.power_unit - assert frbc._per_second_in_fill_rate_unit(0.01) == pytest.approx(36.0) - megawatt_hours = FRBCSimple( - power_sensor_id=1, - price_sensor_id=2, - soc_sensor_id=3, - rm_discharge_sensor_id=4, - energy_unit="MWh", - ) - assert megawatt_hours.fill_rate_unit() == "MW" - assert megawatt_hours._per_second_in_fill_rate_unit(0.01) == pytest.approx(36.0) - # A joule per second already is a watt. - joules = FRBCSimple( - power_sensor_id=1, - price_sensor_id=2, - soc_sensor_id=3, - rm_discharge_sensor_id=4, - energy_unit="kJ", - ) - assert joules.fill_rate_unit() == "kW" - assert joules._per_second_in_fill_rate_unit(0.01) == pytest.approx(0.01) +@pytest.mark.parametrize( + "units, fill_rate_unit, recorded", + [ + # 0.01 kWh/s is 36 kW. + ({}, "W", 36000.0), + ({"fill_rate_unit": "kW"}, "kW", 36.0), + ({"energy_unit": "MWh", "fill_rate_unit": "MW"}, "MW", 36.0), + # A joule per second is a watt. + ({"energy_unit": "kJ"}, "W", 10.0), + ({"energy_unit": "J", "fill_rate_unit": "kW"}, "kW", 0.00001), + ], +) +def test_a_fill_rate_is_recorded_in_the_fill_rate_unit_whatever_the_power_unit( + units, fill_rate_unit, recorded +): + """S2 states a fill rate as fill level per second, which in kWh/s makes tiny numbers, so it is recorded in a power unit, by default W. + + That is not the power unit, which says how an actuator's electric power is posted: for a heat buffer, a fill rate is not electricity at all. + """ + frbc = _handler(power_unit="kW", **units) + assert frbc.fill_rate_unit() == fill_rate_unit + assert frbc._per_second_in_fill_rate_unit(0.01) == pytest.approx(recorded) + + +@pytest.mark.parametrize("fill_rate_unit", ["kWh/s", "kWh", "GW"]) +def test_a_fill_rate_unit_that_is_no_power_unit_is_refused(fill_rate_unit): + with pytest.raises(ValueError, match="fill_rate_unit has to be one of W, kW, MW"): + _handler(fill_rate_unit=fill_rate_unit) @pytest.mark.asyncio @@ -624,7 +625,7 @@ async def test_an_actuator_s_fill_rate_is_posted_in_the_fill_rate_unit( ) ) - assert _posted(frbc, fill_rate_sensor_id)["unit"] == frbc.fill_rate_unit() == "kW" + assert _posted(frbc, fill_rate_sensor_id)["unit"] == frbc.fill_rate_unit() == "W" # The power sensor really does carry a power, and is untouched by this. assert _posted(frbc, 1)["unit"] == frbc.power_unit @@ -679,7 +680,7 @@ async def test_a_leakage_that_could_not_be_recorded_is_planned_all_the_same( frbc._leakage_behaviour_history[str(leakage.message_id)] = leakage frbc._fm_client.post_sensor_data.side_effect = ConnectionError("unreachable") start = datetime(2024, 1, 1, 12, 15, tzinfo=timezone.utc) - assert set(await frbc._planned_leakage(start, 50.0)) == {36.0} + assert set(await frbc._planned_leakage(start, 50.0)) == {36000.0} assert frbc._leakage_recorded is None assert "could not record" in caplog.text.lower() diff --git a/tests/s2/test_frbc_element_selection.py b/tests/s2/test_frbc_element_selection.py index 684920d7..87ef5035 100644 --- a/tests/s2/test_frbc_element_selection.py +++ b/tests/s2/test_frbc_element_selection.py @@ -542,10 +542,10 @@ async def test_the_handler_plans_and_records_the_leakage_of_the_range_it_starts_ datetime(2024, 1, 2, tzinfo=timezone.utc), fill_level ) - assert set(planned) == {rate * 3600} # in kW + assert set(planned) == {rate * 3.6e6} # in W posted = [ call.kwargs["values"] for call in frbc._fm_client.post_sensor_data.await_args_list if call.kwargs.get("sensor_id") == 9 ] - assert posted == [[rate * 3600]] + assert posted == [[rate * 3.6e6]] diff --git a/tests/s2/test_frbc_soft_band.py b/tests/s2/test_frbc_soft_band.py index 6c8135a3..b302cd79 100644 --- a/tests/s2/test_frbc_soft_band.py +++ b/tests/s2/test_frbc_soft_band.py @@ -220,8 +220,8 @@ async def test_the_usage_and_the_leakage_a_resource_manager_declares_are_planned # The forecast goes with the request, with the leakage added to it as a rate over the whole window, # rather than as a share of the stock the storage keeps. planned = [float(step["value"].split()[0]) for step in flex_model["soc-usage"][0]] - assert planned[0] == pytest.approx((0.01 + 0.001) * 3600) # in kW - assert planned[1:] == pytest.approx([0.001 * 3600] * (len(planned) - 1)) + assert planned[0] == pytest.approx((0.01 + 0.001) * 3.6e6) # in W + assert planned[1:] == pytest.approx([0.001 * 3.6e6] * (len(planned) - 1)) assert len(planned) == frbc._schedule_duration / frbc._resolution assert "storage-efficiency" not in flex_model @@ -580,7 +580,7 @@ async def test_a_negative_usage_forecast_is_recorded_as_stated_and_said_so( ) await frbc.send_usage_forecast(forecast) posted = frbc._fm_client.post_sensor_data.await_args.kwargs - assert posted["values"] == pytest.approx([36.0, -72.0, 0.0, -18.0]) # in kW + assert posted["values"] == pytest.approx([36000.0, -72000.0, 0.0, -18000.0]) # in W assert "negative usage on 2 of 4 steps" in caplog.text diff --git a/tests/s2/test_frbc_usage_correction.py b/tests/s2/test_frbc_usage_correction.py index 44f28dc4..841ec655 100644 --- a/tests/s2/test_frbc_usage_correction.py +++ b/tests/s2/test_frbc_usage_correction.py @@ -234,17 +234,17 @@ async def test_a_schedule_is_asked_for_with_a_correction_on_the_storages_stock( assert "sensor" not in stock and "soc-minima" in stock # The usage planned is the forecast the limits come from, not whatever the sensor holds. assert [s["value"] for s in stock["soc-usage"][0]] == [ - "36.0 kW", - "-72.0 kW", + "36000.0 W", + "-72000.0 W", ] assert correction["sensor"] == CORRECTION_SENSOR_ID assert correction["commodity"] == USAGE_CORRECTION_COMMODITY assert correction["state-of-charge"] == {"sensor": SOC_SENSOR_ID} - # Usage of 0.01 kWh/s (36 kW), then a gain of 0.02 kWh/s (72 kW): each may only be moved towards zero. - assert correction["consumption-capacity"][0]["value"] == "36.0 kW" - assert correction["consumption-capacity"][1]["value"] == "0.0 kW" - assert correction["production-capacity"][0]["value"] == "0.0 kW" - assert correction["production-capacity"][1]["value"] == "72.0 kW" + # Usage of 0.01 kWh/s (36000 W), then a gain of 0.02 kWh/s (72000 W): each may only be moved towards zero. + assert correction["consumption-capacity"][0]["value"] == "36000.0 W" + assert correction["consumption-capacity"][1]["value"] == "0.0 W" + assert correction["production-capacity"][0]["value"] == "0.0 W" + assert correction["production-capacity"][1]["value"] == "72000.0 W" @pytest.mark.asyncio @@ -316,7 +316,7 @@ async def test_a_negative_usage_is_recorded_as_stated_where_it_is_corrected( await frbc.send_usage_forecast(_forecast(0.01, -0.02)) posted = frbc._fm_client.post_sensor_data.await_args.kwargs - assert posted["values"] == pytest.approx([36.0, -72.0]) # in kW + assert posted["values"] == pytest.approx([36000.0, -72000.0]) # in W @pytest.mark.asyncio @@ -363,8 +363,8 @@ async def test_without_what_a_correction_needs_negative_usage_is_planned_as_none assert isinstance(flex_model, dict), why assert "commodities" not in flex_context assert [s["value"] for s in flex_model["soc-usage"][0]] == [ - "36.0 kW", - "0.0 kW", + "36000.0 W", + "0.0 W", ] assert said in caplog.text @@ -382,8 +382,8 @@ async def test_a_server_before_the_correction_plans_without_one(system_descripti assert isinstance(flex_model, dict) # Planned without a correction: the forecast goes with the request, without its negative values. assert [step["value"] for step in flex_model["soc-usage"][0]] == [ - "36.0 kW", - "0.0 kW", + "36000.0 W", + "0.0 W", ] @@ -400,8 +400,8 @@ async def test_without_a_correction_sensor_the_request_carries_the_clipped_forec assert isinstance(flex_model, dict) # Planned without a correction: the forecast goes with the request, without its negative values. assert [step["value"] for step in flex_model["soc-usage"][0]] == [ - "36.0 kW", - "0.0 kW", + "36000.0 W", + "0.0 W", ] assert ( "commodities" not in flex_context @@ -458,7 +458,7 @@ async def test_a_negative_usage_is_recorded_as_stated_without_a_correction( assert frbc._fm_client.post_sensor_data.await_args.kwargs[ "values" - ] == pytest.approx([36.0, -72.0]) + ] == pytest.approx([36000.0, -72000.0]) def test_a_breach_price_factor_of_zero_is_refused(system_description): @@ -570,7 +570,7 @@ async def test_the_correction_covers_the_forecast_and_the_leakage_together( _, stock, correction = flex_model usage = _values(stock["soc-usage"][0]) - assert usage[:3] == pytest.approx([39.6, -68.4, 3.6]) # in kW + assert usage[:3] == pytest.approx([39600.0, -68400.0, 3600.0]) # in W assert len(usage) == 48 # the whole window, beyond the forecast assert "storage-efficiency" not in stock # Each step may be moved towards zero by as much as the usage and the leakage there add up to. @@ -582,9 +582,9 @@ async def test_the_correction_covers_the_forecast_and_the_leakage_together( s["start"]: float(s["value"].split()[0]) for s in correction["production-capacity"] } - assert up[START.isoformat()] == pytest.approx(39.6) - assert down[(START + STEP).isoformat()] == pytest.approx(68.4) - assert up[(START + 2 * STEP).isoformat()] == pytest.approx(3.6) + assert up[START.isoformat()] == pytest.approx(39600.0) + assert down[(START + STEP).isoformat()] == pytest.approx(68400.0) + assert up[(START + 2 * STEP).isoformat()] == pytest.approx(3600.0) @pytest.mark.asyncio @@ -597,7 +597,7 @@ async def test_leakage_alone_is_planned_and_corrected_without_a_forecast( flex_model, _ = await _request(frbc, system_description) _, stock, correction = flex_model - assert _values(stock["soc-usage"][0]) == pytest.approx([3.6] * 48) + assert _values(stock["soc-usage"][0]) == pytest.approx([3600.0] * 48) assert correction["sensor"] == CORRECTION_SENSOR_ID @@ -614,7 +614,7 @@ async def test_leakage_is_planned_without_a_usage_forecast_sensor(system_descrip flex_model, _ = await _request(frbc, system_description) _, stock, _ = flex_model - assert _values(stock["soc-usage"][0]) == pytest.approx([3.6] * 48) + assert _values(stock["soc-usage"][0]) == pytest.approx([3600.0] * 48) @pytest.mark.asyncio @@ -627,7 +627,7 @@ async def test_the_leakage_is_scaled_like_every_other_fill_rate(system_descripti flex_model, _ = await _request(frbc, system_description) _, stock, _ = flex_model - assert _values(stock["soc-usage"][0]) == pytest.approx([7.2] * 48) + assert _values(stock["soc-usage"][0]) == pytest.approx([7200.0] * 48) @pytest.mark.asyncio @@ -642,7 +642,7 @@ async def test_no_leakage_is_planned_without_a_sensor_to_record_it_on( flex_model, _ = await _request(frbc, system_description) _, stock, _ = flex_model - assert _values(stock["soc-usage"][0]) == pytest.approx([36.0]) + assert _values(stock["soc-usage"][0]) == pytest.approx([36000.0]) @pytest.mark.asyncio @@ -659,7 +659,7 @@ async def test_without_a_correction_the_forecast_and_the_leakage_are_clipped_tog flex_model, _ = await _request(frbc, system_description) usage = _values(flex_model["soc-usage"][0]) - assert usage[:3] == pytest.approx([39.6, 0.0, 3.6]) + assert usage[:3] == pytest.approx([39600.0, 0.0, 3600.0]) assert "storage-efficiency" not in flex_model @@ -694,8 +694,8 @@ async def version_at_least(version): assert isinstance(flex_model, dict) # Planned without a correction: the forecast goes with the request, without its negative values. assert [step["value"] for step in flex_model["soc-usage"][0]] == [ - "36.0 kW", - "0.0 kW", + "36000.0 W", + "0.0 W", ] From 197e0ff47ef223c0fafc803e309379d7e0a806a9 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 9 Nov 2026 19:38:42 +0100 Subject: [PATCH 5/7] test(FRBC): check the value of a posted fill rate, not only its unit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW Signed-off-by: F.N. Claessen --- tests/s2/test_frbc_declared_profiles.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/s2/test_frbc_declared_profiles.py b/tests/s2/test_frbc_declared_profiles.py index f6801d69..70e1bc61 100644 --- a/tests/s2/test_frbc_declared_profiles.py +++ b/tests/s2/test_frbc_declared_profiles.py @@ -626,6 +626,13 @@ async def test_an_actuator_s_fill_rate_is_posted_in_the_fill_rate_unit( ) assert _posted(frbc, fill_rate_sensor_id)["unit"] == frbc.fill_rate_unit() == "W" + # Halfway through the mode's fill rate, from kWh/s into W. + fill_rate = actuator.operation_modes[0].elements[0].fill_rate + halfway = (fill_rate.start_of_range + fill_rate.end_of_range) / 2 + assert halfway > 0 + assert _posted(frbc, fill_rate_sensor_id)["values"] == pytest.approx( + [halfway * 3.6e6] + ) # The power sensor really does carry a power, and is untouched by this. assert _posted(frbc, 1)["unit"] == frbc.power_unit From 3b183450c6bc7e457cdf99d9ff631b7031b8f3d4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 9 Nov 2026 20:18:59 +0100 Subject: [PATCH 6/7] feat(site): create a site's power sensors in W, like its rate sensors Every power and rate of fill level the CEM sets up is now in W, the handler's default: power, measured-power and usage correction joined fill rate, usage forecast and leakage. Watts read a residential site's small powers as such; a larger site passes its own sensors. An existing site keeps its sensors, and one in another unit than a new site would get is logged as a warning. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW Signed-off-by: F.N. Claessen --- CHANGELOG-S2.rst | 2 +- src/flexmeasures_client/s2/site.py | 21 +++++++++--- tests/s2/test_site.py | 53 ++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/CHANGELOG-S2.rst b/CHANGELOG-S2.rst index 022df023..19fc333a 100644 --- a/CHANGELOG-S2.rst +++ b/CHANGELOG-S2.rst @@ -52,7 +52,7 @@ Added * ``FRBCSimple`` takes an optional ``fill_rate_sensor_id``, and records the actuator's fill rate there. The fill rate is recorded in ``fill_rate_unit``, not in ``power_unit``: a fill rate is what the actuator delivers into its buffer, which for a heat buffer is not electricity at all [`#236 `__, `#244 `__] * ``flexmeasures_client.s2.clock``, with a ``WallClock`` and a ``SimulatedClock``, and ``CEM(clock=...)``. The clock sets the time for the CEM and for every handler it registers. That includes their timers, and the time a recorded state of charge is known from [`#264 `__, `#268 `__] * ``CEM(accept_simulation_metadata=True)`` accepts messages from a co-simulation that are wrapped with the simulated time (``S2Wrapper``), and sets the CEM's clock from them. It is off by default, and then a wrapped message is refused as invalid. The example server turns it on with ``S2_ACCEPT_SIMULATION_METADATA`` [`#264 `__] -* ``flexmeasures_client.s2.site``, with ``FRBC_SITE_SENSORS``, ``ensure_site`` and ``FRBCSiteConfigurator``. It gives each resource manager its own FlexMeasures site, named after it, with an FRBC handler connected to that site's sensors [`#265 `__] +* ``flexmeasures_client.s2.site``, with ``FRBC_SITE_SENSORS``, ``ensure_site`` and ``FRBCSiteConfigurator``. It gives each resource manager its own FlexMeasures site, named after it, with an FRBC handler connected to that site's sensors. The sensors' units suit a residential site and match the handler's defaults: powers and rates of fill level in ``W``, energy and fill levels in ``kWh``, and prices per ``kWh``. For sites at a larger scale, such as charge points, pass ``sensors`` with other units, and the handler's ``power_unit`` and ``fill_rate_unit`` in ``handler_options``. An existing site keeps the sensors it has, and a sensor in another unit than a new site would get is logged as a warning [`#265 `__, `#289 `__] * ``CEM(site_configurator=...)`` sets up a resource manager's site once its details arrive. Until then, the CEM answers its messages with a temporary error. The example server enables this with ``S2_SITE_PER_RESOURCE_MANAGER`` [`#265 `__] * ``examples/S2-co-simulation``: a FlexMeasures stack with one CEM per simulated home, for co-simulations whose resource managers connect over websockets [`#265 `__, `#267 `__] * ``flexmeasures_client.s2.measurements.MeasurementRecorder`` and ``CEM(measurement_recorder=...)`` record a resource manager's measured power. Use one recorder per server, so that a resource manager that reconnects is not recorded twice [`#266 `__] diff --git a/src/flexmeasures_client/s2/site.py b/src/flexmeasures_client/s2/site.py index eee48425..2137d788 100644 --- a/src/flexmeasures_client/s2/site.py +++ b/src/flexmeasures_client/s2/site.py @@ -38,7 +38,7 @@ class SiteSensor: #: The sensors an FRBC site needs, by name. -#: Their units are the units the CEM records in: fill levels in kWh, and fill rates, usage and leakage in W, which S2 states per second. +#: Their units suit a residential site, and match the handler's defaults: energy and fill levels in kWh, prices per kWh, and powers and rates of fill level in W. #: The names are a contract with any reader of the site, such as a co-simulation. #: A co-simulation looks up "price", "production price", "power", "state of charge" and "measured-power" by name. FRBC_SITE_SENSORS: dict[str, SiteSensor] = { @@ -48,9 +48,9 @@ class SiteSensor: # This sensor holds both an actuator's power and the schedule planned for it. # Without this attribute, FlexMeasures treats production as positive on a power sensor. # It would then flip the sign of an incoming schedule, so the power and the schedule would point in opposite directions. - "power": SiteSensor("kW", "PT15M", {"consumption_is_positive": True}), + "power": SiteSensor("W", "PT15M", {"consumption_is_positive": True}), # The power the resource manager measures its site drawing, kept separate from the schedule on "power". - "measured-power": SiteSensor("kW", "PT15M", {"consumption_is_positive": True}), + "measured-power": SiteSensor("W", "PT15M", {"consumption_is_positive": True}), "state of charge": SiteSensor("kWh", "PT0M"), "RM discharge": SiteSensor("dimensionless", "PT15M"), "fill rate": SiteSensor("W", "PT15M"), @@ -66,7 +66,7 @@ class SiteSensor: #: It holds the rate of fill level the correction adds to the stock: positive where it gives back usage, negative where it takes away a gain. #: Kept apart from FRBC_SITE_SENSORS, since a site only gets it where the deployment asks for a correction. USAGE_CORRECTION_SENSORS: dict[str, SiteSensor] = { - "usage correction": SiteSensor("kW", "PT15M", {"consumption_is_positive": True}), + "usage correction": SiteSensor("W", "PT15M", {"consumption_is_positive": True}), } @@ -106,6 +106,7 @@ async def ensure_site( """Find or create the site asset with this name, with its specs, all of these sensors, and its graphs. Running this again is safe: existing parts are kept, and missing parts are created. + An existing sensor in another unit than its spec is kept as well, with a warning. A flex model in the specs is merged into the asset's existing flex model, because others write to it too. For example, a co-simulation's controller sets the value of the asset's stock at the end. Graphs are only set if the asset has none, because others add to them too. @@ -154,7 +155,17 @@ async def ensure_site( existing = {sensor["name"]: sensor for sensor in site_asset.get("sensors", [])} made: dict[str, dict] = {} for sensor_name, spec in sensors.items(): - made[sensor_name] = existing.get(sensor_name) or await fm_client.add_sensor( + kept = existing.get(sensor_name) + if kept is not None and kept.get("unit") not in (None, spec.unit): + # A sensor with data cannot change its unit, and FlexMeasures converts what is posted into it, so it is kept. + _LOGGER.warning( + "Site %r keeps its sensor %r in %s, where a new site would get one in %s; values posted to it are converted.", + name, + sensor_name, + kept["unit"], + spec.unit, + ) + made[sensor_name] = kept or await fm_client.add_sensor( name=sensor_name, event_resolution=spec.resolution, unit=spec.unit, diff --git a/tests/s2/test_site.py b/tests/s2/test_site.py index 5db6ec93..4e5cae3f 100644 --- a/tests/s2/test_site.py +++ b/tests/s2/test_site.py @@ -14,6 +14,7 @@ from flexmeasures_client.s2.cem import CEM from flexmeasures_client.s2.site import ( FRBC_SITE_SENSORS, + USAGE_CORRECTION_SENSORS, FRBCSiteConfigurator, SiteSensor, ensure_site, @@ -84,6 +85,58 @@ async def test_an_existing_site_keeps_its_sensors_and_gets_the_missing_ones(): assert specs_update.args == (7, {"generic_asset_type_id": 6}) +@pytest.mark.asyncio +async def test_an_existing_sensor_in_another_unit_is_kept_and_said_so(caplog): + fm = _flexmeasures( + [ + { + "id": 7, + "name": "home A", + "sensors": [ + {"id": 70, "name": "power", "unit": "kW"}, + {"id": 71, "name": "state of charge", "unit": "kWh"}, + ], + }, + ] + ) + with caplog.at_level("WARNING"): + _, sensors = await ensure_site( + fm, + "home A", + asset_specs={}, + sensors=FRBC_SITE_SENSORS, + timezone="UTC", + ) + assert sensors["power"]["id"] == 70 + warnings = [r.getMessage() for r in caplog.records if r.levelname == "WARNING"] + assert warnings == [ + "Site 'home A' keeps its sensor 'power' in kW, where a new site would get one in W; values posted to it are converted." + ] + + +def test_a_site_s_sensors_suit_a_residential_site(): + """Powers and rates of fill level in W, energy in kWh: in kWh/s, a heat buffer's leakage of some tens of watts would read about 0.00001.""" + units = { + name: spec.unit + for name, spec in {**FRBC_SITE_SENSORS, **USAGE_CORRECTION_SENSORS}.items() + } + assert units == { + "price": "EUR/kWh", + "production price": "EUR/kWh", + "power": "W", + "measured-power": "W", + "state of charge": "kWh", + "RM discharge": "dimensionless", + "fill rate": "W", + "soc minima": "kWh", + "soc maxima": "kWh", + "usage forecast": "W", + "leakage": "W", + "charging efficiency": "dimensionless", + "usage correction": "W", + } + + @pytest.mark.asyncio async def test_a_known_site_is_fetched_by_id_rather_than_found_in_a_listing(): """A resource manager reconnects every round of a co-simulation; a listing of every asset each time is slow.""" From f2d9ccafa414e8809c0b192aa27b1a6200017751 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 9 Nov 2026 20:29:25 +0100 Subject: [PATCH 7/7] feat(site): drop the warning about a kept sensor in another unit FlexMeasures converts what is posted into a sensor's own unit, so an older site's sensor in kW is a valid choice, not a fault. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW Signed-off-by: F.N. Claessen --- CHANGELOG-S2.rst | 2 +- src/flexmeasures_client/s2/site.py | 13 +------------ tests/s2/test_site.py | 29 ----------------------------- 3 files changed, 2 insertions(+), 42 deletions(-) diff --git a/CHANGELOG-S2.rst b/CHANGELOG-S2.rst index 19fc333a..49e13f7d 100644 --- a/CHANGELOG-S2.rst +++ b/CHANGELOG-S2.rst @@ -52,7 +52,7 @@ Added * ``FRBCSimple`` takes an optional ``fill_rate_sensor_id``, and records the actuator's fill rate there. The fill rate is recorded in ``fill_rate_unit``, not in ``power_unit``: a fill rate is what the actuator delivers into its buffer, which for a heat buffer is not electricity at all [`#236 `__, `#244 `__] * ``flexmeasures_client.s2.clock``, with a ``WallClock`` and a ``SimulatedClock``, and ``CEM(clock=...)``. The clock sets the time for the CEM and for every handler it registers. That includes their timers, and the time a recorded state of charge is known from [`#264 `__, `#268 `__] * ``CEM(accept_simulation_metadata=True)`` accepts messages from a co-simulation that are wrapped with the simulated time (``S2Wrapper``), and sets the CEM's clock from them. It is off by default, and then a wrapped message is refused as invalid. The example server turns it on with ``S2_ACCEPT_SIMULATION_METADATA`` [`#264 `__] -* ``flexmeasures_client.s2.site``, with ``FRBC_SITE_SENSORS``, ``ensure_site`` and ``FRBCSiteConfigurator``. It gives each resource manager its own FlexMeasures site, named after it, with an FRBC handler connected to that site's sensors. The sensors' units suit a residential site and match the handler's defaults: powers and rates of fill level in ``W``, energy and fill levels in ``kWh``, and prices per ``kWh``. For sites at a larger scale, such as charge points, pass ``sensors`` with other units, and the handler's ``power_unit`` and ``fill_rate_unit`` in ``handler_options``. An existing site keeps the sensors it has, and a sensor in another unit than a new site would get is logged as a warning [`#265 `__, `#289 `__] +* ``flexmeasures_client.s2.site``, with ``FRBC_SITE_SENSORS``, ``ensure_site`` and ``FRBCSiteConfigurator``. It gives each resource manager its own FlexMeasures site, named after it, with an FRBC handler connected to that site's sensors. The sensors' units suit a residential site and match the handler's defaults: powers and rates of fill level in ``W``, energy and fill levels in ``kWh``, and prices per ``kWh``. For sites at a larger scale, such as charge points, pass ``sensors`` with other units, and the handler's ``power_unit`` and ``fill_rate_unit`` in ``handler_options``. [`#265 `__, `#289 `__] * ``CEM(site_configurator=...)`` sets up a resource manager's site once its details arrive. Until then, the CEM answers its messages with a temporary error. The example server enables this with ``S2_SITE_PER_RESOURCE_MANAGER`` [`#265 `__] * ``examples/S2-co-simulation``: a FlexMeasures stack with one CEM per simulated home, for co-simulations whose resource managers connect over websockets [`#265 `__, `#267 `__] * ``flexmeasures_client.s2.measurements.MeasurementRecorder`` and ``CEM(measurement_recorder=...)`` record a resource manager's measured power. Use one recorder per server, so that a resource manager that reconnects is not recorded twice [`#266 `__] diff --git a/src/flexmeasures_client/s2/site.py b/src/flexmeasures_client/s2/site.py index 2137d788..c2d328c2 100644 --- a/src/flexmeasures_client/s2/site.py +++ b/src/flexmeasures_client/s2/site.py @@ -106,7 +106,6 @@ async def ensure_site( """Find or create the site asset with this name, with its specs, all of these sensors, and its graphs. Running this again is safe: existing parts are kept, and missing parts are created. - An existing sensor in another unit than its spec is kept as well, with a warning. A flex model in the specs is merged into the asset's existing flex model, because others write to it too. For example, a co-simulation's controller sets the value of the asset's stock at the end. Graphs are only set if the asset has none, because others add to them too. @@ -155,17 +154,7 @@ async def ensure_site( existing = {sensor["name"]: sensor for sensor in site_asset.get("sensors", [])} made: dict[str, dict] = {} for sensor_name, spec in sensors.items(): - kept = existing.get(sensor_name) - if kept is not None and kept.get("unit") not in (None, spec.unit): - # A sensor with data cannot change its unit, and FlexMeasures converts what is posted into it, so it is kept. - _LOGGER.warning( - "Site %r keeps its sensor %r in %s, where a new site would get one in %s; values posted to it are converted.", - name, - sensor_name, - kept["unit"], - spec.unit, - ) - made[sensor_name] = kept or await fm_client.add_sensor( + made[sensor_name] = existing.get(sensor_name) or await fm_client.add_sensor( name=sensor_name, event_resolution=spec.resolution, unit=spec.unit, diff --git a/tests/s2/test_site.py b/tests/s2/test_site.py index 4e5cae3f..c38d5c25 100644 --- a/tests/s2/test_site.py +++ b/tests/s2/test_site.py @@ -85,35 +85,6 @@ async def test_an_existing_site_keeps_its_sensors_and_gets_the_missing_ones(): assert specs_update.args == (7, {"generic_asset_type_id": 6}) -@pytest.mark.asyncio -async def test_an_existing_sensor_in_another_unit_is_kept_and_said_so(caplog): - fm = _flexmeasures( - [ - { - "id": 7, - "name": "home A", - "sensors": [ - {"id": 70, "name": "power", "unit": "kW"}, - {"id": 71, "name": "state of charge", "unit": "kWh"}, - ], - }, - ] - ) - with caplog.at_level("WARNING"): - _, sensors = await ensure_site( - fm, - "home A", - asset_specs={}, - sensors=FRBC_SITE_SENSORS, - timezone="UTC", - ) - assert sensors["power"]["id"] == 70 - warnings = [r.getMessage() for r in caplog.records if r.levelname == "WARNING"] - assert warnings == [ - "Site 'home A' keeps its sensor 'power' in kW, where a new site would get one in W; values posted to it are converted." - ] - - def test_a_site_s_sensors_suit_a_residential_site(): """Powers and rates of fill level in W, energy in kWh: in kWh/s, a heat buffer's leakage of some tens of watts would read about 0.00001.""" units = {