Skip to content

feat(S2): #188 finished, reconciled onto main (1 of 2) - #278

Merged
Flix6x merged 66 commits into
mainfrom
dev/s2-frbc-p1
Nov 8, 2026
Merged

Flix6x merged 66 commits into
mainfrom
dev/s2-frbc-p1

Conversation

@Flix6x

@Flix6x Flix6x commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

#188 finished, reconciled onto main. This is the first of two PRs that finish the S2 FRBC Customer Energy Manager. PR 2 (#279) stacks on this one and carries the remaining review findings.

What this is

#188 (dev/fix-handshake-handler) was carved into small PRs, and each was merged into the integration branch dev/s2-frbc-complete. Every one of them had:

  • an adversarial review;
  • a mutation run proving its new tests can fail;
  • full coverage of its added lines;
  • green CI.

This PR is that branch up to package P1 (66 commits, 40 files).

What P1 covers:

  • The instruments: mypy over all of src, coverage for tests/s2, and a lint against print() in unattended code.
  • One element selector, and the time base of the instruction translation: every rate per second, as S2 states it.
  • The example: a heat pump with a buffer that is planned for, instructed and simulated.
  • Energy units.
  • The CEM lifecycle:
    • implicit activation, and session teardown;
    • a clock a co-simulation can set;
    • a site per resource manager, behind a readiness gate;
    • measured power recorded by when it was measured;
    • one plan at a time, for the newest request.
  • The co-simulation stack under examples/S2-co-simulation.

#188 itself is left open for you to close; this PR and the next one replace it.

Please merge with a merge commit, not a squash. The atomic history is what makes a diff this size reviewable. The repo currently allows squash merges only, so this needs the setting changed for the merge.

Evidence

Coverage reporting

This PR changes what the coverage badge measures.

  • On main, .coveragerc omits the S2 package, and the S2 tests upload no coverage. So the README badge (95.7%) covers only the code outside S2: 1042 lines on main.
  • With this PR, the omission is gone and the S2 job uploads its coverage too. Coveralls merges all jobs: a line counts as covered if any job covers it. The badge becomes one number over the whole package, 3064 lines, of which 2015 are S2.

What each flavour covers, measured locally on this branch's head (ad9a12ba), statements only:

Code Covered by the core tests Covered by the S2 tests Combined (the badge)
Outside S2 (1049 lines) 95.7% 20.4% 95.7%
S2 (2015 lines) 0% 94.5% 94.5%
Whole package (3064 lines) 32.8% 69.2% 94.9%

So the badge drops slightly, from 95.7% to 94.9%. Core coverage does not get worse: the S2 package now counts, and it is covered a little less well. The S2 tests add nothing to the core code's coverage beyond what the core tests already reach.

Each job's own coverage report in the CI log measures the whole package, so the core job shows 35% and the S2 job 64%. Neither number describes its own flavour.

Telling the flavours apart. Coveralls gives one badge per branch, so a single README number cannot show both. Two ways to make the distinction visible:

  1. Per-flavour reports in CI (no new service): the core job reports with --omit "*/s2/*" and the S2 job with --include "*/s2/*", each writing its number to the job summary. The uploads stay as they are, so the badge stays the combined number. This also scales to future client flavours: one job and one report scope per flavour.
  2. Per-flavour badges: this needs a service with coverage flags, such as Codecov, where each upload is flagged by flavour and each flag can get its own badge. That means moving off Coveralls.

I'd do (1) in a follow-up and keep Coveralls, unless you want separate badges in the README.

Finding → commit → file map

Findings are numbered as in the review of the carve campaign.

PR 1 is origin/main..ad9a12ba, "#188 finished, reconciled onto main" (55 non-merge commits).

Finding Package Commit(s) Files
A1 example resource manager never receives an instruction (message order) P1.3 2f8902c fix(example): a heat pump with a buffer that is planned for, instructed, and simulated; 392ba50 fix(example): follow only the latest plan, keep the forecasts current, and what review found; e3a5567 fix(example, FRBC): what the second review of the example round found; 41a8f63 fix(example, FRBC): what the third review of the example round found s2/control_types/FRBC/init.py, s2/control_types/FRBC/frbc_simple.py, s2/control_types/translations.py, s2/script/websockets_client.py, s2/script/websockets_server.py
A2 demo prices and description dated ten months apart P1.3 2f8902c (same commit as A1; description now dated now); 392ba50 (prices re-seeded every twelve hours) s2/script/websockets_client.py, s2/script/websockets_server.py
A3 description after the last status plans against the newest status held P1.3 2f8902c s2/control_types/FRBC/init.py, s2/control_types/FRBC/frbc_simple.py
A (stale comment at websockets_server.py:56) P1.3 2f8902c s2/script/websockets_server.py
B1 zero or negative charging efficiency from a standby mode P1.1 (plan says P2.2; landed here) 5ff631d fix(FRBC): answer by the element that applies, whatever order S2 lists them in s2/control_types/FRBC/utils.py, s2/control_types/FRBC/frbc_simple.py
B2 four of six energy units refused by the server P1.4 10f1cc7 fix(FRBC): a state of charge in any supported energy unit reaches FlexMeasures; 6408b6f fix(FRBC): what the review of the energy-unit change found s2/control_types/FRBC/frbc_simple.py, CL-S2
B3 charging efficiency reads last element as if ordered P1.1 5ff631d; b572a86 fix(FRBC): plan with the element where the schedule starts, not the lowest s2/control_types/FRBC/utils.py, s2/control_types/FRBC/frbc_simple.py, s2/control_types/translations.py
B5 resolution hardcoded to 15 minutes in schedule conversion P1.2 4d5435e fix(FRBC): every rate per second, as S2 states it, under the instructions; 2387288 fix(FRBC): follow each step as a resource manager does, which review showed it did not s2/control_types/FRBC/utils.py, s2/control_types/FRBC/frbc_simple.py, s2/control_types/FRBC/frbc_tunes.py, s2/control_types/translations.py
B10 storage efficiency understates leakage by about 3600 P1.2 4d5435e s2/control_types/translations.py, s2/control_types/FRBC/frbc_simple.py
B13 state-of-charge sensor must stay out of the flex model P1.4 10f1cc7 (stands, pinned by a test; no source line added) s2/control_types/FRBC/frbc_simple.py
C1 running fill level advances 3600 times too slowly P1.2 4d5435e; 2387288; 324e7f2 fix(FRBC): follow each step exactly, between the moments anything changes s2/control_types/FRBC/utils.py, s2/control_types/FRBC/frbc_simple.py, s2/control_types/FRBC/frbc_tunes.py
C2 leakage branch dead (two wrong lookups) P1.2 4d5435e s2/control_types/FRBC/utils.py, s2/control_types/FRBC/frbc_simple.py
C3 actuator status power taken from first element P1.1 5ff631d s2/control_types/FRBC/frbc_simple.py, s2/control_types/FRBC/utils.py
C4 fixed fill rate with variable power range instructed at bottom P1.2 4d5435e (op_mode_compute_factor removed) s2/control_types/FRBC/utils.py
C7 nothing cancels handler background tasks on teardown P1.5 0028494 fix(S2): a lost confirmation does not stall a session, and a closed session stops; 6b1d3ea fix(S2): take only the control type being selected as confirmed, and close any handler s2/cem.py, s2/control_types/init.py, s2/control_types/FRBC/frbc_tunes.py, s2/script/websockets_server.py
C8 CEM power buffer is a class attribute P1.5 ccab11b feat(S2): record measured power by when it was measured, in watts, once per step; ab56606 fix(S2): what the review of the measurement recorder found s2/cem.py, s2/measurements.py, s2/script/websockets_server.py
D4 no throttle, one solve per storage status P1.5 00d3676 feat(FRBC): plan one schedule at a time, for the newest request; 29845ea fix(FRBC): a request out of date makes no further attempt, so the newest waits less s2/control_types/FRBC/frbc_simple.py
D9 power and fill-rate posts hardcode 15 minutes P1.2 4d5435e s2/control_types/FRBC/frbc_simple.py
E2 bare print() where a schedule is given up on P1.0 372a39a chore(lint): no print() in the S2 code that runs unattended s2/control_types/FRBC/frbc_simple.py, .pre-commit-config.yaml
P1.0 instruments (mypy over all of src, S2 coverage, print ban; not a numbered finding) P1.0 30dc81e chore(types): check every source file, including untyped function bodies; ff1304c ci: measure the coverage of the S2 tests; c341c4a chore(types): type check against what the code imports, S2 messages included; f791bdb chore: close the gaps an adversarial review found in the instruments pyproject.toml, .coveragerc, .github/workflows/ci.yml, .pre-commit-config.yaml, client.py, s2/init.py, s2/cem.py, s2/utils.py, s2/script/init.py, s2/script/websockets_client.py, s2/script/websockets_server.py, s2/control_types/translations.py, s2/control_types/FRBC/frbc_simple.py, s2/control_types/FRBC/frbc_tunes.py, s2/control_types/FRBC/utils.py

