From e1c9e6454ebf348bcb57cd38220ca27b3ff870c3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sun, 8 Nov 2026 15:48:40 +0100 Subject: [PATCH 1/2] docs(S2): say docstrings and comments plainly Rewrite the docstrings and comments that the S2 work added or changed, so that each one leads with what the code does or why, in short sentences with plain word order. Every fact, condition, unit and caveat is kept. Only text changed: each touched file parses to the same AST once docstrings are set aside. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW Signed-off-by: F.N. Claessen --- src/flexmeasures_client/client.py | 69 +- src/flexmeasures_client/s2/__init__.py | 4 +- src/flexmeasures_client/s2/cem.py | 99 +-- src/flexmeasures_client/s2/clock.py | 28 +- .../s2/control_types/FRBC/__init__.py | 60 +- .../s2/control_types/FRBC/frbc_simple.py | 597 +++++++++--------- .../s2/control_types/FRBC/frbc_tunes.py | 29 +- .../s2/control_types/FRBC/utils.py | 256 ++++---- .../s2/control_types/__init__.py | 20 +- .../s2/control_types/translations.py | 24 +- src/flexmeasures_client/s2/measurements.py | 88 +-- src/flexmeasures_client/s2/script/__init__.py | 5 +- .../s2/script/websockets_client.py | 98 +-- .../s2/script/websockets_server.py | 68 +- src/flexmeasures_client/s2/site.py | 83 +-- src/flexmeasures_client/s2/utils.py | 15 +- 16 files changed, 818 insertions(+), 725 deletions(-) diff --git a/src/flexmeasures_client/client.py b/src/flexmeasures_client/client.py index 002cd671..819228e7 100644 --- a/src/flexmeasures_client/client.py +++ b/src/flexmeasures_client/client.py @@ -459,11 +459,11 @@ def report_rate_limit(message: str) -> None: "Request retry timeout while connecting to the API." ) from exception - # request_once has already parsed this answer with aiohttp's json(), - # which refuses a content type that is not JSON, and the loop above reports that as a ConnectionError. - # A check that used to follow here refused the JSON variants aiohttp accepts too, such as application/problem+json, - # with an error that could not even be printed, so it is gone. - # A body that is not JSON under a JSON content type is refused by json() itself, as a JSONDecodeError. + # request_once has already parsed this response with aiohttp's json(). + # json() refuses a content type that is not JSON, and the loop above reports that as a ConnectionError. + # A check used to follow here, but it also refused JSON variants that aiohttp accepts, such as application/problem+json. + # Its error could not even be printed, so the check was removed. + # A body that is not JSON under a JSON content type makes json() itself raise a JSONDecodeError. return await response.json(), response.status async def request_once( @@ -677,22 +677,24 @@ async def post_sensor_data( The method automatically chooses the appropriate API endpoint based on the provided parameters. - Since FlexMeasures 0.33.0, a server with an ingestion worker available answers ``202 Accepted`` with the id of a background job, - rather than ``200 OK``, and ingests the data after the request has returned. - By default this method then waits for that job to finish, - so that a caller which reads the data back, or asks for a schedule over it, sees what it just posted. + Since FlexMeasures 0.33.0, a server with an available ingestion worker responds ``202 Accepted`` instead of ``200 OK``, + with the id of a background job. + The server then ingests the data after the request has returned. + By default, this method waits for that job to finish. + This way, a caller that reads the data back, or asks for a schedule over it, sees the data it just posted. :param await_ingestion: whether to wait for asynchronous ingestion to finish, - when the server answered ``202 Accepted`` with a job id. - Waiting is polled on the ``ingestion_polling_*`` profile, which is tighter than the one other jobs are polled on. + when the server responded ``202 Accepted`` with a job id. + The job is polled on the ``ingestion_polling_*`` profile, which is tighter than the profile for other jobs. A job that fails raises :class:`JobFailedError`. - A job still pending when ``self.ingestion_polling_timeout`` is reached is logged as an error and otherwise ignored, - since the data may well land moments later, and a caller that has waited that long is better served by carrying on than by failing. + A job that is still pending when ``self.ingestion_polling_timeout`` is reached is logged as an error and otherwise ignored. + The data may well land moments later, and a caller that has waited that long is better off carrying on than failing. Pass ``False`` to return as soon as the server has accepted the data. - A synchronous ``200 OK`` response is unaffected either way. - :param confirm_ingestion: whether ingestion that could not be confirmed while waiting for it raises, rather than being logged and ignored: - :class:`JobTimeoutError` where the job was still pending at the timeout, and what the lookup raised where looking the job up failed. - For a caller that remembers having recorded the data, and would otherwise never record it again. + Either way, a synchronous ``200 OK`` response is unaffected. + :param confirm_ingestion: whether to raise when ingestion could not be confirmed while waiting for it, instead of logging and ignoring that. + If the job was still pending at the timeout, this raises :class:`JobTimeoutError`. + If looking the job up failed, this raises the error from the lookup. + Use this if the caller remembers having recorded the data, and would otherwise never record it again. This function raises a ValueError when an unhandled status code is returned. """ @@ -778,18 +780,21 @@ async def _await_sensor_data_ingestion( ) -> None: """Wait for an ingestion job to finish, so that the data it carries can be read back. - Waiting is left to :func:`wait_for_job`, so ingestion is backed off like every other job this client waits for, - and reported through the same ``job_status_notifier``, on the ``ingestion_polling_*`` intervals rather than the ``job_polling_*`` ones. - - A failed job propagates as :class:`JobFailedError`, for the caller to handle like any other failed post. - Nothing else does. - A timeout is logged as an error and otherwise ignored, for the reason given in :func:`post_sensor_data`, - and so is a failure to look the job up at all, which says nothing about the data: - the server accepted that, and a job record can be gone, or answered for by something in front of the server, by the time it is asked about. - Raising there would report a post that succeeded as a post that failed, - unless the caller asked for ingestion to be confirmed, in which case both are raised once logged. - A lookup that 404s is reported as a ``ConnectionError`` by :func:`request`, which wraps every client error that way, - so that is among the ones caught here, alongside the ``ValueError`` an unexpected status code raises. + Waiting is delegated to :func:`wait_for_job`. + So ingestion is backed off like every other job this client waits for, + and reported through the same ``job_status_notifier``. + The only difference is that it uses the ``ingestion_polling_*`` intervals, instead of the ``job_polling_*`` ones. + + A failed job raises :class:`JobFailedError`, which the caller handles like any other failed post. + By default, no other error is raised. + A timeout is logged as an error and otherwise ignored, for the reason given in :func:`post_sensor_data`. + The same goes for a failure to look the job up at all, which says nothing about the data. + After all, the server did accept the data. + By the time the job is looked up, its record may be gone, or something in front of the server may respond instead. + Raising in these cases would report a successful post as a failed post. + If the caller asked for ingestion to be confirmed, both cases are logged and then raised. + A lookup that returns 404 is reported as a ``ConnectionError`` by :func:`request`, which wraps every client error that way. + So ``ConnectionError`` is among the errors caught here, alongside the ``ValueError`` that an unexpected status code raises. """ try: await self.wait_for_job( @@ -1555,10 +1560,10 @@ async def add_asset( } This function raises a ValueError when an unhandled status code is returned, - when no generic_asset_type_id is given, and when only one of latitude and longitude is. + when no generic_asset_type_id is given, and when only one of latitude and longitude is given. """ if generic_asset_type_id is None: - # Optional in the signature only so that latitude and longitude, before it, can be left out, + # The signature makes this optional only so that latitude and longitude, which come before it, can be left out, # while every argument keeps its position. raise ValueError("add_asset needs a generic_asset_type_id.") if (latitude is None) != (longitude is None): @@ -1570,7 +1575,7 @@ async def add_asset( account_id=account_id, generic_asset_type_id=generic_asset_type_id, ) - # A location is optional to FlexMeasures, which many assets have no use for, such as one standing for a community. + # FlexMeasures does not require a location, and many assets have no use for one, such as an asset that represents a community. if latitude is not None: asset["latitude"] = latitude if longitude is not None: diff --git a/src/flexmeasures_client/s2/__init__.py b/src/flexmeasures_client/s2/__init__.py index 04a7cbcb..9f2ed60e 100644 --- a/src/flexmeasures_client/s2/__init__.py +++ b/src/flexmeasures_client/s2/__init__.py @@ -127,7 +127,7 @@ def __init__(self, max_size: int = 100, timezone: str = "UTC") -> None: self.outgoing_messages_status = SizeLimitOrderedDict(max_size=max_size) self._timezone = ZoneInfo(timezone) - #: The time this handler goes by; a CEM gives each handler it registers its own clock. + #: The clock this handler reads the time from. A CEM sets this to its own clock for each handler it registers. self._clock: Clock = WallClock() self.discover() @@ -253,5 +253,5 @@ def handle_revoke_object(self, message: RevokeObject) -> ReceptionStatus: ) def now(self) -> datetime: - """The current moment by this handler's clock, in its time zone.""" + """Return the current moment by this handler's clock, in the handler's timezone.""" return self._clock.now().astimezone(self._timezone) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index 6d1f70e4..eb3707a5 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -48,7 +48,7 @@ class ConfiguredHandler(Protocol): - """What a site configurator gives back: the handler for a resource manager, and its measured-power sensors.""" + """What a site configurator returns: the handler for a resource manager, and its measured-power sensors.""" @property def handler(self) -> ControlTypeHandler: ... @@ -60,10 +60,10 @@ def power_sensor_ids(self) -> dict[str, int]: ... def asset_id(self) -> int: ... -#: Gives a resource manager a site and a handler of its own, from its details (see flexmeasures_client.s2.site). +#: A callable that takes a resource manager's details and gives it its own site and handler (see flexmeasures_client.s2.site). SiteConfigurator = Callable[[ResourceManagerDetails], Awaitable[ConfiguredHandler]] -#: The control type each family of control-type messages belongs to, by the prefix of its message type. +#: Maps the prefix of a message type to the control type that its messages belong to. CONTROL_TYPE_BY_MESSAGE_PREFIX: dict[str, ControlType] = { "FRBC.": ControlType.FILL_RATE_BASED_CONTROL, "PEBC.": ControlType.POWER_ENVELOPE_BASED_CONTROL, @@ -111,22 +111,24 @@ def __init__( """ Customer Energy Manager (CEM) - :param site_configurator: where a resource manager gets a site and a handler of its own on connecting; - called with its details, in the background (see FRBCSiteConfigurator). - Until the handler is registered, its messages are answered with TEMPORARY_ERROR. - Without one, the handlers registered up front serve every resource manager. - :param measurement_recorder: where the resource manager's measured power is recorded; one of its own by default. - A server that makes a CEM per connection hands each the same one, - so a resource manager that reconnects is not recorded twice. - :param clock: the time this CEM and every handler it registers go by; - the wall clock by default. - :param accept_simulation_metadata: whether to take messages wrapped with a simulated time, as a co-simulation sends them, + :param site_configurator: gives a resource manager its own site and handler when it connects. + It is called with the resource manager's details, in the background (see FRBCSiteConfigurator). + Until the handler is registered, the CEM responds to the resource manager's messages with TEMPORARY_ERROR. + Without a site configurator, the handlers registered up front serve every resource manager. + By default, there is none. + :param measurement_recorder: records the resource manager's measured power. + By default, the CEM creates its own recorder. + A server that creates a CEM per connection should hand each CEM the same recorder. + Then a resource manager that reconnects does not get its measurements recorded twice. + :param clock: the clock that this CEM and every handler it registers read the time from. + By default, this is the wall clock. + :param accept_simulation_metadata: whether to accept messages wrapped with a simulated time, as a co-simulation sends them, and set the clock to that time (see flexmeasures_client.s2.wrapper.S2Wrapper). - Off by default, since the wrapper is not part of S2, + This is off by default, because the wrapper is not part of S2, and a live CEM should not let a resource manager set its time. - A resource manager that wraps its messages, from its handshake on, - is then refused with INVALID_MESSAGE and cannot connect. - On, the clock has to be a SimulatedClock, and one is made where none is given. + When it is off, a resource manager that wraps its messages from its handshake on is refused with INVALID_MESSAGE, and cannot connect. + When it is on, the clock must be a SimulatedClock. + If no clock is given, a SimulatedClock is created. """ super(CEM, self).__init__() if accept_simulation_metadata: @@ -149,7 +151,7 @@ def __init__( fm_client, logger=logger ) self._configuring: asyncio.Task | None = None - #: The control type this CEM asked the resource manager to select, and the id of that message, until confirmed. + #: The control type this CEM asked the resource manager to select, and the id of that request. Reset once confirmed. self._selecting: tuple[ControlType, str] | None = None self._default_control_type = default_control_type @@ -163,11 +165,11 @@ def __init__( self._datastore = datastore if datastore is not None else {} def _is_timer_due(self, name: str) -> bool: - # By this handler's clock, so that a co-simulation's timers run on simulated time. + # Use this handler's clock, so that a co-simulation's timers run on simulated time. now = self.now() due_time = self._timers.get(name, now - self._minimum_measurement_period) if due_time.tzinfo is None: - # A timer handed in without a zone was compared with the host's local time, so it is read as that, as before. + # A timer without a timezone used to be compared with the host's local time, so keep reading it as local time. due_time = due_time.astimezone(self._timezone) if due_time <= now: # Get total seconds of the period @@ -185,8 +187,8 @@ def _is_timer_due(self, name: str) -> bool: next_due = now.replace(minute=0, second=0, microsecond=0) + timedelta( seconds=next_tick_seconds ) - # On a period boundary the ceiling is the moment itself, - # and a clock that stands still, as a simulated one does between messages, then found the timer due on every call. + # On a period boundary, the ceiling equals the current moment. + # A clock that stands still (as a simulated clock does between messages) would then find the timer due on every call. if next_due <= now: next_due += self._minimum_measurement_period self._timers[name] = next_due @@ -231,7 +233,7 @@ def register_control_type(self, control_type_handler: ControlTypeHandler): # add fm_client to control_type handler control_type_handler._fm_client = self._fm_client - # The handler goes by the CEM's time, which a co-simulation sets. + # The handler uses the CEM's clock, which a co-simulation sets. control_type_handler._clock = self._clock # add sending queue @@ -261,7 +263,7 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str): message = json.loads(message) assert isinstance( message, dict - ) # what a message is from here on, whatever form it came in + ) # from here on, the message is a dict, whatever form it came in if "metadata" in message and "message" in message: unwrapped = self._unwrap(message) @@ -273,8 +275,8 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str): self._activate_implicitly(message) # try to handle the message with the control_type handle - # A control type without a handler yet, as while a resource manager's site is being configured, - # leaves its messages unhandled, so they are answered with a TEMPORARY_ERROR for the resource manager to retry. + # A control type may have no handler yet, for example while the resource manager's site is being configured. + # Its messages are then left unhandled, so the CEM responds with a TEMPORARY_ERROR, and the resource manager retries. handler = self._control_types_handlers.get(self._control_type) if handler is None and self._control_type is not None: self._ensure_configuring() @@ -308,10 +310,11 @@ async def handle_message(self, message: Dict | pydantic.BaseModel | str): await self._sending_queue.put(response) def _unwrap(self, wrapped: dict) -> dict | None: - """The S2 message inside a co-simulation's wrapper, with the clock set to the simulated time it carries. + """Return the S2 message inside a co-simulation's wrapper, and set the clock to the wrapper's simulated time. - Where this CEM does not accept the wrapper, the time cannot be read, or there is no message inside, - the message is refused, the clock is left as it was, and None is returned so that nothing is handled. + The message is refused in three cases: + this CEM does not accept the wrapper, the time cannot be read, or there is no message inside. + Then the clock is left unchanged, and None is returned, so that nothing is handled. """ inner = wrapped["message"] reason = None @@ -345,15 +348,16 @@ def _unwrap(self, wrapped: dict) -> dict | None: return None def _activate_implicitly(self, message: dict) -> None: - """Take a message of the control type this CEM is selecting as the resource manager's acceptance of it. - - A resource manager sends a control type's messages only once that control type is active on its side. - So where one arrives for the control type this CEM asked for, while the CEM still waits for the confirmation, - the resource manager is working in it already. #188 saw the CEM stay in NO_SELECTION while a resource manager, - reconnecting during setup in a co-simulation, kept sending FRBC messages, each answered with a TEMPORARY_ERROR - and retried; whether the confirmation was lost or never sent was not established. - Only the control type being selected is activated, never another one a message happens to belong to, - and its pending confirmation is then no longer waited for. + """Treat a message of the control type being selected as the resource manager's acceptance of that control type. + + A resource manager only sends a control type's messages once that control type is active on its side. + Suppose such a message arrives for the control type this CEM asked for, while the CEM still waits for the confirmation. + Then the resource manager is already working in that control type. + In #188, a resource manager reconnected during setup in a co-simulation, and kept sending FRBC messages. + The CEM stayed in NO_SELECTION, responded to each message with a TEMPORARY_ERROR, and the resource manager retried. + It was not established whether the confirmation was lost or never sent. + This method only activates the control type being selected, never another control type that a message happens to belong to. + After that, the CEM no longer waits for the pending confirmation. """ if self._control_type != ControlType.NO_SELECTION or self._selecting is None: return @@ -481,12 +485,12 @@ async def handle_resource_manager_details(self, message: ResourceManagerDetails) return get_reception_status(message) def _ensure_configuring(self) -> None: - """Set the resource manager's site up in the background, unless that is done or under way. + """Set up the resource manager's site in the background, unless that is already done or under way. - In the background, since a site takes a few requests to FlexMeasures to set up, - and the resource manager waits for a reception status meanwhile. - A setup that failed is tried again on the next message that needs it, - while the resource manager's messages are answered with TEMPORARY_ERROR, which it retries. + Setting up a site takes a few requests to FlexMeasures, and meanwhile the resource manager waits for a reception status. + That is why this runs in the background. + If the setup fails, it is tried again on the next message that needs it. + Meanwhile, the CEM responds to the resource manager's messages with TEMPORARY_ERROR, and the resource manager retries them. """ if ( self._site_configurator is None @@ -523,7 +527,7 @@ async def _configure_site(self, details: ResourceManagerDetails) -> None: async def handle_power_measurement(self, message: PowerMeasurement): if self._site_configurator is not None and not self._control_types_handlers: - # Its site is still being set up, so there is nowhere to record it yet: the resource manager is asked to resend it. + # The site is still being set up, so there is no sensor to record on yet. Ask the resource manager to resend the message. self._ensure_configuring() return get_reception_status(message, ReceptionStatusValues.TEMPORARY_ERROR) @@ -531,8 +535,8 @@ async def handle_power_measurement(self, message: PowerMeasurement): for power_measurement in message.values: commodity_quantity = power_measurement.commodity_quantity.value - # Without a sensor for it, a measurement is not recorded. - # It used to go to sensor 357 for ELECTRIC.POWER.L1, a sensor of whichever site on the server had that id. + # A measurement without a sensor is not recorded. + # An ELECTRIC.POWER.L1 measurement used to go to sensor 357, which could belong to any site on the server. sensor_id = (self.power_sensor_id or {}).get(commodity_quantity) if sensor_id is None: self._logger.debug( @@ -544,7 +548,8 @@ async def handle_power_measurement(self, message: PowerMeasurement): continue taken.append((sensor_id, power_measurement.value)) - # Every sensor is made ready before any value is taken, so that a message asked for again is not taken in part twice. + # Get every sensor ready before taking any value. + # Otherwise, part of a message could be taken twice, if the resource manager is asked to send it again. try: for sensor_id, _ in taken: await self._measurement_recorder.resolution(sensor_id) diff --git a/src/flexmeasures_client/s2/clock.py b/src/flexmeasures_client/s2/clock.py index 55646905..19aa6045 100644 --- a/src/flexmeasures_client/s2/clock.py +++ b/src/flexmeasures_client/s2/clock.py @@ -1,9 +1,9 @@ -"""The time an S2 CEM and its handlers go by. +"""Clocks that tell an S2 CEM and its handlers what time it is. -A CEM that runs live goes by the wall clock. -One that steers a co-simulation goes by the time the simulation says it is, -which a resource manager sends along with its messages (see CEM's accept_simulation_metadata), -so that what the CEM records and plans is placed at simulated moments rather than at the moment it ran. +A CEM that runs live uses the wall clock. +A CEM that steers a co-simulation uses the simulated time instead. +The resource manager sends that time along with its messages (see the CEM's accept_simulation_metadata). +This way, the CEM records and plans at simulated moments, not at the moments the code actually ran. """ from __future__ import annotations @@ -14,24 +14,26 @@ class Clock(Protocol): def now(self) -> datetime: - """The current moment, timezone-aware.""" + """Return the current moment, timezone-aware.""" ... class WallClock: - """The time it is.""" + """A clock that returns the current wall-clock time, in UTC.""" def now(self) -> datetime: return datetime.now(timezone.utc) class SimulatedClock: - """The time a simulation says it is, as last set. - - It stands still between settings, as a simulation's time does between the messages that carry it. - Until it is first set it goes by the wall clock, for a resource manager that does not wrap its first messages; - one that wraps every message from its handshake on, as the co-simulation's does, sets it with the first. - Background work, such as a schedule being planned, reads it as it stands, which may be a later message's time. + """A clock that returns the simulated time it was last set to. + + The clock stands still between settings, just as simulated time stands still between the messages that carry it. + Until it is first set, it returns the wall-clock time. + This suits a resource manager that does not wrap its first messages. + A resource manager that wraps every message from its handshake on (as the co-simulation's does) sets the clock with its first message. + Background work, such as planning a schedule, reads the clock at the moment it runs. + By then, a later message may have set it to a later time. """ def __init__(self, start: datetime | None = None) -> None: diff --git a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py index 169de5aa..140bd290 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py @@ -209,16 +209,17 @@ def system_description_fingerprint(message: FRBCSystemDescription) -> int: def handle_system_description( self, message: FRBCSystemDescription ) -> pydantic.BaseModel: - # A resend is acknowledged, and otherwise ignored: storing it again would push a - # description out of a bounded history for no reason, and a handler that did act on a - # description would act on it twice. - # Acknowledging it still matters, or the resource manager retries. - # Ignoring a resend is safe now that planning hangs off storage statuses rather than - # descriptions, and not before: it would otherwise have dropped a planning request. - # The TUNES handler has fingerprinted its descriptions this way all along, and can adopt - # this shared helper separately. - # Every plan is made for one actuator, so a description of several is refused when it arrives, - # rather than acknowledged and then failing in the background on every round, after a solve. + # A resend is acknowledged, and otherwise ignored. + # Storing it again would push an older description out of the bounded history for no reason. + # Also, a handler that acts on a description would act on it twice. + # The acknowledgement still matters, because without it the resource manager retries. + # Ignoring a resend is safe because planning is triggered by storage statuses, not by descriptions. + # Before that was the case, ignoring a resend would have dropped a planning request. + # The TUNES handler has always fingerprinted its descriptions this way. + # It can adopt this shared helper separately. + # Every plan is made for exactly one actuator. + # So a description with several actuators is refused on arrival. + # Otherwise it would be acknowledged, and then fail in the background on every round, after a solve. if len(message.actuators) != 1: self._logger.error( "Refusing an FRBC.SystemDescription with %s actuators: this CEM plans for exactly one.", @@ -242,8 +243,8 @@ def handle_system_description( # store system_description message for later self._system_description_history[system_description_id] = message - # Said once per description, since nothing here would otherwise say it at all: - # an instruction can ask for a switch that a running timer or a missing transition forbids, and the device refuses it. + # Warn once per description, because nothing else here would mention it. + # An instruction can ask for a switch that a running timer or a missing transition forbids, and then the device refuses it. actuator = message.actuators[0] if _transitions_restrict(actuator): self._logger.warning( @@ -252,13 +253,16 @@ def handle_system_description( len(actuator.transitions), ) - # A description says what the device can do, and a storage status what it holds now, - # and a schedule needs both. Planning normally hangs off a storage status (see handle_storage_status), - # but a description that arrives after the latest status, and differs from the description before it, - # is answered with a schedule against that latest status. - # Anything but the message id counts as a difference, its validity included, - # so a resource manager that re-dates its description on every resend asks for a schedule every time. - # Otherwise a resource manager that sends its status first, or whose fill level holds steady so that no new status follows, is never planned for, or keeps being planned for with what it can no longer do. + # A description says what the device can do, and a storage status says what it holds now. + # A schedule needs both. + # Planning is normally triggered by a storage status (see handle_storage_status). + # But suppose a description arrives after the latest status, and differs from the previous description. + # Then the CEM plans a schedule from that latest status. + # Any difference other than the message id counts, including the validity period. + # So a resource manager that re-dates its description on every resend triggers a schedule every time. + # Without this, two kinds of resource managers would be served badly. + # One that sends its status first would never be planned for. + # One whose fill level holds steady sends no new status, so it would keep being planned for with capabilities it no longer has. if self._storage_status_history: request_number = self._count_request(STORAGE_STATUS_REQUESTS) task = asyncio.create_task( @@ -353,8 +357,8 @@ def handle_fill_level_target_profile( @register(FRBCTimerStatus) def handle_frbc_timer_status(self, message: FRBCTimerStatus) -> pydantic.BaseModel: - # Kept, so that what the resource manager said is there to see, though nothing plans with it yet. - # Warned about once per timer, since a resource manager sends a status on every run of every timer. + # Store the timer status, so that it can be inspected, even though no planning uses it yet. + # Warn only once per timer, because a resource manager sends a status every time any timer runs. warned = any( known.timer_id == message.timer_id for known in self._timer_status_history.values() @@ -425,7 +429,7 @@ async def trigger_schedule( actuator = system_description.actuators[0] - # Imported here, since the utilities import from this package. + # Import here, because the utilities module imports from this package. from flexmeasures_client.s2.control_types.FRBC.utils import ( normal_operation_modes, ) @@ -444,13 +448,15 @@ async def trigger_schedule( def _transitions_restrict(actuator) -> bool: - """Whether an actuator's timers and transitions rule out a switch between two of its operation modes. + """Return whether an actuator's timers and transitions rule out a switch between two of its operation modes. - Transitions that allow every switch between modes usable under normal conditions, with no timer to wait for, - rule out nothing, as an on/off device that declares both of its switches does, - so only where something is ruled out is there anything this CEM fails to honour. + Some transitions rule out nothing. + That is the case when they allow every switch between the modes usable under normal conditions, and there is no timer to wait for. + An example is an on/off device that declares both of its switches. + This CEM does not honour timers and transitions, but that only matters if they rule something out. """ - # A transition can only be blocked by a timer the actuator declares, which s2python checks, so timers cover that too. + # A transition can only be blocked by a timer that the actuator declares (s2python checks this). + # So checking for timers covers blocked transitions too. if actuator.timers: return True if not actuator.transitions: 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 2b3bcb9f..bfaad55a 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -50,39 +50,41 @@ translate_usage_forecast_to_fm, ) -#: The unit S2 states a power value in, and so the unit of an operation mode's power ranges. -#: A schedule is read back in this unit, whatever unit the power sensor holding it is recorded in, -#: and what goes out to FlexMeasures in it is what came in from a resource manager in it. +#: The unit of power values in S2, and so the unit of an operation mode's power ranges. +#: A schedule is read back in this unit, whatever unit its power sensor records in. +#: Values sent to FlexMeasures in this unit are passed on as they came in from the resource manager. S2_POWER_UNIT = "W" -#: How many times a schedule is asked for before the storage is left without an instruction for its window. -#: Every attempt costs the server a solve, so this is a handful rather than a vigil. +#: How many times to request a schedule before leaving the storage without an instruction for its window. +#: Every attempt costs the server a solve, so this number is kept small. SCHEDULE_ATTEMPTS = 5 #: How long to wait between two attempts at a schedule. -#: Flat rather than backed off: what it waits out is a post landing or a worker taking the job up, -#: and a resource manager is waiting on an instruction for the whole of it. +#: The wait is fixed, without backoff. +#: It only needs to cover a post landing or a worker picking up the job, and meanwhile a resource manager is waiting for an instruction. SCHEDULE_RETRY_INTERVAL = timedelta(seconds=2) -#: Causes of a failed scheduling job that fail the same way however often the job is asked for again. -#: Asking again only multiplies failed jobs over the server's workers, so these are never asked again. +#: Causes of a failed scheduling job that fail the same way, however often the job is requested again. +#: Requesting again only multiplies failed jobs across the server's workers, so these are never retried. NON_RETRYABLE_SCHEDULE_FAILURES = ( - # A scheduler that records a belief twice collides with its own first write on every attempt. + # A scheduler that records a belief twice collides with its own first write, on every attempt. "UniqueViolation", "duplicate key value violates unique constraint", # An infeasible problem is planned from the same flex model and context every time, so it stays infeasible. - # FlexMeasures answered one with a fallback scheduler until v1.0.0 retired it, and fails the job itself since. + # Until v1.0.0, FlexMeasures handled one with a fallback scheduler. + # Since v1.0.0 retired that fallback, the job itself fails. "InfeasibleProblemException", ) -#: The refusal of a trigger, rather than a failure of the job behind it, that is worth asking again about. -#: A schedule can be asked for before the state of charge it is to be planned from has landed, -#: and FlexMeasures refuses to plan from one it considers too old. +#: Refusals of a trigger (not failures of the job behind it) that are worth retrying. +#: A schedule can be requested before the state of charge to plan from has landed. +#: FlexMeasures refuses to plan from a state of charge that it considers too old. RETRYABLE_TRIGGER_REFUSALS = ("No recent state-of-charge value found",) -#: What a request to FlexMeasures can fail with, short of a defect in this code: -#: refused (ValueError), unreachable (ConnectionError, TimeoutError, ClientError), answered oddly (ContentTypeError), -#: or queued and then failed or never picked up (JobFailedError, JobTimeoutError). +#: The exceptions a request to FlexMeasures can raise when there is no defect in this code. +#: The request may be refused (ValueError), the server unreachable (ConnectionError, TimeoutError, ClientError), +#: or the response unexpected (ContentTypeError). +#: A queued job may also fail, or never be picked up (JobFailedError, JobTimeoutError). REQUEST_FAILURES = ( JobFailedError, JobTimeoutError, @@ -93,13 +95,13 @@ ClientError, ) -#: What FlexMeasures before v0.33.0 answers a failed scheduling job with, having no job-status API to say so: -#: an HTTP 400, which the client raises as a ValueError with this message and nothing more. +#: How FlexMeasures before v0.33.0 reports a failed scheduling job, since it has no job-status API. +#: It responds with an HTTP 400, which the client raises as a ValueError with only this message. BARE_REFUSAL = "Request failed with status code 400" def _merged(ranges: list[tuple[float, float]]) -> list[tuple[float, float]]: - """The union of closed ranges, as the fewest ranges that cover it, in order.""" + """Return the union of closed ranges, as the fewest ranges that cover it, in order.""" merged: list[tuple[float, float]] = [] for lower, upper in sorted(ranges): if merged and lower <= merged[-1][1]: @@ -110,7 +112,10 @@ def _merged(ranges: list[tuple[float, float]]) -> list[tuple[float, float]]: def _without_negative_usage(usage_forecast): - """A usage forecast with each negative expected rate taken as no usage, as one is recorded and planned against.""" + """Return a usage forecast in which each negative expected rate is replaced by zero. + + This is how a usage forecast is recorded and planned against. + """ if usage_forecast is None or all( element.usage_rate_expected >= 0 for element in usage_forecast.elements ): @@ -132,26 +137,27 @@ def _without_negative_usage(usage_forecast): def _operation_mode_power_bands( power_ranges: list[tuple[float, float]], power_unit: str ) -> list[dict[str, list[str]]]: - """Turn the power ranges an actuator states into the bands FlexMeasures plans within. - - A device with operation modes may only run inside one of its bands, - so an on/off heater is asked for as {0 W} and {883.7 W} rather than as everything in between, - which the resource manager would have to round into a full-on block, - overshooting whatever capacity the site is held to. - - S2 states one signed range, where positive means consumption, - while FlexMeasures asks for the two directions separately, each non-negative. - So a range is split at zero: its non-negative part is the consumption range, - its negative part is the production range with the sign flipped, - and a range spanning zero becomes both, each starting at zero. - - :param power_ranges: lower and upper bound of each range the actuator states, signed as S2 states them. - :param power_unit: the unit those bounds are in. - :returns: one band per distinct range, in the order the actuator stated them. + """Turn the power ranges an actuator declares into the bands FlexMeasures plans within. + + A device with operation modes may only run inside one of its bands. + So an on/off heater is requested as {0 W} and {883.7 W}, not as everything in between. + Otherwise, the resource manager would have to round a value in between into a full-on block. + That block would overshoot any capacity limit the site is held to. + + S2 gives one signed range, where positive means consumption. + FlexMeasures expects the two directions separately, each non-negative. + So a range is split at zero. + Its non-negative part becomes the consumption range. + Its negative part, with the sign flipped, becomes the production range. + A range that spans zero becomes both, each starting at zero. + + :param power_ranges: lower and upper bound of each range the actuator declares, signed as in S2. + :param power_unit: the unit of those bounds. + :returns: one band per distinct range, in the order the actuator declared them. """ - # Zero is the first argument of each max() below, so that a bound of exactly zero is stated as zero, - # rather than as the negative zero that flipping a sign leaves behind. + # Zero is the first argument of each max() below, so that a bound of exactly zero is written as zero, + # not as the negative zero that flipping a sign leaves behind. bands: list[dict[str, list[str]]] = [] for lower, upper in power_ranges: band: dict[str, list[str]] = {} @@ -166,7 +172,7 @@ def _operation_mode_power_bands( f"{-lower} {power_unit}", ] if not band: - # A range of exactly {0}: the mode in which the actuator draws nothing either way. + # A range of exactly {0}: the mode in which the actuator neither consumes nor produces. band["consumption-range"] = [f"0 {power_unit}", f"0 {power_unit}"] if band not in bands: bands.append(band) @@ -181,14 +187,14 @@ class FRBCSimple(FRBC): _soc_sensor_id: int _rm_discharge_sensor_id: int _fill_rate_sensor_id: int | None - #: Where what a resource manager declares about its storage is recorded, each optional: - #: without one, that declaration is not recorded, rather than being posted somewhere else. + #: The sensors to record the resource manager's declarations about its storage on. Each one is optional. + #: Without a sensor, that declaration is not recorded at all, instead of being posted somewhere else. _soc_minima_sensor_id: int | None _soc_maxima_sensor_id: int | None _usage_forecast_sensor_id: int | None _leakage_behaviour_sensor_id: int | None - #: Where what the operation modes say about converting drawn power into stored energy is recorded, - #: for the scheduler to plan the storage's conversion with. + #: The sensor to record the operation modes' conversion of drawn power into stored energy on. + #: The scheduler uses it to plan the storage's conversion. _charging_efficiency_sensor_id: int | None _resolution: timedelta _schedule_duration: timedelta @@ -196,16 +202,16 @@ class FRBCSimple(FRBC): _fill_level_scale: float _hard_bound_factor: float #: Whether the power sensor records consumption as positive, as S2 does. - #: Read from the sensor itself the first time a power value is posted, and remembered after that. - #: None until it has been read. + #: This is read from the sensor itself the first time a power value is posted, and remembered after that. + #: It is None until it has been read. _power_sensor_takes_consumption_as_positive: bool | None - #: When a power value was last recorded for the actuator, and for which moment, - #: so that a second status inside one event does not collide with the first. + #: For each moment, when a power value was last recorded for the actuator. + #: This prevents a second status within one event from colliding with the first. _power_recorded_at: dict[datetime, datetime] - #: The charging efficiency last recorded, and the window it was recorded for, - #: so that the same value for the same window is not recorded again on every schedule. + #: The last recorded charging efficiency, and the window it was recorded for. + #: This prevents recording the same value for the same window again on every schedule. _charging_efficiency_recorded: tuple[datetime, float] | None - #: How many times a schedule is asked for, and how long between two attempts. + #: How many times to request a schedule, and how long to wait between two attempts. _schedule_attempts: int _schedule_retry_interval: timedelta @@ -215,52 +221,55 @@ def __init__( soc_sensor_id: int, rm_discharge_sensor_id: int, price_sensor_id: int, - # Where feeding power back is priced differently from drawing it, as a feed-in tariff is. - # Without one, both directions are priced by price_sensor_id. + # Use this if feeding power back is priced differently from drawing it, as with a feed-in tariff. + # Without it, both directions are priced by price_sensor_id. production_price_sensor_id: int | None = None, # Whether the site's connection capacity may be breached at a price (True) or never (False). - # Unset, it is left to FlexMeasures, which since v1.0 relaxes it at a high default breach price, - # under its relax-constraints umbrella, and before that did not. - # A home whose connection is a physical limit can pass False; - # a site whose capacity is a target set by others, as in a community steering its members, can pass True. + # If unset, FlexMeasures decides. + # Since v1.0, FlexMeasures relaxes it at a high default breach price, under its relax-constraints umbrella. + # Before v1.0, it did not relax it. + # A home whose connection is a physical limit can pass False. + # A site whose capacity is a target set by others, as in a community that steers its members, can pass True. relax_site_capacity: bool | None = None, - # Optional so existing callers keep working; when absent the actuator's fill rate is - # simply not recorded, rather than being posted onto the power sensor. + # Optional, so that existing callers keep working. + # Without it, the actuator's fill rate is not recorded at all, instead of being posted onto the power sensor. fill_rate_sensor_id: int | None = None, - # Likewise optional, for the three things a resource manager declares about its storage. + # Also optional: the sensors for three declarations a resource manager makes about its storage. soc_minima_sensor_id: int | None = None, soc_maxima_sensor_id: int | None = None, usage_forecast_sensor_id: int | None = None, - # Its resolution has to be the handler's resolution: a storage efficiency is what remains after one step, - # computed here for a step of the handler's resolution, - # and FlexMeasures reads it as one step of the sensor's own resolution. + # 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. leakage_behaviour_sensor_id: int | None = None, - # Likewise optional: without a sensor to record it on, no charging efficiency is declared, - # and the scheduler plans the storage as if the actuator converted power into stored energy one for one. + # Also optional. + # Without a sensor to record it on, no charging efficiency is declared. + # Then the scheduler plans the storage as if the actuator converted power into stored energy one to one. charging_efficiency_sensor_id: int | None = None, timezone: str = "UTC", schedule_duration: timedelta = timedelta(hours=12), - # How many times a schedule is asked for before the storage is left without an instruction, - # and how long between two attempts. - # One attempt means a failure is never answered by another, as it was before this was bounded at all. + # How many times to request a schedule before leaving the storage without an instruction, + # and how long to wait between two attempts. + # One attempt means a failure is never retried. + # Before this limit existed, a failure was always retried with another attempt. schedule_attempts: int = SCHEDULE_ATTEMPTS, schedule_retry_interval: timedelta = SCHEDULE_RETRY_INTERVAL, - # The resolution a schedule is planned on, which a schedule's start has to line up with. + # The resolution that schedules are planned on; a schedule's start must line up with it. resolution: timedelta = timedelta(minutes=15), max_size: int = 100, valid_from_shift: timedelta = timedelta(days=1), fill_level_scale: float = 1, - # How far above the declared fill-level range the hard upper bound of a schedule sits. - # Half again as high, so that a storage overshooting its declared range stays feasible. - # A deployment whose storage cannot overshoot at all can bring this down to 1. + # How far above the declared fill-level range a schedule's hard upper bound sits. + # The default is 1.5 times as high, so that a storage that overshoots its declared range stays feasible. + # A deployment whose storage cannot overshoot at all can lower this to 1. hard_bound_factor: float = 1.5, - # The unit a power value this handler posts is converted into and labelled with. - # Those values come from the operation modes' power ranges, which S2 states in watts. + # The unit that this handler converts posted power values into, and labels them with. + # These values come from the operation modes' power ranges, which S2 gives in watts. power_unit: str = "W", - # The unit a fill level is recorded in: a state of charge and the minima and maxima - # bounding it all go out in this one unit, so that they can be compared at all. - # Kilowatt hours, because S2 is a residential protocol and a home battery or heat buffer - # is of that order; a deployment at another scale says so here. + # The unit that fill levels are recorded in. + # A state of charge, and the minima and maxima that bound it, are all sent in this one unit, so that they can be compared. + # 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", ) -> None: super().__init__(max_size) @@ -289,14 +298,14 @@ def __init__( f"resolution and schedule_duration have to be positive, but {resolution} and {schedule_duration} were given." ) if schedule_duration % resolution: - # FlexMeasures answers with whole steps of the power sensor's resolution only, - # so a duration between two whole numbers of steps comes back with fewer values than it spans, - # and the schedule's step, read as its duration over its number of values, would come out wrong. + # FlexMeasures only returns whole steps of the power sensor's resolution. + # So a duration that is not a whole number of steps comes back with fewer values than it spans. + # The schedule's step is computed as its duration divided by its number of values, so it would come out wrong. raise ValueError( f"schedule_duration has to be a whole number of resolution steps, but {schedule_duration} is not a multiple of {resolution}." ) if schedule_attempts < 1: - # A schedule is asked for at least once, or no schedule is asked for at all. + # A schedule must be requested at least once, or there is no schedule at all. raise ValueError( f"schedule_attempts has to be at least one, since it counts the attempts at a schedule rather than the retries, but {schedule_attempts} was given." ) @@ -304,9 +313,9 @@ def __init__( self._schedule_retry_interval = schedule_retry_interval self._timezone = ZoneInfo(timezone) if fill_level_scale <= 0: - # Every fill level this handler records is multiplied by the scale, - # and the hard bounds a schedule was planned against are divided back by it, - # so zero divides by zero and a negative scale turns the bounds upside down. + # Every fill level this handler records is multiplied by the scale. + # The hard bounds that a schedule was planned against are divided by it again. + # So a scale of zero would divide by zero, and a negative scale would turn the bounds upside down. raise ValueError( f"fill_level_scale has to be greater than zero, since it converts a fill level into the unit it is recorded in, but {fill_level_scale} was given." ) @@ -316,17 +325,17 @@ def __init__( self._soc_sensor_records_instants: bool | None = None self._planning = asyncio.Lock() if hard_bound_factor < 1: - # Below one, the hard upper bound sits inside the declared range, - # and FlexMeasures clips the band into the hard bounds, - # so the band would be narrowed to something the resource manager never asked for. + # Below one, the hard upper bound sits inside the declared range. + # FlexMeasures clips the band to the hard bounds. + # So the band would be narrowed to something the resource manager never asked for. raise ValueError( f"hard_bound_factor has to be at least 1, so that the hard upper bound sits at or above the declared fill-level range, but {hard_bound_factor} was given." ) self._hard_bound_factor = hard_bound_factor self.power_unit = power_unit if energy_unit not in WATTS_PER_ENERGY_UNIT_PER_SECOND: - # A fill rate is per second of this unit, and has to be compared with the watts S2 states a power in, - # so a unit that cannot be converted to watts cannot be planned with. + # A fill rate is in this unit per second, and must be compared with power values, which S2 gives in watts. + # So a unit that cannot be converted to watts cannot be planned with. raise ValueError( f"energy_unit has to be one of {', '.join(WATTS_PER_ENERGY_UNIT_PER_SECOND)}, but {energy_unit!r} was given." ) @@ -364,12 +373,14 @@ def align_to_resolution(self, moment: datetime) -> datetime: resolution_seconds = int(self._resolution.total_seconds()) if resolution_seconds <= 0: return moment - # Floored on the moment's own clock, in the offset it carries, which is how FlexMeasures floors a series posted with it, - # so an hourly step in a zone half an hour off UTC, or a daily one, starts where FlexMeasures' steps do. - # A boundary in the repeated hour of an autumn DST change exists twice, and the one meant is the later of the two that is not after the moment: taking the first pass every time put a boundary an hour or more too early. - # A boundary in a spring gap, which only a resolution of more than an hour can land on, moves to the end of the gap. - # Compared as pandas timestamps, which compare instants: two datetimes on one local clock compare by wall time, - # whichever pass each is on. + # Floor in the moment's own timezone offset, which is how FlexMeasures floors a series posted with that offset. + # So an hourly step in a timezone that is half an hour off UTC, or a daily step, starts where FlexMeasures' steps start. + # During the repeated hour of an autumn DST change, a boundary occurs twice. + # The intended boundary is the later of the two that is not after the moment. + # Always taking the first pass put the boundary an hour or more too early. + # A boundary in a spring DST gap (which only a resolution of more than an hour can hit) moves to the end of the gap. + # Compare as pandas timestamps, which compare instants. + # Two datetimes in the same local timezone compare by wall time, whichever pass each is on. at = pd.Timestamp(moment) candidates = [ at.floor( @@ -381,15 +392,16 @@ def align_to_resolution(self, moment: datetime) -> datetime: return boundary.to_pydatetime() def schedule_start(self, valid_from: datetime) -> datetime: - """Where a schedule asked for at valid_from starts: shifted and floored to a boundary, but never before now. - - Never before now, since planning happens on every storage status while a description may be days old: - anchored to that description alone, every status in a long session was answered with the same window, - which slid into the past, so its instructions were all due already and arrived as one burst. - Where now is past that boundary, the schedule starts at the next one, not at the start of the step now is in. - A schedule is planned from the fill level reported now, and FlexMeasures takes that to be the level at its start, - so a schedule starting at a step already under way was planned as if the whole step were left, - and every replan within it asked for less power over what remained of it, delivering less than planned. + """Return the start of a schedule requested for valid_from: shifted and floored to a boundary, but never before now. + + Planning happens on every storage status, while a description may be days old. + If the start were based on that description alone, every status in a long session would get the same window. + That window slid into the past, so all its instructions were already due, and arrived as one burst. + Hence the start is never before now. + If now is past that boundary, the schedule starts at the next boundary, not at the start of the current step. + A schedule is planned from the fill level reported now, and FlexMeasures takes that as the fill level at the schedule's start. + So a schedule that started at a step already under way was planned as if the whole step remained. + Every replan within that step then asked for less power over the rest of it, and delivered less than planned. The step under way keeps the instruction it is already following. """ now = self.now() @@ -400,12 +412,13 @@ def schedule_start(self, valid_from: datetime) -> datetime: return current if current == now else current + self._resolution async def _soc_duration(self) -> timedelta: - """What a reported fill level is posted as lasting: an instant, where the state-of-charge sensor records instants. + """Return the duration to post a reported fill level with. - Posted over the minute after it, a reading on such a sensor was taken for a forecast of that minute. - FlexMeasures refuses a single value of no duration on a sensor with a resolution, though, - so on any other sensor, or one that cannot be read, it is posted over a minute, as before. - The sensor is asked until it answers, and its answer kept, since its resolution is a property of how it was set up. + If the state-of-charge sensor records instants, the duration is zero. + On such a sensor, a reading posted over the following minute was taken as a forecast of that minute. + However, FlexMeasures refuses a single value without duration on a sensor that has a resolution. + So on any other sensor, or on a sensor that cannot be read, the reading is posted over a minute, as before. + The sensor is queried until it responds, and the response is kept, because its resolution is fixed when the sensor is set up. """ if self._soc_sensor_records_instants is None: try: @@ -438,17 +451,18 @@ async def _soc_duration(self) -> timedelta: async def send_storage_status( self, status: FRBCStorageStatus, request_number: int | None = None ): - # Read by this handler's clock, and known as of then, which in a co-simulation is the simulated time: - # without a prior, FlexMeasures took the moment the request reached it, - # and a simulated reading was recorded as known years after it was taken. + # Use this handler's clock for both the reading time and the belief time (in a co-simulation, that is the simulated time). + # Without a prior, FlexMeasures used the moment the request reached it as the belief time. + # A simulated reading was then recorded as known years after it was taken. now = self.now() - # Guarded, because a failed post would otherwise unwind this method before the planning below, - # and a schedule starts from the state of charge this status reports, sent inline, not from the one recorded here. + # Catch failures here, because otherwise a failed post would exit this method before the planning below. + # Planning does not depend on this post: a schedule starts from the state of charge in this status, which is sent inline. try: await self._fm_client.post_measurements( self._soc_sensor_id, start=now, - # Scaled like every other fill level this handler records, or the level and the band around it would be in different scales wherever fill_level_scale is not 1. + # Scale this like every other fill level this handler records. + # Otherwise, if fill_level_scale is not 1, the fill level and the band around it would be on different scales. values=[status.present_fill_level * self._fill_level_scale], unit=self.energy_unit, duration=await self._soc_duration(), @@ -462,10 +476,10 @@ async def send_storage_status( now, ) - # The state of charge this status reports is what a schedule starts from, so this is - # where a schedule is asked for. - # A system description is needed to turn that schedule into instructions. - # Without one yet, nothing is planned here, and the description plans against this status when it arrives. + # A schedule starts from the state of charge in this status, so this is where a schedule is requested. + # Turning that schedule into instructions requires a system description. + # If none has arrived yet, nothing is planned here. + # Then the description triggers planning from this status when it arrives. if not self._system_description_history: self._logger.warning( "Cannot ask for a schedule: no FRBC.SystemDescription has arrived yet." @@ -562,7 +576,7 @@ def _in_the_power_unit(self, power: float) -> float: @staticmethod def _latest(history: dict): - """The message a history holds that arrived last, or None while it holds none.""" + """Return the last message that arrived in a history, or None if the history is empty.""" return list(history.values())[-1] if history else None def _fill_level_band( @@ -572,14 +586,14 @@ def _fill_level_band( soc_unit: str, to_soc_unit: float, ) -> tuple[list[dict], list[dict]]: - """The fill levels to keep the storage between over the window planned, as time segments FlexMeasures takes. - - The declared fill-level range applies to every step, and the latest fill-level target profile, - held inside that range, applies where it covers a step. - The range used to reach FlexMeasures only through the band sensors, and only where a target profile was recorded on them, - so a storage without the sensors, or beyond the profile's end, was planned with no band at all, - down to empty and half again above its range. - Clipped at zero, since FlexMeasures takes no negative minima. + """Return the fill levels to keep the storage between over the planned window, as time segments for FlexMeasures. + + The declared fill-level range applies to every step. + The latest fill-level target profile, held inside that range, applies to the steps it covers. + The range used to reach FlexMeasures only through the band sensors, and only where a target profile was recorded on them. + So a storage without those sensors, or past the profile's end, was planned without any band. + It could then be planned down to empty, and up to half again above its range. + The values are clipped at zero, because FlexMeasures does not accept negative minima. """ steps = int( pd.Timedelta(self._schedule_duration) / pd.Timedelta(self._resolution) @@ -608,11 +622,12 @@ def _fill_level_band( ) def _on_the_window(self, profile: pd.Series, index: pd.DatetimeIndex) -> pd.Series: - """A target profile's values at the window's steps, by the instant each step starts at, where the profile covers it. + """Return a target profile's values at the window's steps, for the steps that the profile covers. - The profile is floored to steps on its own clock, and the window on the description's, - so in a time zone half an hour off the other the two sets of steps never share an instant, - and matching them by label dropped the whole profile without a word. + Steps are matched by the instant at which they start. + The profile is floored to steps in its own timezone, and the window in the description's timezone. + If the two timezones are half an hour apart, the two sets of steps never share an instant. + Matching them by label then silently dropped the whole profile. """ if profile.empty: return profile @@ -621,7 +636,7 @@ def _on_the_window(self, profile: pd.Series, index: pd.DatetimeIndex) -> pd.Seri return at_steps[(index >= profile.index[0]) & (index < covered_until)] def _as_segments(self, series: pd.Series, unit: str) -> list[dict]: - """A series on this handler's resolution as FlexMeasures' time segments, one per run of equal values.""" + """Convert a series on this handler's resolution into FlexMeasures time segments, one per run of equal values.""" segments: list[dict] = [] for moment, value in series.items(): end = moment + pd.Timedelta(self._resolution) @@ -641,7 +656,7 @@ def _as_segments(self, series: pd.Series, unit: str) -> list[dict]: return segments def _soc_unit(self) -> tuple[str, float]: - """The unit a state of charge goes to FlexMeasures in, and what a fill level is multiplied by to be in it.""" + """Return the unit for sending a state of charge to FlexMeasures, and the factor that converts a fill level into it.""" if self.energy_unit in ("kWh", "MWh"): return self.energy_unit, 1.0 return "kWh", ( @@ -650,7 +665,10 @@ def _soc_unit(self) -> tuple[str, float]: ) def _latest_fill_level(self) -> float | None: - """The fill level the latest storage status reported, in the resource manager's own units, if it reported a number.""" + """Return the fill level from the latest storage status, in the resource manager's own units. + + Return None if there is no storage status, or if its fill level is not a number. + """ if not self._storage_status_history: return None fill_level = list(self._storage_status_history.values())[-1].present_fill_level @@ -658,8 +676,8 @@ def _latest_fill_level(self) -> float | None: async def send_actuator_status(self, status: FRBCActuatorStatus): factor = status.operation_mode_factor - # After a reconnection an actuator status can come before any description, - # which this needs to tell what power the status stands for. + # After a reconnection, an actuator status can arrive before any description. + # This method needs a description to tell what power the status stands for. if not self._system_description_history: self._logger.warning( "Not recording the power of an FRBC.ActuatorStatus: no FRBC.SystemDescription has arrived yet to tell what its operation mode draws." @@ -697,13 +715,13 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): ) operation_mode = actuator.operation_modes[0] - # The element the actuator runs on depends on the fill level, as a heat pump derates while its buffer warms, - # so it is the one that applies at the latest fill level reported, not whichever the mode happens to list first. + # The element the actuator runs on depends on the fill level, just as a heat pump derates while its buffer warms up. + # So use the element that applies at the latest reported fill level, not the element that the mode happens to list first. fill_level = self._latest_fill_level() if fill_level is not None: element = element_for_fill_level(operation_mode.elements, fill_level) else: - # As if at the bottom of the range, which is as good a guess as any, and is said to be one below. + # Assume the bottom of the range, which is as good a guess as any, and log below that it is a guess. element = element_for_fill_level(operation_mode.elements, float("-inf")) if len(operation_mode.elements) > 1: self._logger.warning( @@ -728,21 +746,19 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): start = status.transition_timestamp or self.now() - # A belief time that supersedes the one before it, rather than colliding with it. - # FlexMeasures keys a belief on its sensor, its source, the event and the moment it was formed, - # and it floors the event to the sensor's resolution. - # Two statuses inside one resolution step, milliseconds apart, were refused in practice, - # with a duplicate key whose belief time had lost its sub-second part. - # Posting two such beliefs by hand does not reproduce that, - # so the exact rule is the server's to state. - # Either way, a second apart is unambiguous: the later status supersedes the earlier one, - # which is what a resource manager reporting twice in a second means. - # A resource manager sends a status for each instruction it applies, - # and this handler sends several at once, so that is the ordinary case rather than an edge. - # The comparison is on the event as FlexMeasures keys it, - # since two statuses seconds apart still land on the same resolution step. - # Kept per event, since statuses for different steps can interleave within a second, - # and a comparison with the latest status alone then missed the one before it about the same step. + # Pick a belief time that supersedes the previous one, instead of colliding with it. + # FlexMeasures keys a belief on its sensor, its source, the event, and the moment the belief was formed. + # It floors the event to the sensor's resolution. + # In practice, two statuses within one resolution step, milliseconds apart, were refused. + # The error was a duplicate key, with a belief time that had lost its sub-second part. + # Posting two such beliefs by hand does not reproduce that, so only the server can say what the exact rule is. + # Either way, belief times a second apart are unambiguous: the later status supersedes the earlier one. + # That is what a resource manager means when it reports twice within a second. + # A resource manager sends a status for each instruction it applies, and this handler sends several instructions at once. + # So this is the ordinary case, not an edge case. + # The comparison uses the event as FlexMeasures keys it, because two statuses seconds apart still land on the same resolution step. + # Keep the times per event, because statuses for different steps can interleave within a second. + # Comparing with the latest status alone then missed the earlier status for the same step. event = self.align_to_resolution(start) recorded_at = self.now() last_recorded_at = self._power_recorded_at.get(event) @@ -751,7 +767,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): ): recorded_at = last_recorded_at + timedelta(seconds=1) self._power_recorded_at[event] = recorded_at - # Steps more than a day older than this one are forgotten, to keep this bounded: a status that late is not expected. + # Forget steps more than a day older than this one, to keep this bounded. A status that late is not expected. for old_event in [ e for e in self._power_recorded_at if e < event - timedelta(days=1) ]: @@ -796,7 +812,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): fill_rate.start_of_range + (fill_rate.end_of_range - fill_rate.start_of_range) * factor ) * self._fill_level_scale - # Guarded like the power above, since this runs in a task nobody awaits. + # Catch failures, like for the power above, because this runs in a task that nobody awaits. try: await self._fm_client.post_sensor_data( self._fill_rate_sensor_id, @@ -820,18 +836,18 @@ async def _record_charging_efficiency( start: datetime, fill_level: float, ) -> bool: - """Record what the actuator converts drawn power into stored energy at, over the window a schedule covers. - - FlexMeasures reads a charging efficiency from a sensor rather than taking a value in the flex model, - so what the operation modes say about it is recorded here, by the call that is about to plan with it. - Recorded before the schedule is asked for, and not alongside it: - a schedule planned against a sensor holding nothing for its window is planned as if the actuator converted power one for one. - - :param start: where the schedule starts, which is where the value is recorded from. - :param fill_level: the fill level the schedule starts from, in the resource manager's own units, - which picks the element of each mode that answers. - :returns: whether that schedule may be planned against the sensor, - which it may not where nothing is recorded on it for this window. + """Record the efficiency at which the actuator converts drawn power into stored energy, over the window a schedule covers. + + FlexMeasures reads a charging efficiency from a sensor, instead of taking a value in the flex model. + So the efficiency that the operation modes imply is recorded here, by the call that is about to plan with it. + It is recorded before the schedule is requested, not at the same time. + A schedule planned against a sensor that holds nothing for its window is planned as if the actuator converted power one to one. + + :param start: where the schedule starts; the value is recorded from there. + :param fill_level: the fill level the schedule starts from, in the resource manager's own units. + It decides which element of each mode is used. + :returns: whether the schedule may be planned against the sensor. + It may not, if nothing is recorded on the sensor for this window. """ if self._charging_efficiency_sensor_id is None: return False @@ -839,8 +855,8 @@ async def _record_charging_efficiency( # One actuator, as everywhere else in this handler. actuator = system_description.actuators[0] efficiencies: dict[str, float] = {} - # The energy unit is one fill_rate_in_watts knows, which the constructor saw to. - # Only modes it may be instructed into under normal conditions, which are the ones the schedule is planned in. + # The constructor already checked that fill_rate_in_watts knows the energy unit. + # Only use the modes the actuator may be instructed into under normal conditions, because the schedule is planned in those. for operation_mode in normal_operation_modes(actuator): efficiency = operation_mode_conversion_efficiency( operation_mode, self._fill_level_scale, self.energy_unit, fill_level @@ -884,8 +900,8 @@ async def _record_charging_efficiency( # and for a heat pump, whose fill rate is the heat it delivers, it is well above one. unit="dimensionless", duration=self._schedule_duration, - # Remembered below as recorded, and so never posted again for this window: - # an ingestion still pending at the timeout is not taken as recorded. + # Below, the value is remembered as recorded, and is then never posted again for this window. + # So an ingestion that is still pending at the timeout must not count as recorded. confirm_ingestion=True, ) except (*REQUEST_FAILURES, NotImplementedError): @@ -921,14 +937,14 @@ async def _is_worth_asking_again( message = str(failure) if any(marker in message for marker in NON_RETRYABLE_SCHEDULE_FAILURES): return False - # A job nobody picked up in time, and a server that could not be reached, may both be there for the next attempt: - # a scheduling queue without a live worker, and a gateway restarting, are the likeliest of either. - # A timeout has already waited out the job's whole timeout, and a dead queue will not come back two seconds later, - # so it is asked about once more, not on every attempt allowed. + # A job that nobody picked up in time, or a server that could not be reached, may both work on the next attempt. + # The likeliest causes are a scheduling queue without a live worker, and a gateway that is restarting. + # A timeout has already waited for the job's whole timeout, and a dead queue will not come back two seconds later. + # So after a timeout, retry only once, not on every allowed attempt. if isinstance(failure, JobTimeoutError): return attempt == 1 - # The client wraps every HTTP error in a ConnectionError, - # and one answering 4xx, such as an endpoint an old server lacks, answers the same next time. + # The client wraps every HTTP error in a ConnectionError. + # A 4xx response, such as for an endpoint that an old server lacks, will be the same next time. cause = failure.__cause__ if isinstance(cause, ClientResponseError) and cause.status < 500: return False @@ -939,11 +955,11 @@ async def _is_worth_asking_again( return True if any(marker in message for marker in RETRYABLE_TRIGGER_REFUSALS): return True - # FlexMeasures before v0.33.0 reports a failed job only as a bare HTTP 400, - # which cannot be told apart from a refused trigger, so it is asked again, within the attempts allowed. + # FlexMeasures before v0.33.0 reports a failed job only as a bare HTTP 400. + # That cannot be told apart from a refused trigger, so retry it, within the allowed number of attempts. if message != BARE_REFUSAL: return False - # Looked up inside the handling of another failure, so a failure of its own must not take that one's place. + # This lookup happens while handling another failure, so a failure of the lookup itself must not replace that one. try: return not await self._fm_client.server_version_at_least("0.33.0") except REQUEST_FAILURES: @@ -956,22 +972,22 @@ async def _ask_for_a_schedule( flex_model: dict, request_number: int | None = None, ) -> dict | list[dict] | None: - """Ask FlexMeasures for a schedule, and ask again where the attempt is worth making again. - - One failed scheduling job otherwise leaves the storage without an instruction for its window. - The resource manager is told nothing, and goes on polling, - and since this runs as a task whose exception is logged and dropped, nothing else notices either. - That is what one failed job did in a co-simulation: - a worker crashed inside its solver, - and the resource manager it belonged to polled until its round timed out, every round, for the rest of the run. - - :param start: the window the schedule is asked for, which every attempt asks for again. - :param request_number: the planning request this answers; where a newer one arrives between attempts, - no further attempt is made and None is returned, so that the newer one is planned sooner. - :raises JobFailedError: where the last attempt worth making failed inside the server. - :raises JobTimeoutError: where the last attempt worth making was not picked up in time. - :raises ConnectionError: where the server could not be reached on the last attempt worth making. - :raises ValueError: where the trigger itself was refused. + """Ask FlexMeasures for a schedule, and retry if the failure is worth retrying. + + Without retries, one failed scheduling job leaves the storage without an instruction for its window. + The resource manager is not told, and keeps polling. + This runs as a task whose exception is logged and dropped, so nothing else notices either. + That happened in a co-simulation, after a worker crashed inside its solver. + The affected resource manager polled until its round timed out, in every round, for the rest of the run. + + :param start: the start of the window to request the schedule for; every attempt requests the same window. + :param request_number: the planning request this call serves. + If a newer request arrives between attempts, no further attempt is made, and None is returned. + That way, the newer request is planned sooner. + :raises JobFailedError: if the last attempt worth making failed inside the server. + :raises JobTimeoutError: if the last attempt worth making was not picked up in time. + :raises ConnectionError: if the server could not be reached on the last attempt worth making. + :raises ValueError: if the trigger itself was refused. """ attempt = 0 while True: @@ -1023,19 +1039,20 @@ async def _ask_for_a_schedule( def _warn_where_a_fallback_scheduler_answered( self, schedule: dict | list[dict], start: datetime ) -> None: - """Say so where a schedule was planned by a scheduler this handler did not ask for. + """Log an error if a schedule was planned by a scheduler that this handler did not ask for. - FlexMeasures answered an infeasible problem with a fallback scheduler until v1.0.0 retired it. - Against an older server, the schedule endpoint follows that fallback job rather than reporting the failure, - or redirects to it where the server sets FLEXMEASURES_FALLBACK_REDIRECT, which this client follows, - so either way a schedule nobody asked for arrives looking like the one that was. - It is a coarse charging policy rather than a plan, made without the band, the efficiencies and the power bands this handler asked with. + Until v1.0.0 retired it, FlexMeasures handled an infeasible problem with a fallback scheduler. + On an older server, the schedule endpoint follows that fallback job instead of reporting the failure. + Or, if the server sets FLEXMEASURES_FALLBACK_REDIRECT, it redirects to the fallback job, and this client follows the redirect. + Either way, a schedule that nobody asked for arrives looking like the requested one. + It is a coarse charging policy, not a plan. + It ignores the band, the efficiencies and the power bands that this handler sent with its request. - :param schedule: what came back, which names the scheduler that planned it. - :param start: the window it was asked for. + :param schedule: the response, which names the scheduler that planned it. + :param start: the start of the window it was requested for. """ if not isinstance(schedule, dict): - # A schedule per device, which only an asset-wide trigger is answered with. + # A schedule per device, which is only returned for an asset-wide trigger. return scheduler = str((schedule.get("scheduler_info") or {}).get("scheduler", "")) if "fallback" not in scheduler.lower(): @@ -1049,19 +1066,21 @@ def _warn_where_a_fallback_scheduler_answered( async def trigger_schedule( self, system_description_id: str, request_number: int | None = None ): - """Plan, one schedule at a time, and only for the newest request once it is this one's turn. + """Plan one schedule at a time, and when it is this request's turn, only plan if it is the newest request. Each storage status is a planning request. - One arriving while a schedule was being planned used to start a second solve beside it, whose answer replaced the first's, - so a resource manager reporting faster than FlexMeasures plans kept several solves running for nothing. - Now a request waits for the one in flight, and is dropped where a newer one has arrived meanwhile; - the one in flight, once out of date, makes no further attempt after a failed one. - So a burst of statuses costs at most the attempt in flight and one plan more, for the newest, - which starts once that attempt is over rather than beside it. + A request that arrived while a schedule was being planned used to start a second solve in parallel. + The second result then replaced the first. + So a resource manager that reported faster than FlexMeasures could plan kept several solves running for nothing. + Now a request waits for the one in flight. + It is dropped if a newer request has arrived in the meantime. + Once the request in flight is out of date, it makes no further attempt after a failed one. + So a burst of statuses costs at most the attempt in flight, plus one more plan for the newest request. + That plan starts after the attempt in flight is over, not in parallel. :param system_description_id: system description to base the schedule on - :param request_number: which planning request this schedule answers, which is checked when its turn comes, - between attempts, and before the schedule is translated and sent + :param request_number: the planning request this schedule serves. + It is checked when its turn comes, between attempts, and before the schedule is translated and sent. """ async with self._planning: if request_number is not None and not self._is_latest_request( @@ -1080,7 +1099,7 @@ async def _plan( """Translates S2 System Description into FM API calls. :param system_description_id: system description to base the schedule on - :param request_number: which planning request this schedule answers + :param request_number: the planning request this schedule serves """ system_description: FRBCSystemDescription = self._system_description_history[ @@ -1122,49 +1141,50 @@ async def _plan( min(power_range.start_of_range, power_range.end_of_range), max(power_range.start_of_range, power_range.end_of_range), ) - # Only modes the actuator may be instructed into under normal conditions, - # since a band the scheduler plans in is a mode it may then be instructed into. + # Only use the modes the actuator may be instructed into under normal conditions. + # The actuator may be instructed into any band that the scheduler plans in. for operation_mode in normal_operation_modes(actuator) for element in operation_mode.elements for power_range in element.power_ranges ] - # The declared range is where a resource manager wants its storage kept, not where its storage can be: - # S2 calls it the range in which the fill level should remain, - # and has a resource manager free to ignore instructions while the fill level is outside it. - # So the range goes out as a band whose breach FlexMeasures prices (see _fill_level_band), - # rather than as the hard soc-min and soc-max, which made states a storage really reaches infeasible, - # and hard bounds are still sent, but wide ones. + # The declared range is where a resource manager wants its storage kept, not the limits of what its storage can reach. + # S2 calls it the range in which the fill level should remain. + # While the fill level is outside it, S2 lets a resource manager ignore instructions. + # So the range is sent as a band, and FlexMeasures puts a price on breaching it (see _fill_level_band). + # Sending the range as the hard soc-min and soc-max made states that a storage really reaches infeasible. + # Hard bounds are still sent, but wide ones. _, range_top = get_soc_min_max(system_description, self._fill_level_scale) # Zero below, since a stock cannot be negative and FlexMeasures accepts no lower floor. soc_min = 0.0 - # Multiplied, a top below zero, as S2 allows, would be pulled further down, - # but the bound widens below to take in the state of charge at the start, which is never below zero. + # S2 allows a top below zero, and multiplying it would pull it further down. + # But below, the bound widens to include the state of charge at the start, which is never below zero. soc_max = range_top * self._hard_bound_factor - # A state of charge outside the hard bounds is infeasible before the first step, - # so the bounds widen to take it in. - # A storage outside its declared range is not an error: - # a resource manager is free to report one, and a schedule still has to be planned from where it says its storage is. - # Upward only, since FlexMeasures takes no negative soc-min: - # a storage reported below zero is planned from zero, and said so, rather than refused with no schedule. + # A state of charge outside the hard bounds is infeasible before the first step. + # So the bounds widen to include it. + # A storage outside its declared range is not an error. + # A resource manager is free to report one, and a schedule must still be planned from the reported fill level. + # The bounds only widen upward, because FlexMeasures does not accept a negative soc-min. + # A storage reported below zero is planned from zero, with a warning, instead of being refused without a schedule. if soc_at_start < 0: self._logger.warning( "The storage is reported at %s, below zero, which FlexMeasures cannot plan from; planning from zero instead.", soc_at_start, ) soc_at_start = 0.0 - # And read back from zero, so the instructions follow the plan from where it started. + # Also translate the schedule from zero, so that the instructions follow the plan from where it started. fill_level_at_start = 0.0 soc_max = max(soc_max, soc_at_start) - # Where this schedule starts, which is both what it is asked for, - # and the window a charging efficiency has to be recorded over to be planned against. + # The start of this schedule. + # It is both the start of the requested window and the start of the window that a charging efficiency must be recorded over. start = self.schedule_start(system_description.valid_from) - # FlexMeasures takes a state of charge in kWh or MWh only. - # So a fill level recorded in another energy unit is converted here, at the edge, rather than refused, - # and the bounds read back below stay in the recorded unit. - # The usage and the leakage are read from sensors, which FlexMeasures converts itself, and the band is converted here. + # 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 band is converted here. soc_unit, to_soc_unit = self._soc_unit() flex_model: dict = { "soc-unit": soc_unit, @@ -1173,13 +1193,13 @@ async def _plan( "soc-min": soc_min * to_soc_unit, "soc-max": soc_max * to_soc_unit, } - # The band the resource manager declared, sent with the request itself rather than named by sensor, - # so that it is there with or without the sensors it is also recorded on. + # Send the band the resource manager declared within the request itself, instead of referring to a sensor. + # That way, the band is included whether or not the sensors it is also recorded on exist. soc_minima, soc_maxima = self._fill_level_band( start, system_description, soc_unit, to_soc_unit ) - # FlexMeasures has the relax-soc-constraints that makes it a band from v0.26.0, - # and a flex context naming a field the server does not know is refused outright, leaving no schedule at all. + # FlexMeasures has relax-soc-constraints, which makes this a band, from v0.26.0 on. + # A server refuses a flex context with a field it does not know, which would leave no schedule at all. if await self._fm_client.server_version_at_least("0.26.0"): flex_model["soc-minima"] = soc_minima flex_model["soc-maxima"] = soc_maxima @@ -1189,9 +1209,9 @@ async def _plan( self._fm_client.server_version, ) if self._usage_forecast_sensor_id is not None: - # Not where the resource manager said it sends no usage forecast, - # since nothing would then be recorded on the sensor for the scheduler to read. - # Or where it sent one anyway, which is what it says it will do, whatever its flag says. + # Skip this if the resource manager says it provides no usage forecast, + # because then nothing is recorded on the sensor for the scheduler to read. + # But use it if the resource manager sent a forecast anyway, whatever its flag says. if ( system_description.storage.provides_usage_forecast or self._usage_forecast_history @@ -1229,16 +1249,17 @@ async def _plan( ) flex_model["production-capacity"] = f"{production_capacity} {S2_POWER_UNIT}" operation_modes = _operation_mode_power_bands(power_ranges, S2_POWER_UNIT) - # Only where the bands say more than the two capacities above already do. - # The capacities say the actuator can run at any power from minus its production capacity to its consumption capacity, - # so bands that together cover exactly that, without a gap, say nothing new, - # such as an idle mode at zero beside a mode running from zero up, and each band costs the solver a binary per step. - # Bands with a gap between them do: an actuator that runs at 200 W or not at all rules out everything in between, - # and so does one that only ever feeds back. - # And only to a server that has the field. - # FlexMeasures added operation-modes in v1.0.0, - # and a flex model naming a field the server does not know is refused outright, - # which would leave the resource manager with no schedule at all. + # Only send the bands if they add information to the two capacities above. + # The capacities say that the actuator can run at any power from minus its production capacity up to its consumption capacity. + # Bands that together cover exactly that range, without a gap, add nothing. + # An example is an idle mode at zero next to a mode running from zero up. + # Each band costs the solver a binary variable per step. + # Bands with a gap between them do add information. + # An actuator that runs at 200 W or not at all rules out everything in between. + # So does an actuator that only ever feeds back. + # Also, only send the bands to a server that has the field. + # FlexMeasures added operation-modes in v1.0.0. + # A server refuses a flex model with a field it does not know, which would leave the resource manager with no schedule at all. says_more_than_the_capacities = _merged(power_ranges) != [ (-production_capacity, consumption_capacity) ] @@ -1257,10 +1278,10 @@ async def _plan( "consumption-price": {"sensor": self._price_sensor_id}, "site-power-capacity": f"{3 * 25 * 230} VA", } - # The band always goes out, and without this its minima and maxima are hard bounds, - # which makes states a storage really reaches infeasible. - # The breach price is left to FlexMeasures' own default, - # since what a breach of comfort is worth is the deployment's judgement, not the protocol's. + # The band is always sent, and without this setting its minima and maxima are hard bounds. + # Hard bounds make states that a storage really reaches infeasible. + # The breach price is left to FlexMeasures' own default. + # The value of a comfort breach is for the deployment to judge, not for the protocol. if "soc-minima" in flex_model: flex_context["relax-soc-constraints"] = True if self._relax_site_capacity is not None: @@ -1273,8 +1294,8 @@ async def _plan( return self._warn_where_a_fallback_scheduler_answered(schedule, start) - # send_instruction_batch checks this once more, but translating a schedule follows every step of it, - # which is wasted on a request that is already out of date. + # send_instruction_batch checks this again, but translating a schedule goes through every step of it. + # That work is wasted on a request that is already out of date. if request_number is not None and not self._is_latest_request( STORAGE_STATUS_REQUESTS, request_number ): @@ -1297,10 +1318,10 @@ async def _plan( soc_min / self._fill_level_scale, soc_max / self._fill_level_scale, ), - # What the storage loses by itself and what is drawn from it, as the resource manager last said, - # so that the fill level each step is answered at falls as well as rises. + # Pass what the storage loses by itself and what is drawn from it, as the resource manager last reported. + # Then the fill level used for each step can fall as well as rise. leakage_behaviour=self._latest(self._leakage_behaviour_history), - # Without negative usage, as it was recorded and planned against (see send_usage_forecast). + # Leave out negative usage, as when the forecast was recorded and planned against (see send_usage_forecast). usage_forecast=_without_negative_usage( self._latest(self._usage_forecast_history) ), @@ -1361,8 +1382,8 @@ async def send_fill_level_target_profile( # that comes back begins there, not at the start_time the resource manager sent. Posting # it at the raw start_time would shift every value by the difference. start = self.align_to_resolution(fill_level_target_profile.start_time) - # Recorded to be seen, since the band a schedule is planned against goes out inline, - # so a failure is logged and the other bound still recorded, rather than raised in a task nobody awaits. + # These are recorded for inspection only, because the band that a schedule is planned against is sent inline. + # So a failure is logged, and the other bound is still recorded, instead of raising in a task that nobody awaits. for sensor_id, values in ( (self._soc_minima_sensor_id, soc_minima), (self._soc_maxima_sensor_id, soc_maxima), @@ -1397,10 +1418,10 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): strategy="mean", fill_level_scale=self._fill_level_scale, ) - # A negative usage is a gain the storage is forced to take, which FlexMeasures cannot relax: - # past the hard ceiling it makes every schedule for the window infeasible, so the storage gets no instruction at all. - # S2 allows the sign, but no storage in reach of this handler fills by being drawn from, - # so a negative value is taken as an artefact of the forecast, counted, and recorded as no usage. + # A negative usage is a gain that the storage is forced to take, and FlexMeasures cannot relax it. + # Above the hard ceiling, it makes every schedule for the window infeasible, so the storage gets no instruction at all. + # S2 allows the negative sign, but no storage that this handler controls fills up by being drawn from. + # So a negative value is treated as an artefact of the forecast: it is counted, and recorded as no usage. negative = translated < 0 if negative.any(): self._logger.warning( @@ -1424,10 +1445,12 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): ) async def send_leakage_behaviour(self, leakage_behaviour: FRBCLeakageBehaviour): - """Keep a resource manager's leakage behaviour for planning, which records it when it asks for a schedule. + """Keep a resource manager's leakage behaviour for planning; it is recorded when a schedule is requested. - Recorded on arrival instead, it covered the hours after the message rather than the window being planned, - at a fill level nobody had reported yet, and planning went on without it once those hours had passed. + It used to be recorded on arrival. + Then it covered the hours after the message, instead of the window being planned. + It was also computed at a fill level that nobody had reported yet. + And once those hours had passed, planning went on without it. """ if self._leakage_behaviour_sensor_id is None: self._logger.info( @@ -1437,9 +1460,9 @@ async def send_leakage_behaviour(self, leakage_behaviour: FRBCLeakageBehaviour): async def _record_storage_efficiency( self, start: datetime, fill_level: float ) -> bool: - """Record what the storage keeps of itself per step, over the window a schedule covers, from where it starts. + """Record the share of its stock that the storage keeps per step, over the window a schedule covers, from the schedule's start. - :returns: whether the schedule may be planned against the sensor, which it may not where nothing is recorded on it. + :returns: whether the schedule may be planned against the sensor. It may not, if nothing is recorded on the sensor. """ leakage_behaviour = self._latest(self._leakage_behaviour_history) if ( @@ -1448,17 +1471,19 @@ async def _record_storage_efficiency( or not self._system_description_history ): return False - # S2 states a leakage as a rate, and FlexMeasures takes a share of the stock, so one share stands for the rate only at one fill level, and the loss scales with the stock away from it. - # 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, - # and the loss would 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, which is the milder error. + # 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 - # What remains after one step of this handler's resolution, - # which FlexMeasures reads as one step of the leakage sensor's resolution, so the two have to agree. + # 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, @@ -1476,7 +1501,7 @@ async def _record_storage_efficiency( values=[efficiency], unit="%", duration=self._schedule_duration, - # As for the charging efficiency: remembered below as recorded, so only once it is. + # As for the charging efficiency: the value is remembered below as recorded, so it must really be recorded. confirm_ingestion=True, ) except REQUEST_FAILURES: diff --git a/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py b/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py index 23babaf3..5d2b0a14 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_tunes.py @@ -169,11 +169,11 @@ def __init__( self._datastore = datastore if datastore is not None else {} def _is_timer_due(self, name: str) -> bool: - # By this handler's clock, so that a co-simulation's timers run on simulated time. + # Use this handler's clock, so that a co-simulation's timers run on simulated time. now = self.now() due_time = self._timers.get(name, now - self._minimum_measurement_period) if due_time.tzinfo is None: - # A timer handed in without a zone was compared with the host's local time, so it is read as that, as before. + # A timer without a timezone used to be compared with the host's local time, so keep reading it as local time. due_time = due_time.astimezone(self._timezone) if due_time <= now: # Get total seconds of the period @@ -191,8 +191,8 @@ def _is_timer_due(self, name: str) -> bool: next_due = now.replace(minute=0, second=0, microsecond=0) + timedelta( seconds=next_tick_seconds ) - # On a period boundary the ceiling is the moment itself, - # and a clock that stands still, as a simulated one does between messages, then found the timer due on every call. + # On a period boundary, the ceiling equals the current moment. + # A clock that stands still (as a simulated clock does between messages) would then find the timer due on every call. if next_due <= now: next_due += self._minimum_measurement_period self._timers[name] = next_due @@ -315,9 +315,9 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): duration=timedelta(minutes=15), ) - # It takes neither argument the base class declares: TUNES plans from its latest description, - # and does not take part in dropping superseded requests. - # Accepting them and ignoring them would turn a caller's mistake into a silent one, so the mismatch stays. + # This override takes neither of the arguments that the base class declares. + # TUNES plans from its latest description, and does not take part in dropping superseded requests. + # Accepting the arguments and ignoring them would hide a caller's mistake, so the signatures stay different. async def trigger_schedule(self): # type: ignore[override] """ Ask FlexMeasures for a new schedule and create FRBC.Instructions to send back to the ResourceManager @@ -485,8 +485,9 @@ async def trigger_schedule(self): # type: ignore[override] except Exception as exc: self._logger.error(str(exc)) - # Posted per hour and scaled into FlexMeasures' units by send_usage_forecast, and read back as such, - # while the schedule translation takes a usage rate per second, in the resource manager's own units, as S2 states it. + # send_usage_forecast posts the usage forecast per hour, scaled to FlexMeasures' units, so that is how it is read back. + # The schedule translation needs a usage rate per second, in the resource manager's own units, as in S2. + # So convert it back here. usage_forecast = pd.Series( usage_forecast["values"], index=idx, name="usage_forecast" ) / ((pd.Timedelta(hours=1) / pd.Timedelta(seconds=1)) * self._fill_level_scale) @@ -512,7 +513,7 @@ async def trigger_schedule(self): # type: ignore[override] system_description, initial_fill_level=soc_at_start / self._fill_level_scale, logger=self._logger, - # What the storage loses by itself, so that the running fill level falls as well as rises. + # Pass what the storage loses by itself, so that the running fill level can fall as well as rise. leakage_behaviour=( list(self._leakage_behaviour_history.values())[-1] if self._leakage_behaviour_history @@ -693,9 +694,9 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): fill_level_scale=self._fill_level_scale, ) - # From per second, as S2 states a usage rate, to per hour, which is what a value labelled MW means: - # FlexMeasures reads soc-usage as a power and multiplies it by the step's share of an hour. - # Posted per quarter hour instead, it was planned as a quarter of the real draw. + # Convert from per second (as S2 gives a usage rate) to per hour, which is what a value in MW means. + # FlexMeasures reads soc-usage as a power, and multiplies it by the step's fraction of an hour. + # When this was posted per quarter hour instead, the scheduler planned for a quarter of the real draw. scale = timedelta(hours=1) / timedelta(seconds=1) scaled_usage_forecast = usage_forecast_series * scale @@ -703,7 +704,7 @@ async def send_usage_forecast(self, usage_forecast: FRBCUsageForecast): sensor_id=self._usage_forecast_sensor_id, start=start_time, values=scaled_usage_forecast.tolist(), - unit=POWER_UNIT, # a rate per hour, as MW is + unit=POWER_UNIT, # a rate per hour, like MW duration=str(pd.Timedelta(RESOLUTION) * len(usage_forecast_series)), ) diff --git a/src/flexmeasures_client/s2/control_types/FRBC/utils.py b/src/flexmeasures_client/s2/control_types/FRBC/utils.py index aef2c27a..0b597962 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/utils.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/utils.py @@ -37,16 +37,17 @@ def compute_next_fill_level( leakage_behaviour: FRBCLeakageBehaviour | None = None, usage_rate: float = 0.0, ) -> float: - """The fill level one step later. + """Return the fill level one step later. - This is the unit contract for every rate it takes, and it is the one S2 states: - each is a change in fill level per second, in the resource manager's own fill-level units. - A fill rate raises the fill level, while a usage rate and a leakage rate both lower it where they are positive. - The step is a duration, rather than a number of some unit, so that it cannot be taken in the wrong one. + This function sets the unit contract for every rate it takes, and that contract is the one S2 uses. + Each rate is a change in fill level per second, in the resource manager's own fill-level units. + A fill rate raises the fill level. + A positive usage rate or a positive leakage rate lowers it. + The step is a duration, not a number in some unit, so that it cannot be given in the wrong unit. - :param leakage_behaviour: what the storage loses by itself, from the element that applies at this fill level; - none where the resource manager has not said. - :param usage_rate: what is drawn from the storage, held over this step. + :param leakage_behaviour: what the storage loses by itself; the element that applies at this fill level is used. + None if the resource manager has not reported it. + :param usage_rate: what is drawn from the storage, constant over this step. """ leakage_rate = 0.0 if leakage_behaviour is not None: @@ -56,23 +57,25 @@ def compute_next_fill_level( return fill_level + (fill_rate - usage_rate - leakage_rate) * step.total_seconds() -#: How close two factors are to count as the same instruction. -#: It absorbs the float noise of converting a schedule from MW to W, which is not a new instruction. +#: How close two factors must be to count as the same instruction. +#: It absorbs the float noise from converting a schedule from MW to W, which should not lead to a new instruction. FACTOR_TOLERANCE = 1e-6 class UsageProfile: - """What a usage forecast expects, per second, at any moment: piecewise constant, looked up by bisection. + """The usage rate per second that a usage forecast expects at any moment. - A forecast is a run of elements, each lasting its own duration from where the one before it ended. - Outside that run, nothing is forecast, and nothing is assumed: the rate is zero. - Built once per schedule, since a forecast may hold hundreds of elements and a schedule asks about it often. + The rate is piecewise constant, and is looked up by bisection. + A forecast is a sequence of elements. + Each element starts where the previous one ended, and lasts its own duration. + Outside that sequence, nothing is forecast, and nothing is assumed: the rate is zero. + Build this once per schedule, because a forecast may hold hundreds of elements, and a schedule looks up the rate often. """ def __init__( self, usage_forecast: FRBCUsageForecast | None = None, rate: float | None = None ): - #: The moments the rate changes at, and the rate from each of them on; one constant rate has none. + #: The moments at which the rate changes, and the rate from each moment on. A constant rate has no changes. self._changes: list[pd.Timestamp] = [] self._rates: list[float] = [] self._before = 0.0 if rate is None else rate @@ -98,14 +101,15 @@ def next_change_after(self, moment: pd.Timestamp) -> pd.Timestamp | None: def usage_rate_at( usage_forecast: FRBCUsageForecast | None, moment: pd.Timestamp ) -> float: - """The usage rate a forecast expects at a moment, per second, as S2 states it (see UsageProfile).""" + """Return the usage rate that a forecast expects at a moment, per second as in S2 (see UsageProfile).""" return UsageProfile(usage_forecast).rate_at(moment) def _sides(elements, fill_level: float): - """The element that applies just below a fill level, and the one just above it. + """Return the element that applies just below a fill level, and the element that applies just above it. - Inside an element both are that element; at a boundary two elements share, they are the two neighbours. + Inside an element, both are that element. + At a boundary between two elements, they are the two neighbouring elements. """ above = element_for_fill_level(elements, fill_level) below_candidates = [ @@ -133,18 +137,20 @@ def follow_operation_mode( usage: UsageProfile | None = None, bounds: tuple[float, float] | None = None, ) -> float: - """The fill level after following one operation mode at one factor for a step, as a resource manager does. - - An instruction fixes the mode and the factor, not the element: - within the step the fill level can pass from one element into another, of the mode or of the leakage behaviour, - and the usage a forecast expects can change. - Every rate is constant between such moments, so the step is followed exactly, from one of them to the next. - A level that reaches a boundary its far side would push it back from, as where a heat pump cannot heat above it, - stays at that boundary, as a real storage does, rather than flipping across it. - So does a level that reaches one of the bounds. - - :param usage: what is drawn from the storage, per second; none where nothing is known. - :param bounds: the fill levels the storage is held between. + """Return the fill level after following one operation mode at one factor for one step, as a resource manager does. + + An instruction fixes the mode and the factor, but not the element. + Within the step, the fill level can pass from one element into another, of the mode or of the leakage behaviour. + Also, the usage that a forecast expects can change. + Every rate is constant between such moments. + So the step is followed exactly, from one such moment to the next. + Sometimes a fill level reaches a boundary where the rate on the far side would push it back. + For example, a heat pump may be unable to heat above that boundary. + Then the fill level stays at that boundary, as in a real storage, instead of flipping back and forth across it. + The same holds for a fill level that reaches one of the bounds. + + :param usage: what is drawn from the storage, per second; None if nothing is known. + :param bounds: the fill levels between which the storage is held. """ usage = usage or UsageProfile() leakage_elements = leakage_behaviour.elements if leakage_behaviour else [] @@ -183,7 +189,7 @@ def net_rate(mode_element, leakage_element, moment) -> float: elif falling < 0 and fill_level > lowest: rate = falling else: - rate = 0.0 # held where it is, at a boundary or a bound, until something changes + rate = 0.0 # stay at the boundary or bound until something changes horizon = end change = usage.next_change_after(moment) if change is not None and change < horizon: @@ -194,7 +200,7 @@ def net_rate(mode_element, leakage_element, moment) -> float: elif rate < 0: target = next((b for b in reversed(boundaries) if b < fill_level), None) if target is not None: - # Compared in seconds first: a slow rate can put a boundary further off than a timedelta can say. + # Compare in seconds first: at a slow rate, a boundary can be further away than a timedelta can represent. seconds_to_target = (target - fill_level) / rate if seconds_to_target < (horizon - moment).total_seconds(): fill_level = target @@ -206,11 +212,11 @@ def net_rate(mode_element, leakage_element, moment) -> float: def normal_operation_modes(actuator) -> list[FRBCOperationMode]: - """The operation modes an actuator may be instructed into under normal conditions. + """Return the operation modes an actuator may be instructed into under normal conditions. - S2 marks a mode abnormal_condition_only where it is reserved for an abnormal condition, such as an emergency boost, - and a resource manager refuses an instruction into one that is not marked abnormal itself, - which every instruction a schedule is turned into is not. + S2 marks a mode as abnormal_condition_only if it is reserved for an abnormal condition, such as an emergency boost. + A resource manager refuses an instruction into such a mode, unless the instruction itself is marked abnormal. + The instructions made from a schedule are never marked abnormal. So such a mode is neither planned in nor instructed into. """ return [ @@ -229,31 +235,33 @@ def fm_schedule_to_instructions( ) -> List[FRBCInstruction]: """Turn a FlexMeasures schedule into the instructions that follow it. - Each step is answered with the operation mode, and the factor within it, that draws the power the schedule asks for - at the fill level the storage is then at, so a running fill level is kept alongside, under the contract of compute_next_fill_level. - An instruction is sent where that answer changes, which can happen at a constant power too, - where the fill level passes from one element of a mode into another. + For each step, this picks the operation mode, and the factor within it, that draw the power the schedule asks for. + That choice depends on the fill level the storage is at during the step. + So a running fill level is tracked alongside, under the unit contract of compute_next_fill_level. + An instruction is sent whenever the chosen mode or factor changes. + That can happen even at constant power, when the fill level passes from one element of a mode into another. :param schedule: a schedule as FlexMeasures returns it, or a frame indexed by time with a "schedule" column. - A frame may also carry a "usage_forecast" column, per second as S2 states it, - which then takes the place of usage_forecast. - :param fill_level_bounds: the fill levels this conversion holds its own running fill level between, + A frame may also have a "usage_forecast" column, per second as in S2. + If it does, that column is used instead of usage_forecast. + :param fill_level_bounds: the fill levels between which this conversion holds its own running fill level, in the resource manager's units. - These have to be the hard bounds the schedule was planned against, - or a step is answered with the operation mode for a fill level the scheduler never planned. - Without them the declared fill-level range is used, - which is right only for a caller that asked for a schedule against that same range. - :param leakage_behaviour: what the storage loses by itself, as the resource manager last said. + These must be the hard bounds that the schedule was planned against. + Otherwise, a step may get the operation mode for a fill level that the scheduler never planned. + Without them, the declared fill-level range is used. + That is only right for a caller that asked for a schedule against that same range. + :param leakage_behaviour: what the storage loses by itself, as the resource manager last reported it. :param usage_forecast: what is drawn from the storage, as the resource manager last forecast it. - The step is the schedule's own: a schedule from FlexMeasures states it as its duration over its number of values, - which is the resolution of the sensor it was asked for, whatever resolution the caller plans on. + The step comes from the schedule itself. + A schedule from FlexMeasures gives a duration and a number of values, and the step is the duration divided by that number. + That step is the resolution of the sensor the schedule was requested for, whatever resolution the caller plans on. """ if fill_level_bounds is not None and fill_level_bounds[0] > fill_level_bounds[1]: - # A mistake in the caller, which computes these, rather than anything a resource manager sent: - # held between a lower bound above its upper one, the running fill level would sit at the lower one, - # and every step would be answered with the operation mode for a fill level the storage is never at. + # This is a mistake by the caller, which computes these bounds, not something a resource manager sent. + # With a lower bound above the upper bound, the running fill level would stay at the lower bound. + # Then every step would get the operation mode for a fill level the storage is never at. raise ValueError( f"fill_level_bounds runs from its lower bound to its upper one, but {fill_level_bounds} was given." ) @@ -270,7 +278,7 @@ def fm_schedule_to_instructions( idx = pd.date_range(start=start, periods=len(values), freq=step) schedule = pd.Series(values, index=idx, name="schedule").to_frame() else: - # A frame of one row has no later step for its fill level to matter to. + # A frame with one row has no later step, so the fill level after it does not matter, and a zero step will do. step = ( pd.Timedelta(schedule.index[1] - schedule.index[0]) if len(schedule) > 1 @@ -290,9 +298,9 @@ def fm_schedule_to_instructions( ) modes_by_id = {str(mode.id): mode for mode in operation_modes} - # Held between the hard bounds the schedule was planned against, - # so that each step is answered with the operation mode for the fill level the scheduler planned. - # Falling back to the declared range keeps every other caller as it was. + # Hold the running fill level between the hard bounds that the schedule was planned against. + # Then each step gets the operation mode for the fill level that the scheduler planned. + # Falling back to the declared range keeps the behaviour unchanged for every other caller. if fill_level_bounds is None: fill_level_range: NumberRange = system_description.storage.fill_level_range fill_level_min = fill_level_range.start_of_range @@ -309,8 +317,8 @@ def fm_schedule_to_instructions( for timestamp, row in schedule.iterrows(): power = row["schedule"] - # The mode and factor for this power at this fill level, every step, - # since the element that applies can change while the power does not. + # Pick the mode and factor for this power at this fill level in every step, + # because the element that applies can change while the power stays the same. results = [ (om, *power_to_fill_rate_with_metrics(om, power, fill_level)) for om in operation_modes @@ -353,10 +361,10 @@ def fm_schedule_to_instructions( explain_choice(results, best), ) - # The factor is the one that draws the power asked for, taken from the power range itself. - # It used to be worked back out of the fill rate instead, - # which fails wherever the fill-rate range is a single value and the power range is not: - # asked for 1200 W from a mode running 800 to 1200 W at one fill rate, that asked for 800 W. + # Take the factor that draws the requested power from the power range itself. + # It used to be derived from the fill rate instead. + # That fails wherever the fill-rate range is a single value but the power range is not. + # For example, given a request for 1200 W from a mode that runs at 800 to 1200 W with a single fill rate, the old code instructed 800 W. answer = (str(best_operation_mode.id), operation_mode_factor) if last_instructed is None or not ( answer[0] == last_instructed[0] @@ -377,8 +385,9 @@ def fm_schedule_to_instructions( instructions.append(instruction) last_instructed = answer - # A gap in a usage column, as FlexMeasures returns for a moment it holds nothing for, is no usage known, not NaN: - # a NaN fill level would be held at the top of the bounds, and answered there from then on. + # FlexMeasures returns a gap in a usage column for a moment it holds no data for. + # Treat such a gap as no known usage, not as NaN. + # A NaN fill level would be held at the top of the bounds, and every later step would be chosen from there. usage = forecast_usage if has_usage_column: usage = UsageProfile( @@ -400,7 +409,7 @@ def fm_schedule_to_instructions( ) if logger.isEnabledFor(logging.DEBUG): - # The whole batch in one line, by mode name, which the per-step lines above do not give. + # Log the whole batch in one line, by mode name, which the per-step log lines above do not show. logger.debug( "Instructions: %s", [ @@ -429,9 +438,9 @@ def get_soc_min_max( return soc_min, soc_max -#: What one unit of energy per second is in watts, for the units a fill level can be recorded in. -#: A joule per second is a watt by definition, -#: and an hour's worth of a watt is 3600 times as much energy as a watt-second. +#: The number of watts in one unit of energy per second, for each unit a fill level can be recorded in. +#: A joule per second is a watt, by definition. +#: A watt-hour is 3600 times as much energy as a watt-second. WATTS_PER_ENERGY_UNIT_PER_SECOND = { "J": 1.0, "kJ": 1e3, @@ -443,15 +452,15 @@ def get_soc_min_max( def fill_rate_in_watts(fill_rate: float, energy_unit: str) -> float: - """A fill rate, stated per second in the unit a fill level is recorded in, as watts. + """Convert a fill rate to watts, given the unit that the fill level is recorded in. - S2 states a fill rate as fill level per second, so what it is in watts depends on the unit the fill level itself is in: - a kilowatt hour per second is 3.6 million watts. - The conversion is needed wherever a fill rate is compared against a power value, - which S2 always states in watts. + S2 gives a fill rate as fill level per second. + So its value in watts depends on the unit of the fill level itself. + For example, one kilowatt-hour per second is 3.6 million watts. + This conversion is needed wherever a fill rate is compared with a power value, which S2 always gives in watts. - :raises NotImplementedError: for an energy unit this does not know, - rather than returning a number that is wrong by a factor of thousands. + :raises NotImplementedError: for an unknown energy unit, + instead of returning a number that is wrong by a factor of thousands. """ try: return fill_rate * WATTS_PER_ENERGY_UNIT_PER_SECOND[energy_unit] @@ -468,41 +477,42 @@ def operation_mode_conversion_efficiency( energy_unit: str, fill_level: float, ) -> float | None: - """How much energy an operation mode puts into the storage for each unit of power it draws, at a fill level. - - An operation mode states a fill rate and a power range, - so the ratio between the two is what the mode converts drawn power into stored energy at: - a heat pump's coefficient of performance, or a battery's charging efficiency. - Both sides are taken in watts, since FlexMeasures asks for a charging efficiency as a dimensionless ratio, - and for a thermal actuator that ratio is well above one. - - A mode may convert differently at different fill levels, as a heat pump does when its buffer warms, - and FlexMeasures takes one charging efficiency for a whole schedule. - The element that applies at the fill level the schedule starts from answers, - since the start of a schedule is the part that runs before the next storage status asks for a new one. - Not the lowest element over the whole range: - that overstates what the actuator needs to fill the storage, so the plan overshoots its upper band and buys more than it uses, - and against a usage the plan cannot relax it can turn a problem the actuator can meet into one the scheduler finds infeasible. - - The ratio is taken at the top of that element's range, rather than as the slope over it. - An actuator may well declare degenerate ranges, - where a mode runs at one power and one fill rate only (the heaters in our own smoke tests state on as [883.7, 883.7] W), - and a slope is zero over such a range while the ratio is the efficiency the mode really runs at. + """Return how much energy an operation mode puts into the storage per unit of power it draws, at a fill level. + + An operation mode gives a fill rate and a power range. + The ratio between the two is the rate at which the mode converts drawn power into stored energy. + Examples are a heat pump's coefficient of performance and a battery's charging efficiency. + Both sides are taken in watts, because FlexMeasures expects a charging efficiency as a dimensionless ratio. + For a thermal actuator, that ratio is well above one. + + A mode may convert differently at different fill levels, as a heat pump does when its buffer warms up. + But FlexMeasures takes a single charging efficiency for a whole schedule. + So this uses the element that applies at the fill level where the schedule starts. + The start of a schedule is the part that runs before the next storage status triggers a new schedule. + The lowest element over the whole range would be the wrong choice. + It overstates what the actuator needs to fill the storage. + The plan then overshoots its upper band, and buys more than it uses. + And if the plan cannot relax the usage, the scheduler may find a problem infeasible that the actuator can actually meet. + + The ratio is taken at the top of that element's range, not as the slope over the range. + An actuator may well declare degenerate ranges, where a mode runs at only one power and one fill rate. + For example, the heaters in our own smoke tests declare their on mode as [883.7, 883.7] W. + Over such a range the slope is zero, while the ratio is the efficiency the mode really runs at. :param fill_level_scale: converts a fill level, and so a fill rate, from the resource manager's own units into the unit it is recorded in. - :param energy_unit: the unit a fill level is recorded in, which the fill rate is per second of. - :param fill_level: where the schedule starts, in the resource manager's own units, as the elements' ranges are. - :returns: the efficiency, or None where the element that applies converts no drawn power into stored energy. - It converts nothing where it draws no power, as an idle mode does, - where it only feeds power back, which FlexMeasures takes as a separate discharging efficiency, - or where it draws power while the storage does not fill, as a standby or circulation mode does: - no single ratio states that, and a ratio of zero would tell the scheduler the storage cannot be filled at all. + :param energy_unit: the unit a fill level is recorded in; the fill rate is in this unit per second. + :param fill_level: where the schedule starts, in the resource manager's own units, like the elements' ranges. + :returns: the efficiency, or None if the element that applies converts no drawn power into stored energy. + That is the case in three situations. + The element draws no power, as in an idle mode. + The element only feeds power back, which FlexMeasures handles with a separate discharging efficiency. + Or the element draws power while the storage does not fill, as in a standby or circulation mode. + No single ratio can express that, and a ratio of zero would tell the scheduler that the storage cannot be filled at all. """ element = element_for_fill_level(operation_mode.elements, fill_level) - # todo: an element states one power range per commodity quantity, - # and no distinction is made between them here, - # as the rest of this handler does not make one either (see power_to_fill_rate_with_metrics). + # todo: an element gives one power range per commodity quantity, and this makes no distinction between them. + # Neither does the rest of this handler (see power_to_fill_rate_with_metrics). power = element.power_ranges[0].end_of_range fill_rate = element.fill_rate.end_of_range * fill_level_scale if power <= 0 or fill_rate <= 0: @@ -562,7 +572,7 @@ def power_to_fill_rate_with_metrics( power_penalty: float efficiency: float element: FRBCOperationModeElement that produced fill_rate - factor: the operation mode factor that draws this power, which is what an instruction asks for + factor: the operation mode factor that draws this power; an instruction asks for this factor """ # --- 1. Select the element that applies at this fill level --- best_element = element_for_fill_level(operation_mode.elements, fill_level) @@ -586,9 +596,10 @@ def power_to_fill_rate_with_metrics( pr_end = power_range.end_of_range if pr_end == pr_start: - # Every factor draws this one power, so the power cannot say which factor is meant. - # The top of the fill-rate range is asked for, which is where operation_mode_conversion_efficiency declares the efficiency FlexMeasures plans with, so the plan and the instruction agree. - # Where the fill-rate range is a single value too, the factor makes no difference and stays at zero. + # Every factor draws this same power, so the power cannot tell which factor is meant. + # Ask for the top of the fill-rate range. + # That is where operation_mode_conversion_efficiency declares the efficiency that FlexMeasures plans with, so the plan and the instruction agree. + # If the fill-rate range is a single value too, the factor makes no difference, and it stays at zero. fr_start = best_element.fill_rate.start_of_range fr_end = best_element.fill_rate.end_of_range factor = 0.0 if fr_end == fr_start else 1.0 @@ -624,23 +635,24 @@ def clamp_distance(value: float, start: float, end: float) -> float: def element_for_fill_level( elements: Sequence[ElementWithFillLevelRange], fill_level: float ) -> ElementWithFillLevelRange: - """The element that applies at a fill level, whatever order the elements were listed in. + """Return the element that applies at a fill level, whatever order the elements are listed in. S2 lets the elements of an operation mode, or of a leakage behaviour, cover the fill-level range in pieces, in any order. - s2python checks that an operation mode's elements are contiguous, on a sorted copy, and leaves the list as it was sent; - it checks nothing of the kind for a leakage behaviour, whose elements may leave gaps or overlap. + s2python checks that an operation mode's elements are contiguous, using a sorted copy, and leaves the list in the order it was sent. + It does not check this for a leakage behaviour, whose elements may leave gaps or overlap. - Two neighbouring elements share a boundary, which belongs to the upper of the two: - of every element whose range holds the fill level, the one starting highest applies, and of two starting together, the one reaching higher. - A range that is a single point therefore applies only at the very top. - The standard says a range starts below where it ends, so such a range is outside it, though s2python lets it through. - Two elements with the same range are ambiguous, which overlapping elements always are, and the first listed answers. + Two neighbouring elements share a boundary, which belongs to the upper of the two. + Of all elements whose range holds the fill level, the one that starts highest applies. + If two of them start at the same level, the one that reaches higher applies. + So a range that is a single point only applies at the very top. + The standard says a range starts below where it ends, so such a range breaks the standard, but s2python lets it through. + Two elements with the same range are ambiguous (as overlapping elements always are), and then the first one listed applies. - A fill level outside the range the elements cover is answered as the nearest end of that range is, - since a storage may be outside its declared range, and the answer should not jump as it crosses that end. - A fill level in a gap between elements is answered by the nearer, and on a tie the lower. + A fill level outside the range the elements cover is treated as the nearest end of that range. + That is because a storage may be outside its declared range, and the result should not jump when the fill level crosses that end. + For a fill level in a gap between elements, the nearer element applies; on a tie, the lower one applies. - :raises ValueError: for a fill level that is not a number, which no element applies at. + :raises ValueError: for a fill level that is not a number, since no element applies there. """ if math.isnan(fill_level): raise ValueError("No element applies at a fill level that is not a number.") diff --git a/src/flexmeasures_client/s2/control_types/__init__.py b/src/flexmeasures_client/s2/control_types/__init__.py index c708c2b2..f2504ffa 100644 --- a/src/flexmeasures_client/s2/control_types/__init__.py +++ b/src/flexmeasures_client/s2/control_types/__init__.py @@ -44,18 +44,20 @@ async def send_message(self, message: BaseModel) -> None: await self._sending_queue.put(message) async def close(self): - """Stop this handler's work in flight, when the CEM closes as its websocket tears down. + """Stop this handler's work in flight. - Defined here so that CEM.close(), which calls close() on every registered handler, works for any of them: - a handler lacking close() raised AttributeError inside CEM.close(), - killing the CEM's request handler and hanging the resource manager. + The CEM calls this when it closes, which happens when its websocket tears down. + CEM.close() calls close() on every registered handler, so every handler needs this method. + It is defined here for that reason. + Before, a handler without close() raised AttributeError inside CEM.close(). + That killed the CEM's request handler and left the resource manager hanging. """ self._logger.debug(f"Closing {self.__class__.__name__} handler") - # Work still in flight would otherwise go on after the session has ended, - # planning for a resource manager that is no longer there and racing the session that replaces it. - # That includes recording what the resource manager last reported, which is stopped too: - # a reading cut off mid-post is reported again by a resource manager that reconnects. - # Not every handler keeps background tasks, so a handler without them has nothing to stop. + # Otherwise, work in flight would continue after the session has ended. + # It would plan for a resource manager that is gone, and race the session that replaces it. + # Recording the resource manager's last reports is stopped too. + # That is safe: a resource manager that reconnects reports a reading again if its post was cut off. + # Not every handler keeps background tasks; a handler without them has nothing to stop. for task in list(getattr(self, "background_tasks", ())): task.cancel() diff --git a/src/flexmeasures_client/s2/control_types/translations.py b/src/flexmeasures_client/s2/control_types/translations.py index e61744db..54bc0158 100644 --- a/src/flexmeasures_client/s2/control_types/translations.py +++ b/src/flexmeasures_client/s2/control_types/translations.py @@ -58,10 +58,10 @@ def leakage_behaviour_to_storage_efficiency( """ - # FlexMeasures takes one storage efficiency here, for the whole window it covers. - # Where the fill level is known, the element that applies at it answers, as for a charging efficiency. - # Where it is not, the leakiest answers, looked for rather than assumed to come last, - # since S2 does not fix the order the elements are listed in, and the leakiest is not always the top one. + # FlexMeasures takes a single storage efficiency here, for the whole window it covers. + # If the fill level is known, use the element that applies at that fill level, as for a charging efficiency. + # If not, use the leakiest element. + # Search for it instead of assuming it comes last: S2 does not fix the order of the elements, and the leakiest one is not always the top one. if fill_level is not None: element = element_for_fill_level(message.elements, fill_level) else: @@ -70,13 +70,15 @@ def leakage_behaviour_to_storage_efficiency( e.fill_level_range.end_of_range * fill_level_scale for e in message.elements ) leakage = element.leakage_rate * fill_level_scale - # S2 states a leakage rate per second, so what remains after one second is compounded over the seconds in a step. - # This used to compound it over the hours in a step instead, - # which declared a buffer losing 1.5 kWh of 10 a day as holding its heat to within 0.002% over twelve hours, - # where it loses about 7%, and had the scheduler pre-charge in a cheap hour on the strength of it. - # FlexMeasures applies a storage efficiency to the stock as it stands, so the loss is a share of a fill level: - # of share_of where given, as the fill level a schedule starts from is, - # and otherwise of the top of the range, which understates it at lower fill levels. + # S2 gives a leakage rate per second. + # So the share that remains after one second is compounded over the number of seconds in a step. + # This used to be compounded over the number of hours in a step instead. + # Take a buffer that loses 1.5 kWh of 10 kWh a day: over twelve hours, it loses about 7%. + # The old calculation declared that it lost only 0.002% over those twelve hours. + # Because of that, the scheduler pre-charged the buffer in a cheap hour. + # FlexMeasures applies a storage efficiency to the current stock, so the loss is a share of a fill level. + # That fill level is share_of, if given, such as the fill level a schedule starts from. + # Otherwise, it is the top of the range, which understates the loss at lower fill levels. reference = ( share_of * fill_level_scale if share_of is not None and share_of > 0 diff --git a/src/flexmeasures_client/s2/measurements.py b/src/flexmeasures_client/s2/measurements.py index 5f154514..3f173f36 100644 --- a/src/flexmeasures_client/s2/measurements.py +++ b/src/flexmeasures_client/s2/measurements.py @@ -1,4 +1,7 @@ -"""Recording what a resource manager measures on FlexMeasures sensors, one value per step of each sensor's resolution.""" +"""Record what a resource manager measures on FlexMeasures sensors. + +Each sensor gets one value per step of its resolution. +""" from __future__ import annotations @@ -19,17 +22,17 @@ _LOGGER = getLogger(__name__) -#: The unit S2 states every power in, measured ones included. +#: The unit of every power value in S2, including measured power. S2_POWER_UNIT = "W" -#: How long a recorded step is remembered, so that a resource manager sending it again is not recorded twice. +#: How long a recorded step is remembered. A step that a resource manager sends again within this time is not recorded twice. REMEMBERED_FOR = timedelta(days=7) -#: How often a step is tried before it is given up on, and how long to wait between tries. +#: How many times to try recording a step before giving up, and how long to wait between tries. ATTEMPTS = 3 RETRY_AFTER = timedelta(seconds=30) -#: What a post can fail with, short of a defect in this code. +#: The exceptions a post can raise when there is no defect in this code. POST_FAILURES = ( ValueError, ConnectionError, @@ -41,24 +44,30 @@ class MeasurementRecorder: - """Records measured power on a sensor as the average of what was measured in each step of its resolution. - - A step is recorded once it is over by the CEM's clock, as it stands when the step's last measurement arrives, - with the moment it ended as the moment it became known, - so a measurement is a belief formed at the end of what it measures, rather than a forecast of it. - Steps are taken by the time each measurement is stamped with, not by when it arrives, - since a co-simulation's resource manager sends a window's measurements together once the window has passed, - each stamped at the start of its quarter hour, while a live one sends them as they are taken, - in which case a step is recorded with the first measurement after it is over. - - Recording happens in the background, so that a resource manager is not kept waiting for FlexMeasures - before its measurement is acknowledged, one post at a time per sensor. - A step that could not be recorded is tried again a while later, and given up on after a few tries. - - Made once per server and handed to each CEM, it outlives the reconnections of a resource manager, - which a co-simulation makes every round, and so remembers what was recorded: - a step sent again is not recorded again, and the first value recorded for a step stands. - A sensor is meant to be one resource manager's: one that several share has their measurements averaged together. + """Record measured power on a sensor, as the average of the measurements in each step of its resolution. + + A step is recorded once it is over, according to the CEM's clock when the step's last measurement arrives. + The recorded belief time is the moment the step ended. + So a measurement is recorded as a belief formed at the end of the period it measures, not as a forecast of it. + + Each measurement is assigned to a step by its timestamp, not by its arrival time. + This matters because resource managers send measurements in different ways. + A co-simulation's resource manager sends a window's measurements together, once the window has passed. + It stamps each one at the start of its quarter hour. + A live resource manager sends each measurement when it is taken. + In that case, a step is recorded when the first measurement after the step arrives. + + Recording happens in the background, so the resource manager does not wait for FlexMeasures before its measurement is acknowledged. + Per sensor, only one post runs at a time. + If a step could not be recorded, it is tried again a while later. + After a few failed tries, it is given up on. + + The server creates one recorder and hands it to each CEM. + So the recorder outlives the reconnections of a resource manager, which a co-simulation makes every round. + The recorder therefore remembers what it recorded. + A step that is sent again is not recorded again, and the first value recorded for a step stands. + Each sensor is meant to belong to one resource manager. + If several resource managers share a sensor, their measurements are averaged together. """ def __init__(self, fm_client: FlexMeasuresClient, logger: Logger | None = None): @@ -67,16 +76,17 @@ def __init__(self, fm_client: FlexMeasuresClient, logger: Logger | None = None): self._resolutions: dict[int, timedelta] = {} self._pending: dict[int, dict[datetime, list[float]]] = defaultdict(dict) self._attempts: dict[int, dict[datetime, int]] = defaultdict(dict) - #: By sensor, the steps recorded, in the order they were, which is roughly that of time. + #: The recorded steps per sensor, in recording order, which is roughly chronological. self._recorded: dict[int, dict[datetime, None]] = defaultdict(dict) self._locks: dict[int, asyncio.Lock] = defaultdict(asyncio.Lock) self._tasks: set[asyncio.Task] = set() async def resolution(self, sensor_id: int) -> timedelta: - """The sensor's resolution, asked once, since it is a property of how the sensor was set up. + """Return the sensor's resolution. - A caller taking several measurements at once asks this for each sensor first, - so that none is taken where one of them cannot be. + The resolution is fetched from FlexMeasures only once, because it is fixed when the sensor is set up. + A caller that takes several measurements at once should first call this for each sensor. + Then, if any one sensor fails, no measurement is taken at all. """ if sensor_id not in self._resolutions: sensor = await self._fm_client.get_sensor(sensor_id) @@ -93,7 +103,7 @@ async def resolution(self, sensor_id: int) -> timedelta: async def add( self, sensor_id: int, measured_at: datetime, watts: float, now: datetime ) -> None: - """Take one measurement, and record in the background every step of this sensor that is over by now.""" + """Take one measurement, then record every step of this sensor that is over by now, in the background.""" resolution = await self.resolution(sensor_id) step = step_of(measured_at, resolution) if step in self._recorded[sensor_id]: @@ -105,10 +115,11 @@ async def add( self._in_background(self.record(sensor_id, now)) async def record(self, sensor_id: int, now: datetime) -> None: - """Record every pending step of this sensor that is over by now, each in a request of its own. + """Record every pending step of this sensor that is over by now, each in its own request. - Each with its own end as the moment it became known, which one request for several steps cannot say. - One at a time per sensor, so a step is not posted twice, nor taken from under a post in flight. + Each step needs its own end as its belief time, and a single request for several steps cannot express that. + Per sensor, only one call runs at a time. + This way, a step is never posted twice, and no step is removed while a post for it is in flight. """ async with self._locks[sensor_id]: resolution = await self.resolution(sensor_id) @@ -159,7 +170,7 @@ async def _retry(self, sensor_id: int, now: datetime) -> None: await self.record(sensor_id, now) async def settle(self) -> None: - """Wait for every recording in the background to finish, retries included.""" + """Wait for every background recording to finish, including retries.""" while self._tasks: await asyncio.gather(*list(self._tasks), return_exceptions=True) @@ -176,7 +187,10 @@ def _done(self, task: asyncio.Task) -> None: ) def _forget_before(self, sensor_id: int, cutoff: datetime) -> None: - """Forget recorded steps from before the cutoff, oldest first, so that this costs little when nothing is old.""" + """Forget recorded steps from before the cutoff. + + Steps are checked oldest first, so this costs little when no step is old. + """ recorded = self._recorded[sensor_id] while recorded: oldest = next(iter(recorded)) @@ -186,10 +200,12 @@ def _forget_before(self, sensor_id: int, cutoff: datetime) -> None: def step_of(moment: datetime, resolution: timedelta) -> datetime: - """The start of the step a moment falls in, on the moment's own clock, as FlexMeasures floors it. + """Return the start of the step that a moment falls in. - In the repeated hour of an autumn DST change a boundary exists twice, and the step is the later of the two - that is not after the moment; taken from the moment's pass alone, flooring cannot always be done at all. + The step start is in the moment's own timezone, floored the way FlexMeasures floors it. + During the repeated hour of an autumn DST change, a step boundary occurs twice. + The step then starts at the later of the two boundaries that is not after the moment. + Flooring based only on which of the two passes the moment is in does not always work. """ if not resolution: return moment diff --git a/src/flexmeasures_client/s2/script/__init__.py b/src/flexmeasures_client/s2/script/__init__.py index c777fdcf..35f6224c 100644 --- a/src/flexmeasures_client/s2/script/__init__.py +++ b/src/flexmeasures_client/s2/script/__init__.py @@ -1 +1,4 @@ -"""Example scripts: a CEM server and a resource manager that talk S2 to each other, run by path as the CEM documentation shows.""" +"""Example scripts: a CEM server and a resource manager that talk S2 to each other. + +Run them by path, as the CEM documentation shows. +""" diff --git a/src/flexmeasures_client/s2/script/websockets_client.py b/src/flexmeasures_client/s2/script/websockets_client.py index b73ce4c9..6edbbf69 100644 --- a/src/flexmeasures_client/s2/script/websockets_client.py +++ b/src/flexmeasures_client/s2/script/websockets_client.py @@ -1,12 +1,12 @@ -"""An example resource manager: a heat pump keeping a hot-water buffer warm, speaking S2's FRBC to a CEM. +"""An example resource manager: a heat pump that keeps a hot-water buffer warm, and talks S2's FRBC to a CEM. Run it by path, next to the example CEM server, as the CEM documentation shows. -It declares what the heat pump can do and what its buffer needs, then simulates that buffer: -it follows each instruction from the moment it is due, and reports its fill level every so often, -which is what asks the CEM for a new schedule. +It declares what the heat pump can do and what its buffer needs, and then simulates that buffer. +The simulation follows each instruction from the moment it is due. +Every so often, it reports its fill level, which triggers the CEM to plan a new schedule. -The buffer is simulated from S2's own definitions, and not with the CEM's code, -so that it is a second opinion on where the instructions take the storage rather than a copy of the first. +The buffer is simulated from S2's own definitions, not with the CEM's code. +That way, the simulation is an independent check on where the instructions take the storage, not a copy of the CEM's own calculation. """ from __future__ import annotations @@ -58,20 +58,20 @@ #: The range the buffer's fill level should stay in, in kilowatt hours of stored heat. FILL_LEVEL_RANGE = (2.0, 10.0) -#: Below this fill level the water is cold enough for the heat pump to run at a COP of 4; above it, 3. +#: Below this fill level, the water is cold enough for the heat pump to run at a COP of 4. Above it, the COP is 3. COLD_BELOW = 6.0 -#: What the buffer holds when the simulation starts, and what it can physically hold at most. +#: The buffer's fill level when the simulation starts, and the most it can physically hold. STARTING_FILL_LEVEL = 5.0 PHYSICAL_CAPACITY = 12.0 #: How often the simulated buffer reports its fill level, in seconds of wall-clock time. STATUS_INTERVAL = float(os.getenv("S2_EXAMPLE_STATUS_INTERVAL", "300")) -#: How often the forecasts are sent again, each covering the day from then on, so that planning never runs out of them. +#: How often the forecasts are sent again, each covering the next day, so that planning never runs out of them. FORECAST_INTERVAL = float(os.getenv("S2_EXAMPLE_FORECAST_INTERVAL", "3600")) ELECTRIC_POWER = CommodityQuantity.ELECTRIC_POWER_3_PHASE_SYMMETRIC def heat(watts: float) -> float: - """A rate of heat in watts as a fill rate in kilowatt hours per second, which is how S2 states a fill rate.""" + """Convert a heat rate in watts into a fill rate in kilowatt-hours per second, which is how S2 expresses a fill rate.""" return watts / 3.6e6 @@ -101,7 +101,7 @@ def _element( def operation_modes() -> list[FRBCOperationMode]: - """Idle, or heating at anything from 400 to 1500 W, at a COP of 4 while the buffer is cold and 3 once it is warm.""" + """Return two modes: idle, and heating at 400 to 1500 W, with a COP of 4 while the buffer is cold and 3 once it is warm.""" idle = FRBCOperationMode( id=get_unique_id(), diagnostic_label="idle", @@ -123,8 +123,8 @@ def operation_modes() -> list[FRBCOperationMode]: def system_description( modes: list[FRBCOperationMode], valid_from: datetime ) -> FRBCSystemDescription: - # S2 lists the transitions an actuator can make, so the heat pump says it can go from either mode to the other, - # at once and at no cost. + # S2 lists the transitions an actuator can make. + # This heat pump declares that it can switch from either mode to the other, immediately and at no cost. transitions = [ Transition( id=get_unique_id(), @@ -165,7 +165,7 @@ def system_description( def leakage_behaviour(valid_from: datetime) -> FRBCLeakageBehaviour: - """A warmer buffer loses more: 0.8 kWh a day while cold, 1.5 kWh a day once warm.""" + """Return the leakage behaviour: a warmer buffer loses more, 0.8 kWh a day while cold, and 1.5 kWh a day once warm.""" return FRBCLeakageBehaviour( message_id=get_unique_id(), valid_from=valid_from, @@ -185,12 +185,12 @@ def leakage_behaviour(valid_from: datetime) -> FRBCLeakageBehaviour: def expected_usage(hour: int) -> float: - """Hot water drawn in a given hour of the day, in kWh: showers in the morning, washing up in the evening.""" + """Return the hot water drawn in a given hour of the day, in kWh: showers in the morning, washing up in the evening.""" return {7: 3.0, 19: 1.0, 20: 1.0}.get(hour, 0.1) def usage_forecast(start: datetime) -> FRBCUsageForecast: - """A day of hot-water use, hour by hour, from the hour start falls in.""" + """Return a forecast for a day of hot-water use, hour by hour, from the start of the hour that start falls in.""" start = start.replace(minute=0, second=0, microsecond=0) return FRBCUsageForecast( message_id=get_unique_id(), @@ -206,7 +206,7 @@ def usage_forecast(start: datetime) -> FRBCUsageForecast: def fill_level_target_profile(start: datetime) -> FRBCFillLevelTargetProfile: - """Kept within the declared range, and at least 6 kWh in the hour before the morning showers.""" + """Return a target profile within the declared range: at least 3 kWh, and at least 6 kWh in the hour before the morning showers.""" start = start.replace(minute=0, second=0, microsecond=0) return FRBCFillLevelTargetProfile( message_id=get_unique_id(), @@ -226,12 +226,12 @@ def fill_level_target_profile(start: datetime) -> FRBCFillLevelTargetProfile: @dataclass class Buffer: - """The simulated buffer: where its fill level goes, following S2's definitions. + """The simulated buffer, whose fill level changes according to S2's definitions. - S2 states every rate per second, in the fill level's own units: - a fill rate raises the fill level, and usage and leakage lower it. - An operation mode's fill rate is interpolated between the ends of its element's range by the factor, - from the element whose fill-level range holds the fill level. + S2 gives every rate per second, in the fill level's own units. + A fill rate raises the fill level, and usage and leakage lower it. + An operation mode's fill rate comes from the element whose fill-level range holds the fill level. + It is interpolated between the ends of that element's fill-rate range, using the factor. """ modes: dict[str, FRBCOperationMode] @@ -243,7 +243,11 @@ class Buffer: @staticmethod def _holding(elements, fill_level: float): - """The element whose range holds the fill level, the upper one at a shared boundary, the nearest outside.""" + """Return the element whose range holds the fill level. + + At a shared boundary, the upper element applies. + Outside all ranges, the nearest element applies. + """ lowest = min(e.fill_level_range.start_of_range for e in elements) highest = max(e.fill_level_range.end_of_range for e in elements) level = min(max(fill_level, lowest), highest) @@ -262,7 +266,7 @@ def _holding(elements, fill_level: float): ) def rates(self, moment: datetime) -> tuple[float, float, float]: - """Fill, usage and leakage, each per second, at this fill level and moment.""" + """Return the fill, usage and leakage rates, each per second, at this fill level and moment.""" fill = 0.0 if self.mode_id is not None: element = self._holding(self.modes[self.mode_id].elements, self.fill_level) @@ -273,7 +277,7 @@ def rates(self, moment: datetime) -> tuple[float, float, float]: return fill, usage, leakage def advance(self, until: datetime, step: timedelta = timedelta(minutes=1)) -> None: - """Move the fill level forward to a moment, a minute at a time, so that an element boundary is crossed in time.""" + """Move the fill level forward to a moment, a minute at a time, so that element boundaries are crossed at the right time.""" while self.at < until: dt = min(step, until - self.at) fill, usage, leakage = self.rates(self.at) @@ -290,7 +294,10 @@ def follow(self, instruction: FRBCInstruction, moment: datetime) -> None: async def send_instruction_status( ws, instruction_id, status: InstructionStatus ) -> None: - """Tell the CEM where an instruction stands, which is what it revokes by: only one not yet carried out.""" + """Send the CEM the status of an instruction. + + The CEM uses these statuses to decide what to revoke: it only revokes instructions that are not yet carried out. + """ await ws.send_str( InstructionStatusUpdate( message_id=get_unique_id(), @@ -310,14 +317,14 @@ async def apply_at_execution_time( ) -> None: """Enter the operation mode an instruction asks for, at the moment it asks for it. - An instruction carries an execution_time, - which is when the resource manager is to be in that mode, not when the instruction happens to arrive. - A resource manager that answers on arrival reports a mode it is not in yet, - and a customer energy manager recording that status has a measurement of a power the device is not drawing, + An instruction has an execution_time. + That is when the resource manager should be in that mode, not when the instruction happens to arrive. + A resource manager that acts on arrival reports a mode it is not in yet. + A customer energy manager that records that status then has a measurement of a power the device is not drawing, timestamped now. - The mode it reports leaving is the one the last instruction carried out put it in, kept in applied, - rather than whatever the instruction before it asked for, which may have been revoked and never carried out. + The mode it reports leaving is the mode the last executed instruction put it in, which is kept in applied. + It is not the mode the previous instruction asked for, because that instruction may have been revoked and never carried out. """ delay = (instruction.execution_time - datetime.now(timezone.utc)).total_seconds() @@ -325,7 +332,7 @@ async def apply_at_execution_time( print(f"WAITING: {delay:.0f}s until this instruction is due") await asyncio.sleep(delay) - # From here on it is being carried out, so a revocation arriving now is too late to stop it. + # From here on, the instruction is being carried out, so a revocation that arrives now is too late to stop it. if on_due is not None: on_due() now = datetime.now(timezone.utc) @@ -351,8 +358,9 @@ async def apply_at_execution_time( async def answer_instructions(ws, buffer: Buffer | None = None) -> None: """Acknowledge every instruction, and report the transition each one asks for when it is due. - An instruction the CEM revokes, because a newer plan replaces the one it belonged to, is not followed: - without that, the buffer would end up in whatever the last instruction to fire across every plan ever sent said. + The CEM revokes an instruction when a newer plan replaces the plan it belonged to. + A revoked instruction is not followed. + Otherwise, the buffer would end up in whatever mode the last instruction to fire said, across every plan ever sent. """ applied: dict = {"mode": None} @@ -405,7 +413,7 @@ def forget(done: asyncio.Task, key: str = key) -> None: async def report_fill_level( ws, buffer: Buffer, interval: float = STATUS_INTERVAL ) -> None: - """Report where the buffer is every interval, which is what asks the CEM for a new schedule.""" + """Report the buffer's fill level every interval, which triggers the CEM to plan a new schedule.""" while True: await asyncio.sleep(interval) buffer.advance(datetime.now(timezone.utc)) @@ -420,9 +428,9 @@ async def report_fill_level( async def refresh_forecasts( ws, leakage: FRBCLeakageBehaviour, interval: float = FORECAST_INTERVAL ) -> None: - """Send the leakage, the usage forecast and the comfort profile again every interval, each from now on. + """Send the leakage behaviour, the usage forecast and the comfort profile again every interval, each starting now. - Sent only once, they would cover the first day, and the CEM would plan without them from then on. + If they were sent only once, they would only cover the first day, and after that the CEM would plan without them. """ while True: await asyncio.sleep(interval) @@ -439,7 +447,7 @@ async def refresh_forecasts( async def receive_until(ws, message_type: str) -> dict: - """The next message of a type, past the reception statuses that arrive in between.""" + """Return the next message of a type, skipping the reception statuses that arrive in between.""" while True: message = (await ws.receive()).json() print("RECEIVED: ", message) @@ -450,10 +458,10 @@ async def receive_until(ws, message_type: str) -> dict: async def run_resource_manager( ws, interval: float = STATUS_INTERVAL, forecast_interval: float = FORECAST_INTERVAL ) -> Buffer: - """Introduce the heat pump, declare what it can do and what its buffer needs, then follow and report. + """Introduce the heat pump, declare what it can do and what its buffer needs, then follow instructions and report. - In the order S2 suggests: the system description first, then the storage's status. - Returns the simulated buffer, for whoever wants to look at where the instructions took it. + The messages follow the order that S2 suggests: the system description first, then the storage's status. + Returns the simulated buffer, so that a caller can inspect where the instructions took it. """ await ws.send_str( Handshake( @@ -469,7 +477,7 @@ async def run_resource_manager( message_id=get_unique_id(), resource_id=get_unique_id(), name="heat pump with a hot-water buffer", - # It draws electricity and stores heat, so it is a consumer of the commodity it is planned in. + # It draws electricity and stores heat, so it consumes the commodity it is planned in. roles=[ Role(role=RoleType.ENERGY_CONSUMER, commodity=Commodity.ELECTRICITY) ], @@ -478,8 +486,8 @@ async def run_resource_manager( ControlType.FILL_RATE_BASED_CONTROL, ControlType.NO_SELECTION, ], - # It sends no PowerForecast. It could measure its power, which is what the next field says, - # and S2 requires at least one type there. + # It sends no PowerForecast. + # It could measure its power, which is what the next field declares, and S2 requires at least one type there. provides_forecast=False, provides_power_measurement_types=[ELECTRIC_POWER], ).model_dump_json() diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 591540bd..4768f4ba 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -35,8 +35,8 @@ SITE_NAME = "My CEM" SITE_TIMEZONE = "Europe/Amsterdam" -# Enough days of demo prices to cover what the CEM plans for, which is twelve hours from now, -# with the seeding repeated every SEED_PRICES_EVERY so that a demo left running never runs out. +# Enough days of demo prices to cover the CEM's planning horizon, which is twelve hours from now. +# Seeding is repeated every SEED_PRICES_EVERY, so that a demo left running never runs out of prices. SEED_PRICE_DAYS = 3 SEED_PRICES_EVERY = timedelta(hours=12) @@ -47,17 +47,17 @@ "yes", ) -#: Each sensor the site needs, which is the library's table of what an FRBC site records. +#: The sensors the site needs, taken from the library's table of sensors for an FRBC site. SITE_SENSORS = FRBC_SITE_SENSORS -#: What every site asset is made with: a building, whose connection takes three phases of 25 A at 230 V. +#: The specs to create every site asset with: a building with a three-phase connection of 25 A per phase at 230 V. SITE_ASSET_SPECS: dict[str, Any] = dict( generic_asset_type_id=6, flex_model={"power-capacity": f"{3 * 25 * 230} VA"}, ) -#: Whether each resource manager gets a site of its own, named after it, as in a co-simulation of a community, -#: rather than all of them sharing the one site this server sets up at startup. +#: Whether each resource manager gets its own site, named after it, as in a co-simulation of a community. +#: If not, they all share the one site that this server sets up at startup. SITE_PER_RESOURCE_MANAGER = os.getenv("S2_SITE_PER_RESOURCE_MANAGER", "").lower() in ( "1", "true", @@ -92,8 +92,8 @@ async def rm_details_watchdog(ws, cem: CEM): cem._logger.debug(f"CONTROL TYPE: {cem._control_type}") - # From here on, a schedule is asked for on each storage status, - # and on a system description that arrives after one. + # From here on, each storage status triggers a schedule request. + # So does a system description that arrives after a storage status. async def websocket_producer(ws, cem: CEM): @@ -168,22 +168,22 @@ async def serve_resource_manager( ) -> None: """Act as the CEM for one resource manager connected over ws, until it disconnects. - On the site whose sensors are given, or, with a configurator, on a site of the resource manager's own, - which is set up once its details arrive. + The CEM uses the site whose sensors are given. + With a configurator, it uses the resource manager's own site instead, which is set up once its details arrive. """ cem = CEM( fm_client=fm_client, logger=LOGGER, site_configurator=configurator, - # The server's, which outlives a connection, so a resource manager that reconnects is not recorded twice. + # The server's recorder outlives a connection, so a resource manager that reconnects is not recorded twice. measurement_recorder=recorder, - # Where what the resource manager measures is recorded, on the fixed site; a configured site wires its own. + # The sensor to record the resource manager's measurements on, for the fixed site. A configured site wires its own. power_sensor_id=( {"ELECTRIC.POWER.L1": sensors["measured-power"]["id"]} if sensors is not None and "measured-power" in sensors else None ), - # A co-simulation's resource manager sends its messages wrapped with the simulated time, which this sets the clock to. + # A co-simulation's resource manager wraps its messages with the simulated time, and this sets the clock to that time. accept_simulation_metadata=ACCEPT_SIMULATION_METADATA, ) if sensors is not None: @@ -192,13 +192,13 @@ async def serve_resource_manager( def fixed_site_handler(sensors: dict[str, dict]) -> FRBCSimple: - """The handler for a resource manager on the one site this server sets up at startup.""" + """Return the handler for a resource manager on the one site that this server sets up at startup.""" frbc = FRBCSimple( power_sensor_id=sensors["power"]["id"], price_sensor_id=sensors["price"]["id"], soc_sensor_id=sensors["state of charge"]["id"], rm_discharge_sensor_id=sensors["RM discharge"]["id"], - # Where the resource manager's declarations are recorded, so that each can be seen next to the schedule planned against it. + # Record the resource manager's declarations, so that each can be inspected next to the schedule planned against it. fill_rate_sensor_id=sensors["fill rate"]["id"], soc_minima_sensor_id=sensors["soc minima"]["id"], soc_maxima_sensor_id=sensors["soc maxima"]["id"], @@ -206,20 +206,21 @@ def fixed_site_handler(sensors: dict[str, dict]) -> FRBCSimple: leakage_behaviour_sensor_id=sensors["storage efficiency"]["id"], charging_efficiency_sensor_id=sensors["charging efficiency"]["id"], timezone=SITE_TIMEZONE, - # Plan from now, rather than from a day after the description, so that a demo shows its instructions being followed. + # Plan from now, not from a day after the description, so that a demo shows its instructions being followed. valid_from_shift=timedelta(0), ) - # A newer plan revokes what is left of the one before, so the resource manager does not follow both. + # A newer plan revokes the remainder of the previous plan, so the resource manager does not follow both. frbc.send_revocations = True return frbc async def serve(ws, cem: CEM) -> None: """Serve one resource manager with this CEM, for as long as its connection lasts.""" - # The session lasts as long as the resource manager's messages do. - # When they end, the CEM is closed, which stops what its handlers still have in flight, - # and the producer and the watchdog stop with it: left running, they outlived the connection, - # and a resource manager that reconnected was served by two sessions planning for it at once. + # The session lasts as long as the resource manager keeps sending messages. + # When the messages end, the CEM is closed, which stops any work its handlers still have in flight. + # The producer and the watchdog stop too. + # When they were left running, they outlived the connection. + # A resource manager that reconnected was then served by two sessions, both planning for it at once. consumer = asyncio.create_task(websocket_consumer(ws, cem)) side_tasks = [ asyncio.create_task(websocket_producer(ws, cem)), @@ -244,7 +245,7 @@ async def configure_site( asset_specs=SITE_ASSET_SPECS, sensors=SITE_SENSORS, timezone=SITE_TIMEZONE, - # The demo's own graphs, on every start, so changes made to them in the UI do not outlive a restart. + # Reset the demo's graphs on every start, so that changes made to them in the UI do not survive a restart. reset_graphs=True, ) await seed_prices(fm_client, sensors["price"]["id"]) @@ -252,12 +253,12 @@ async def configure_site( async def seed_prices(fm_client: FlexMeasuresClient, price_sensor_id: int) -> None: - """Demo prices, so that a site has something to schedule against. + """Post demo prices, so that a site has something to schedule against. - One day's hourly shape, repeated over SEED_PRICE_DAYS from this morning, because the CEM - plans ahead of the moment a resource manager reports its storage: - a fixed date here goes stale the day after it is written, and the scheduler then fails the - job with UnknownPricesException rather than returning a schedule. + The prices repeat one day's hourly shape over SEED_PRICE_DAYS, starting this morning. + The CEM plans ahead of the moment a resource manager reports its storage. + A fixed date here would go stale the day after it was written. + The scheduler would then fail the job with UnknownPricesException, instead of returning a schedule. """ one_day = [ 0.10, 0.11, 0.12, 0.15, 0.18, 0.17, 0.11, 0.09, @@ -277,9 +278,10 @@ async def seed_prices(fm_client: FlexMeasuresClient, price_sensor_id: int) -> No async def keep_prices_seeded(app: web.Application): - """Seed the prices again every SEED_PRICES_EVERY for as long as the server runs, so a demo never runs out of them. + """Seed the prices again every SEED_PRICES_EVERY for as long as the server runs, so that a demo never runs out of them. - Not where each resource manager has a site of its own, whose prices are whoever runs them to post. + This is skipped if each resource manager has its own site. + Then whoever runs those resource managers is responsible for posting their prices. """ if SITE_PER_RESOURCE_MANAGER: yield @@ -324,16 +326,16 @@ async def configure_site_once(app: web.Application) -> None: app[FM_CLIENT] = fm_client app[RECORDER] = MeasurementRecorder(fm_client, logger=LOGGER) if SITE_PER_RESOURCE_MANAGER: - # One configurator for the server's lifetime, so a resource manager that reconnects finds its site again by id. + # Use one configurator for the server's lifetime, so that a resource manager that reconnects finds its site again by id. app[CONFIGURATOR] = FRBCSiteConfigurator( fm_client, asset_type_id=SITE_ASSET_SPECS["generic_asset_type_id"], asset_specs={"flex_model": SITE_ASSET_SPECS["flex_model"]}, timezone=SITE_TIMEZONE, - # The co-simulation's resource managers are heat pumps, which cannot feed power back: - # FlexMeasures then keeps their production at zero even where constraints are relaxed. + # The co-simulation's resource managers are heat pumps, which cannot feed power back. + # With this attribute, FlexMeasures keeps their production at zero, even where constraints are relaxed. power_sensor_attributes={"is_strictly_non_positive": True}, - # A community's capacity is a target set by its members' steering, breached at a price, as #188 asked for. + # A community's capacity is a target for steering its members, so it may be breached at a price, as #188 asked for. handler_options={"relax_site_capacity": True}, logger=LOGGER, ) diff --git a/src/flexmeasures_client/s2/site.py b/src/flexmeasures_client/s2/site.py index cf3f783a..56279f9f 100644 --- a/src/flexmeasures_client/s2/site.py +++ b/src/flexmeasures_client/s2/site.py @@ -1,8 +1,9 @@ -"""A FlexMeasures site for an S2 resource manager: its asset, its sensors, its graphs, and the handler wired to them. +"""Set up a FlexMeasures site for an S2 resource manager. +A site consists of an asset, its sensors and its graphs, plus a handler wired to those sensors. The example server keeps one site for whichever resource manager connects (see ensure_site). -A CEM serving many resource managers, as a co-simulation of a community does, gives each one a site of its own, -named after it, with FRBCSiteConfigurator. +A CEM can also serve many resource managers, as in a co-simulation of a community. +Such a CEM uses FRBCSiteConfigurator to give each resource manager its own site, named after it. """ from __future__ import annotations @@ -29,7 +30,7 @@ @dataclass(frozen=True) class SiteSensor: - """What a site's sensor is made with: its unit, its resolution, and any attributes.""" + """The settings for creating a site's sensor: its unit, its resolution, and any attributes.""" unit: str resolution: str @@ -37,18 +38,18 @@ class SiteSensor: #: The sensors an FRBC site needs, by name. -#: Their units are the ones the CEM records in: fill levels in kWh, rates of fill level per second, as S2 states them. -#: The names are a contract with whoever reads the site, a co-simulation among them, -#: which looks up "price", "production price", "power", "state of charge" and "measured-power" by name. +#: Their units are the units the CEM records in: fill levels in kWh, and fill rates per second, as in S2. +#: 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] = { "price": SiteSensor("EUR/kWh", "PT15M"), "production price": SiteSensor("EUR/kWh", "PT15M"), - # S2 states a power as positive when it is consumed, - # and this sensor holds both an actuator's power and the schedule planned for it. - # Left unsaid, FlexMeasures reads a power sensor the other way round, - # and flips a schedule's sign on the way in, which puts the two in opposite directions. + # In S2, consumed power is positive. + # 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}), - # What the resource manager measures its site drawing, kept apart from the schedule on "power". + # 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}), "state of charge": SiteSensor("kWh", "PT0M"), "RM discharge": SiteSensor("dimensionless", "PT15M"), @@ -62,13 +63,14 @@ class SiteSensor: def frbc_site_graphs(sensors: dict[str, dict]) -> list[dict]: - """A site's graphs, top to bottom, as FlexMeasures takes an asset's sensors_to_show. + """Return a site's graphs, top to bottom, in the format of an asset's sensors_to_show in FlexMeasures. - In the form with a title and a list of sensors per graph, which every FlexMeasures version reads, - rather than the newer one with plots, which needs v0.32 or later and draws the same graphs. - A graph whose sensors the site does not have is left out. + Each graph has a title and a list of sensors. + Every FlexMeasures version reads this format. + The newer format with plots draws the same graphs, but needs FlexMeasures v0.32 or later. + A graph is left out if the site has none of its sensors. """ - # Titled as #188 titled them, since a co-simulation's controller adds to the graph titled "Power". + # Keep the titles from #188, because a co-simulation's controller adds to the graph titled "Power". graphs = [ ("Prices", ["price", "production price"]), ("Power", ["power", "measured-power"]), @@ -93,16 +95,17 @@ async def ensure_site( known_asset_id: int | None = None, reset_graphs: bool = False, ) -> tuple[dict, dict[str, dict]]: - """Find or make the site asset of this name, with its specs and every one of these sensors, and its graphs. + """Find or create the site asset with this name, with its specs, all of these sensors, and its graphs. - It is safe to run again: what exists is kept, and what is missing is made. - A flex model in the specs is merged into the one the asset has, since others write to it too, - such as a co-simulation's controller setting what its stock is worth at the end. - Graphs are set where the asset has none, since others add to them too, unless asked to reset them. + Running this again is safe: existing parts are kept, and missing parts are created. + 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. + Pass reset_graphs to set them anyway. - :param known_asset_id: the asset's id where it was found before, which spares a listing of every asset. - :param reset_graphs: whether to set the graphs even where the asset has some. - :returns: the asset, and its sensors by name. + :param known_asset_id: The asset's id from an earlier lookup, which avoids listing every asset. + :param reset_graphs: Whether to set the graphs even if the asset already has some. + :returns: The asset, and its sensors by name. """ site_asset = None if known_asset_id is not None: @@ -115,7 +118,7 @@ async def ensure_site( if candidate is not None and candidate.get("name") == name: site_asset = candidate if site_asset is None: - # The newest of any of that name, as a co-simulation picks it too. + # Pick the newest asset with that name, as a co-simulation does too. named = [ asset for asset in await fm_client.get_assets(parse_json_fields=True) @@ -163,20 +166,20 @@ async def ensure_site( @dataclass class ConfiguredSite: - """What a CEM needs to serve one resource manager: its handler, and where its measured power goes.""" + """What a CEM needs to serve one resource manager: its handler, and the sensors to record its measured power on.""" handler: FRBCSimple - #: The sensor each commodity quantity's measured power is recorded on, as the CEM's power_sensor_id takes it. + #: Per commodity quantity, the sensor to record measured power on, in the format of the CEM's power_sensor_id. power_sensor_ids: dict[str, int] asset_id: int class FRBCSiteConfigurator: - """Gives each resource manager a site of its own, named after it, and an FRBC handler wired to its sensors. + """Give each resource manager its own site, named after it, and an FRBC handler wired to the site's sensors. - Made once per server and handed to each CEM it makes (see CEM's site_configurator), - so that a resource manager that reconnects finds its site by the id remembered here, - rather than by a listing of every asset on the server. + The server creates one configurator and hands it to each CEM it creates (see the CEM's site_configurator). + The configurator remembers each site's asset id. + So when a resource manager reconnects, its site is found by that id, without listing every asset on the server. """ def __init__( @@ -193,15 +196,15 @@ def __init__( logger: Logger | None = None, ) -> None: """ - :param asset_type_id: the FlexMeasures asset type each site is made with. - :param timezone: the time zone of each site's sensors, and of its handler. - :param asset_specs: anything else each site asset is made or updated with, such as a flex model. - :param sensors: the sensors each site gets; FRBC_SITE_SENSORS by default. - :param power_sensor_attributes: attributes added to the "power" sensor's, which a device's nature decides, + :param asset_type_id: The FlexMeasures asset type to create each site with. + :param timezone: The timezone of each site's sensors, and of its handler. + :param asset_specs: Other specs to create or update each site asset with, such as a flex model. + :param sensors: The sensors each site gets; FRBC_SITE_SENSORS by default. + :param power_sensor_attributes: Extra attributes for the "power" sensor that depend on the device, such as that it cannot produce. - :param handler_options: keyword arguments for each FRBCSimple beyond its sensors. - By default it plans from now, over 24 hours, as a co-simulation steps through days. - :param send_revocations: whether a newer plan revokes what is left of the one before. + :param handler_options: Keyword arguments for each FRBCSimple, other than its sensors. + By default, it plans from now over 24 hours, because a co-simulation steps through days. + :param send_revocations: Whether a newer plan revokes the remainder of the previous plan. """ self._fm_client = fm_client self._timezone = timezone diff --git a/src/flexmeasures_client/s2/utils.py b/src/flexmeasures_client/s2/utils.py index 0da31bcb..004fc7e9 100644 --- a/src/flexmeasures_client/s2/utils.py +++ b/src/flexmeasures_client/s2/utils.py @@ -28,14 +28,14 @@ def __init__(self, *args, max_size=100, **kwargs): super(SizeLimitOrderedDict, self).__init__(*args, **kwargs) self._max_size = max_size - # deleting values to make the dictionary of length _max_size at most, oldest first + # Delete the oldest entries until the dictionary holds at most _max_size entries. while len(self) > self._max_size: self.popitem(last=False) def __setitem__(self, __key: KT, __value: VT) -> None: - # The oldest entry makes room, not the newest, which popitem() takes by default: - # a full history otherwise kept a session's first entries and lost every later one but the last. - # A key already present takes no room of its own. + # Evict the oldest entry, not the newest (which is what popitem() removes by default). + # Otherwise, a full history kept a session's first entries and lost all later ones except the last. + # A key that is already present needs no extra room. if ( self._max_size is not None and __key not in self @@ -44,7 +44,8 @@ def __setitem__(self, __key: KT, __value: VT) -> None: self.popitem(last=False) super().__setitem__(__key, __value) - # Set again, an entry is the latest, which is what reading the last entry means everywhere this is used. + # An entry that is set again becomes the latest entry. + # Everywhere this class is used, reading the last entry means reading the latest one. self.move_to_end(__key) @@ -72,7 +73,7 @@ def get_message_id(message: pydantic.BaseModel) -> str | None: class _HasMessageId(Protocol): - """Any S2 message: every one of them carries a message id.""" + """Any S2 message, since every S2 message carries a message id.""" message_id: Any @@ -86,7 +87,7 @@ def get_reception_status( This function returns a ReceptionStatus message for the subject message `subject_message`. By default, the status ReceptionStatusValues.OK is sent. - :param diagnostic_label: why, for a status other than OK, which the resource manager can log. + :param diagnostic_label: For a status other than OK, the reason, which the resource manager can log. """ return ReceptionStatus( subject_message_id=str(subject_message.message_id), From 9f031885bbbc4acea2d9c76cff1671d72bec3727 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sun, 8 Nov 2026 15:54:24 +0100 Subject: [PATCH 2/2] docs(S2): keep what the plain rewrite of the comments got wrong A check of every rewritten docstring and comment against the code found one false claim (that a failure was always retried before retries were bounded, when there were no retries at all), a capacity comment that read as a community-level capacity rather than each member site's, and smaller shifts: a condition made of a fact, a lost "because", a lost example, a tense slip, a softened "cannot", and a line over the length limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW Signed-off-by: F.N. Claessen --- src/flexmeasures_client/s2/cem.py | 3 ++- .../s2/control_types/FRBC/__init__.py | 2 +- .../s2/control_types/FRBC/frbc_simple.py | 14 +++++++------- .../s2/control_types/FRBC/utils.py | 2 +- src/flexmeasures_client/s2/measurements.py | 2 +- .../s2/script/websockets_server.py | 2 +- src/flexmeasures_client/s2/utils.py | 2 +- 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/flexmeasures_client/s2/cem.py b/src/flexmeasures_client/s2/cem.py index eb3707a5..34122e77 100644 --- a/src/flexmeasures_client/s2/cem.py +++ b/src/flexmeasures_client/s2/cem.py @@ -126,7 +126,8 @@ def __init__( and set the clock to that time (see flexmeasures_client.s2.wrapper.S2Wrapper). This is off by default, because the wrapper is not part of S2, and a live CEM should not let a resource manager set its time. - When it is off, a resource manager that wraps its messages from its handshake on is refused with INVALID_MESSAGE, and cannot connect. + When it is off, a resource manager that wraps its messages from its handshake on is refused with INVALID_MESSAGE, + and cannot connect. When it is on, the clock must be a SimulatedClock. If no clock is given, a SimulatedClock is created. """ diff --git a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py index 140bd290..258889eb 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/__init__.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/__init__.py @@ -213,7 +213,7 @@ def handle_system_description( # Storing it again would push an older description out of the bounded history for no reason. # Also, a handler that acts on a description would act on it twice. # The acknowledgement still matters, because without it the resource manager retries. - # Ignoring a resend is safe because planning is triggered by storage statuses, not by descriptions. + # Ignoring a resend is safe because planning is normally triggered by storage statuses, and a resend never differs from the previous description. # Before that was the case, ignoring a resend would have dropped a planning request. # The TUNES handler has always fingerprinted its descriptions this way. # It can adopt this shared helper separately. 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 bfaad55a..748c6702 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/frbc_simple.py @@ -250,8 +250,8 @@ def __init__( schedule_duration: timedelta = timedelta(hours=12), # How many times to request a schedule before leaving the storage without an instruction, # and how long to wait between two attempts. - # One attempt means a failure is never retried. - # Before this limit existed, a failure was always retried with another attempt. + # One attempt means a failure is never retried, + # which is how this handler behaved before retries were added. schedule_attempts: int = SCHEDULE_ATTEMPTS, schedule_retry_interval: timedelta = SCHEDULE_RETRY_INTERVAL, # The resolution that schedules are planned on; a schedule's start must line up with it. @@ -396,7 +396,7 @@ def schedule_start(self, valid_from: datetime) -> datetime: Planning happens on every storage status, while a description may be days old. If the start were based on that description alone, every status in a long session would get the same window. - That window slid into the past, so all its instructions were already due, and arrived as one burst. + That window would slide into the past, so all its instructions would already be due, and arrive as one burst. Hence the start is never before now. If now is past that boundary, the schedule starts at the next boundary, not at the start of the current step. A schedule is planned from the fill level reported now, and FlexMeasures takes that as the fill level at the schedule's start. @@ -592,7 +592,7 @@ def _fill_level_band( The latest fill-level target profile, held inside that range, applies to the steps it covers. The range used to reach FlexMeasures only through the band sensors, and only where a target profile was recorded on them. So a storage without those sensors, or past the profile's end, was planned without any band. - It could then be planned down to empty, and up to half again above its range. + It could then be planned down to empty, and up to the hard upper bound (by default, half again above its range). The values are clipped at zero, because FlexMeasures does not accept negative minima. """ steps = int( @@ -715,7 +715,7 @@ async def send_actuator_status(self, status: FRBCActuatorStatus): ) operation_mode = actuator.operation_modes[0] - # The element the actuator runs on depends on the fill level, just as a heat pump derates while its buffer warms up. + # The element the actuator runs on depends on the fill level; for example, a heat pump derates while its buffer warms up. # So use the element that applies at the latest reported fill level, not the element that the mode happens to list first. fill_level = self._latest_fill_level() if fill_level is not None: @@ -1142,7 +1142,7 @@ async def _plan( max(power_range.start_of_range, power_range.end_of_range), ) # Only use the modes the actuator may be instructed into under normal conditions. - # The actuator may be instructed into any band that the scheduler plans in. + # This is because the actuator may then be instructed into any band that the scheduler plans in. for operation_mode in normal_operation_modes(actuator) for element in operation_mode.elements for power_range in element.power_ranges @@ -1211,7 +1211,7 @@ async def _plan( if self._usage_forecast_sensor_id is not None: # Skip this if the resource manager says it provides no usage forecast, # because then nothing is recorded on the sensor for the scheduler to read. - # But use it if the resource manager sent a forecast anyway, whatever its flag says. + # But use it if the resource manager sent a forecast anyway, since sending one says it provides one, whatever its flag says. if ( system_description.storage.provides_usage_forecast or self._usage_forecast_history diff --git a/src/flexmeasures_client/s2/control_types/FRBC/utils.py b/src/flexmeasures_client/s2/control_types/FRBC/utils.py index 0b597962..4e8fb54f 100644 --- a/src/flexmeasures_client/s2/control_types/FRBC/utils.py +++ b/src/flexmeasures_client/s2/control_types/FRBC/utils.py @@ -492,7 +492,7 @@ def operation_mode_conversion_efficiency( The lowest element over the whole range would be the wrong choice. It overstates what the actuator needs to fill the storage. The plan then overshoots its upper band, and buys more than it uses. - And if the plan cannot relax the usage, the scheduler may find a problem infeasible that the actuator can actually meet. + And because the plan cannot relax the usage, the scheduler may find a problem infeasible that the actuator can actually meet. The ratio is taken at the top of that element's range, not as the slope over the range. An actuator may well declare degenerate ranges, where a mode runs at only one power and one fill rate. diff --git a/src/flexmeasures_client/s2/measurements.py b/src/flexmeasures_client/s2/measurements.py index 3f173f36..b5c9f443 100644 --- a/src/flexmeasures_client/s2/measurements.py +++ b/src/flexmeasures_client/s2/measurements.py @@ -205,7 +205,7 @@ def step_of(moment: datetime, resolution: timedelta) -> datetime: The step start is in the moment's own timezone, floored the way FlexMeasures floors it. During the repeated hour of an autumn DST change, a step boundary occurs twice. The step then starts at the later of the two boundaries that is not after the moment. - Flooring based only on which of the two passes the moment is in does not always work. + Flooring based only on which of the two passes the moment is in cannot always be done. """ if not resolution: return moment diff --git a/src/flexmeasures_client/s2/script/websockets_server.py b/src/flexmeasures_client/s2/script/websockets_server.py index 4768f4ba..80d736ec 100644 --- a/src/flexmeasures_client/s2/script/websockets_server.py +++ b/src/flexmeasures_client/s2/script/websockets_server.py @@ -335,7 +335,7 @@ async def configure_site_once(app: web.Application) -> None: # The co-simulation's resource managers are heat pumps, which cannot feed power back. # With this attribute, FlexMeasures keeps their production at zero, even where constraints are relaxed. power_sensor_attributes={"is_strictly_non_positive": True}, - # A community's capacity is a target for steering its members, so it may be breached at a price, as #188 asked for. + # Each member's site capacity is a target set by the community that steers it, so it may be breached at a price, as #188 asked for. handler_options={"relax_site_capacity": True}, logger=LOGGER, ) diff --git a/src/flexmeasures_client/s2/utils.py b/src/flexmeasures_client/s2/utils.py index 004fc7e9..86e6f7d9 100644 --- a/src/flexmeasures_client/s2/utils.py +++ b/src/flexmeasures_client/s2/utils.py @@ -34,7 +34,7 @@ def __init__(self, *args, max_size=100, **kwargs): def __setitem__(self, __key: KT, __value: VT) -> None: # Evict the oldest entry, not the newest (which is what popitem() removes by default). - # Otherwise, a full history kept a session's first entries and lost all later ones except the last. + # Popping the newest instead made a full history keep a session's first entries, and lose all later ones except the last. # A key that is already present needs no extra room. if ( self._max_size is not None