56 Commits
Author SHA1 Message Date
adminandClaude Opus 5 905b6fd2b3 MAINT-05 fix round 2: the refusal has to survive the NEXT start, and both marker guards have to be visible
checks / unit (pull_request) Successful in 5m14s
REJECTED because the fix round's own load-bearing sentence was FALSE, and the
whole suite was blind to that. `_verify_moves()` produced the red the reviewer
reproduced - that half stood - but DECISIONS §2 claimed "`ok` gates the marker,
so a misdirected migration is not stamped and the next start runs it again ...
a re-run is recoverable". THE REFUSAL LASTED EXACTLY ONE START.

MEASURED, Controller level, ONE persisted store, roster ["garage","shed"], flat
last_completed 2026-08-07, call site still handing `inverter-a` on both starts:

    construction 1: ok=False misdirected=4 moved=4 kept=0   marker=<absent>
    construction 2: ok=True  misdirected=0 moved=0 kept=4   marker=1
    history parked under unit/inverter-a/...; marker stamped after ONE restart.

WHY. `_verify_moves()` graded only `rep.moved`. On start 2 the same wrong call
site finds the four keys already present, so rule 1 routes every one of them to
`rep.kept`, `rep.moved` is EMPTY, the roster loop iterates over nothing,
`misdirected` is empty and `ok` is True. Start 3 then takes the
`from_version >= MAINT_SCHEMA_VERSION` early return FOREVER - including after
the call site is fixed. Loud-and-recoverable became silent-and-permanent after
one restart: the exact state store.py's header says must never happen, reached
by the fix written to prevent it.

WHY THE CHECK MISSED IT - the recurring defect, this time INSIDE the remedy.
Part 9b's recovery check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` - the
CORRECTED name. It measures recovery along the one path where the operator has
ALREADY fixed the bug. Nothing in the 19 files called `migrate_maint_keys()`
twice with the SAME WRONG `unit`. The check was true and passed for the wrong
reason.

WHAT CHANGED

1. app/store.py `_verify_moves()` grades `set(rep.moved) | set(rep.kept)`.
   Sound by construction: every entry in both lists is a `unit_key(unit,
   legacy)` built from the SAME `unit` argument, so grading `kept` grades the
   same namespace decision `moved` does. A correct call site's `kept` entries
   sit under a roster name and pass; a wrong one cannot launder its namespace by
   having already written it. `migrate_unit_keys()` passes `roster=None` and
   returns before the loop, so it is untouched.

2. test_maintenance.py part 9c - THE CHECK THAT WAS MISSING. Two starts, SAME
   wrong unit, ONE store: the marker is still withheld on the second, the
   refusal names the same roster, the flat history is still there to recover
   from, and a third start with the CORRECTED name still gets a run (which a
   stamped marker would have made impossible). Its PRECONDITION asserts the
   SHAPE of the second report - `moved` empty, `kept` full - so it cannot
   quietly revert to grading 9b's path. Counterfactual beside it: the same two
   calls with the right name are accepted, then skipped as `already at schema`.