Rows: 20 (19 finding rows, including the stale-comment sub-row of A1, plus the P1.0 instruments row).

PR 1 commits that address no numbered finding

Changelog only:

  • 9ae5a5c docs(changelog): FillRateBasedControlTUNES records a leakage behaviour
  • d0b1c1d docs(changelog): the element that applies, and what a standby mode states
  • 1928d56 docs(changelog): an unlabelled TUNES mode, and two helpers that never worked
  • d0ae8ce docs(changelog): every rate per second under the instructions
  • fa3dc59 docs(changelog): the example, and the planning triggers it needed
  • 5ba8ee2 docs(changelog): which energy units a handler takes, and what FlexMeasures is sent
  • c7fe0a7 docs(changelog): describe the example round as it now stands
  • 7c8ae45 docs(changelog): the handler options, the dropped translation, and assets without a location
  • cc54bb6 docs(changelog): the example site's graphs
  • 9053b86 docs(changelog): implicit activation and session teardown
  • 5b9793e docs(changelog): the clock and the simulation metadata
  • bffe6c9 docs(changelog): sites per resource manager and the co-simulation stack
  • 809b136 docs(changelog): the measurement recorder
  • 40288e8 docs(changelog): the co-simulation stack on FlexMeasures 1.1
  • 2b5d481 docs(changelog): the state of charge on the clock
  • 401599b docs(changelog): one schedule at a time

Carve features and fixes with no review finding (session lifecycle, clock, sites, recorder, handler options):

  • 80ad945 feat(S2): a clock the CEM and its handlers go by, which a co-simulation can set (s2/clock.py, s2/cem.py, FRBC and TUNES handlers, server)
  • 47c695a fix(S2): what the review of the clock found
  • 6a12ea4 feat(S2): a site of its own for each resource manager, and the co-simulation stack (s2/site.py, examples/S2-co-simulation)
  • 65b359f fix(S2): what the review of the sites per resource manager found
  • f0c4d4e fix(FRBC): a state of charge is known as of the handler's clock
  • 93e3ae3 feat(FRBC): a production price of its own, an opt-in relaxed site capacity, and assets without a location (client.py, frbc_simple, utils)
  • 47d295d fix(FRBC): send relax_site_capacity either way, or leave it to FlexMeasures
  • bc90b2b fix(FRBC): plan from the next boundary, not the start of the step under way
  • bcdbd79 feat(example): the site's graphs show the buffer, its comfort band, and what fills and draws it
  • c466752 fix(example): set the site graphs in the form every FlexMeasures reads

Fixes found by reading the code, tied to no finding id:

  • 43bb171 fix(TUNES): record a leakage behaviour instead of refusing it
  • 9587c94 fix(TUNES): an operation mode without a label no longer stops planning
  • 4585f2f fix(client): drop a content-type check that could not fire, nor be printed
  • 2a644e6 fix: correct what this branch claimed, and test what review found untested

Docs and examples:

  • b8aff1d fix(examples): the co-simulation stack runs on a current FlexMeasures image
  • 4807388 docs(examples): what the co-simulation stack runs, and how to build it from a worktree
  • 5564983 docs(examples): the instance settings a co-simulation needs

Style and test-only:

  • 9824ea5 style(FRBC): keep the soc-at-start TODO off the line it annotates
  • 11e9a39 test(FRBC): an overtaken plan sends nothing, whether dropped before or after translation (tests only)
  • 1cc6261 test(S2): a message without a type activates nothing (tests only)
  • 69f5dbe test(S2): a typeless message activates nothing while a selection is pending (tests only)

Numbered findings in neither range, from the PR 1 viewpoint

All A, B, C findings land in PR 1 or PR 2 (see the PR 2 table).
Findings in neither range:

  • D5 (system description replaced mid-planning): already on main through the request counting of fix(example): a heat pump with a buffer that is planned for, instructed, and simulated #259; 0028494 only adds a test pinning it.
  • D "UniqueViolation never retried": already a retry marker in frbc_simple.py on origin/main; no plan package names it, and no commit in either range addresses it.
  • D timers, transitions, several actuators, provides_* flags as features: out of scope per plan section 9, with P2.3 making each loud (PR 2).
  • F "two tests in test_frbc_registry reimplement send_storage_status" and "mocked 202 payload uses job_monitor_url": in no plan package and no commit; unplaced.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW

Flix6x and others added 30 commits November 5, 2026 13:10
FillRateBasedControlTUNES.send_leakage_behaviour read its fill-level scale from
self._fill_level_scalefill_level_scale, an attribute name pasted in twice, which
has been there since #158. The AttributeError that raised was caught by the
handler's broad except as if the post had failed, so every FRBC.LeakageBehaviour
was answered with PERMANENT_ERROR and none was ever recorded.

mypy could not see it: the type-check task only looks at files that contain
`from typing import`, and the FRBC base class is not one of them, so the
attribute lookup went to Any. The next commit widens that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
The type-check task fed mypy only the files that contain `from typing import`,
which were seven of eighteen, and left out frbc_simple.py, both control-type
__init__.py files and the example scripts. With --follow-imports skip, anything
those files defined then looked like Any to the files that were checked, which
is how the previous commit's misspelt attribute went unnoticed since #158.

It now checks all of src, and with --check-untyped-defs, so the bodies of
functions without annotations are checked too. What that surfaced, besides the
attribute fixed in the previous commit:

- get_access_token stored a token whose type could be None and then used it as a
  header value; it now returns the token, so the caller holds a str.
- get_access_token and get_versions indexed a response typed as a dict or a list;
  each now says so plainly when the server answers with something other than an
  object, instead of failing on the lookup.
- The S2 translations annotated their resolution as a string while frbc_simple
  passes a timedelta, which pandas accepts as readily; the annotation now says so.
- FillRateBasedControlTUNES overrode trigger_schedule without the arguments the
  base class declares. It calls its own override without them and ignores them,
  so it now accepts and ignores them.
- The example server indexed an account that can be None and spread a dict of
  mixed values into add_asset; it now says so when the demo user has no
  organisation, and types the dict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