3. test_maintenance.py part 9d - THE OTHER GUARD ON THE SAME LINE, and a
   survivor nothing in the repository could see. `if rep.ok:` is
   `not lost and not misdirected`; every check that reached the marker did so on
   a store whose `kept` was empty, so

       if rep.ok:        ->      if rep.ok or rep.kept:        (+12 B)

   stamped the marker on a migration that LOST A MONTH OF HISTORY - defeating
   the older, settled `lost` guard as well as the new one - and ALL 19 SUITES
   WERE GREEN on it (measured at 65cddf3, purged, :ro). 9d's fixture is the one
   shape that separates the guards: two keys already namespaced (`kept`
   non-empty, the mutant's `or` fires) and two whose write the store drops
   (`lost` non-empty), `misdirected` empty throughout.

4. FOUND HERE AND CLOSED: THE SAME MUTATION ONE FUNCTION UP. `migrate_unit_keys()`
   - SAFETY-02's LATCH migration - gates its own marker the same way, and every
   check that reached it ran on a store with an empty `kept`. `if rep.ok or
   rep.kept:` at store.py:307 SURVIVED ALL 19 FILES. It stamps `schema_version`
   on a migration that DROPPED THE LATCH: the guard is off, the file still reads
   `sign_tripped: true`, and the store now says the migration finished, so the
   next start skips the one thing that would re-arm it. test_inverter.py part 10
   now drops the `sign_tripped` write BESIDE an already-namespaced
   `sign_reason`, with the accepted-baseline counterfactual. No source change -
   the shipped gate was right; nothing could see it. Mutation table 34 -> 35.

5. DECISIONS/MAINT-05 §2 CORRECTED. The paragraph that stated the false
   guarantee now states what was measured, why grading `moved` alone could not
   hold, and why the union is sound. §7 records the marker-gate hole found in
   `migrate_unit_keys()` and that it was closed. MUTATION.md gains habit 9 -
   "exercise a refusal a SECOND time with the SAME wrong input, not the
   corrected one" - and two survey rows.

MUTATION LEDGER - 11 mutations, ALL RED on the final tree, every run on a fresh
copy with the bytecode cache purged (entry count printed before and after) and
the tree mounted :ro so none could be written. Two were survivors BEFORE this
commit and both are recorded as such. Full transcript on the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 19:40:51 +02:00
adminandClaude Opus 5 1aef19e995 MAINT-05: one recalibration per pack, and only one pack moves power at a time
checks / unit (pull_request) Successful in 4m48s
§12's monthly cycle drains a pack to empty and refills it to true its coulomb
counter. There was exactly one `Maintenance` and one un-namespaced history, so
at two packs only the first was ever recalibrated - and nothing prevented the
cycle draining pack A while the loop charged pack B, which is two power-moving
phases on one grid connection pulling against each other.

* ONE `Maintenance` PER ROSTER ENTRY, each with its own schedule, phase and
  history under `unit/<name>/...`. `store.migrate_maint_keys()` moves an
  install's flat history into the first pack's namespace under
  `migrate_unit_keys()`'s three rules - shared in code via `_move_flat_keys()`
  and `_verify_moves()`, not restated - on its own marker, because unlike
  DEV-02's it runs only where the roster has more than one pack. The flat keys
  are kept LIVE as the rollback view.
* ONE MAINTENANCE SLOT PER SITE (`maintenance.arbitrate()`). At most one pack
  in a power-moving phase - drain, charge AND hold. The holder is DERIVED from
  the packs' own stored phases, never persisted, so a restart mid-drain
  re-derives it. Several packs due in the same hour: the slot goes to the one
  that has gone longest without a completed cycle; roster order breaks a
  numeric tie and nothing else. Nothing preempts a cycle in flight.
* THE DEFERRED PACK IS NOT STARVED AND IS NOT A FAULT. A deferral does not
  stamp `last_start_attempt` and does not move the phase, so neither the 12 h
  drain nor the 36 h charge timeout is reachable by waiting.
* The claim is filed on the pack whose state machine produced it, and stays
  SCOPE_UNIT - ARCH-01's rule.
* `validate.py` rule 6 grades `maintenance_charge_w` against the WEAKEST cap on
  the roster now, as its own comment said it must once `maint_unit` stopped
  being `inverters[0]`.
* At a roster of one nothing changes, and it is asserted: no new store key, the
  migration does not run, the four cycle events are the shipped strings, the
  banner is the bare phase.
* RECORDED NOT FIXED: a drain pins its pack while the loop's target is a site
  claim, so below the pin the allocator refuses the split and the site parks at
  0 W. Pre-existing at 2d025ee. Pinned as a running check.

Verified: 19/19 suites green in the CI image; 17 mutations run through
tools/mutation_run.sh (bytecode purged), every one observed red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 13:38:29 +02:00
admin 2d025eebf7 Merge pull request 'SAFETY-30: grade each inverter against its own power, and the site's total against the meter' (#46) from SAFETY-30 into release/1.0 2026-09-04 12:28:44 +02:00
admin 18f052a071 Merge pull request 'CFG-04: an operator can declare a second inverter, and the shape was measured against the Supervisor' (#45) from CFG-04 into release/1.0 2026-09-04 11:51:57 +02:00
adminandClaude Opus 5 f000547518 SAFETY-30: grade each inverter against its own power, and the site's total against the meter
checks / unit (pull_request) Successful in 4m38s
THE DEFECT, in one line. `inv.sign_wd.evaluate(stood, self.grid, ...)` compared
the command THIS controller put on ONE actuator against what the WHOLE SITE is
exchanging with the network. At a roster of one those are two views of the same
thing - specs.md §6.2's identity - and that is what makes the check sound and
what earned it the best record in the safety table (NOTES.md: 93 % agreement
while healthy against 4 % during runaway, four observed episodes, zero false
positives). At two inverters they are not, and it fails in BOTH directions:

  FALSE TRIP  inverter A commanded -1500 W while the meter reads +1500 W is
              evidence of nothing when inverter B is discharging 3000 W exactly
              as told. Nothing is faulty and the highest-value check fires.
  MISSED      the converse, and it is worse. A unit commanded +1500 W and
              MEASURED at -1500 W, beside a sibling at +1500 W, leaves P1
              exactly where a healthy site leaves it. The summation destroys the
              evidence and no threshold recovers it.

DEV-02 had already shipped the per-INSTANCE half - one SignWatchdog per
inverter, namespaced latch, a trip on B no longer latching A. What was left was
the COMPARISON, and it is two questions rather than one:

  PER UNIT  this unit's standing command against ITS OWN measured power
            (`Inverter.batt`, holding register 0x0518, per-instance since
            DEV-02). Conclusion: "the actuator I am driving is not doing what I
            told it" - a fact about one device, so the remedy stays the per-unit
            `failsafe` SET that arbiter.CLAIM_SCOPE has classified SCOPE_UNIT
            from the day it was written. That entry read "the evidence is
            site-wide and the remedy is not"; the first half is now simply true.
  SITE      the SUM of the standing commands against the meter, as a new
            `sign_site` SET at P_SAFETY on the site actuator, latched under flat
            `site_sign_tripped` / `site_sign_reason`. This is the 2026-08-20
            fault day and NO per-unit check can see it, because every inverter
            may be faithfully executing its share of a number that is itself
            wrong. It stops every inverter and the banner names the site.

WHAT IT IS NOT, said here because two guards that overlap without saying so is
how a false-alarm generator gets built. It is not SAFETY-31: circulation.py
compares the units AGAINST EACH OTHER and catches them pushing power THROUGH
each other, which nets to zero at the meter and about which no single command is
evidence. This compares each unit against ITS OWN COMMAND. They overlap in
exactly one scenario - a unit reversing while its sibling pulls the other way -
and test_watchdog.py asserts on that fixture that both fire and what each
concludes. It is also not the saturation freeze, which is a MAGNITUDE test
(|command - measured| > 500 W) that shapes the loop and fires on a merely
saturated unit; this is a DIRECTION test that latches and parks one.

THE FOUR NUMBERS, RE-DERIVED AT TWO INVERTERS - one carried with a justification
and one refused. 1890 closed-loop runs on admin/goodwe's TEST-19 two-master rig
in which NOTHING is faulty, swept over the whole range config.yaml accepts for
`gain` and `slew_w`, across load reversals with write staggers and flapping
loads, and RUN AT BOTH ROSTERS so the N=1 arm is the control:

  unit_sign_disagree_w  250   `saturation_w / 2`. The check gates on both sides,
                              opposed, so the smallest gap it can see is 500 W -
                              exactly where compute()'s own anti-windup test
                              already says the inverter is not following. It
                              cannot fire on a divergence the control law calls
                              ordinary tracking. Worst legitimate opposition at
                              250 W: 10.0 s; the curve is nearly flat, because a
                              unit's own power chases its own command.
  unit_sign_disagree_s   85   8.5x that 10.0 s, 2.8x the firmware's own 30 s
                              link veto (inverter.LINK_STALE_S), under half the
                              180 s site hold.
  site_sign_disagree_w  300   CARRIED. Its argument is the meter's noise floor
                              and the 15 W deadband; the roster touches neither,
                              and the campaign agrees - the gate opens in 994 of
                              1050 runs at two inverters and 955 of 1050 at one.
  site_sign_disagree_s  325   NOT CARRIED, and this is the number the card
                              exists to not assume. The worst legitimate
                              crossing at 300 W is 110 s with one inverter and
                              215 s with TWO, because the command must travel a
                              rail that is the roster sum at a `slew_w` that is
                              not - reachable at gain 0.05, slew_w 50 and a
                              2.2 kW load reversal, all schema-legal. 180 s
                              would have shipped a false trip. 325 s is 1.51x
                              the measured worst, inside the 900 s billing
                              quarter and inside every episode of the fault day.

AT ONE INVERTER NOTHING CHANGES, and "nothing" is asserted rather than assumed.
The two questions are the same sentence there, so only the detector that shipped
is armed: same comparison against the meter, same 300 W / 180 s, same reason
string byte for byte, same namespaced latch and flat rollback mirror, same
`failsafe` claim, same banner, same single status row - and the site detector is
NEVER CALLED, counted on the object over twelve cycles.

VERIFIED BY BREAKING IT. Thirteen mutations on fresh per-mutation trees with no
__pycache__, mounted :ro, with the battery refusing to report at all unless the
baseline is green. Twelve red on the first pass. M4 - the site half armed at N=1
as well - SURVIVED, because the check read `disagree_since is None` and the unit
detector trips first, parks the inverter and leaves the site standing at 0 W:
the assertion ran, the sentence was true, and the data could not reach the
failure it named. Replaced with a call counter on the object; M4 is red now.
Recorded as a survivor in DECISIONS/SAFETY-30-per-unit-sign.md §5, with the
first battery's own RED BASELINE - a shared mutation directory read mid-copy -
discarded rather than reported.

RECORDED, NOT FIXED: the site comparison is defeated by a square-wave load whose
half period is about one control cycle, which holds it opposed for ~1000 s. The
N=1 control arm is what makes that interpretable - it is identical at the roster
this add-on ships, so it is a property of comparing a lagged command against a
meter and has nothing to do with the second inverter. It wants an alternation
test rather than a number, and a card of its own.

SAFETY-24's lo-only asymmetry proof re-run unchanged: 1296 combinations, 0
negative floors, `hi` +inf on every claim, every claim on the SITE actuator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 09:33:20 +02:00
adminandClaude Opus 5 6b85acb0f1 CFG-04: an operator can declare a second inverter, and the shape was measured
checks / unit (pull_request) Successful in 4m30s
DEV-02 made every per-device value per-instance - rating, SoC, watchdogs,
heartbeat, store keys - so the code stopped assuming one inverter. The options
schema did not. `build_roster()` returned a list of exactly one and said so in
its own docstring, and there was no `inverters:` key for an operator to write,
so the roster could only ever be a roster of one.

THE SHAPE, AND WHY IT WAS MEASURED RATHER THAN CHOSEN

config.yaml had no list-of-objects anywhere, and `static_profile` carries a
whole tariff structure as a JSON string in a `str?` precisely because a nested
schema was doubted. That is a question about somebody else's software, so it was
answered by running it: `tools/supervisor_shape_probe.py` (committed) runs
Supervisor 2026.08.0's OWN `AppOptions` validator and `SCHEMA_APP_CONFIG`, out
of the release image, against this repository's config.yaml. 2026.08.0 is the
version the live install reports. The full transcript and the argument are in
DECISIONS/CFG-04-inverter-roster.md.

It accepts the list, enforces per-entry types, refuses a missing `name` /
`setpoint_entity` / `max_w` BY NAME, renders the option in the UI as a
repeatable section, and drops the key with a warning under an older schema - so
a downgrade is not the forward trap in reverse.

⚠️ AND THE `inverters: []` DEFAULT IS LOAD-BEARING, WHICH IS THE MEASUREMENT
THAT MATTERED MOST. Supervisor answers `Missing option 'inverters' in root` for
an options blob that has no such key - which every existing install's has, since
the key did not exist when they were written. `App.options` is the shipped
`options:` block deep-merged with the operator's stored one, so the empty
default is the difference between an upgrade nobody notices and an add-on that
cannot start with a schema pinned to the version that would fix it. That is
docs/deploy-rollback.md §4's trap in the FORWARD direction, found before it was
sprung. The merger was checked too: lists override rather than concatenate, so a
non-empty default could not have grown a roster behind the operator's back.

NOTHING IS INHERITED, WHICH IS DEV-02 RESTATED AS A CONFIG RULE

`name`, `setpoint_entity` and `max_w` are required of every entry. An entry that
omits `soc_entity` has no SoC sensor; it does not borrow the flat one. A
three-line fallback would have rebuilt the sibling-grading defect DEV-02 spent a
card removing, in YAML. Defaults still live in exactly one place (CFG-03):
there is no per-entry default anywhere, because there is no optional per-entry
number. A flat `setpoint_entity` left set beside a declared roster is refused
rather than ignored - it names an actuator nothing will write.

TWO PERMISSIVE READERS IN SHIPPED CODE, AND THREE MORE IN THE CHECKS

  validate.py rules 5 and 6 compared their thresholds against the FLAT `max_w`.
  At a roster of two 2000 W units with `max_w: 5000` left over from a
  single-inverter install, `sign_disagree_w: 3000` passed rule 5 while
  SAFETY-02's watchdog could fire on NEITHER unit - rule 5's own failure mode,
  reached through rule 5. They read `unit_caps()` now, which at one inverter is
  `[max_w]` exactly.

  main.py's `run_control()` re-ran the device-rating check behind
  `if self.o.get("setpoint_entity")` - the flat key, which a roster leaves blank
  - so on a two-inverter site no inverter's `max_w` was graded against the
  device it is written to, and the skip was silent. `validate_unit_ranges()`
  grades each unit against its own ESPHome and runs unconditionally.

  test_peakguard.py's `read_option()` and `_schema_bound()` and
  test_inverter.py's `read_schema_max()` matched a STRIPPED line and took the
  first hit, so the nested per-inverter `max_w:` began outranking the flat one -
  silently, and correctly, because the two bounds are the same int(100,5000)
  today. A reader that is right by coincidence is this project's named failure
  mode. All three are anchored at exactly two spaces now; the file's two halves
  disambiguate `peak_cap_w` instead of first-match, and an ambiguous key raises
  rather than picking one. test_tariff.py's copy of the idiom went with them.

THE AUDIT IS A CHECK, NOT A PROMISE

test_inverter.py part 11 finds every config.yaml-reading function in the suite -
transitively, because two of the readers CFG-04 had to change never name the
file - EXECUTES each against a synthetic two-inverter config.yaml whose
per-inverter numbers differ from its flat ones, and fails if any answers with
the per-inverter value. The table is not maintained by hand: the signature
decides, so the eleventh reader is named on its first run. config.yaml declares
`inverters:` BEFORE `max_w:` on purpose and part 11 asserts that ordering, so
the trap stays baited.

SAFETY-28's `actuator_writers()` needed no change - it was already a structural,
case-folding census - but until now there was no configuration in which two
writers on one actuator could be WRITTEN DOWN. There is now, and it is refused.

VERIFIED BY BREAKING IT

Ten mutations run through tools/mutation_run.sh and observed red, listed in
test_inverter.py part 12 with the check that bites each. Two survived the first
run and the CHECKS were changed, not the table: the inheritance check had blank
flat keys, so "inherited" and "not inherited" were the same empty string; and
the `run_control()` check asserted a SOURCE LINE was present, which re-indenting
it does not remove. Both are recorded in MUTATION.md's "the check's data cannot
reach the failure it names" section, which now carries two more habits: assert
on behaviour rather than on source text, and give a check whose failure mode is
a hang a deadline.

19/19 green in the goodwe-addon-ci image. The one-inverter path is byte-
identical: the whole existing suite runs unmodified against it.

Version bumped to 0.6.0 - the Supervisor keys the built image by `version:`, so
a schema change without a bump is a schema nobody reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 04:29:45 +02:00
admin b30797e1f9 Merge pull request 'SAFETY-31: measure circulating power, which nets to zero at the meter' (#44) from SAFETY-31 into release/1.0 2026-09-04 02:40:50 +02:00
adminandClaude Opus 5 7ef3d23245 SAFETY-31: measure circulating power, which nets to zero at the meter
checks / unit (pull_request) Successful in 4m28s
Inverter A charging at 3 kW while inverter B discharges at 3 kW nets ~0 W at
P1. The site reads idle while both packs are cycled through two round-trip
conversion losses continuously, for no benefit, and the wear is real.

NOTHING EXISTING CAN SEE IT, AND THAT IS STRUCTURAL. Every guard in this
add-on is a consumer of the NET figure at the grid connection: SAFETY-02's
sign watchdog gates on abs(grid_w) > 300 and the gate never opens at a netted
zero; the peak guard and the capacity tariff both bill grid exchange.
Circulating power is precisely the component of per-inverter power that the
sum destroys, so no threshold on any existing detector could reach it.

WHAT ALLOC-01 ALREADY COVERED, AND IS NOT RE-IMPLEMENTED HERE. Its AC 7
shipped the sign-coherence gate (allocator.REFUSE_SIGN, graded by
test_alloc.py part 6): an allocation whose shares carry two signs is REFUSED,
both units named, no shares returned. That is the COMMAND half - the allocator
can no longer command circulating power. This commit is the MEASUREMENT half:
circulation the allocator NEVER COMMANDED.

  app/circulation.py   the sensor and the trip
  main.py              site_circulating_w, the evaluation, the claim, the alarm
  arbiter.py           `circulation` classified SCOPE_SITE, with the argument
  store.py             a flat, site-level durable latch
  mqtt.py              sensor.goodwe_circulating_power
  config.yaml          two options, two schema ranges, CFG-03 discipline

THE SENSOR: sum(|Bi|) - |sum(Bi)|. Zero when the units pull the same way,
2*min(|Ba|,|Bb|) when they oppose. DERIVED FROM MEASURED PER-INVERTER POWER -
Inverter.batt, register 0x0518 - and never from the allocation record. The
cheap version would be simpler and worth nothing: ALLOC-01's gate already
guarantees the record is sign-coherent, so a record-derived sensor reads zero
BY CONSTRUCTION and can only confirm that the allocator agrees with itself,
which is the third failure mode MUTATION.md names. What this catches is a unit
ignoring or lagging its share, a stale share still being executed, a
maintenance or SAFETY-25 pin holding one unit while the loop drives the other,
or HW-01's power stage running its last instruction for 6 h 25 m with the
comms dead and the allocation record impeccable throughout. batt became
per-instance in DEV-02; before that this could not have been written.

VERIFIED BY MAKING ONE UNIT IGNORE ITS SHARE, on the TEST-19 two-master rig:

  S = -2500 W   shares a = -1250 W  b = -1250 W   <- record ok, ONE sign, sums
  MEASURED      A = -1250 W  B = +1000 W          <- hardware
  sensor over the RECORD     :    0 W
  sensor over MEASURED power : 2000 W  -> trips at 190 s

and the card's own scenario, A -3000 W against B +3000 W at zero house load:
the meter reads +0 W, the sensor reads 6000 W, and the shipped SignWatchdog's
predicate is FALSE on 30 of 30 cycles.

WHAT "ACTED ON" MEANS - decided, and recorded in DECISIONS/. The trip files
Claim.set("circulation", P_SAFETY, 0.0) on the SITE actuator: every inverter
to 0 W, latched. HW-01 established that commanding a wedged inverter achieves
nothing, so the remedy is not aimed at it. A circulating loop needs TWO
participants and one of them is THIS CONTROLLER, faithfully driving the
listening sibling into the one that stopped listening. Stopping (1) halves the
loop, which needs no cooperation from the wedged unit, and (2) - the more
important half - makes the residue VISIBLE at the meter instead of cancelled
at it, where every ordinary guard can finally see it. What it cannot do is
stop the wedged unit; the notification says so and names the isolator as the
last actuator. SITE-scoped because the quantity is defined over the roster and
is identically zero for any one unit - there is no fact of the matter about
which of two opposing inverters is the offender, so filing it per-unit would
invent an attribution and could park the only unit still listening.

THE WINDOW IS FROM THE RIG, NOT A ROUND NUMBER (AC 3). 924 closed-loop runs in
which NOTHING is faulty, over both families of legitimate opposition (7
reversals x 5 write staggers; 7 flap periods x 4 amplitudes), swept across the
whole range the schema accepts for gain and slew_w. Worst CONTINUOUS
legitimate opposition by threshold: 505.0 s at 100 W, 40.0 s at 250 W, 10.0 s
at 300 W, 5.0 s at 400 W and above. The knee is 400 W and it is the schema
floor - below it, ordinary operation holds the sensor over the line for
minutes and no window inside the 900 s billing quarter could tell that from a
fault. circulation_w 700 is 1.75x the knee, the headroom bought for inverter
B's ramp and lag being PLACEHOLDERS until SPIKE-04 measures them.
circulation_hold_s 75 clears three bounds: 15x the worst legitimate episode,
2.5x the firmware's own LINK_STALE_S veto, 12x inside the quarter. A momentary
opposition while one unit slews through zero is LEGITIMATE - writes are
serialised, so B's number lands up to a cycle after A's - and 0 of the 924
runs trip.

THE ALARM SURVIVES AN HA RESTART (SAFETY-34). persistent_notification is in
memory, which is exactly how the 2026-09-02 outage went unseen for 6 h 25 m
with the detector correct throughout. The latch is persisted to /data as flat
site-level keys and the notification is RE-RAISED on the first cycle after
every start, through the same code path as a fresh trip - two paths would be
two chances for the restart one to rot unnoticed.

AT N=1 NOTHING CHANGES (AC 5). |B| - |B| is 0 for every reading including one
nobody took, no claim is ever filed, the arbitration is byte-identical, and
the status page says n/a rather than claiming to watch something that cannot
happen.

VERIFIED BY BREAKING IT (AC 4). 18 mutations, all RED on named checks, each on
a freshly copied tree with no __pycache__ mounted :ro. M10 - the sensor
rewritten to read self.allocation.shares - is the headline: it takes AC 2 red.
Three mutations are +0 B, the risky delta, and are marked as such. One
equivalent mutant (the max(0.0,...) guard) is ARGUED rather than claimed:
searched over 700 000 rosters without a negative. One genuine SURVIVOR was
found and closed - publish() turning an unknown into a published 0 W, the
sensor's own None rule defeated one layer out. Full ledger in DECISIONS/.

arbiter.CLAIM_SCOPE gains its ninth source, so test_peakguard.py's 10f and 10g
tripwires and test_arbiter.py's scope table went red on the line the append
was written. Updated deliberately, with the argument, as their own comments
require. Full 19-file suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 01:56:23 +02:00
admin b6f41f736c Merge pull request 'DEV-02: per-instance Inverter objects - rating, SoC, watchdogs, heartbeat and store keys stop being scalars' (#43) from DEV-02 into release/1.0 2026-09-04 00:36:06 +02:00
admin f594543988 Merge pull request 'SAFETY-28: gate the write path on the allocation record, not on how many config keys exist' (#42) from SAFETY-28 into release/1.0 2026-09-02 15:44:40 +02:00
admin 3030f72b9e Merge pull request 'ALLOC-01: allocate the site grid target across N inverters, sum-invariant' (#41) from ALLOC-01 into release/1.0 2026-09-02 12:13:33 +02:00
admin df88b323f5 Merge pull request 'ARB-03: restore Claim.actuator and make resolve() three-stage' (#40) from ARB-03 into release/1.0 2026-09-02 04:16:45 +02:00
adminandClaude Opus 5 06f95e0a64 ARB-03: restore Claim.actuator and make resolve() three-stage
checks / unit (pull_request) Successful in 3m45s
specs.md:467 declares `actuator: str` as the FIRST field of `Claim`.
app/arbiter.py:55 had no such field. The specification designed
per-actuator claims and the implementation dropped it, so every claim was
implicitly about "the one and only actuator", resolve() returned one scalar
Resolution.target_w, and main.py assigned it to one self.target. THERE WAS
NO SEAM AN ALLOCATOR COULD OCCUPY - and ARCH-01 (specs.md §6.2, 2026-09-02)
decided the site grid target is ALLOCATED across several inverters and never
broadcast. This is a regression fix against the written design; creating that
seam is the whole of the change.

WHY IT MATTERS, IN THE UNITS THE SYSTEM IS BILLED IN. The control primitive
is meter emulation: the master reports a fabricated grid reading and the
inverter regulates itself to it, so one number means "the battery moves X W"
to the add-on and "the grid is at X W" to the inverter - an identity that
holds only at N=1. Broadcast one target to two inverters and each supplies it
in full; the loop gain at the meter becomes N times the per-unit gain, which
at the gain config.yaml:374 accepts is a permanent limit cycle with every
watchdog in §11.2 reporting healthy. And a per-inverter reserve filed as a
site `hi` against the peak guard's site `lo` is `lo > hi`, arbiter clause 3,
0 W IN THE BILLED QUARTER - the parked battery SAFETY-25's memo calls fatal.

THE SPLIT, VERIFIED AT EACH CONSTRUCTION SITE RATHER THAN ACCEPTED FROM THE
SURVEY. Four sources are about the GRID CONNECTION and are SITE claims:
`loop` (compute() regulates grid_w, the P1 measurement, and §6.2's whole
architecture is one integrator on P1 whose output is then split), `peak` (the
15-minute average offtake AT the grid connection, which is what the capacity
tariff bills - one quarter-hour peak per site, not one per pack),
`arbitrage` (buys and sells at the grid against a day-ahead price; the price
is a property of the connection and the energy crosses the meter) and
`safety` (both construction sites read: "stopped" is the operator switching
control off for the site, "inputs missing" is the site's own meter gone -
neither is about a pack, and both must stop every inverter).

Four are about a pack, a device or one write and are PER-INVERTER: `device`
(one inverter's own reported min/max - there is no site rating),
`supervised` (max_w caps what may be WRITTEN to one setpoint entity, §11.3,
so it is scoped like `device` for the same reason), `maintenance` (§12's
recalibration is a pack procedure - drain THIS pack, charge THIS pack,
recalibrate THIS pack's counter) and `failsafe`. `failsafe` is the entry that
needed the reading rather than the survey: SAFETY-02's EVIDENCE is site-wide
(it compares P1 against the standing command) but its CONCLUSION is
"the unit I am driving is pushing power the wrong way" and its remedy is to
park that unit. At N>=2 the same evidence cannot be attributed to a sibling
this controller never commanded, so a site-wide stop would park an inverter
this watchdog never graded. The reasoning for all eight is recorded AT THE
LINE, in arbiter.CLAIM_SCOPE and again at each claims.append in main.py.

WHAT WAS BUILT. resolve() is three stages, each a separate function so a test
can construct a case at any one of them:

  pre_resolve()  - one inverter's own claims -> that unit's interval. Clauses
                   1 and 2 transposed: the unit's own winning SET gates its
                   own limits. The SET's value travels BESIDE the interval,
                   not inside it - folding a -2500 W drain into
                   [-2500, -2500] would turn "clamped to max_w" into
                   "contradicts max_w", a different and wrong answer.
  site_resolve() - THE SHIPPED ARBITER, UNCHANGED. Same code, new name.
  allocate()     - THE SEAM, AND DELIBERATELY NOT AN ALLOCATOR. ALLOC-01
                   builds it. At N<=1 it passes the target through (§6.2's
                   identity). At N>=2 it DECLINES - no shares,
                   allocated=False - and main.py refuses to write rather than
                   broadcast, because the obvious placeholder (hand the target
                   to each) is the fault ARCH-01 was filed about and no guard
                   we have can tell it apart from the real thing.

THE CONTRADICTION IS NOT GUARDED AGAINST, IT CANNOT BE WRITTEN DOWN. The
partition is by `actuator` and it is a partition: a unit's `hi` and the site's
`lo` are never both terms of one `lo > hi` test. Constructed and swept - 40
filings (every per-inverter source x unit x bound, including a unit
contradicting ITSELF at lo 900 > hi 100) against a live site floor, and not
one moves the site target or raises a contradiction. A unit's own impossible
pair parks that unit and says so on its own bounds; it does not park the site.
That is SAFETY-25 §0c's table made mechanical.

AT N=1 THIS IS A PURE REFACTOR, AND THAT IS PROVEN RATHER THAN ASSERTED. With
one inverter the fabricated grid reading and the battery setpoint are the same
number, so the grid connection and the only unit ARE one actuator:
actuator_for() routes every source to the site, no claim is filed on a unit,
stage 2 sees the whole list and answers what the shipped arbiter answered.
All 17 suites replay UNEDITED above the ARB-03 sections and are green on the
same numbers; test_arbiter.py additionally runs 13 claim lists through both
resolve() and site_resolve() and compares target, winner, binder,
contradiction flag, the considered list and the operator line.

EVIDENCE, BY BREAKING EACH THING (tools/mutation_run.sh, purged tree):
  * Claim's dataclass default emptied            -> RED (test_arbiter)
    ...and it survived until the check was added: the two helpers pass their
    own default down, so the FIELD's default never ran. Now checked directly.
  * `peak` reclassified SCOPE_UNIT               -> RED (arbiter x4, peakguard x2)
  * `maintenance` reclassified SCOPE_SITE        -> RED (arbiter x4, peakguard)
  * the N=1 identity dropped in actuator_for()   -> RED (arbiter, peakguard
    incl. the maintenance/peak suppression checks, freeze) - which is the
    measurement that says splitting at N=1 would be a behaviour change
  * resolve() stops partitioning                 -> RED (arbiter x4)
  * allocate() broadcasts at N>=2                -> RED (arbiter x2)
  * stage 1 drops clause-2 gating                -> RED (arbiter)
  * a call site routes `device` through `loop`   -> RED (peakguard 10g)
  * SAFETY-25 Option B built as a ninth source   -> RED (peakguard 10f, both
    halves, plus 10g) - the tripwire still bites

SAFETY-24's asymmetry proof is re-run and holds: 0 negative floors over the
shipped sweep, `hi` still +inf on every claim the guard files, and it now also
asserts every one of them is on the SITE actuator - a floor bound to one
inverter's share would leave the site, the thing the capacity tariff meters,
with no floor at all.

test_peakguard.py part 10f is kept, not adapted around. `source` is still
Claim.set()'s first positional argument precisely so its AST scan keeps
reading it, which is why `actuator` is the LAST field of Claim and not the
first - moving `source` off position 0 would silently blind the tripwire that
guards SAFETY-25's deferred option. The one adaptation is `**kw` on its spy,
so main.py's roster passes through unread rather than being silently dropped.
New part 10g pins CLAIM_SCOPE to the same eight-source set and grades a REAL
two-inverter cycle, which is the only place a mis-typed actuator_of(...) at a
call site is observable - at N=1 every source legitimately routes to the site,
so a swap there is invisible by construction.

No config key, no option, no operator surface, no default moved. Inverter B
does not exist yet and nothing here measures it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-02 01:54:29 +02:00
admin 0c7e786ed5 Merge pull request 'SAFETY-25: record the owner's decision - Option A, with ALLOC-01 as the revisit trigger' (#39) from SAFETY-25-decision into release/1.0 2026-09-02 00:58:15 +02:00
admin 1c59cfeaf5 Merge pull request 'TEST-13: grade record_block()'s policy argument, and the two import spellings SAFETY-20's check could not see' (#38) from TEST-13 into release/1.0 2026-09-01 09:16:37 +02:00
admin 950472662e Merge pull request 'TEST-16: pin the two one-sided arbitrage rungs, and un-mask them from each other' (#37) from TEST-16 into release/1.0 2026-09-01 08:02:57 +02:00
admin 48cd5f31ca Merge pull request 'TEST-11: pin the clamp guard to the write it reads' (#36) from TEST-11 into release/1.0 2026-09-01 07:45:15 +02:00
admin e8fb596b99 Merge pull request 'TEST-10: pin max_w beside the meter_freeze_s key TEST-06 already pinned' (#35) from TEST-10 into release/1.0 2026-09-01 07:00:03 +02:00
admin f630ed949b Merge pull request 'CFG-03: give maintenance option defaults one source of truth' (#34) from CFG-03 into release/1.0 2026-09-01 06:28:57 +02:00
admin b7267eb51a Merge pull request 'SAFETY-25: say what peak_soc_floor bounds, and measure what it does not' (#31) from SAFETY-25 into release/1.0 2026-09-01 05:23:38 +02:00
admin 99321892cb Merge pull request 'CI-02: grade the merge, and stop a cancelled run reading as a test failure' (#30) from CI-02 into release/1.0 2026-09-01 04:06:26 +02:00
adminandClaude Opus 5 182102eeaf SAFETY-25: say what peak_soc_floor bounds, and measure what it does not
checks / unit (pull_request) Successful in 3m41s
peak_soc_floor reads like a pack-wide reserve and is not one. The
grid-following loop has no SoC guard of any kind, so on a sustained-load
evening it discharges straight past that number and keeps going until the
pack's own BMS cut-out stops it.

The gap has now been written down four times without being filed -
SAFETY-18's finding (freeze.py:18), SAFETY-24's module note, its PR body,
and its honestly-uncaught 13th mutation - and never once measured. It is
measured here, on the committed 2026-08-20 capture, driving the SHIPPED
control.compute(), peakguard.decide(), peakguard.claim() and the real
arbiter.resolve() in main.py's order:

    19:00-22:00 UTC, 830 W mean, minimum 701 W, zero export in the window
    reference pack (300 Ah x 51.2 V x DoD 90) started at 21 %, one point
    above peak_soc_floor

      ends at 10.0 %  -  the bms_soc_floor cut-out
      1525 Wh delivered by the LOOP over 587 cycles
      549 cycles below peak_soc_floor, 343 of them still discharging
      0 cycles on which the peak guard filed any floor at all

AND IT IS A BEHAVIOUR, NOT A CONFIG VALUE. The same evening ends at
9.9715 % at peak_soc_floor = 5, 11, 20 and 60 - the shipped value and both
ends of the schema range - identical to four decimal places. The option an
operator would reach for cannot move the number by any amount at all. On
the same day's high-load block the two states coexist: 49 cycles reporting
PEAK GUARD FLOOR-LIMITED and, on all 49 of them, the arbiter simultaneously
granting the loop up to 2000 W of discharge.

WHY SAFETY-24 COULD NOT CLOSE IT. The peak guard files a `lo` limit - a
LOWER bound on the setpoint. That asymmetry is exactly what makes it
trustworthy: a reviewer proved across 19,800 combinations that it can never
lower a target, cap a discharge or contradict the arbiter. The same property
makes it structurally incapable of stopping a discharge the loop itself
wants. A floor on the guard cannot become a floor on the pack by being
raised, and the sweep above is that sentence measured.

WHAT THIS COMMIT CHANGES IS THE HONESTY, on the four surfaces an operator
actually reads. That half is required whichever way the open question is
answered, and it is the real misstatement on the live system today:

  * config.yaml, beside the option itself - it BOUNDS THE PEAK GUARD ONLY
    and is NOT A PACK-WIDE RESERVE, with the replayed evening and the sweep;
  * DOCS.md, in the options table and in a new section carrying the figures;
  * the STATUS-PAGE ROW for a floor-limited guard. This is the one line a
    human reads at the moment the guard stops discharging, and read alone it
    says "the battery is being held at peak_soc_floor" - which the replay
    shows is false in every cycle that row is rendered;
  * the PERSISTENT NOTIFICATION, which outlives the page. It now names what
    has stopped (the guard buying a lower bill) and what has not (the pack
    being spent).

peakguard.py and freeze.py carry the measurement beside the findings they
already recorded, so the next reader does not have to take the caveat on
trust.

THE PRODUCT DECISION IS NOT TAKEN HERE, and must not be. Whether the loop
gets a reserve floor of its own decides whether this house's battery may
discharge to the BMS floor on a heavy evening; that moves real energy and it
is the owner's call. DECISIONS/SAFETY-25-loop-soc-floor.md is the memo AC 1
asks for: the options, what each costs, what each protects, weighed against
MAINT-01 (a pack repeatedly at the BMS floor is the SoC-drift condition the
maintenance cycle exists to correct, and an over-tight reserve fights it from
the other side) and against the fact that this would be the FIFTH SoC key -
OPT-04's arbitrage_soc_floor fired SAFETY-24's "revisit if a third floor
appears" trigger unremarked, so SAFETY-27's naming decision should land
first. It carries a recommendation and says plainly that it is not a
decision.

It also records a trap found while writing it, and now CHECKED through the
real arbiter rather than described: the obvious shape of a loop-side floor
is a `hi` limit at P_LOOP, and filed while the peak guard is asking for a
positive floor that is `lo > hi` - which arbiter.py resolves to a
CONTRADICTION at 0 W, the battery PARKED in the one quarter of the month
that sets the bill. It is the same crossing-limits hazard
shaping_suspended() already exists to avoid. The ordering
`bms_soc_floor < loop_soc_floor <= peak_soc_floor` removes it provably,
because wherever the reserve binds the guard is already floor-limited and
files lo = 0; both halves are run in part 10d.

test_peakguard.py part 10 is a TRIPWIRE, not a guarantee. It records a
defect and goes RED the day somebody builds a loop-side floor, so the
exposure cannot change size under a green suite and closing it has to be
deliberate. Part 11's mutation 13 stays honestly NOT CAUGHT - nothing
prevents the loop discharging past this floor - but it now reports a number
instead of a sentence.

Verified: 16/16 suites green in the CI image. Eight mutations, each against
a tree copied fresh with __pycache__ excluded and mounted :ro, so no .pyc
existed to go stale (MUTATION.md's structural immunity, the same one it
records for CI); byte deltas +61, -8, -465, -430, -1088, -46, file deleted,
and -95; none is +0 B. All eight go red on the named check:

  M1 peakguard.claim() also files hi=floor_w        -> 7 red, incl. 10c
  M2 control.compute() gain forced to 0             -> 6 red, incl. 10a/10b
  M3 scope sentence removed from the status row     -> 10e red
  M4 scope sentence removed from the notification   -> 10e red
  M5 scope paragraph removed from config.yaml       -> 10e red
  M6 scope bullet weakened in DOCS.md               -> 10e red
  M7 the decision memo deleted                      -> 10e red
  M8 arbiter clamps crossing limits instead of
     reporting a contradiction                      -> 5 red, incl. 10d

AC 2 is left explicitly OPEN, pending the owner's answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 03:46:43 +02:00
adminandClaude Opus 5 1e4bad8aa7 CI-02: grade the merge, and stop a cancelled run reading as a test failure
checks / unit (pull_request) Successful in 3m44s
Two things a green from `checks / unit (pull_request)` was claiming without
having observed. Both were measured on a deliberately conflicted throwaway pull
request (#29, now closed and its branch deleted) rather than read off the
workflow, because the Actions log endpoints 404 on this Gitea and a step's own
conclusion is the only channel out of a job.

CASE 1, AND THE TICKET'S MECHANISM IS WRONG IN A WAY THAT MAKES IT WORSE.
The card says a conflicted pull request loses its merge ref, so that

    git fetch -q --depth 1 origin "$GITHUB_REF" \
      || git fetch -q --depth 1 origin "$GITHUB_SHA"

falls through to the second half and quietly grades the bare branch head. The
symptom is real - PR #14 reported success while `mergeable: false` - but that
is not how. Asking one question per step on the conflicted probe (runs 558,
559):

    GITHUB_REF == refs/pull/<n>/head          P01 passed
    GITHUB_REF != refs/pull/<n>/merge         P02 failed
    GITHUB_SHA == the pull request head sha   P03 passed
    fetching GITHUB_REF succeeds              P04 passed  <- even conflicted
    ...and it lands on the bare head          P05 passed
    refs/pull/<n>/merge does not exist        P06 failed

`git ls-remote origin 'refs/pull/*'` on this server advertises only `/head`, for
open and closed pull requests alike. There are no merge refs here at all. The
first fetch has therefore always succeeded and the fallback has never fired
once; both halves name the same commit anyway. So the gate did not lose merged
content on conflicted pull requests - it has never had merged content, on any
pull request in this repository's history. Green has meant "this branch passed
on its own", and with `block_on_outdated_branch` off on release/1.0 the base has
usually moved underneath it.

Reproduced end to end on one pull request, same conflict, gate swapped:

    run 567  the ORIGINAL gate   -> success, "Successful in 3m38s"
    run 566  the gate below      -> failure in 2s, merge step red,
                                    both test steps `skipped`

The checkout now fetches the base tip and the pull request head, merges them,
and refuses if the merge does not apply - as its own NAMED step, because
/actions/runs/<id>/jobs carries a per-step conclusion and that is what lets a
reader tell "nothing was tested" from "the tests spoke". `--depth 1` is gone: a
merge needs a merge base, and a full fetch of this repository measured 6.8 MB
and 1.1 s. The fallback survives only on the workflow_dispatch path, which has
no pull request to merge and now announces which bare ref it took.

CASE 2, AND `cancel-in-progress` IS NOT THE KNOB.
Runs 531 and 533 on PR #14 were cancelled at 0s, each at the exact second the
next run on that pull request was created:

    531  created 17:08:19 `Waiting to run`, cancelled 17:10:44 = 532's creation
    533  created 17:45:11 `Waiting to run`, cancelled 18:11:22 = 534's creation

Both have `started_at` at the Unix epoch. A cancelled run posts `failure` on the
required context, described `Canceled after 0s`; branch protection compares
state, not description, so "nothing ran" and "your tests are broken" arrive as
the same red wanting opposite responses. Three settings were measured, each with
one run holding the single runner slot and two further pushes behind it:

    no concurrency block at all   runs 561 and 562 both queued, both executed
    group, cancel-in-progress:false
                                  run 564 sat `Blocked by required conditions`
                                  and was cancelled at 0s the second 565 existed

A shared group cancels a run that has not started regardless of that flag -
GitHub documents it and Gitea 1.27 implements it. Having no group is the only
configuration measured to leave a queued run alive, so the block is gone and the
measurement is written above it. The cost is stated in the file: a developer who
pushes four times occupies the shared runner four times. That is CI-01's own
trade written the other way round - a delayed verdict, never a destroyed one -
and a destroyed verdict is worse here because it is indistinguishable from a
real failure. A sha-keyed group was considered and rejected: the only
cancellations it still permits are same-sha ones, whose red lands on a commit
that IS still the head.

WHAT IS NOT ESTABLISHED, SAID PLAINLY. Nothing here makes a cancelled run
distinguishable at the gate BY STATE; that is Gitea posting the status, not this
workflow, and no edit to this file can change it. What ships removes the cause
instead of relabelling the symptom. And a commit that never had a run carries an
empty `/statuses/<sha>` list, while a queued one carries `pending / Waiting to
run` within seconds and a concurrency-held one `pending / Blocked by required
conditions` - those were already distinguishable at the API. `/actions/tasks`,
which is what was polled when this was first reported, lists only tasks that
started and cannot answer the question at all.

test_ci_gate.py grades the gate by RUNNING it. It extracts the checkout and
merge steps out of checks.yml and executes the shipped commands against two
throwaway local repositories it builds - one whose pull request merges, one
whose does not - and pins that the old one-liner exits 0 on the unmergeable one
while the new steps refuse and say nothing was tested. Part 1 pins the two
tokens that assemble `checks / unit (pull_request)`: renaming `name:` or the
`unit:` job key would leave every open and future pull request waiting forever
on a context that never arrives, with no error anywhere in that sequence, and
admin override blocked. Seven mutations of checks.yml were each confirmed red,
including both renames and a restored `--depth 1`. .gitea/ci/Dockerfile gains
`git` for it; the fixtures are local paths and no network is touched.

17/17 green in the checks image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 03:42:54 +02:00
admin 6ea79f1043 Merge pull request 'SAFETY-20: measure the freeze rail against the reachable ceiling, not max_w' (#28) from SAFETY-20 into release/1.0 2026-09-01 02:57:58 +02:00
admin d64790b3e2 Merge pull request 'TEST-15: purge the bytecode cache before a mutation run, not after' (#27) from TEST-15 into release/1.0 2026-09-01 02:22:15 +02:00
adminandClaude Opus 5 83911ed637 SAFETY-20: measure the freeze rail against the reachable ceiling, not max_w
checks / unit (pull_request) Successful in 3m37s
SAFETY-18's conjunction needs `abs(commanded_w) >= 0.95 * max_w`, but the
emitted command is clipped to the setpoint device's own [dev_min, dev_max]
range as well as to max_w - in clamp_to_rating()'s second stage and again as
the arbiter's `device` P_SAFETY limit claim. Measured on the shipped predicate
at the ticket's numbers, max_w 5000 on a +-4700 W inverter:

    looks_frozen(unchanged_s=3600, commanded_w=4700, max_w=5000)  ->  False

The command is pinned at the hardware's rail, the meter has been frozen for an
hour, and term 2 is unsatisfiable by construction because 4700 < 4750. Any
device rating at or below 0.95 * max_w disarms the conjunction outright, and
the status page then printed `armed (... command is over 4750 W)` - a guard
that cannot fire, advertising that it can, which is the failure SAFETY-15,
SAFETY-19 and validate.py rule 5 all exist to prevent.

The rail is now measured against min(max_w, |dev_min|, |dev_max|), through a
new `rail_ceiling_w()` threaded into looks_frozen(), FreezeWatchdog.evaluate()
and both call sites in main.py. An unread rating leaves the old behaviour
exactly, and correctly: hass.limits() returns None whenever ESPHome is not up,
and on that path no device clamp is applied either, so max_w really is the
ceiling. A rating larger than max_w cannot raise the rail - this is a min(),
not a swap.

THE DELIBERATE CHOICE, since SAFETY-03 has landed. The arithmetic was kept
rather than substituting Decision.i_clamped / Decision.clamped. i_clamped is
the integrator bound (control.py:208, `bounded != i_w`), not "the command is
at the device rating", and `clamped` is the max_w output bound, unreachable on
a default install - neither is the device rail. More decisively, both answer
"did the LOOP ask for more than a bound", while the rail term asks "is the
command standing at the ceiling", and those coincide only when the loop is the
claimer that won. A maintenance SET or a peak-guard floor reaches the ceiling
through arbiter LIMIT claims with no clamp engaging at all, so a boolean rail
would go False exactly during a maintenance phase running against a frozen
meter - which freeze.py's own docstring names as the same fault with a worse
actuator behind it. The predicate is also public with two call sites that pass
DIFFERENT command values (self.target, and `standing` = last_write); a
per-cycle boolean cannot be passed per call site, so substituting it would
have silently decided the pre-clamp-versus-wire question that PM recorded as
still open. AC 1 governs, and it is satisfied literally.

The smaller of the two device bounds, not a per-sign pair. A per-sign rail is
tighter and is not wrong, but it is two rails needing two coverage verdicts on
a status row that carries one number, and the only configuration that
distinguishes them is refused at startup anyway. The symmetric ceiling can only
lower the rail, which widens term 2 rather than disarming it; term 1 carries
the whole no-false-positive argument and is untouched.

A ceiling of 0 W is NOT COVERED, not "armed at 0 W". A device that can command
nothing in one direction would make `abs(command) >= 0` true of every command
including a parked one - term 2 degenerately TRUE, collapsing the conjunction
into the bare unchanged_s > N threshold TEL-05 refused for false-tripping at
this controller's own operating point. rail_unreachable() returns the reason,
looks_frozen() stays silent, and the page uses the NOT COVERED row the
unsupported-transport case already has - a second reason to use that surface,
not a new mechanism.

AC 4, decided rather than assumed: NO NEW validate.py RULE. The configuration
is max_w above the device rating, which rule 2 (CFG-01) already refuses on the
identical predicate, re-run by run_control() once the real rating is known:

    validate_startup_config({... max_w: 5000}, -4700, 4700)
      -> ['max_w is 5000 W but number.sp accepts at most 4700 W - THE DEVICE
          WINS ...']

A rail-specific rule would fire on a strict subset of that (0.95 * max_w >
dev_max implies max_w > dev_max) and could never be the rule that stopped a
start. The residue is the state where hass.limits() answered (None, None) -
ESPHome not up at boot, range checks skipped rather than guessed at, rating
never re-read - and there is nothing to refuse there by construction, which is
why the answer is a runtime row. Recorded in validate.py's "deliberately not
refused" list and pinned by four checks in test_validate.py, so the decision's
premise goes red if rule 2 is ever softened back to a warning.

HOW REACHABLE THIS IS, stated rather than glossed. Because rule 2 refuses the
configuration whenever the rating is known, min(max_w, dev_max) IS max_w on
every install that reaches the control loop with a rating it could read, and
this change is arithmetically dead there. That is clamp.py's argument for its
own second stage word for word: the case where it bites is the case where the
startup gate did not run or no longer holds, and a guard that quietly assumes
the gate ran is a guard that reports armed in exactly the state it cannot fire
in.

Verified by breaking it, per this project's rule. Reverting rail_w() to bare
max_w fails nine checks; deleting the NOT COVERED handling fails five. The one
that mattered: shipping the corrected predicate while NOT passing the rating
from check_meter_frozen() left the entire suite green, because nothing graded
the caller - so test_freeze.py part 5b now drives the real cycle() on a +-4700 W
device and asserts on the notification that leaves the process, and the
unwiring mutation fails three checks there. The peak-guard call site's wiring
is still ungraded (behind p1_enabled, needs a fed averager for `frozen` to have
any observable consequence) and that gap is named in the file rather than
implied.

Full suite: 15/15 in goodwe-addon-ci.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 02:14:07 +02:00
admin 358fbb0172 Merge pull request 'TEST-17: grade the status page's peak row at a stated month, not at today's' (#26) from TEST-17 into release/1.0 2026-09-01 01:32:44 +02:00
admin f6ecd5ff03 DATA-01: thirty days of the real meter, and what AC 3 actually measures (#25)
Thirty days of the house's own P1 meter, exported from HA's recorder with a committed exporter, closing the dataset gap that made TARIFF-01's and SAFETY-07's AC 3 unmeetable. 2879 closed quarters, 485841 samples, 99.950 % covered, 13 gaps over 30 s and nothing interpolated.

Reviewed independently. The re-export is byte-for-byte identical after decompression on both previously committed days (865024 B / 15600 samples and 894387 B / 16351 samples), verified by decompressing and comparing rather than by reading --verify; the only difference is the gzip mtime field, and writing 0 rather than a wall clock makes this exporter reproducible where the 2026-08-25 one-off was not. The committed CRLF is reproduced rather than tidied because reproducing it IS the verification, and .gitattributes marks *.gz binary so git can never drift it - confirmed with git check-attr. All 30 manifest sha256s match.

replay_ac3.py --expect 189/190 reproduces test_peakguard part 6 exactly: 5029.8 -> 3485.3 W, 189/190, 878 Wh, 110 graded + 5 blind - every one a figure the reviewer had already derived independently on a different harness while reviewing TARIFF-01 and SAFETY-07.

AND THE RESULT IS A FINDING ABOUT THE CRITERION. Over the month the guard takes the peak from 5029.8 W to 4107.3 W (-18.3 %, still over the 3500 W soft limit) while moving the interval count from 98.89 % to 98.99 %. The UNCONTROLLED month already passes the 95 % bar, with 112 quarters of slack, and the guard contributes 3 - a signal-to-threshold ratio of 0.021. The criterion counts intervals while the capacity tariff bills the month's maximum, so it cannot separate a working guard from an absent one. AC 3 is met on the letter and uninformative on the substance; it should not be read as evidence the guard works, and if rewritten it should be on the monthly peak.

The failed-read discipline was verified by producing all three states: a typo'd entity and an unreachable recorder both exit 1 having written nothing, and a day whose read fails is recorded FAILED with file null, no .gz, days_failed 2 and empty_days [] - so an empty response cannot look like a quiet hour. The 30 days sit in a subdirectory and are glob-invisible to both suites' non-recursive scenarios/p1-net-*.jsonl.gz; neither suite had a check added, removed or altered. Full suite green, 15 files.
2026-08-31 11:35:45 +02:00
admin 86c43eba24 DATA-02: twelve months of real Belgian day-ahead prices, closing OPT-04 AC4 (#24)
8760 hourly EPEX BE clearings from the Energy-Charts API (Fraunhofer ISE), CC BY 4.0 from Bundesnetzagentur | SMARD.de, committed with attribution and a fetcher that refuses to write if the licence stops saying CC BY 4.0. Raw wholesale with all_in false and the markup/levy/VAT assumption labelled as an assumption; the additive terms cancel out of every daily spread, so trade counts turn on the VAT multiplier alone. DST handled by iterating in UTC and rendering through price.brussels(): 25 points on 2025-10-26, 23 on 2026-03-29. app/ diff is zero lines - nothing tuned. Section 7B asserts that the real year cannot grade section 17, so the synthetic year stays.
2026-08-31 11:12:49 +02:00
admin 4601682815 SAFETY-24: an energy floor under the peak guard's forced discharge (#21)
SAFETY-07 could command a discharge to hold a billed quarter down and nothing below it consulted the state of charge - the BMS was the sole energy backstop. decide() now takes soc and soc_floor, and discharge_permitted() is the one place the question is answered: public, no default arguments, and consulted only AFTER a forced discharge is actually being asked for. The denied action is a 0 W floor - one-directional, forbids charging, structurally cannot cap the discharge the loop is already doing.

Reviewed independently. The trade-off is ruled honest and was made a check rather than a footnote: on a pack deliberately sized so the floor must bind, the worst quarter goes 4338 -> 5030 W while end SoC goes 9.6 -> 20.0 % and cycles pinned at the BMS cut-out go 1227 -> 0. Reproduced on the reviewer's own replay, including the 304.2 Wh withheld against 292.8 Wh between the two floors - half a cycle at the rail apart, so the floor is doing the arithmetic it claims.

The guard's central guarantee was re-derived across 19,800 combinations of load x elapsed x battery x SoC x floor: 0 negative floors, 0 claims with hi other than +inf, 0 decisions both floor-limited and saturated, 0 arbiter resolutions lowered or contradicted. Rule 8's <= is right: at equality the EMS floor could never be the thing that stops the discharge.

Eleven author mutations plus nine reviewer ones, all red under python -B with a purged __pycache__ and real byte deltas. One survivor is an equivalent mutant and provably so: a float(soc or 100) defaulting above the call site is dead code, because a Controller with no SoC never reaches decide() at all - inputs_ok is False and cycle() stops at the safety claim first. That property is now asserted on the service call, replacing an assertion whose sentence was wider than its check.

Merged after two rebases. The second one broke a grader neither card could have seen alone: test_peakguard graded the FIRST .decide( in main.py and required exactly one, which stopped being true when OPT-04 added arbitrage.decide(). It failed loudly rather than grading the wrong call. Fixed by selecting peakguard.decide by qualified name and requiring exactly one of that, verified still red on a decoy and on the maintenance_soc_floor swap.

Follow-ups filed as SAFETY-27, not addressed here: arbitrage_soc_floor has no rule-8 twin against bms_soc_floor, and this card's own 'revisit if a third floor appears' trigger has fired.
2026-08-31 10:24:36 +02:00
admin b7272169b1 SAFETY-26: pin the negative-price boundary from below, not just from above (#23)
Closes the survivor found reviewing OPT-04: price < 0 weakened to < -0.001 left every suite green while a -0.0005 EUR/kWh sell hour discharged. Pinned as a sweep rather than a point - five magnitudes spanning >10000x down to -0.000001, the last place the canonical 6-decimal format expresses - with the well-formedness of each stimulus asserted first so section 17 is the only rung that can refuse, and the same five sign-flipped asserted to discharge so it is a boundary at exactly zero. app/ untouched.
2026-08-31 07:37:46 +02:00
admin 8e589fee3f OPT-04: price arbitrage, at priority 3, shipped switched off (#22)
app/arbitrage.py consumes TARIFF-02s canonical prices, TARIFF-03s Arbitrage verdict and FCST-03s firm/provisional split rather than re-implementing them. An idle decision files NO claim - a 0 W set at P_ARBITRAGE would out-rank the loop and kill self-consumption. Peak shaving is honoured twice: TARIFF-03s required peak_guard_active interlock, and P_PEAK 50 clamping P_ARBITRAGE 30 in the arbiter. The fetch lives in run_tariff_refresh(), asserted on the shipped AST to be absent from cycle() and read_inputs(). Selling into a negative-price hour is refused. arbitrage_enabled ships false.
2026-08-31 01:53:31 +02:00
admin a30bde1ab1 FCST-03: provisional prices - yesterday flagged as yesterday, and unable to move anything (#20)
app/price_forecast.py fills unknown hours from yesterday, flagged provisional by four independent mechanisms: _carried() has no provisional parameter, published points pass through by identity, _audit() re-derives every flag and raises rather than repairs, and an AST check refuses a non-literal provisional. Gate blocks irreversible actions on provisional hours. Arbitrage refusal 3 rewritten to fewer-than-two-FIRM-hours so filling does not switch arbitrage off every morning, with the 11.4 fallback still refused by a truer route. Real-time products get a measured 2.5x wider threshold, following the declared PriceProduct.
2026-08-31 00:23:41 +02:00
admin e7c95bfd8a SAFETY-07: cap the loop before it sets the month's billed capacity peak (#18)
The first thing in this system that can STOP a runaway rather than alarm on one. Each cycle it projects the open 900 s block from P1 telegrams and, over the ceiling, files one arbiter claim: a lower bound at P_PEAK, sized as excess = 900*(projected-ceiling)/remaining added to the MEASURED battery power. No writes, no I/O, no clock, no state between calls.

Replayed independently by the reviewer on a separate harness: 2026-08-20's billed quarter 5029.8 W -> 3485.3 W, both days 189/190 -> 190/190 under the 3500 W soft limit, 878 Wh modelled discharge on the failure day and 0 Wh with 0 engagements on the healthy one.

Verified as properties rather than witnesses: the lo-limit claim cannot cap discharge or export (hi is inf; 0 of 54 loop/floor pairs lowered the target; no floor in 0..5000 W reduces a full-rate discharge; decide() emitted 0 negative floors over 630 input combinations), and the 900/remaining gain is exactly 1.0 at the top of the block, growing, bounded at the end by the achievable_w clamp so it can never file lo > hi.

Round one held this PR on three wiring mutations that left all twelve suites green while a default install parked at 0 W in the billed quarter - including the suppression re-gated on the optional peak_forecast_entity, verbatim the historical defect this card's own comment described. All three are closed. The conjunction moved out of main.py into peakguard.shaping_suspended() so the check can assert on the shipped expression, and part 7 now executes the mutations against the real Controller.cycle(). The reviewer confirmed the harness cannot go vacuous: re-inlining the conjunction in main.py with behaviour-identical code turns B1 and B2 RED rather than green, because they assert the mutation takes effect.

Eight further reviewer mutations this round, all red, no survivors. One note recorded, not blocking: the parsed-syntax grade on decide(batt_w=...) is defeated by a decoy .decide() call or by overwriting self.batt before the call, and it is the behavioural checks beside it that catch both.

AC 3 remains TARIFF-01's and is NOT met - do not close the card as AC 3 met. SAFETY-24, the missing SoC backstop on the forced discharge, is named in peakguard.py with the 878 Wh figure and must land before this reaches a client install.
2026-08-30 23:56:20 +02:00
admin cf5094c608 TARIFF-03: the static day/night profile as a first-class product and the 11.4 fallback (#19)
app/static_tariff.py: StaticProfile derives STATIC vs FIXED from its own prices, one generator serves both the configured product and the degradation fallback differing only in the provisional flag. TariffPlan.arbitrage() is a refusal type behind a required keyword-only peak_guard_active, and a flat tariff is refused by arithmetic rather than by matching PriceProduct.FIXED. main.py touched only in read_inputs() else-branch, one field, one status row - nothing in cycle(), the arbiter or the peak path; an empty profile is bit-for-bit the old behaviour. Rule 7 tripwire re-aimed rather than silenced.
2026-08-30 23:24:53 +02:00
admin 91ea676af4 TARIFF-02: price provider plugins + the canonical all-in price format (#16)
Five providers behind a registry, canonical all-in {start, eur_per_kwh, provisional}. Plugins return RawPoints and declare capabilities().sources; assemble() adds the complement once. register() refuses at import any plugin whose class code mentions a framework key, PRICE_OPTION_DEFAULTS, price_option or _o - closing the three routes review used to double-count. PriceRead.series/no_data as an absence type; all-zero is a series, no_data forbids arbitrage. Additive only: main.py untouched, zero deletions.
2026-08-30 22:35:49 +02:00
adminandClaude Opus 5 862f0fa574 Merge release/1.0 (TARIFF-01) into TARIFF-02
checks / unit (pull_request) Successful in 2m39s
TARIFF-01 (#17) landed on release/1.0 first, so this branch was two
commits behind and Gitea reported the PR unmergeable. Integrating rather
than rebasing: the two reviewed commits stay addressable, and the branch
is protected against force-push anyway.

ONE CONFLICT, AND IT IS THE BORING HALF OF THE ONE REVIEW PREDICTED.
Review of #16 expected collisions in four places - the tail of
`options:`, the tail of `schema:`, the rule list inside
validate_startup_config(), and the tail of validate.py. None of them
happened: TARIFF-01 touched neither config.yaml nor validate.py. It
added app/tariff.py, test_tariff.py, two P1 scenario captures, and edits
to main.py and p1.py, none of which this branch goes near.

The only conflict was CHANGELOG.md, where both branches prepended a
block under the same `## Unreleased` heading. Both blocks kept, TARIFF-02
above TARIFF-01 since it lands second. Nothing was dropped or reworded on
either side, and the resolution adds no line of its own beyond the blank
one between them.

Checked rather than assumed after the merge:
  - all ELEVEN test files pass, including TARIFF-01's own test_tariff.py
    (which this branch had never run) and the 93 s test_loop.py;
  - the 24-mutation battery still has 0 survivors;
  - no semantic overlap in config: TARIFF-01's peak tracker reads the
    peak_cap_w / peak_forecast_entity options that already existed, so
    the price options added here neither shadow nor duplicate anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 19:42:16 +02:00
adminandClaude Opus 5 e102cde841 TARIFF-02 review: close the three other doors to the markup, and stop overclaiming
checks / unit (pull_request) Successful in 2m34s
Review of PR #16 wrote a double-counting plugin four ways. PluginOptions
blocked one. The one that decided it touched nothing but
PRICE_OPTION_DEFAULTS - a public dict in the module every plugin must
already import for register() - so "the number is not in scope" was
simply false, and an absolute guarantee a reviewer trusts is worse than
a stated limit, because they stop checking.

register() now refuses, AT IMPORT, any plugin whose own code mentions a
framework-owned name: FRAMEWORK_KEYS plus PRICE_OPTION_DEFAULTS,
price_option and _o. code_leaks() parses the class and drops every
docstring node before searching, because every plugin is REQUIRED to
name its sourced components in prose - engie_dynamic says "markup" in
its docstring and a raw text scan false-positives on the documentation
the same file mandates. A check that fires on required documentation is
a check somebody deletes. test_price.py asserts that a raw scan really
would have false-positived, so the strip is proven load-bearing rather
than asserted.

It fails OPEN if inspect.getsource() cannot find the .py, which the
add-on image always can. Refusing to start a battery controller because
a static analysis could not run would trade a real outage for a
hypothetical accounting error - so instead the checks assert source was
found for all five shipped plugins, and a build that lost it is a red
check rather than a quietly disarmed guard.

All three reachable attacks are written out as plugins in the suite and
each must be refused at registration. The cost is measured, not
asserted: honest wholesale 0.188, every attack 0.219 - one markup high,
every hour, invisible in every log this add-on writes.

The guarantee is now stated at its true size everywhere it appears: the
framework adds the complement exactly once, GIVEN A CORRECT sources
DECLARATION. A plugin that declares {ENERGY} while its feed is all-in
still doubles while touching no framework name, and no code can catch
that - the declaration is an unverifiable claim about somebody else's
tariff. Measured in the suite (0.276) so the limit is a number rather
than a caveat. The docstring/declaration check verifies that a plugin's
two claims AGREE, not that either is true; that now says so.

The three surviving mutations:

M3 - _NoData's reason taking a default. This is the identical survivor
left open on RIG-01's _Unread.cause: the shape was borrowed from
sim/riglock.py including its blind spot, which is what borrowing a shape
does if nobody re-runs the mutations against the copy. Closed by
SIGNATURE - no constructor or handler of an outcome type may carry a
default - so the next one is red without anyone writing a new case.

M5 - the ENERGY-required guard was untested. Its consequence is that
adds becomes {ENERGY, TAXES} and the emitted price silently omits the
energy it is pricing.

M7 - deleting the TLS refusal went green AND the check named for it
still passed, because the old parser stripped a fixed len("https://")
and mangled http://api.energy-charts.info/p into the host
pi.energy-charts.info, which then failed the ALLOW-LIST. An assertion
that does not test what it names, inside a security check. Fixed on both
sides: the scheme is parsed as a scheme, so an http URL now resolves to
its real and declared host and only the TLS rule can refuse it; and the
check asserts the REASON. Fixing it surfaced M7b - the no-scheme guard
was survivable the same way - found by mutating my own fix, and closed
the same way.

Also carried forward: rule 7's refusal is correct forever but its stated
reason ("the provider decides nothing") expires the moment TARIFF-03 or
OPT-04 lands. test_price.py section 12 now greps app/ by AST for a
load_prices consumer and goes red when one appears, telling whoever
lands it to update the message in the same commit. The first draft of
that tripwire used a substring and matched the comment in validate.py
explaining the tripwire - the same false-positive class as the plugin
docstrings, hit twice in one ticket, and fixed the same way.

Documented rather than coded around: vars(read).get("points", []) and
bool(read) both still get past the absence type, and neither is worth
code - defining __bool__ would turn a line that reads like a guard into
one that IS a guard by accident, which is how .get(k, default) got here
in the first place.

Stated plainly in DOCS/CHANGELOG/PR: every fixture is synthetic, so the
five feed URLs and JSON shapes are verified against a belief about what
those suppliers publish rather than against the feeds. A wrong shape
degrades to no-price-data, not to a wrong price.

Review's own DST verification recorded: 149,040 hourly instants,
2020-2036, zero mismatches against zoneinfo. The residual is political,
not arithmetic, and the docstring now says so.

24 mutations driven through the suite, 0 survivors. Whole add-on suite
green. No deleted lines outside this ticket's own files; main.py still
untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 19:35:36 +02:00
admin 0dbd485b5a TARIFF-01: measure the month's billed capacity peak, and make it survive a restart (#17)
The instrument SAFETY-07 will read: the highest clock-aligned 15-minute average offtake of the calendar month, persisted in /data/tariff.db across two retention tiers, with a monthly peak that recompute_month() proves has not diverged from the 15-minute tier. Files no claim, applies no clamp, commands nothing.

AC 1 was already built and unwired - p1.QuarterAverager on the base already does clock-aligned 900 s blocks, time-weighted over irregular spacing, offtake-only, per-phase, with the max_hold_s gap rule; verified on 9aadf46 rather than taken on the PR's word, and the p1.py diff removes zero lines. So this is persistence and reduction rather than a second source of truth for the quantity the money depends on.

Block rather than rolling, and the reading is provable: a 900 s burst centred on a :15 boundary bills at 2000 W and reads 4000 W through a trailing window - exactly 2.00x, the supremum - and since the aligned windows are a subset of all trailing windows, a rolling figure can only ever read HIGH, never low. On the real captures, 1.09x and 1.05x.

Reviewed independently: the encoded EU DST rule graded against zoneinfo at minute resolution over eight transitions, all of 2026 hourly and every month boundary - 0 mismatches - and the wrong-arm fromutc() mutation that differs only inside the one-hour transition window now goes red, which is the class that escaped the first draft. All four persistence behaviours produced rather than read, including a hard os._exit with no close() losing neither a banked quarter nor the open checkpoint. The SAFETY-20 syntax grader discriminates on names; two evasions remain in its import half.

AC 3 is NOT met and the check says so: no 30-day dataset exists. The two days that do exist replay to 189/190 quarters = 99.5 % under the 3500 W soft limit, uncontrolled - the baseline SAFETY-07 must improve on - and 2026-08-20's peak reproduces independently at 5029.8 W, a billed quarter over the live install's own 5000 W rating. Do not close the card as AC 3 met; start the 30-day capture.

Four reviewer mutations survive, recorded on the PR. The one to act on: record_block() ignoring its policy argument stays green, because every block reaching the store in the check is single-phase where NET, SUM and MAX collapse - the third instance of the degeneracy trap this file already documents twice, sitting on the exact line the pending per-phase product decision will change.

No conflict with TARIFF-02 (#16) beyond CHANGELOG.md, verified from both sides.
2026-08-30 19:33:57 +02:00
adminandClaude Opus 5 852fc7ced6 TARIFF-02: one canonical price format, and a seam that keeps supplier names out of the optimiser
checks / unit (pull_request) Successful in 2m33s
Five provider plugins - generic_dayahead, engie_dynamic,
totalenergies_dynamic, eneco_eddy, manual_csv - all emitting exactly
{"prices": [{"start", "eur_per_kwh", "provisional"}]}, tz-aware at the
Brussels offset, EUR/kWh, all-in. Nothing consumes it yet; the seam
lands first, per the PM's ordering.

It is built in goodwe_controller/app/ rather than at specs.md 2.2's
ems/ingest/prices/, per the product owner's decision of 2026-08-30. The
behaviour 9.3 / 8.3 / 11.4 specifies is unchanged.

Three things shape the design, and each is the mechanism rather than a
comment saying to be careful:

Double-counting the markup is unreachable. A plugin does not return a
price - it returns the figure its source published and declares which of
{energy, markup, taxes} that figure already contains. assemble() adds
the complement, once, in the framework. A plugin is never handed
price_markup_eur_kwh: PluginOptions raises FrameworkOption on it, so the
line that adds the margin a second time cannot be written. Engie and
TotalEnergies publish a retail figure with their margin inside; adding
the configured 3 c/kWh on top would have made every arbitrage decision
wrong by that amount with nothing reporting it.

The product type is declared, not inferred from the plugin's name.
eneco_eddy is real-time/imbalance and gets 8.3's widened thresholds
through capabilities().product and volatility_regime(), never through a
name comparison - which is AC2. test_price.py asserts mechanically that
no plugin id appears anywhere in the framework's code, and drives a
sixth provider defined inside the test file to the right regime with
price.py untouched.

"No price data" is a type, not an empty list - the same rework
sim/riglock.py took under TEST-04. A fetch returns PriceRead.series(...)
or PriceRead.no_data(reason), and only the first carries fields. An
empty series is not constructible; no_data has no .points, no .at(), and
.get("points", []) raises rather than returning the default. A day of
free electricity and a day with no prices are different instructions -
arbitrage is pointless under one and forbidden under the other - and an
implementation returning [] cannot tell them apart. Every site-level
failure returns no_data; nothing raises into the control tick.

Also: injection stays a separate scalar (export_price_eur_kwh, AC4),
negative prices survive the boundary validator (17's regression case),
egress is allow-listed against each plugin's own declaration (16), and
no tzdata is used - the shipped Alpine image has none, so Brussels
offsets come from the EU rule and instants are carried as UTC.

validate.py grows rule 7: an unknown provider id, manual_csv with no
path, and - the HA overlap the PM told us to decide about -
price_provider set alongside price_now_entity/price_avg_entity, which is
two price sources where the HA pair silently wins. The HA sensors are
kept and still work alone; TARIFF-03 and OPT-04 are what replace them.

CFG-03 is not widened: the price defaults have ONE in-code mirror,
PRICE_OPTION_DEFAULTS, which validate.py imports rather than restating
and which test_price.py pins to config.yaml by reading the file.

test_price.py is runnable, assert-free in the house style, and every
verdict it prints is derived. It grades the markup arithmetic with
0.031/0.057 rather than the shipped 0.03/0.06, because 0.06 is exactly
twice 0.03 and a degenerate pair would make double-counting
indistinguishable from correct addition - and it asserts that
non-degeneracy as its first section. The DST checks run on the 23-hour
and 25-hour days, not on a normal day where a fixed 24-slot
implementation and a correct one agree. Thirteen mutations were driven
through it; all thirteen go red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 19:05:12 +02:00
admin 9aadf469bc TEST-06: pin the cycle() ordering, the NOT COVERED string and the shipped meter_freeze_s (#14)
Closes the three mutations that survived SAFETY-18 suite green. Ordering pinned by consequence (N8 red above the assignment, N3 green below it); NOT COVERED asserted in both directions; shipped meter_freeze_s and its schema bounds pinned. notes_of()/all_notes_of() split so this guard and the whole notification surface are separately nameable, with the three surface-wide claims decided per scenario after SAFETY-03. app/ change is comment-only (AST-identical).
2026-08-30 18:39:07 +02:00
admin 6aabb832fc SAFETY-03: alarm when a computed setpoint exceeds the rating, do not just clip it (#12)
The clamp already existed; the alarm did not, and specs 10.3 records a silently clipped integrator that reached 14,768 W while every dashboard read a legal 5000 W. OUTPUT_CLAMP now alarms on every engagement, with its own reason code, notification_id, status row and counter, distinct from ordinary saturation. Clamp lives in write_setpoint(), the one path every write takes; the alarm runs after it, out of the write path. Not a trip: the clamped value is written and the loop keeps running.

Reviewed over two rounds. Round one held the PR on three findings: clamp_to_rating() returned early from the max_w stage so the device bound was never applied when the first stage engaged (the bigger the overshoot, the less the guard did), and two checks that could not fail - range(CLEAR_CYCLES - 1) issued zero clean cycles at CLEAR_CYCLES=1, and REASSERT_S + 9 passed for any value including an hour of silence. All three fixed in 4e66583 and verified independently: both clamp stages now run on symmetric, asymmetric and wider-device ratings with no escapes; the 492-point sweep was shown to bite where the witness values cannot (a positive-side-only device bound gives 181 escapes with both witnesses passing, and a single-grid-point hole gives 1); and the rewritten checks pin the requirement rather than the constant - CLEAR_CYCLES red at 1 and at 51, REASSERT_S red at <=5 and at >=300.

Author's six mutations all reproduced red, including the cross-suite one where removing the event-log coalescing turns test_freeze red - this guard's log had been evicting SAFETY-18's METER FROZEN? row. Headline claim reproduced: keying the alarm on Decision.reason instead of the i_clamped/clamped booleans takes the 10.3 replay from 2 engagements to 0. AC2 verified with an independent saturation-without-clamping scenario: sat_count 10 at 21.6% of the rating, zero clamp engagements.

Two reviewer mutations survive, both recorded and neither blocking: describe() dropping the bound name (the bound field itself is pinned), and abs() on limit_w (unreachable under the int(100,5000) schema).

First merge in this repository to pass through CI-01's gate rather than around it: 46383c5 merged release/1.0 in so the required context could run at all, and checks / unit (pull_request) reports success in 2m32s.

Note for TEST-06 (#14), which rebases behind this: it conflicts in BOTH app/main.py and test_freeze.py, and its 8 red assertions are its own service-keyed notification counting - keying those four call sites on notification_id turns the whole suite green against this app/ with no change here.
2026-08-30 17:22:30 +02:00
admin 5e966ca59f CI-01: this repository's first automated check (#13) 2026-08-30 16:00:42 +02:00
admin 0cdaa3bd3e SAFETY-18: alarm on a meter frozen at a wrong-but-fresh value (#11)
A meter answering 200 OK with a document that never changes reads healthy to every freshness mechanism in the stack while the loop rails. Detected by a conjunction - unchanged_s >= meter_freeze_s AND |command| within 5% of max_w - which fails in both directions for independent reasons. Alarm only: no claim, no latch, no 0 W.

Reviewed: all six author mutations reproduced red (redder than reported), four more added, three survivors recorded for follow-up. Capture figures re-derived independently. Attack point 4 (dead poll task) measured, not just reasoned. Known follow-up: a device rating that clips max_w by >5% silently disarms the rail term while the status page says armed - does not affect the live install, where dev_max == max_w == 5000.
2026-08-30 03:20:38 +02:00
admin b0a17474a3 MAINT-04: refuse a maintenance charge the ceiling cannot deliver (#10)
Rule 6 and the default change verified; four claimed mutations plus six of my own all reproduce red. The cross-file default divergence is now pinned by a check that fails in both directions, including when someone closes it - verified against MaintConfig-only, one-call-site-refactored, both-refactored and one-literal-silently-changed.
2026-08-30 03:01:14 +02:00
admin bb64f8fdd7 SAFETY-02 follow-up: persist the latch, and make the gate assertion bite (#9) 2026-08-28 23:09:27 +02:00
admin 2513ba1f12 SAFETY-02: the sign-disagreement watchdog (#8) 2026-08-28 21:55:52 +02:00
admin 35856a73ae SAFETY-12: a loop that only thinks when the number changes cannot fail safe (#7) 2026-08-28 21:18:20 +02:00
admin e2e331ea54 CFG-01 AC2: refuse to start on a config the lower layer will silently override (#6) 2026-08-28 20:17:55 +02:00
adminandClaude Opus 5 1b85eb41ad Handover doc: state, known breakage, and what to do next
Written for a cold start. Covers what is actually running (0.2.0, on a real
battery), how to deploy without falling into the version-bump trap, the four
things that are known broken, and the ranked next steps.

Leads with the two facts most likely to cause harm if missed: never run the
add-on and the old YAML packages together, and a "-20"/G2 inverter cannot be
driven by this at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
2026-08-23 02:51:34 +02:00
adminandClaude Opus 5 017b798fe6 Precedence arbiter: one rule instead of an if/else ladder
Two controllers writing one actuator is the failure this system exists to
avoid. "Exactly one writer" was true, but only as a convention held up by
careful reading - which does not survive an EV charger and a heat pump wanting
the same battery.

Strategies now return claims and arbiter.py resolves them:

  highest-priority `set` wins (none at all means 0 W), then every `limit` whose
  priority is >= that set's applies, most restrictive first; contradictory
  limits command 0 W and are flagged as the bug they are.

The second clause is the whole point. "Money outranks maintenance" used to be a
hand-written exception inside a Jinja template; it is now a consequence of the
priorities - the charge-only limit binds the loop but cannot bind a
higher-priority peak claim.

Also: maintenance shaping moved out of control.py, which is a controller again
and not a policy engine; the loop now tracks the arbiter's actual output rather
than its own last wish, so it does not jump when it regains control; and every
decision explains itself ("loop -> 0 W, limited by maintenance(charge-only)")
in the UI and the log.

19 new assertions in test_arbiter.py, each one a precedence question someone
will eventually ask in the field. Deployed to the reference site as 0.2.0 and
holding grid within a few watts of zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
2026-08-23 02:45:32 +02:00
adminandClaude Opus 5 36cc837446 Point the manifests at the real repository URL
Also documents the two things that stop an install dead: the repository must be
public for Home Assistant to clone it anonymously (a private repo shows nothing
in the store and gives no useful error), and config.yaml's `version` must be
bumped for every change or Supervisor reuses the previously built image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
2026-08-23 02:33:22 +02:00
adminandClaude Opus 5 594f8f9fc9 Six silent failures found by installing this on a live system
Migrated the reference site off the YAML packages and onto the add-on. Every
bug below presented identically: the add-on starts, logs "started", serves its
UI, and cannot do its job.

- run.sh needs #!/usr/bin/with-contenv sh. s6-overlay sanitises the environment
  for services, so a plain shebang means SUPERVISOR_TOKEN is absent and every
  Core API call is 401 - while homeassistant_api: true makes permissions look
  granted. Startup now prints the token length and probes the API.
- Supervisor keys the image by config.yaml `version`, so rebuilding without a
  bump reuses the old image. Two fixes appeared not to work because of it.
- Alpine is musl and has no aiohttp wheel on PyPI; deps now come from apk so
  nothing compiles on a client's Pi.
- Alpine ships paho-mqtt 1.x, which has no CallbackAPIVersion. That raised at
  construction and took the control loop down with it - so MQTT setup is now
  wrapped too. Observability must never be able to stop the controller.
- MQTT discovery is published from on_connect: paho silently drops QoS-0
  publishes issued before the CONNACK, so the previous code announced nothing
  while logging "MQTT connected".
- Repeated failures now log once a minute. Six warnings a second rolled the log
  buffer and destroyed the startup diagnostics needed to find the 401.
- auto_start could never fire, because the store's defaults always supplied
  auto: False for the fallback to find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
2026-08-23 02:25:58 +02:00
adminandClaude Opus 5 1abca15520 Compatibility gate: check the serial tag, not the model name
Platform is identified by characters 6-8 of the serial number, and only
platform 105 (ES/EM/BP - ESU EMU ESA BPS BPU EMJ IJL) speaks the AA55-era
meter bus this product emulates. The reference unit is 95000BPS225W0290 -> BPS.

Everything with a "-20"/G2 suffix is a different platform: SBP G2 (SPB/SPN) and
ES G2 (ESN/ESC) are platform 745, ET/EH/BT are 205/745/753. PV-only inverters
(SDT/DST/MSU/NSU) have no battery to control at all.

Added as the first item in Step 0 of the field guide, because it is a 60-second
check that decides whether the job can be quoted, and as a table in the README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
2026-08-23 01:56:20 +02:00
adminandClaude Opus 5 266684bed7 Field guide as a 10-page A4 PDF, one page per step
Rebuilt the guide as something usable on site rather than a document to read
at a desk: strict order of operations, DO / VERIFY / IF NOT blocks per step,
pass-fail gates in a table, a printable sign-off sheet, and troubleshooting
keyed by symptom rather than by subsystem.

Includes real screenshots of the add-on's own Web UI - captured by running it
against stub data - showing the healthy state and the misconfigured one side
by side, because telling those apart at a glance is the single most useful
skill on site. Plus the ESPHome step from a live builder.

The text lives only in docs/build_guide.py, which renders the HTML and drives
headless Chrome to produce the PDF. One source: a Markdown copy would
inevitably drift from the PDF someone is holding in a cellar.

Pagination is enforced, not hoped for. Each section must render under the A4
printable height (1039 px at 96 dpi) or Chrome silently spills it onto a second
page and the page numbers stop matching the step numbers. Measured every
section rather than eyeballing it: the Web UI reference page was 1170 px and is
now 1017 px, and the PDF comes out at exactly one page per section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
2026-08-23 01:44:08 +02:00
adminandClaude Opus 5 0a1e61dbc9 Packaged as a Home Assistant add-on, with a field guide
Turns the reference RS485 controller into something a technician can install
at a client site: a typed config form instead of YAML, an ingress UI that
names misconfiguration in words, and persistent state that cannot be broken by
a timezone.

Why an add-on rather than YAML packages or blueprints:

- Blueprints cannot create helpers, and the maintenance cycle is a state
  machine whose phase and completion date must survive restarts.
- YAML packages need filesystem access, a configuration.yaml edit and a
  restart - none of which belong in a client install.
- Add-ons authenticate with SUPERVISOR_TOKEN, so there is no long-lived token
  to generate, store or leak on someone else's machine.
- Requires HA OS/Supervised. Container and Core installs cannot run add-ons at
  all, which is a market decision, not an oversight.

The control law and the maintenance machine are pure functions with no Home
Assistant imports, and both ship with runnable checks (22 and 22 assertions).
Every assertion corresponds to a rule whose absence caused an observed failure
on hardware - the saturation duration term, the clamp-before-slew ordering, the
deadband, the sign convention.

One behaviour deliberately differs from the implementation it replaces: when
its inputs go missing this commands 0 W rather than replaying the last
setpoint. The reference version kept replaying, which the hardware watchdog
cannot catch - from the ESP32's side, Home Assistant is still talking to it.

Includes the ESPHome firmware (now parameterised: node name, inverter rating,
watchdog timeout) and the optional RS485 e-stop. FIELD-GUIDE.md carries the
commissioning gates, all judged on the wire rather than on how Home Assistant
looks, plus the written statement a site without an e-stop needs signed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
2026-08-23 01:15:25 +02:00