None of the S2 integration had a coverage number, for two reasons that each hid
the other. The S2 tests run in a CI job of their own, since they need the s2
extra, and that job never asked for coverage. And .coveragerc omitted
src/flexmeasures_client/s2/* outright, since #183, so asking for it would have
reported nothing about S2 while looking as though it did.

The omission is gone, and the S2 job now reports and uploads its own partial
report, which Coveralls merges with the other job's in the finalize step, as it
already does across the test matrix. The poe task that runs the S2 tests locally
collects it as well. The job that skips tests/s2 will now list the S2 files as
unexecuted in its own report; the merged figure is the one that counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
A CEM's handlers run for days with nobody watching standard output, so anything
they print is lost to a deployment, which keeps the log instead. FRBCSimple gave
up on a schedule that way when no storage status had arrived, which is the very
failure, a storage left without an instruction, that the handler otherwise goes
to some length to say out loud. It now logs a warning.

A pygrep hook keeps it that way for s2/control_types and s2/cem.py. The example
scripts print on purpose, as do the client's interactive delete confirmations,
which answer an input() prompt on the console, so the hook leaves those alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
- The print ban now matches a print anywhere on a line and with a space before
  its parenthesis, plus pprint and writes to stdout or stderr, and covers all of
  s2/ but the example scripts. It only caught an indented `print(` at the start
  of a line before, in two directories, and missed s2/__init__.py, utils.py and
  wrapper.py, which run inside the CEM as much as the handlers do.
- The example scripts get an __init__.py. Without one, coverage never listed
  websockets_server.py at all, since no test imports it, so the S2 report left
  it out instead of showing it at 0%. Running them by path is unaffected.
- The TUNES leakage test now pins what is posted, its value and its unit,
  rather than only that something reached the sensor.
- A client without a token now has a test that it authorises with the one it
  fetches.
- FillRateBasedControlTUNES.trigger_schedule is back to taking no arguments,
  with the override mismatch marked for mypy instead. Accepting the base
  class's arguments and ignoring them would have turned a caller's mistake,
  such as expecting its superseded-request protection, into a silent one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…s them in

S2 lets the elements of an operation mode, and of a leakage behaviour, cover the
fill-level range in pieces, and s2python checks that they are contiguous on a
sorted copy, leaving the list in whatever order it was sent. Four places took an
element by its position instead, and answered three different ways:

- the measured power of an actuator status took elements[0], so a heat pump that
  derates as its buffer warms was recorded at its cold power when warm: 2000 W
  where it draws at most 1200 W;
- the charging efficiency took elements[-1] as though it were the top of the
  range, so a mode listed high element first planned against the wrong ratio;
- the storage efficiency took the leakage rate of elements[-1], on the same
  assumption, though the leakiest element need not be the top one;
- the schedule translation selected by fill level, but settled a shared boundary
  by list order.

element_for_fill_level now answers wherever a fill level is known: the element
whose range holds it, the upper of two at a shared boundary, and past either end
of the range the element that applies at that end, so the answer does not jump
there. The measured power and the schedule translation use it. Before any storage
status there is no fill level, so the measured power takes the bottom of the range
and says in the log that it guessed.

Where FlexMeasures takes one number for a whole schedule, it does not know which
fill levels the schedule will pass through, so the most cautious element answers,
which is the rule #253 already applies across modes: the lowest conversion
efficiency, and the leakiest element. That is also independent of order.

The efficiency now also skips an element that draws power while the storage does
not fill, such as a standby or circulation mode. Before, such a mode gave a ratio
of zero or below, which then won the minimum across modes, went onto the
charging-efficiency sensor, and told the scheduler that no power could fill the
storage at all; with the fill-level band priced rather than hard, never running
the heat pump was then a feasible plan.

Two guards were dropped rather than tested, because S2 makes them unreachable:
every operation mode and leakage behaviour has at least one element, and every
element at least one power range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…ates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…owest

An adversarial review showed that the lowest efficiency over a mode's elements
is not the cautious choice the previous commit called it. In FlexMeasures' linear
program a charging efficiency scales what the actuator adds to the stock, so
understating it protects the lower band only: the plan overshoots the upper
band, buys more than it uses, and against a usage forecast the plan cannot
relax, it can turn a problem the actuator can meet into one the scheduler
finds infeasible. A heat pump at a COP of 4 cold and 2 warm, planned at 2,
empties its planned buffer in a few hours while the real one keeps up.

So within a mode, the element that applies at the fill level the schedule
starts from answers. That is also the part of a schedule that runs before the
next storage status asks for a new one, and it is what the actuator really does
there. The storage efficiency likewise takes the leakage element at the latest
fill level, and the leakiest only where no fill level is known yet.
Across modes, the lowest still goes out, as #253 decided; the same argument
applies to it, and that is left as a question rather than changed here.

Also from the review:
- A fill level that is not a number, which s2python lets through, counts as no
  fill level, and the selector refuses one, where it used to pick the bottom
  element silently.
- The selector's docstring now says what is true: s2python checks contiguity for
  an operation mode only, never for a leakage behaviour; a single-point range,
  which the standard rules out, applies only at the top; and of two elements
  with the same range, the first listed answers.
- Tests now cover the latest of several storage statuses, a fill-level scale
  other than 1 where scaling would pick another element, no warning for a mode
  with one element, and a schedule starting in the upper element. Each was a
  production mutation that passed the suite before.

Formatted with black as well, which the previous commit was not: the
pre-commit hooks were never installed in this repository, so they did not run
on commit, and CI's check caught it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
S2 makes an operation mode's diagnostic label optional, and
FillRateBasedControlTUNES tells its modes apart by looking for "THP" or "NES"
inside it. On a mode without one that raised a TypeError, so a single
unlabelled mode stopped both planning and the recording of every mode's
conversion efficiency. A missing label now simply matches neither.

Found by type checking against s2python's own types, which also asked for two
variables that held a range and then a number, or a message and then a series,
to be given a name for each. New tests cover the planning path, the efficiency
path, and posting a usage forecast, none of which any test reached before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…inted

request() ran check_content_type after its retry loop. It could never raise
there: request_once parses every answer with aiohttp's json(), which refuses any
content type but JSON, and the loop already reports that refusal as a
ConnectionError, like every other failure to reach the API.

Had it fired, it would have done harm. It built aiohttp's ContentTypeError from
a message and a dict, where aiohttp expects the request's info and history, so
anything that formatted the error, a log line included, raised an AttributeError
of its own; and it read the body with response.text() without awaiting it.
Its only test passed a mock whose text() was not a coroutine, and never
formatted the error.

The check is gone. The test that replaces it states where a non-JSON answer,
such as a proxy's error page during a restart, is actually handled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…ncluded

mypy ran with --follow-imports skip, so s2python, pydantic, pandas and aiohttp
were Any to it, and a misspelt attribute on an S2 message passed. It now follows
imports, with pydantic's plugin, since S2 messages are pydantic models, and the
pre-commit hook installs the s2 extra so those types are there to check. The
plugin is also the guard against sliding back: without the extra it fails to
load, rather than leaving every S2 type silently Any. A misspelt message_id on a
leakage behaviour is now an error naming it.

What it found, besides the two fixes in the previous commits:

- get_validation_error_summary iterated a pydantic ValidationError, which is not
  iterable, so it raised a TypeError on any error it was given. Nothing called
  it, so it is gone rather than repaired.
- Revocations were recorded by the RevokeObject's raw id, while every other id
  a handler keeps is text, so a lookup by the text of the id would have missed.
  They are recorded as text now. Nothing calls is_revoked yet, which means a
  revocation is acknowledged and then never consulted; that is recorded as a
  finding for later rather than changed here.
- The rest is annotation: a message that arrives as a model, a dict or text is
  normalised to a dict once and checked as one; get_message returns the dict it
  always returned rather than claiming a str; the message type of a registered
  schema is read through model_fields rather than the deprecated __fields__; an
  operation mode's id is text where a label is wanted; and the actuator's power
  ranges are pairs rather than tuples of any length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
… worked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…ested

An adversarial review ran every claim on this branch, and three were false:

- The removed content-type check could fire. aiohttp's json() accepts JSON
  variants such as application/problem+json and application/vnd.api+json, and
  the check refused those, with an error that could not be printed. Removing it
  is right, but it changes behaviour: such an answer is now read like any other.
  The comment at the removed call, the test's docstring and the changelog entry
  now say so, instead of calling it dead code.
- "A non-JSON answer is reported as a ConnectionError, as before" was only true
  of a wrong content type. A body that is not JSON under a JSON content type is
  refused by json() itself, as a JSONDecodeError, as it was before.
- One unlabelled TUNES mode stopped planning, and the recording of the
  efficiencies of the modes listed after it, not of every mode.

Two more overstatements live only in pushed commit messages and the PR body:
following imports leaves pandas Any, since it ships no type information and no
stubs are installed, and the efficiency path was already reached by a test.

Tests now cover what five mutations showed was unpinned: a revocation taken by
the CEM itself, with no control type active; a model of a type a handler does
not handle; a usage forecast starting off the quarter hour; and one averaged
within a quarter hour rather than taking its highest value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…ions

The schedule translation keeps a running fill level, so that each step is
answered with the operation mode for the fill level the storage is then at. It
advanced that level by a fill rate per second times a step in hours, so it moved
3600 times too little, and every step of a twelve-hour schedule was answered at
the fill level the schedule started from (finding C1). Fixing that alone would
have been worse: the level would then rise at the real rate and never fall, and
pin every step at the top. So the integrator gets one written contract, the one
S2 states for all three rates: a change in fill level per second, in the
resource manager's own units, with usage and leakage lowering it where positive.
Its step is a duration rather than a number of some unit.

- Leakage now counts (C2). It read leakage_rate_per_second, which S2 does not
  have, and an actuator's leakage_behaviour, which cannot exist, both through
  getattr with a default, so the branch never ran. The handler now passes the
  latest FRBC.LeakageBehaviour, and the element that applies at the fill level.
- Usage now counts. The handler passes the latest FRBC.UsageForecast, read per
  step by usage_rate_at; a frame may still carry a usage_forecast column instead,
  per second, which is what TUNES does, now dividing by the quarter hour it
  posts in.
- The step is the schedule's own (B5): its duration over its number of values,
  which is the resolution of the sensor FlexMeasures answered at. It was taken
  to be a quarter hour whatever it was, so an hourly sensor's twelve values were
  laid on forty-eight quarter hours, pandas refused, and no instruction went out.
- An instruction goes out wherever the mode or its factor changes, not only
  where the power does: at a constant power the fill level can pass from one
  element into another, which needs another factor.
- The factor is the one that draws the power asked for (C4), taken from the power
  range, rather than worked back out of the fill rate, which failed wherever the
  fill rate was one value and the power a range: asked for 1200 W from a mode
  running 800 to 1200 W, that asked for 800 W. Where the power is one value and
  the fill rate a range, every factor draws that power, so the one that fills
  most is asked for. op_mode_compute_factor is gone.
- The storage efficiency compounds a leakage rate per second (B10), where it
  compounded it per hour, declaring a buffer that loses about 7% over twelve
  hours as holding its heat to within 0.002%.
- The handler's power and fill-rate measurements cover its own resolution
  (D9), rather than a quarter hour whatever it planned on.
- translations.py is linted again: it opened with `# flake8: noqa`, which hid
  nothing, as it turns out.

TUNES shares the translation, so its instructions change with it, for the
better: it carried the same per-hour integration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…showed it did not

An adversarial review built its own integrator from S2's definitions, a second at
a time, and found the previous commit agreed with it only while nothing changed
within a step. Where something did, it did not:

- A step that crossed from one element into another was integrated as if it had
  not: from 46, filling at 0.02 per second below 50 and 0.01 above, a quarter hour
  at factor 1 reaches 57, where holding the first element said 64, and at an hour's
  step the client thought a buffer full that was at 84. The same held for leakage.
- Usage was sampled at the instant a step began, where FlexMeasures planned with
  the mean over the step, so a five-minute draw in each quarter hour counted fully
  or not at all, and a forecast starting off the step counted as nothing.

follow_operation_mode now follows a step a minute at a time, as a resource manager
follows an instruction: the mode and the factor are what the instruction fixes,
while the element of the mode and of the leakage behaviour, and the usage a
forecast expects, are each taken where and when they apply. The fill level is held
between its bounds throughout, rather than only at the end of a step.

Also from the review:
- An instruction no longer goes out for a solver's own rounding: near factor 0,
  math.isclose without an absolute tolerance is exact equality, so 800 W give or
  take a ten-millionth from a mode running 800 to 1200 W sent 30 instructions in
  twelve hours instead of one.
- A gap in a usage column, as FlexMeasures returns for a moment it holds nothing
  for, is no usage known, rather than a NaN that pinned the running fill level at
  the top of its bounds for the rest of the schedule.
- FillRateBasedControlTUNES reads its usage forecast back divided by the
  fill-level scale it posted it with, which left it ten times too small at TUNES'
  default scale, and plans with its latest leakage behaviour. The previous commit
  message called TUNES' change "for the better"; without these it was not.
- One power and a fill-rate range listed downwards asks for the top of the range,
  where operation_mode_conversion_efficiency declares the efficiency FlexMeasures
  plans with, so the instruction and the plan agree.
- FRBCSimple refuses a schedule_duration that is not a whole number of resolution
  steps: FlexMeasures answers with whole steps only, so the step, read as the
  duration over the number of values, would come out wrong.
- The handler's storage efficiency is what remains after one step of its own
  resolution, and FlexMeasures reads it per step of the leakage sensor's
  resolution, so the two have to agree. That is now said where the sensor is
  taken and where the value is posted.

Tests cover each of these, and each of the four mutations the review found
surviving, among them an on/off heat pump told to switch off: both modes ask for
factor 0, and only the mode tells them apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…ed, and simulated

The shipped example sent no instruction at all, for two reasons that only show
with both halves running (findings A1, A2). Its resource manager sent its storage
status before its system description, which planned nothing, since planning hangs
off a storage status and needs a description to turn a schedule into
instructions; and its description was dated 2026-01-15, while the server seeds
prices from the day it starts, so a schedule's window had no prices.

It also could not have shown steering if it had: one operation mode, with a power
range of -0.5 to 0.5 W against a fill rate of -0.5 to 0.5 per second, which is
1.8 MW, over a declared range of 0.4.

The resource manager is now a heat pump keeping a hot-water buffer warm. It is
idle, or heats at anything from 400 to 1500 W, at a COP of 4 while the buffer is
below 6 kWh and 3 above it. It declares its leakage, warmer losing more; a day of
hot-water use with showers in the morning and washing up in the evening; and a
comfort profile that pre-heats before the showers. It sends its description first,
dated now, and then simulates its buffer from S2's own definitions, written
separately from the CEM's code: it follows each instruction from the moment it is
due, and reports its fill level every so often, which asks for a new schedule.
That simulation is a second opinion on the CEM's arithmetic, and a test holds the
two to agreement across an element boundary.

The server gives the handler a sensor for every declaration (fill rate, band,
usage, leakage, charging efficiency), so each can be seen next to the schedule
planned against it, and plans from now rather than a day after the description.
Its connection handling is split out of the aiohttp handler, so a test can serve
the shipped resource manager with the shipped CEM, over an in-memory websocket,
with only FlexMeasures stood in for.

Two library changes the example needed:
- A system description that arrives after the latest storage status, or that
  changes what the device can do, is planned for against that status (finding
  A3), rather than waiting for a status that a resource manager whose fill level
  holds steady may never send. It plans without re-posting the old status as a
  new measurement.
- A schedule never starts before now. Planning happens on every storage status
  while a description can be days old, so a schedule anchored to the description
  alone slid into the past, and its instructions all arrived as one overdue burst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…xMeasures

FRBCSimple passed its energy_unit straight through as the flex model's soc-unit,
while FlexMeasures validates soc-unit with OneOf(["kWh", "MWh"]). So four of the
six units fill_rate_in_watts documents, J, kJ, MJ and Wh, made every trigger a
422, and the storage got no schedule at all (finding B2).

The plan for this was to refuse those units when the handler is made. The carve
source answers it better, by converting joules at the edge, and that is what
happens now, for every supported unit: the state of charge and its two hard
bounds go to FlexMeasures in kWh, converted from the recorded unit with the table
fill_rate_in_watts already keeps, while kWh and MWh pass through as they are.
The band, the usage and the leakage are read from sensors, which FlexMeasures
converts itself, and the bounds the schedule is read back against stay in the
recorded unit, since the operation modes are stated in it.

An energy unit outside that table is now refused when the handler is made, where
it used to surface only at planning time, as a logged failure to record a
charging efficiency. The handler for that failure is gone, since nothing can now
reach it.

Not changed, and now pinned by a test: the measured state-of-charge sensor is
never named in the flex model (finding B13 stands). Named there, FlexMeasures
would save the state of charge it plans onto that sensor, beside the
measurements. The carve source does name it, and the carve will have to leave
that line behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…sures is sent

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…nges

A second round of review showed that following a step a minute at a time traded
one class of error for four others, and cost ten times the time:

- Usage was sampled at the start of each minute. Half-minute draws starting on
  the half minute were never seen at all, and the same draws starting on the
  minute were counted twice.
- A level settling on an element boundary, where filling stops above it, flipped
  across that boundary every step: 127 instructions in 144 steps, against one.
- An element narrower than a minute's travel was skipped: a level that stops at
  50 was carried to 80.
- A 48-hour schedule against the largest forecast S2 allows took 1.7 s, on the
  event loop, scanning the forecast once a minute.

Every rate is constant between the moments the usage changes and the moments
the level reaches an element boundary, of the mode or of the leakage behaviour,
or one of its bounds. So follow_operation_mode now steps from one such moment
to the next and is exact. A level that reaches a boundary whose far side would
push it back stays at that boundary, as a real storage does, and so does a level
at one of its bounds, which rises again from there once filling wins. The usage
forecast is read through a UsageProfile, built once per schedule and looked up
by bisection.

Also from the second round:
- FillRateBasedControlTUNES posted its usage forecast as an amount per quarter
  hour labelled MW. FlexMeasures reads soc-usage as a power and multiplies it by
  the step's share of an hour, so it planned a quarter of the real draw. It now
  posts per hour, and reads back accordingly.
- FRBCSimple refuses a resolution or schedule duration that is not positive,
  where a zero resolution raised a ZeroDivisionError.
- The factor tolerance's justification was wrong: FlexMeasures rounds a
  schedule to six decimals of MW, so what it absorbs is the noise of converting
  to watts, not a solver's rounding. The tolerance stays; the comment and the
  changelog now say what it is for.
- One of the first round's mutations had stopped applying after black split
  its line, so the gate's count overstated what was checked. The harness now
  rejects a mutant that cannot run, and every mutation is anchored to the
  formatted code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…2-example-heat-pump

Signed-off-by: F.N. Claessen <claessen@seita.nl>
…, and what review found

An adversarial review of the example, and of the two library changes it needed,
found the example resource manager following every plan it had ever been sent.
Each instruction ran in a task of its own, nothing cancelled the tasks of a
replaced batch, and the server sent no revocations. In the live demo against a
sandboxed FlexMeasures, thirty instructions for the same quarter hour fired at
once when it came, one from each replan.

The server now sets send_revocations, so a newer plan revokes what is left of the
one before, and the resource manager cancels a revoked instruction it has not yet
followed, and acknowledges the revocation.

Also from the review:
- The leakage, the usage forecast and the comfort profile were sent once, at
  connect, and the storage efficiency was recorded once, for twelve hours from
  the message, so planning went without leakage after twelve hours and without
  usage or a band after a day. The resource manager now sends all three again
  every S2_EXAMPLE_FORECAST_INTERVAL seconds, from then on, and the server seeds
  its demo prices again every twelve hours.
- In the library, a storage efficiency is now recorded each time a schedule is
  asked for, over the window it covers and as a share of the fill level it starts
  from, and named in the flex model only where it was recorded. On arrival, it
  covered the wrong hours, at a fill level nobody had reported yet, and as a share
  of the top of the range it understated the loss at lower fill levels.
- align_to_resolution floored the local clock, which in the repeated hour of an
  autumn DST change took the first pass of that hour, so a schedule could start
  67 minutes in the past. It now floors the instant, in UTC.
- A state of charge is posted as a reading at an instant, where it was posted over
  the minute after it, which FlexMeasures stored as a forecast.
- The example declares what it does: transitions between its two modes, which S2
  lists as the ones possible; no PowerForecast, since it sends none; and a
  consumer of electricity rather than a storage of it.
- Claims corrected: a description that differs from the one before it, its
  validity included, asks for a schedule, not only one that "changes what the
  device can do"; and a schedule starts in the current step, which has begun, not
  "never before now".
- The test that held the example's buffer to the CEM's integrator compared two
  copies of one discretisation. The CEM is now exact and the buffer steps a
  minute at a time, so the test bounds their difference by a minute of the rate
  difference at the crossing, which is what two correct discretisations owe each
  other.

Tests cover everything the review's thirteen mutations found unpinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
- The example now tells the CEM where each instruction stands: accepted on
  arrival, succeeded once carried out, revoked once revoked. Without that every
  instruction stayed NEW, so each replan revoked the whole previous batch, the
  instructions already carried out included.
- The mode a transition reports leaving is the one the last carried-out
  instruction put the heat pump in. It was the mode the previous instruction to
  arrive asked for, which after a revocation the heat pump was never in.
- A storage efficiency takes the loss as a share of the start fill level or of
  the middle of the storage's range, whichever is larger. A share of a near-empty
  start left almost nothing per step, and overstated the loss many times over
  once the plan filled the storage.
- A state of charge is posted as an instant only on a sensor that records
  instants: FlexMeasures refuses a single value of no duration on a sensor with
  a resolution, so there it is posted over a minute, as before.
- align_to_resolution floors on the moment's own clock, which is the grid
  FlexMeasures floors to, with the pass of an autumn DST hour taken from the
  moment. Flooring in UTC put hourly steps half an hour off in zones that are,
  and daily steps on the wrong midnight.
- The example server's price reseeding survives a failed ingestion job, a wrong
  content type and a wrong API version, not only a lost connection.
- Statuses about different steps that interleave within a second get belief
  times a second apart per step, where only the latest status was compared. The
  demo gate's first run had thirty statuses for one quarter hour arrive at once,
  and one was refused as a replacement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
- In the repeated hour of an autumn DST change, a boundary is the later of the
  two candidates that is not after the moment. Taking the pass from the moment
  put 03:10 at two-hourly steps on 02:00 of the first pass, three hours back.
  The candidates are compared as pandas timestamps, which compare instants.
- The changelog says what the floor does: on the clock of the moment, in the
  offset it carries, which is how FlexMeasures floors a posted series. "The
  site's own clock" was not true for series a resource manager anchors in UTC.
- A state-of-charge sensor stating a resolution that is not a duration is
  posted over a minute and logged, where it aborted the status before planning.
  The docstring says the sensor is asked until it answers, which it is.
- An instruction that has become due is no longer pending, so a revocation
  arriving while it is carried out is not answered as REVOKED for an
  instruction the heat pump is following.
- The comment on the storage efficiency's reference says that below the middle
  of the range the loss is understated, and by how much at most.
- Tests pin the forgetting of belief times more than a day old.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…er way

The demo gate's second run, against a sandboxed FlexMeasures, heated the buffer
in the quarter hour the plan chose, but every replan inside that quarter
replanned the same quarter, at 0.72 kW, then 0.67, 0.63 and on down to 0.51 kW.

A schedule asked for mid-step started at the boundary that step began at, while
it was planned from the fill level reported now, which FlexMeasures takes to be
the level at the schedule's start. So each replan counted on the whole step
where only part of it was left, asked for less power over what remained, and
the step delivered less than any plan meant it to.

A schedule now starts at the next boundary, or at now where now is one. The
step under way keeps the instruction it is already following, which a newer
plan does not revoke, since it has been carried out.

The end-to-end test stands the CEM's clock on a boundary, so that its schedules
start there and the first instruction is due within the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…ix/frbc-energy-unit

Signed-off-by: F.N. Claessen <claessen@seita.nl>
Black had split the expression around the trailing comment after the merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Flix6x and others added 18 commits November 5, 2026 21:44
…ulation stack

Carved from #188, rewritten for the library rather than transcribed.

#188 made every CEM find or create a FlexMeasures asset named after the
resource manager that connected, with its sensors (config_utils.configure_site,
a 12-tuple, a module-level id cache and logging configured at import), and
registered an FRBCSimple wired to them, for every deployment. It also seeded
constant prices from the wall clock's today with a belief time in 2026, which a
simulation of 2022 never reads.

- flexmeasures_client.s2.site holds FRBC_SITE_SENSORS, the sensors an FRBC
  site records on, which the example's table becomes; ensure_site, which finds
  or makes a site of a name with its sensors and graphs, and is safe to run
  again; and FRBCSiteConfigurator, which gives each resource manager a site
  named after it and an FRBCSimple wired to it, planning from now over 24 hours
  as #188 did, with the asset type, time zone, asset specs and the power
  sensor's attributes as options, and the site's id remembered for a reconnect.
  The sensor names the co-simulation reads by ("price", "production price",
  "power", "measured-power", "state of charge") are kept.
- CEM(site_configurator=...) configures in the background once a resource
  manager's details arrive, and wires the site's measured-power sensor. Until
  the handler exists, the active control type's messages are answered with
  TEMPORARY_ERROR, which the resource manager retries; before, the CEM raised a
  KeyError on them. Closing the CEM stops a configuration in flight.
- The example server takes S2_SITE_PER_RESOURCE_MANAGER: no fixed site and no
  demo prices, and a configurator for the server's lifetime, whose power
  sensors say the heat pumps cannot produce (is_strictly_non_positive), as
  #188's did. Its fixed site is set up with ensure_site, and gains the
  production price and measured-power sensors.
- examples/S2-co-simulation holds the docker-compose overlay the co-simulation
  runs on, moved from #188's root docker-compose.override.yml, which compose
  applied to anyone running the repo's base file. It sets both switches, uses
  s2-python as this repo locks it (0.8.1, which the co-simulation's resource
  managers use too, where #188 pinned 0.8.2), runs one worker command for all
  three workers, logs at INFO, and drops the py-spy debugging setup. Its README
  says how to keep it apart from a development database: FlexMeasures keeps
  that database's data inside its checkout, so a project name does not.

Not carried from #188: mirroring measured power onto the community's
aggregate-power sensor (the maintainer dropped it, since nothing reads it back),
and the price seeding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Against the co-simulation it is for:
- A reconnect overwrote the apartment's flex model with the configurator's,
  which reset what the co-simulation's controller writes into it (the stock's
  value at the end), and replaced its graphs, dropping the charts the
  co-simulation adds. A flex model is now merged into the asset's, and graphs
  are set only where an asset has none (the demo's own fixed site resets them,
  as before). They carry #188's titles again, since the controller adds to the
  graph titled "Power".
- #188 always relaxed the site capacity; the configured sites in the example
  server do so again, explicitly.
- Of several assets with a resource manager's name, the newest is taken, as the
  co-simulation takes it.

In the CEM:
- A power measurement arriving before a resource manager's site was set up was
  recorded on sensor 357, a fallback from #115 that named whichever sensor of
  whichever site had that id, and answered OK, so it was never sent again. It is
  now answered with TEMPORARY_ERROR while the site is being set up, and without
  a sensor for it, it is not recorded at all. The fixed site's measured-power
  sensor is now wired too.
- A setup that failed was not tried again, and only two exception types were
  logged; anything else surfaced as a task exception never retrieved. A failed
  setup is now logged, whatever failed, and tried again on the next message that
  needs it.

In the co-simulation stack:
- The overlay's workers serve the reporting queue too, as the base file's do.
- Its README says what isolates the database: the overlay mounts ../flexmeasures
  and ../flexmeasures-client, so a folder of its own with checkouts of those
  names, not a FlexMeasures worktree on its own. Mailhog's ports are listed.
- Comments break after punctuation.

Still not here: recording a co-simulation's measured power, which arrives in
bursts stamped at past quarter hours and which the CEM's binning on now() does
not catch. That is the next package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…ce per step

Carved from #188, rewritten for the library rather than transcribed.

The CEM averaged a resource manager's measured power into five-minute bins
ending at its own now, posted one at most every five minutes, and dropped
whatever fell outside that bin. 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, so none of them ever fell in the bin, and nothing
was recorded; the buffer kept them all, in a class attribute that every CEM in
the process shared. The values, which S2 states in watts, also went out
labelled kW, a thousand times too large. #188 bypassed the binning and posted
each value at its own timestamp, deduplicated in module-level globals.

flexmeasures_client.s2.measurements.MeasurementRecorder takes each measurement
into the step of the sensor's resolution its timestamp falls in, and records a
step once it is over by the CEM's clock, as the average of what was measured in
it, in watts, with the step's end as the moment it became known. Consecutive
steps go in one request, so a co-simulation's window of quarter hours costs one.
A step that could not be posted is kept and tried again. A server makes one
recorder and hands it to every CEM, so a resource manager that reconnects and
sends its series again, as a co-simulation's does every round, is not recorded
twice: the first value for a step stands, for a week.

The CEM hands measurements to its recorder, its own by default, and asks the
resource manager to resend one it could not take at all, such as where the
sensor's resolution could not be read. get_commodity_unit, which labelled power
kW, goes; nothing else used it.

Not carried from #188: a barrier awaiting the posts before planning, since the
co-simulation's resource manager sends its storage status before its
measurements, so the barrier never covered the step being planned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
- A step the server kept refusing blocked its sensor for good: steps were never
  dropped, and consecutive ones went in one request, so every later request
  carried the bad step and failed with it. Each step now goes in a request of
  its own, is tried a few times with a wait in between, and is given up on, with
  an error naming its values, after that.
- One request for several steps gave the earlier ones a belief time after their
  own end. With a request per step, each is known at its own end, as the
  docstring said. The batching the first version claimed only ever happened
  after a failure, and its test checked nothing about it; both are gone.
- Two measurements for one sensor at once, from two CEMs or a sample landing
  while its step's post was in flight, posted the step twice and then raised a
  KeyError the CEM did not catch, so no reception status went out. Recording is
  now one post at a time per sensor.
- Posting held up each measurement's reception status, which #188 had kept off
  the co-simulation's critical path by posting in the background. Recording
  happens in the background again, a defect in it is logged, and a failed post
  is tried again a while later without waiting for another measurement, which a
  co-simulation's final window never gets.
- A moment in the repeated hour of an autumn DST change could not be floored to
  its step, so such a measurement was refused on every resend. Its step is now
  the later boundary not after it, as align_to_resolution finds it.
- Remembering recorded steps cost a rebuild of the whole set per measurement,
  which on an instantaneous sensor grew large; the oldest are now dropped from
  the front.
- A message with measurements for several sensors was taken in part where one
  sensor could not be read, and, asked for again, had that part taken twice.
  Every sensor is now made ready before any value is taken.
- The server's request handler is tested for handing each CEM its recorder, and
  the changelog says what changes for a live resource manager: one value per
  step of the sensor's resolution, recorded with the first measurement after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
… image

Found by running the stack, from checkouts of its own, against FlexMeasures
main (1.1.0) with the community scheduler plugin, and a two-home, one-day
co-simulation through it (no community steering): every step ran, every home's
measured power was recorded for every quarter hour, and no CEM error remained.
It took four fixes to the overlay first.

- The server and the workers installed this client with the system pip, while
  FlexMeasures' image runs FlexMeasures and its plugins from its own
  virtualenv, so the plugin could not import flexmeasures_client and the
  server never booted. They now install with that interpreter's pip, as the
  CEMs already did, and no longer try to install /app itself, which the
  current image has no project file in.
- A checkout mounted without git history it can read, as a git worktree is,
  gives hatch-vcs no version, so the editable install failed. The install is
  given one, in the plain SETUPTOOLS_SCM_PRETEND_VERSION, since hatch-vcs does
  not match the per-distribution variable the CEMs had.
- A development server trusts only loopback names, and the CEMs reach it as
  "server", so every request of theirs was refused as from an untrusted host.
  The server now trusts that name too.
- The README says how to build FlexMeasures' image from a worktree (its
  version as a build argument) and how to move the ports where they are taken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…t from a worktree

Review found the README saying the stack runs FlexMeasures as its image
installs it, while the mounted checkout is what runs, and an s2-python pin on
the server and workers, which speak no S2 and whose locked environment it
should not shift. The README now shows the build-argument overlay a worktree
needs, says the UI may lack its fetched libraries, and the changelog no longer
claims a FlexMeasures version the overlay does not pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Every other value FRBCSimple records says when it became known; the state of
charge did not, so FlexMeasures took the moment the request reached it. In the
co-simulation gate that recorded readings of 2022 as known in 2026, 1436 days
after they were taken. It is now posted with the handler's time as its prior,
which in a co-simulation is the simulated time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…2-belief-times-on-the-clock

Signed-off-by: F.N. Claessen <claessen@seita.nl>
Package D4 of the plan to finish #188.

Each storage status is a planning request, and one arriving while a schedule
was being planned started a second solve beside it, whose answer replaced the
first's. A resource manager reporting faster than FlexMeasures plans kept
several solves running on the server for nothing.

FRBCSimple now plans one schedule at a time: a request waits for the one in
flight and, when its turn comes, is dropped where a newer one arrived
meanwhile, as a system description's plan already was where a status overtook
it. A burst of statuses costs at most the solve in flight and one more, for the
newest. No clock is involved, so a co-simulation's retrigger round, which
shares a simulated time with the round before it, is planned as before, and
the newest request is never left without a plan.

The plan's revision 1 proposed a minimum replanning interval instead; its
review showed that a deferred plan could strand the last status of a session,
and that on a simulated clock a retrigger would always fall inside the interval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…s2-plan-throttle

Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…est waits less

Review found that a request overtaken while its schedule was being asked for
kept the planning lock through every retry of a failed attempt, up to five
attempts with a wait between each, and that the schedule they finally got was
thrown away; only then was the newest request planned, where before this PR it
would have started at once. Between attempts the request now checks whether a
newer one has arrived, and stands down if so.

The docstring, the PR's cost bound and the changelog line now say what holds:
a burst costs at most the attempt in flight and one plan more, for the newest,
which starts after that attempt rather than beside it. The docstring breaks
after punctuation, and _plan documents its parameters again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Found by running a steered two-home co-simulation through the stack: a
steering round replans a window at the same simulated moment, so without
FLEXMEASURES_ALLOW_DATA_OVERWRITE the scheduler could not save the second plan
and the homes got no instructions for their window; and every home triggers a
schedule each round from one account, far above the default trigger limit, so
every trigger past it waited minutes. Neither is read from the environment, so
the README says what to put in the instance config, with the solver time limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcpawsJs74cSwFLXkz7ZwW
Signed-off-by: F.N. Claessen <claessen@seita.nl>
…s2-cosim-instance-config

Signed-off-by: F.N. Claessen <claessen@seita.nl>
@Flix6x Flix6x added this to the 0.10.0 milestone Nov 6, 2026
@Flix6x Flix6x added the S2 label Nov 6, 2026
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 42143335770

Coverage decreased (-0.7%) to 94.941%

Details

  • Coverage decreased (-0.7%) from the base build.
  • Patch coverage: 6 uncovered changes across 3 files (816 of 822 lines covered, 99.27%).
  • 1 coverage regression across 1 file.

Uncovered Changes

File Changed Covered %
src/flexmeasures_client/s2/script/websockets_client.py 142 139 97.89%
src/flexmeasures_client/s2/site.py 88 86 97.73%
src/flexmeasures_client/s2/clock.py 25 24 96.0%
Total (15 files) 822 816 99.27%

Coverage Regressions

1 previously-covered line in 1 file lost coverage.

File Lines Losing Coverage Coverage
src/flexmeasures_client/response_handling.py 1 93.68%

Coverage Stats

Coverage Status
Relevant Lines: 3064
Covered Lines: 2909
Line Coverage: 94.94%
Coverage Strength: 4.64 hits per line

💛 - Coveralls

Comment thread .github/workflows/ci.yml
run: uv sync --frozen --extra s2 --group test
- name: Run S2 tests
run: uv run pytest tests/s2 -rFEx --durations 10 --color yes
run: uv run pytest tests/s2 -rFEx --durations 10 --color yes --cov

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A short section in the PR description explaining what including the CEM does to the coverage reporting on the fm-client repo. I think the readme banner can report only one number, but it feels as if we should be making a distinction in the coverage reporting over the different installation types. Now it's just S2, but there may be other client flavours in the future.

Comment thread CHANGELOG-S2.rst
* ``FRBCSimple`` takes an optional ``production_price_sensor_id``, for a site where feeding back is priced differently from drawing, as under a feed-in tariff. Without it, both directions are priced by ``price_sensor_id``, as before [`#261 <https://github.com/FlexMeasures/flexmeasures-client/pull/261>`__]
* ``FRBCSimple`` takes ``relax_site_capacity``: ``True`` lets a schedule breach the site's connection capacity at a price, for a site whose capacity is a target set by others, and ``False`` keeps it a hard limit. Unset, it is left to FlexMeasures, which since v1.0 relaxes it by default, so a home whose connection is a physical limit has to pass ``False`` [`#261 <https://github.com/FlexMeasures/flexmeasures-client/pull/261>`__]
* An FRBC handler logs the instructions of a batch once, at debug level, by operation-mode name [`#261 <https://github.com/FlexMeasures/flexmeasures-client/pull/261>`__]
* ``flexmeasures_client.s2.clock``, with a ``WallClock`` and a ``SimulatedClock``, and ``CEM(clock=...)``: the time a CEM and every handler it registers go by, timers and the moment a state of charge is known as of included [`#264 <https://github.com/FlexMeasures/flexmeasures-client/pull/264>`__, `#268 <https://github.com/FlexMeasures/flexmeasures-client/pull/268>`__]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sentence constructions like these reduce legibility. It's a widespread problem.

| the moment it is known as of | included

@Flix6x
Flix6x merged commit 1e21422 into main Nov 8, 2026
40 of 41 checks passed
@Flix6x
Flix6x deleted the dev/s2-frbc-p1 branch November 8, 2026 16:40
@Flix6x
Flix6x restored the dev/s2-frbc-p1 branch November 8, 2026 16:42
Flix6x added a commit that referenced this pull request Nov 8, 2026
main's squash commit 1e21422 has the same tree as #278's head ad9a12b,
which this branch already contains, so this branch's tree is kept as is.

Signed-off-by: F.N. Claessen <claessen@seita.nl>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants