safety-81-load-too-high
436
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
074157c4c2 |
CI-9: drop the pull_request branches filter, because a skipped check clears the gate
checks / unit (pull_request) Successful in 7m57s
`branches: [release/1.0, master]` meant a stacked pull request - one based on another feature branch - never matched. Gitea does not stay silent about a workflow it sits out: it posts the required context itself with state `skipped`, the combined-status rollup reports that as `success`, and the merge gate accepts it. Retargeting does not repair it, because the skip is a commit status on the head sha and so survives the base change. Measured on Gitea 1.27.3 with throwaway PR #94 against a branch carrying a copy of release/1.0's protection rule: only status is `skipped` -> POST .../merge 409 head out of date (CLEARED) no status at all -> POST .../merge 405 not all required (refused) status `pending` -> POST .../merge 405 not all required (refused) status `success` -> POST .../merge 409 head out of date (CLEARED) The 409s come from a deliberately wrong head_commit_id in the probe, which Gitea checks only after branch protection - so a 409 means the request had already cleared the gate, and nothing was ever merged. `skipped` is the only state that leaks. Fixing this at the gate is not possible. Gitea branch protection can require that a context is PRESENT, never that it is SUCCESS: its whole status-check vocabulary is `enable_status_check` and `status_check_contexts`, per this server's own /swagger.v1.json. So the fix has to be upstream - never emit a skip - which means no event filter. Cost: every pull request now runs the full ~8 minute suite, including the intermediate steps of a stack. 49 of the 50 pull requests in this repository's history were opened straight against release/1.0 and would have run anyway. Also corrects two things CI-01's header stated as fact. A filter miss is fail-OPEN, not the fail-closed pending-forever it described; and it is a skip-ci keyword, not a filter miss, that actually withholds the context and leaves it pending. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
186a044a3a |
Merge pull request 'SAFETY-48: the slew step ceiling reaches the integrator, and what it bounds is the LEAD' (#93) from SAFETY-48 into release/1.0
Reviewed on the pull request and merged there. Retargeted from TEL-10 to release/1.0 after #92 merged, and NOT merged on the inherited green: retargeting alone left the old `skipped` conclusion attached to
|
||
|
|
660d079bf3 |
Merge pull request 'TEL-10: the loop's integrator stops being invisible' (#92) from TEL-10 into release/1.0
Reviewed on the pull request and merged there. `checks / unit (pull_request)` run 798 is a real execution, not a skip: all five steps ran and "the add-on's own tests" occupied 08:04:51 to 08:12:38 UTC.
CONTROL BEHAVIOUR IS UNCHANGED, VERIFIED MECHANICALLY RATHER THAN READ. goodwe_controller/app/control.py is blob
|
||
|
|
a6af332048 |
SAFETY-48: what the lead cap actually does to a frozen integrator, and what the peak figure depends on
checks / unit (pull_request) Successful in 7m55s
Four review findings, none of them in executable code. The control law, the tests and the instruments' arithmetic are untouched; every number below was re-measured against the committed 16-day capture before it was written down. R1 - app/control.py carried a justification the decision record and MUTATION.md habit 22 had already retracted, and it was false about the code it sits in. It said an unconditional placement "would also pull the integrator toward a command the SATURATION freeze is pinning", priced at 118 of 256 250 ticks. The 118 was measured against a different pair; on the pair the sentence names the two forms are bit-identical - 0 of 256 246 ticks, re-measured. And the narrow form that shipped DOES move a saturation-frozen integrator: it runs before the freeze block and is not gated on `frozen`. Of 79 160 frozen ticks the cap changes `i_w` on 1095, and on 679 of those it moves it further out on the side it was already on - putting back magnitude the freeze had just let fall, which the freeze's own rule forbids. Worst case 2424 W, at prev_w -3400 W, grid +4697 W, actual -4144 W, sat_count 2 -> 3: the freeze leaves the integrator at -176 W and the cap drags it to -2600 W. It never overrides a same-cycle refusal (the freeze clipped on 75 512 ticks, the slew bound on none of them), and that is stated too. It is also where SAFETY-13's settle values went: gating the cap on `not frozen` puts test_control.py part 5 back at -1190 W exactly, while part 4(b) does not come back - that one moved for the winding reason. R3 - the record read its own sensitivity table backwards. `fit_plant()` picks lag 7.5 s on battery-power RMS, which separates it from 2.5 s by 0.3 %, while the tool's own printed fidelity measure (export against the recorded 29.43 kWh) prefers 2.5 s by the whole 35 % gap. Swept properly: at every lag <= 5.0 s release/1.0's worst quarter is ALREADY 3434 W and this card changes it by exactly zero. The 3739 -> 3434 W headline exists at one plant. New section 3a carries the table; the export reduction now leads every summary and the peak is presented as lag-dependent. The form-A rejection is strengthened rather than weakened: form A is worse on the billed quarter at every lag where release/1.0 produces a peak at all, and at every top-N cut of the quarters. R2 - DOCS.md stated modelled watts to the owner as measurement. It now leads with the export reduction, says the figures are simulated, and says plainly that no capacity-tariff improvement is promised. R4 - tools/slew_sim.py --slew narrated "the OVER-ABSORPTION does not move, and at the slowest slews it is slightly WORSE" while printing a monotonically falling column. That sentence is TRUE on release/1.0 (reproduced: 958/1107/1133/1058/987/1029/1150/1143/1081) and false under this branch's law (777/734/663/563/553/507/435/358/321), so SAFETY-39 is not retracted: its record gets a forward reference, and the script now reads SLEW_LEAD_STEPS out of the law it imported and narrates from the column it just printed. Both branches of that narration were driven. R5 - three counts in the L table were decoration and are re-driven in the CI image on a `cp -a`, purged, `:ro`, one file at a time: L5 is 5+5 with the function names exchanged and 5+4 with the branch bodies exchanged (two mutations wearing one name, now listed separately), L6 is 6+3. The separable- state count is grid-dependent and three sweeps disagree, so all three are quoted with their grids and the load-bearing claim is WHERE they separate. R7 - "a large import answered too weakly" is not what the aggregate shows. Form A cuts import by 5.45 kWh and export by 5.18 kWh, and paired per tick delivers 8.56 kWh less offtake energy on the ticks release/1.0 was importing. The harm is a tail redistribution inside quarters that already net to offtake. The conclusion stands; the sentence is narrowed to it. Verified: the full suite in the CI image, purged tree, `:ro` mount, one file at a time, exactly the command in .gitea/workflows/checks.yml - 30/30, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
03ef61fa3b |
SAFETY-48: the slew step ceiling reaches the integrator, and what it bounds is the LEAD
`compute()` freezes the integrator with an asymmetric rule and the comment beside it states the requirement outright - "the requirement is that the INTEGRATOR stop accumulating, not only the command" - but `frozen` is computed from ONE signal, `abs(prev_w - actual_w) > saturation_w`, and slew-limiting never enters it. The integrator moves `gain * error` a cycle and the command moves at most `slew_w`, so the integrator outruns the command whenever `|error| > slew_w / gain`: 667 W at the live shape, against a measured median grid error of 1114 W at the onset of a recorded episode. SAFETY-39's retune made it MORE frequent, not less - a smaller step is a lower threshold. ⚠️ THE FORM THE CARD ASKED FOR RAISES THE BILLED QUANTITY, WHICH IS WHY IT IS NOT WHAT SHIPPED. Reusing the asymmetric freeze on a carried slew flag was built from release/1.0's own source and measured over 16.00 days / 256 252 meter instants of the live install's recorder: export 39.61 -> 34.43 kWh, and the worst quarter-hour average OFFTAKE 3739 -> 3935 W with the mean quarter +2.73 W, in five of six plant and tuning variants. A frozen integrator stops at `prev + gain*error`, which undershoots by (1 - gain); for an export that is free and for an import it is offtake, for as long as the ramp lasts. The card said "do not invent a second rule"; the measurement says to, and both sentences are recorded. WHAT SHIPS BOUNDS THE LEAD AT ONE SLEW STEP, inside the branch where the slew actually bound. Same 16 days, same ticks, same disturbance, same plant, same seed: export 39.61 -> 34.07 kWh, wasted throughput 4.67 -> 3.19 kWh, battery sign reversals 2992 -> 2770, median recovery 232 -> 199 s, p99 849 -> 850 s, max 901 -> 898 s, unrecovered 53 -> 31, worst |i_w - prev_w| 9200 -> 800 W - and the worst 15-minute average offtake 3739 -> 3434 W, i.e. the billed quantity IMPROVES. `i_w = slewed` (a cap of zero) is better again at 32.02 kWh and is refused: it is the velocity form this module's docstring says `i_w=None` collapses to. TWO COMMITTED NUMBERS MOVED, DELIBERATELY, WITH THE REASON BESIDE THEM. SAFETY-13's released command settles at -800 W and -1200 W where release/1.0 settled at -1190 W and -2390 W, because there is less wound integrator to settle onto; AC 4's property - it stops at the frozen integrator, not the rail - is what is asserted and the second is now graded against `i_w` rather than a literal. And SAFETY-39's "the slew does not own the over-absorption" is now FALSE - INVERTED, on the independent first-order plant test_control.py writes itself: 1005/1109/1285/1330 W rising with a slower slew becomes 729/550/424/326 falling. Two plant models that share nothing agree on the sign. Evidence: `tools/replay_recorded_loop.py` plus the read-only capture it made (scenarios/loop-2026-09-02_2026-09-17). The "before" law is the shipped file with the block removed by a named substitution - never a transcription - and a missing anchor is a REFUSAL carrying "NOTHING HAS BEEN MEASURED". The instrument prints its own fidelity first: it reproduces 39.6 kWh of export where the install exported 29.4, i.e. it is ~35 % pessimistic about the loop's tracking, so only the delta is claimed. 30/30 suites green, including test_control.py's exhaustive i_w=None equivalence sweep against the transcribed release/1.0 law, untouched: the cap moves `i_w` and not `target`, and that sweep compares (target_w, sat_count, frozen, reason). Six law mutations, one instrument battery. L4 - the cap applied unconditionally, -92 B - was a SURVIVOR of all thirty files: it is bit-identical on the shipped configuration (0 of 256 246 ticks) and separable on 2050 of 257 400 swept states, all at `integrator_max_w` above `max_w`, which part 4 now drives. L5 and L6 are ZERO BYTES. AND TWO COMMENTS IN THIS CARD WERE INVENTED AND ARE CORRECTED IN PLACE. The one-sided spelling was justified by a two-sided form that would "drag a trailing integrator" - it does not, and the two agree on 205 920 of 205 920 swept states; and a "118 of 256 252 ticks" figure had been measured against a different pair. MUTATION.md gains habits 21 and 22 for both shapes. NOT DEPLOYED, NOT VERSIONED. The live install still runs release/1.0's law. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
b6c6da31d8 |
TEL-10: two sentences that named assertions this file does not make
checks / unit (pull_request) Successful in 7m57s
Both are review findings on the merged-in-spirit branch, and neither is in executable code. R6 - test_mqtt.py part 1's headline said "every one of release/1.0's NINE messages is still byte-identical and still first". The assertion under it is `announce(...)[:len(BASELINE) - 1] == BASELINE[:-1]`, which covers EIGHT: the ninth, `goodwe_ctl/availability`, is byte-identical but deliberately moves to last, so a prefix comparison cannot include it. Nothing was unguarded - the `== SITE_NOW` on the next check pins it in its new position - but the sentence was not the check. Part 3 carried the same mis-statement two lines from a check that IS about all nine, and is corrected the same way. R5 - M7's red count was decoration. Re-driven in the CI image on a `cp -a` of the tree, purged, `:ro`, one file at a time, it is 10 reds in test_mqtt.py and not 8, on this tree and on SAFETY-48's alike. The direction was right; the number was inherited rather than re-measured, which is MUTATION.md's reporting rule 3. A note beside the table now says that a red count is a property of the SUITE as much as of the mutation and therefore has to name a tree. Verified: the full suite in the CI image, purged tree, `:ro` mount, one file at a time, exactly the command in .gitea/workflows/checks.yml - 30/30, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
b548525d10 |
TEL-10: the loop's integrator stops being invisible
`self.i_w` has been a separate quantity from the command since SAFETY-04 and has left the process nowhere at all - no entity, no MQTT attribute, no log line. The investigation behind SAFETY-48 could only RECONSTRUCT it from the grid trace plus a replay; the reconstruction reproduced about a third of the engagements it was checked against and was discarded, so the card it was gathered for had to be argued from a measurement nobody could stand behind. It could not have been made to work either: `i_w` depends on every cycle before it, including the ones where the arbiter overrode the loop and `main.py` re-seeded `self.i_w = self.target`, and that re-seed is invisible in the grid trace. THREE SITE ROWS, APPENDED AFTER release/1.0's EIGHT. `loop_i` is `Decision.i_w` - the accumulator after the cycle, before the output clamp, the slew limit and quantisation. `loop_lead` is `decision.i_w - prev_w` against the command `compute()` was HANDED, which is not a subtraction of the other two published rows: `Setpoint` is the arbiter's output and is this cycle's, so on any cycle the arbiter overrode the loop the two differ - and that is exactly the cycle on which the integrator was re-seeded. `loop_reason` is `compute()`'s own word; the arbiter's `explain()` prints the winning claim's NAME and has never carried the claim's reason, so `slew-limited` appears in neither the `Why` row nor `Controller status`. Measured on the same cycle: arbiter `'loop -> -5000 W'`, loop `'i-clamped'`. NOTHING IN THE CONTROL PATH MOVED. `app/control.py` is not in this diff. `compute()`, its returned `Decision` and every constant it reads are identical to release/1.0, and all nine of release/1.0's retained discovery messages are byte-identical and still FIRST in the announcement. Evidence: 30/30 suites green in the CI image. Nine mutations, ONE survivor, reported as one: `decision.i_w - self.target` for the lead is +2 B and green because `cycle()` has not reassigned `self.target` at that line - the name is kept for the coupling, not for a behaviour it buys, and M2b (+8 B) is the mutation that does change the value and is red. AND TWO OF THIS CARD'S OWN CHECKS COULD NOT REACH THE FAILURE THEY NAMED. "the published integrator reaches its bound" was GREEN under the card's named mutation (publish `prev_w`, -3 B), because the command reaches the bound too - twelve cycles later. It asserts the LEAD TIME now and is red on 5. "a stopped controller publishes not-running" was GREEN under deleting that branch's assignments (-45 B, -67 B), because the fixture STARTED stopped and `__init__` writes the same values; it stops the controller part-way through a run that has already wound the integrator to -5000 W. Both are MUTATION.md habit 4, found by the battery rather than by review. No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## ` heading that is not config.yaml's `version:`, and opening one would mean taking a packaging decision this card does not get to take. The operator account is in DOCS.md and the release note belongs to the next PKG ticket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
68c5ede7b9 |
Merge pull request 'PKG-14: cut 0.10.0, so fifteen merged tickets can reach an install' (#91) from PKG-14 into release/1.0
config.yaml said 0.9.0 while the live box runs 0.7.0 with 0.7.0's files, so it has never received 0.8.0 or 0.9.0 either. 28 runtime commits had landed since the 0.9.0 bump at |
||
|
|
cb3f686555 |
PKG-14: cut 0.10.0, and say what an upgrade from 0.7.0 crosses
checks / unit (pull_request) Successful in 7m51s
`config.yaml` 0.9.0 -> 0.10.0, and the top `## Unreleased` block is closed
into `## 0.10.0` with a preamble that names the backward-incompatible
changes and who they reach.
MINOR, and NOT because eighteen tickets landed. A stored options blob the
previous version started on is REFUSED on this one:
* TARIFF-07 retires three `price_provider` ids; a site set to one is
refused BY NAME at startup. Measured on the reference blob, with a
control on an accepted id so the refusals are about the ID.
* TARIFF-18 moves `price_taxes_eur_kwh` 0.06 -> 0.11736. A site that
STORED 0.06 is untouched, and - the non-obvious half - a taxes period
table beside that stored 0.06 does NOT become a startup refusal on
upgrade, because `SUPERSEDED_TAXES_DEFAULTS_EUR_KWH` reads it as
untouched. Measured, with the counterfactual that the rung is not
weakened (ORES's 0.1198 is still refused).
* SAFETY-10 adds `shadow_mode`, shipped `auto`. An existing CONTROLLING
install does not change by one watt and a FRESH one comes up observing.
Measured cross-revision,
|
||
|
|
587495e143 |
Merge pull request 'SAFETY-47: say what a shadow run can and cannot show' (#90) from SAFETY-47 into release/1.0
DOCS.md, app/shadow.py's header and DECISIONS/SAFETY-10 all claimed the strategies ask for a fixed power from inputs that do not depend on what the battery did, so a shadow run shows you the real trades. That was true when shadow mode was designed and false by the time it merged: SAFETY-45 landed hours earlier and made the buy leg min(asked, headroom), where headroom = (900/remaining) x (ceiling - projected) and `projected` reads the open quarter's accumulator - the meter, which is exactly a function of what the battery did. The strategy became closed-loop and the exemption went stale. Measured at a binding cap, 1500/1200/2000, the same hour twice: live grid 1224-1506 W held at the ceiling against a flat 1200 W in shadow; SoC 50.000019 -> 51.856789 % against 50.000000 -> 50.000000; the two runs differ on 716 of 720 cycles; the final recorded figure is -302.4 W live against -2000.0 W in shadow, 6.61x. On a SAFETY-45-reverted tree the limit cycle is 28 onsets live against 1, guard engaged 410/720 against 0/720. Two of the three defects the card cites as motivation are closed-loop phenomena shadow cannot exhibit; TARIFF-14's half genuinely is visible, hour and price and spread identical 720/720. THE CAVEAT'S DIRECTION WAS MEASURED RATHER THAN WRITTEN, and the ticket had it wrong. A draft saying a live run reaches a SMALLER number was checked pairwise over 3600 charging cycles in five configurations: the shadow record is never below live - zero cycles below and 730 EXACTLY EQUAL, at the start of each quarter where the amplification is 1.0. `Smaller` would have been false on 730 rows. The shipped sentence says UPPER BOUND and the two-point arithmetic behind it is graded. Caveated rather than re-computed, with the rejections recorded: re-computing would make the recorded decision no longer the decision the shipped path reached while arbiter, clamp, the SAFETY-28 gate, the log and MQTT all ran on the real one - a second controller everywhere except the wire; recording nothing blanks the row exactly when arbitrage trades, and the log's edge key IS the watts; recording the unsized ask beside it adds a second number with no warning, and at the shipped 3500/2000 both read 2000 W. The trap avoided: keying the caveat on `capped` would fire at the quarter's START, where the record is right, and go SILENT at the end, where it is 6.61x out. It keys on the headroom having been MEASURED. AND THE TICKET'S OWN ANALYSIS WAS INCOMPLETE. I said the committed evidence table could not fail for two reasons. They are independent in the FIX direction too: widening the fixture to a binding cap still leaves the comparison passing, because it reads index zero and the first charging cycle is -300.0000 W in both runs. Fixing only the fixture closes nothing. Recorded in MUTATION.md as a new habit-4 shape - an INDEX, not a fixture - and the agreement at index zero is now graded as an expectation so nobody rebuilds on it. Evidence: 29/29 green. Fourteen mutations, no survivors. One was a REAL survivor first - deleting the caveat from the action log left all 29 files green, because the sentence was an f-string in _shadow_settle() and the only suite that drives it runs with meter_source: off; it is shadow.log_line() now. One CORRECTLY survived and is reported as such: min -> max makes the record larger, which is still an upper bound, so it is not the inversion that matters. And the new section nearly shipped FCST-06's shape - a mutation made it raise on its first min() and report zero reds - so every check now carries the section's precondition. Two design limits stated rather than smoothed: the caveat discloses but does not help, since an engineer still cannot obtain the real number without arming the battery - the fix for that is a rig mode replaying the site's own captured meter through the shipped decision path, filed separately and deliberately NOT by giving shadow mode a plant model, which is the one change that would break its safety argument. And SAFETY-10's takeaway still oversells `auto`, which this card did not rewrite. |
||
|
|
e3ccd9f6ea |
SAFETY-47: what a shadow run can show, and the watts on its status row
checks / unit (pull_request) Successful in 7m48s
Not a leak and not a write-path defect. Shadow mode's safety is intact -
the substitution, the re-seed, the authorisation gate and the clamp are
untouched. What was wrong is what three merged documents SAY a shadow run
can show you, and a number on the status row that can be 6.61x what a live
controller would command.
SHADOW MODE CANNOT SURFACE A CLOSED-LOOP DEFECT, AND THE DOCUMENTS NOW SAY
SO. The battery is held at 0 W and the carried command is re-seeded to it,
so every measured input - the meter, the billed-quarter accumulator behind
it, the battery power, the SoC - describes a plant that did not respond. A
closed-loop defect is one whose stimulus IS that response. This is the same
property that makes shadow mode safe, so it is not a gap to close later:
the closed-loop answer comes from the closed-loop rig or from a live run.
SAFETY-10 §5 already conceded this for the grid-following loop. It was
never the loop leg's limitation; it is the general case.
THE "STRATEGIES ARE UNAFFECTED" EXEMPTION WAS TRUE UNTIL SAFETY-45 MERGED,
ONE COMMIT EARLIER. That card sizes the buy leg as min(asked, headroom)
with headroom = (900/remaining) x (ceiling - projected), and `projected`
reads the open quarter's accumulator - the meter - which is a function of
what the battery did earlier in that quarter. Measured on TEST-23 stage 3
at peak_cap_w 1500 / house 1200 / max_w 2000, one hour at 5 s:
SAFETY-45's limit cycle NOT visible. 28 onsets live vs 1; grid
-455.5..+3200.0 W vs flat at the 1200 W house;
guard engaged 410/720 cycles vs 0/720.
SAFETY-46's SoC churn NOT visible. The SoC moves 0.000000000 pp.
TARIFF-14's wrong hour VISIBLE, and it is the only one: which hour, at
what price, clearing what spread - identical on
720 of 720 charging cycles at a binding cap.
The exemption narrows to exactly what survives: WHICH hour a strategy picks
and WHY are unaffected; HOW HARD it trades is not. The peak guard's ceiling
and reasons are worth reading from a shadow run; its ENGAGEMENTS are not.
THE NUMBER IS CAVEATED, NOT RE-COMPUTED, AND THAT IS THE DECISION. The
arithmetic is not wrong - 900/remaining is correct for a meter genuinely
under the ceiling, and in shadow the meter genuinely is. It is the right
answer to a question nobody asked. Both surfaces now introduce the watts as
what would have been commanded ON THIS CYCLE, in every state, and carry
shadow.SIZED_FROM_METER whenever a measured billed quarter sized them.
Rejected, and recorded in the decision doc: re-computing it (the recorded
decision would no longer be the decision the shipped path reached, while the
arbiter, clamp, SAFETY-28 gate, log and MQTT all ran on the real one - a
second controller everywhere except the wire); recording nothing (blanks the
row exactly when arbitrage is trading, and the log's edge key is the watts);
and recording the unsized ask beside it (a second number, no warning, and at
the shipped 3500/2000 both figures read 2000 W).
"UPPER BOUND" AND NOT "SMALLER", AND THE DIFFERENCE WAS MEASURED. Charging
only adds offtake, so the shadow size is >= the live size with EQUALITY at
the start of every quarter. Pairwise over 3600 charging cycles in five
configurations: 0 below, 730 exactly equal. The first draft said "smaller",
which would have been false on every one of those 730.
AND THE PREDICATE IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED. arbitrage's
own sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" only when the
headroom came out below the ask - in a shadow run, the START of the quarter,
where the two runs agree to the float. By the end, 900/remaining has
multiplied the headroom past the ask, the trade is not capped, the sentence
is silent, and the record is at its most wrong. A caveat keyed on it would
be quietest exactly where it is needed loudest.
SAFETY-10 §6's COMMITTED EVIDENCE COULD NEVER HAVE FAILED, FOR TWO
INDEPENDENT REASONS, AND CLOSING EITHER ALONE CLOSES NOTHING. Its fixture is
peak_cap_w 15000 against a 350 W house, so the guard never engages and the
sizing is inert - the harness's own comment says so. AND every identity row
reads the FIRST charging sample, where at elapsed=0 the projection IS the
held level and the two runs agree to the float even at a cap that does bind.
Measured: widening the fixture alone leaves that comparison passing. That
second reason is a new shape for MUTATION.md's habit-4 list - an INDEX, not
a fixture - and it is recorded there as the twelfth instance. §6 is re-taken
at a binding cap across the whole run: 716 of 720 charging cycles differ,
headroom 300.0..54000.0 W in shadow against 299.9..307.8 W live, recorded
-2000.0 W against a live -302.4 W.
THE GRADER IS test_arbitrage.py PART 3d, beside SAFETY-45's own closed-loop
regression and built on the same s45_loop() harness, so the runs differ in
one argument. Three lines stand in for _write_unit()'s substitution and
_shadow_settle()'s record and re-seed, marked as a transcription;
test_clamp.py part 8 remains the check on the shipped placement. The section
carries its own precondition into every check: a mutation that stopped
either run trading made it raise on its first min(), kill the file and
report ZERO reds - FCST-06's shape, driven rather than imagined.
AND THE FIX'S OWN FIRST DRAFT CARRIED THE SAME DEFECT. Deleting the caveat
from the action-log line left all 29 files green, because the sentence was
an f-string inside _shadow_settle() and the only suite that drives it uses a
meter_source:off fixture where no arbitrage decision exists. It is
shadow.log_line() now, for the reason words() is a function.
14 mutations, no survivors; M13 and M14 are +0 B and were confirmed on disk
by sha256 with every run purged. M14's first form was aimed at the wrong
direction and correctly survived - min->max makes the shadow record larger,
which is still an upper bound. All 29 check files green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
e6da478f87 |
Merge pull request 'SAFETY-10: shadow mode, and 0 W is a number rather than a silence' (#89) from SAFETY-10 into release/1.0
The closed-loop rig made the case for this: with arbitrage armed and a binding cap the loop ran 28 charge onsets an hour (SAFETY-45); the price lookup returned the wrong quarter-hour for 45 minutes in every 60 (TARIFF-14); the SoC ceiling churns 598 Wh to gain 310 (SAFETY-46). Every one was invisible from outside, and there was no way to watch what the optimiser WOULD do without letting it do it.
THE TICKET SAID DEFAULT-ON AND THE AGENT REFUSED IT, correctly. The Supervisor resolves options as {**config.yaml defaults, **stored user options}, so a NEW key with a new default reaches every EXISTING install on the next update - including the one regulating the author's battery. `shadow_mode: true` would have stopped it silently, and the only symptom would be a battery that stopped doing anything. There is no mechanism by which one key defaults differently for a new install and an existing one - `options:` IS the default and there is one of it.
So the default is a value the add-on RESOLVES: shadow_mode: list(auto|on|off), shipped `auto` = shadow while control is STOPPED, live once STARTED. A fresh install ships auto_start: false, so it comes up in shadow and observes before it ever commands - default-on delivered where it is safe. An existing install that is controlling does not change by one watt; verified before merge, `auto` resolves to `not control_started`.
SECOND PUSHBACK, ALSO RIGHT: commands nothing is a NUMBER, not a silence. The inverter holds its last command forever, so a shadow mode that stopped writing would leave whatever was standing STANDING, under a page saying nothing is commanded - the exact looks-healthy-but-is-inert failure this repo keeps finding - and would hold the ESP32's 30 s stale-input watchdog tripped for the whole run, an alarm used as a mode. The actuator is held at 0.0 W, refreshed on the ordinary heartbeat, by the ordinary single writer, as a substitution inside the existing single choke point. Not a second write path. It is the LAST substitution, below SAFETY-28's gate and SAFETY-03's clamp, so both still grade the number the decision produced.
Every failsafe's behaviour is decided and recorded rather than inherited. The sign watchdogs are INERT because they grade standing_w(), held at 0 W - feeding them the decision would have them disagree with a meter that never saw it and LATCH A TRIP SURVIVING INTO LIVE OPERATION AND A RESTART. Clamp, write gate, meter freeze and peak guard OBSERVE the real decision. Circulation and delivery may legitimately latch, because their evidence is measured power. The operator stop is enforced at the wire rather than as a P_SAFETY claim, which would make every recorded decision read `stopped`. A standing latch is named in the shadow banner and turns it red.
Graded on the writes and the rendered surface, never on the flag, with the precondition asserted first: the same stimulus at `off` produces 30 non-zero writes with a largest of 5000 W; at `on`, set(writes) == {0.0} and 26 of them. Closed loop over the same hour twice: live -2000 W throughout, SoC 50.000 -> 62.331 %, grid to 2350 W; shadow 0 W throughout, SoC 50.0 -> 50.0 exactly, grid = the house exactly - with the arbitrage decision identical in action, instant, signed power, reason verbatim and arbiter winner across 3600/3600 samples.
Evidence: 29/29 green, CI green. Twenty-two mutations, no survivors, including a write escaping, silence instead of 0 W, observers blinded, default-on-for-everyone, the stop claim refiled, and a re-seed moved one line earlier which blinds SAFETY-18. Two mutation findings recorded: one first read as a survivor and was not, because a TypeError killed the section and reported zero reds - FCST-06's shape; and one had to become a PAIR because two fields both hold the detector at 0 W - TEST-16's shape.
Recorded limitation rather than glossed: the re-seed makes the exit safe by construction, and the price is that the loop leg's shadow decision is one slew step rather than a trajectory. Graded by name. The strategies are unaffected, which is why all three motivating defects remain visible.
|
||
|
|
8fa4febfef |
SAFETY-10: the whole decision path runs and nothing reaches the inverter
checks / unit (pull_request) Successful in 7m58s
Every optimiser defect this project has found was invisible from outside while it was happening, and each was paid for at the meter first: SAFETY-45's 28 charge onsets an hour against a binding peak_cap_w, TARIFF-14's wrong quarter-hour for 45 minutes in every 60, SAFETY-46's 598 Wh spent to gain 310 Wh. There was no way to watch what the optimiser WOULD do without letting it do it. `shadow_mode` runs strategies, arbiter, clamps and guards, records the decision with its reason, and commands nothing. ⚠️ IT WRITES. It writes 0 W, on the ordinary heartbeat, through the ordinary single writer. The inverter holds its last command forever - it has no meter timeout - so "commands nothing" implemented as "stops writing" would leave whatever was standing when shadow mode came on standing for as long as it was left on, under a page saying nothing is being commanded. That is the failure this repository keeps finding. Refreshing 0 W is also what firmware/goodwe-master.yaml's 30 s stale-input watchdog requires of us; going quiet would keep that board tripped for the whole run, which is an alarm being used as a mode. ⚠️ IT IS NOT A SECOND WRITE PATH. No claim, no transport, no arithmetic of its own. It is a substitution inside Controller._write_unit() - the single choke point every write already reaches - in the same form as the three above it, and it is the LAST of them, so SAFETY-28's gate and SAFETY-03's clamp still grade the number the decision path produced. ⚠️ THE DEFAULT IS `auto`, NOT `true`, AND THAT IS THE CARD'S REAL CONTENT. The Supervisor composes options as {**config.yaml defaults, **stored user options}, so a new key with a new default reaches EVERY EXISTING INSTALL on the next update - including the one regulating a real battery. A boolean default-on would have stopped it silently. There is no mechanism by which one key can default differently for a new install and an existing one, so "default-on for new" is a value the add-on RESOLVES: `auto` is shadow while control is STOPPED and live once it is STARTED. A fresh install ships `auto_start: false` and comes up observing; an install that is controlling is started and does not change by one watt. THE FAILSAFES. Sign watchdogs (SAFETY-02/30): INERT - they grade Inverter.standing_w(), which shadow mode holds at 0 W. Fed the shadow decision they would disagree with a meter that never saw it and LATCH A TRIP THAT SURVIVES INTO LIVE OPERATION AND A RESTART. Clamp (SAFETY-03), write gate (SAFETY-28), meter freeze (SAFETY-18), peak guard: OBSERVE. Circulation (SAFETY-31) and delivery (SAFETY-42): observe and may legitimately latch, because their evidence is measured power. The operator stop is the one deliberate change: enforced at the wire rather than as a P_SAFETY claim, which would otherwise make every recorded decision read "stopped". ⚠️ RECORDED LIMITATION: _shadow_settle() re-seeds the carried command to what stands on the wire, which makes the exit safe by construction - and therefore the loop leg's shadow decision is ONE SLEW STEP, not a trajectory. A shadow run answers "what would it command now", not "what would it have done next". The strategies are unaffected; all three defects above are visible from one. Graded on the writes and on the rendered surface, never on the flag: 22 mutations, no survivors, including one that lets a write escape and one that goes silent instead of commanding 0 W. Closed loop on TEST-23 stage 3's rig, same hour twice: live drives the pack to -2000 W and SoC 50.000 -> 62.331 %; shadow commands 0 W throughout, SoC stays 50.0 exactly, and the arbitrage decision is identical in action, instant, signed power, reason and arbiter winner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
8f82dd4e06 |
Merge pull request 'SAFETY-45: size the trade to the headroom instead of interlocking against it' (#88) from SAFETY-45 into release/1.0
With arbitrage armed and a binding peak_cap_w, the loop did not settle: 28 charge onsets an hour, period 131 s, grid swinging -455 to +3200 W, and a billed quarter 59 W OVER the cap it had just spent fifteen minutes holding. Section 10.2 is explicit that an EMS which oscillates is worse than no EMS, for hardware wear and for the peak it is trying to avoid. Not reachable by any unit test in the repo - it needed the closed loop. THE TICKET'S MECHANISM WAS WRONG AND THE AGENT DISPROVED IT. I wrote that the quarter boundary drives the cycle. Of the 27 gaps between the 28 onsets, 23 are 90-120 s entirely INSIDE one quarter; the four long gaps are exactly the four that SPAN a boundary, so the boundary lengthens the interval rather than causing it. A fix aimed at boundary handling would have removed 4 of 28. The real cycle is a binary interlock keyed on a continuous quantity: the trade charges at max_w while `wanted = batt_w + excess` is still negative and the guard files nothing; `wanted` crosses zero; the guard engages on a FIVE WATT floor; that flips guard_state(), which cancels the WHOLE trade; the grid-following loop - which targets grid = 0, not grid = ceiling - takes the actuator, discharges to +1870 W and exports 420 W; the projection collapses; the trade re-arms at max_w. So the fix sizes the buy leg rather than interlocking it. control.maintenance_charge_floor() already caps MAINTENANCE charging at the headroom under the site's cap, because a maintenance charge was the only thing big enough and long enough to set the peak - and that stopped being true when OPT-04 landed. peakguard.charge_headroom_w() publishes the same quantity off the MEASURED guard rather than the optional forecast entity, arbitrage.decide() takes it keyword-only and sizes the buy, main.py passes it. It is wanted_w read in the other direction: no new arithmetic and no new constant. THE GUARD'S DECISION FUNCTION IS UNTOUCHED, and that is the proof 2026-08-20's bang-bang did not come back. peakguard.py is +139/-0 - zero deleted lines - and sha256(decide) is identical on both trees at 7252 bytes; replay_ac3.py reproduces 190/190 records, 110 engaged, 878 Wh identically. Verified independently before merge. Reverting that rung is red on eight named checks. Rejected and recorded: a negative `lo`, because it creates a lo > hi contradiction against main.py's cheap-window maintenance shaping that resolves to 0 W - the battery parked in the billed quarter - and closing that would mean suspending maintenance shaping permanently on every affected site. And a backoff timer in arbitrage, because both modules' notes forbid inventing timers; the trade now re-arms at `ceiling - load`, which IS learning from the refusal. After, on the same rig and the same command: onsets 28 -> 1, grid swing 3655 -> 282 W, and the billed quarters land at exactly 1500.0 W three times over. At the shipped defaults the two affected rows buy ~2% less energy and the worst quarter goes from 59 W over the cap to zero - which is section 10.1's ordering, since the tariff bills the month's single worst quarter. On an aggressive cap the sized trade buys 58% MORE. The non-binding control row is bit-identical. Evidence: 29/29 green, CI green. Fourteen mutations, all red, two of them zero-byte. The regression closes the loop over the shipped guard, strategy, arbiter, control law and the real QuarterAverager, and asserts BOTH directions from one changed argument plus an inert-where-inert control. TWO DEFECTS FOUND BY THE DISCIPLINE RATHER THAN BY REVIEW. The agent's own harness restored two edits to one file in list order and left one on disk - caught by the post-revert fingerprint, habit 18 biting a tool written the same afternoon. And test_peakguard.py part 1 formatted floor_w with a bare :.0f one line after the check that reverting the 2026-08-20 rung turns red, so the file ABORTED and parts 2-13 went ungraded: that mutation's coverage was one check and is now eight. |
||
|
|
e1b9254626 |
SAFETY-45: a price-driven charge is SIZED by the capacity guard, not switched by it
checks / unit (pull_request) Successful in 7m42s
With arbitrage armed at the shipped defaults, arbitrage and the capacity-tariff guard drove each other in a sustained limit cycle. Re-measured on TEST-23 stage 3's closed rig against |
||
|
|
478ba93710 |
Merge pull request 'TARIFF-16: the injection leg gets its own coefficients' (#87) from TARIFF-16 into release/1.0
export_price_eur_kwh() modelled injection as one scalar. Eneco publishes 0,1 x BELPEX-H - 1,188 on the same card as its consumption formula, identical on the January and September 2026 cards; ENGIE publishes (B x eSpot_15) - A; Aspiravi uses 0,07 against ~0,10 on its own consumption leg. Reproduced against the shipped readers over one real BELPEX day: the published formula gives 24 distinct prices with a spread of 0.16710 EUR/kWh, the scalar gives 0.00000, and 5 of 24 hours are NEGATIVE - down to -0.03008 EUR/kWh - which are the midday hours a PV household exports in. The 6% VAT asymmetry is not wrong in the scalar, it is unstateable. THE CARD'S BEFORE-STATE WAS WRONG AND THE AGENT CORRECTED IT. I wrote that this affects what the operator is shown. It did not: export_price_eur_kwh() had ZERO callers under app/ - not arbitrage, not the status page, not MQTT. Verified independently before merge; the only references are its own definition, the defaults entry, FRAMEWORK_KEYS and two comments. So the honest before-state is weaker than the card claimed: the scalar was not a misleading display, it was an UNREAD OPTION. DOCS, CHANGELOG and the startup line say so, and a check grades it. An operator actually seeing their injection price is a separate ticket. Machinery reused rather than copied, which is TARIFF-17's move applied a second time: parse_export_formula() is parse_price_formula() with ONE argument moved, selecting a row of a _LEGS table, so everything that differs between the legs is a string and the units table, NaN rule, closed enumerations, unknown-key refusal, card comparison and DST handling are one implementation. Pinned by a check that parses every file under app/ and asserts all 32 grammar names are defined in exactly one module, plus an AST pin on the wrapper's body, plus twelve refusal sentences DERIVED from the consumption leg's own. A NEGATIVE INJECTION PRICE IS ACCEPTED AND AN IMPLAUSIBLE ONE IS STILL REFUSED, and the bound had to move to achieve it: implausible_all_in() floors consumption at +0.02 EUR/kWh of energy, which is fatal on a leg that is legitimately negative. The bound is now off the PRICE and onto the COEFFICIENTS, where it is index-free and sign-free - a pass-through band (0.10-10x; Eneco 1.00x, Aspiravi 0.70x) and an offset-magnitude bound (0.20 against published 0.01188 and 0.02). One spec is put through both bands and the answers printed: the consumption band refuses -0.03008, the injection band accepts it. The missing printed estimate is stated, not worked around. Eneco's injection line has no worked example beside it, so `card` is optional on this leg and unchanged on the other; where an estimate exists the self-check runs at the same tolerance, and where it does not validate.py logs that the coefficients are UNGRADED and names the two bounds that ran instead. A check MEASURES the resulting gap: a factor of 0.070 where the contract says 0.100 - 30% of every export payment - passes both bounds, because 0.70x is a pass-through somebody really publishes. The scalar path is genuinely unchanged: the function body is pinned on the AST and seven option dicts are compared against the pre-TARIFF-16 expression with ==. Evidence: 29/29 green, CI green. Twenty mutations, every one red on a named check. Giving the injection band TARIFF-15's floor is red in test_price.py and GREEN IN THE OTHER 28 FILES; so are the second-grammar and ignore-the-formula mutants. One is a single byte, one is ZERO bytes and moves no decision - legs transposed in the VAT banner, caught by an ordered-pair check. One is recorded for its SHAPE rather than its result: rerouting the scalar through the seam recurses, so the suite dies naming nothing, and a second mutation of the same property exists so a check can name it. Two sharp edges flagged rather than smoothed: `index` and `resolution` on the injection leg name a series nobody fetches, which an operator could misread as a promise to fetch it; and the coefficient bands are materially weaker than a card check, catching the factor-of-10 class and nothing else. |
||
|
|
995da777ce |
TARIFF-16: the injection leg stops being one number
checks / unit (pull_request) Successful in 7m42s
`price.py` modelled what a site is PAID for export as ONE scalar. The market publishes a line, and the cards say so in their own words: Eneco Zon & Wind Dynamisch `0,1 x BELPEX-H - 1,188` c EUR/kWh - "het injectietarief is dynamisch en kan per uur wijzigen", identical on the January and the September 2026 cards; ENGIE Dynamic `(B x eSpot_15) - A`; Aspiravi `0,07 x Belpex - 2`. `price_export_formula` carries that line with its OWN coefficients. ⚠️ THIS IS NOT A WRONG-TRADE FIX, AND SAYING SO IS PART OF SHIPPING IT. `arbitrage.py` does not read `export_price_eur_kwh` at all - its own comment says so, citing §9.3's near-zero Belgian injection price as the reason the economically sound trade on this market is charge-cheap-to-displace-expensive- IMPORT. Both hours it compares are all-in CONSUMPTION prices. No trade this add-on takes today is decided by an injection price and nothing here changes one. What it changes is what an operator is told, and what a future export-aware strategy rests on: OPT-06 and any export logic would otherwise inherit a scalar that cannot represent the market, and the 6% VAT asymmetry would be invisible in the arithmetic. test_price.py 17h parses every module under app/ and goes red the day something calls an injection reader, so that sentence is graded rather than attested. FOUR PROPERTIES A SCALAR CANNOT HOLD. Its own factor (Eneco 0,102 consumption against 0,100 injection; Aspiravi 0,07 against ~0,10 - not the consumption B with a sign flip). A negative offset, which `price_markup_eur_kwh: float(0,1)` cannot express at all. VAT-exemption - "injectietarieven zijn vrijgesteld van btw" - against 6% on consumption, representable only because `vat_pct` is PER SPEC. And a price that goes below zero with no floor, under 11,88 EUR/MWh of BELPEX-H, which is the middle of a sunny day. ⚠️ A NEGATIVE INJECTION PRICE IS ACCEPTED, AND THAT NEEDED A DIFFERENT BAND RATHER THAN A LOWER FLOOR. `implausible_all_in()` floors the consumption leg at +0.02 EUR/kWh of energy; applied to injection that refuses the real market in the hours it matters most. Measured rather than argued: 17c puts ONE spec through BOTH bands and prints that the consumption band refuses -0.03008 EUR/kWh while the injection band accepts it. The floor was not decoration - it is what catches a card read a hundred times too small, the one unit error the card self-check cannot see because it cancels out of a comparison between the two sides - so the bound moves OFF the price and ONTO the coefficients, where it is index-free and sign-free: a pass-through band (Eneco's injection leg is 1.00x, Aspiravi's 0.70x; the band is 0.10-10x) and an offset magnitude bound (0.20 EUR/kWh against published 0.01188 and 0.02). Two rungs that can both answer a hundredfold error, so each is graded on its REASON, each gets a stimulus the other cannot reach with the precondition asserted as its own check, and they are mutated TOGETHER (TEST-16). ⚠️ THE CARD SELF-CHECK APPLIES WHERE THERE IS A CARD, AND WHERE THERE IS NOT THAT IS STATED RATHER THAN WORKED AROUND. `price_formula` can require a `card` block because a Belgian card prints the consumption formula, the reference index AND the resulting price. Eneco's INJECTION formula is printed with no price beside it. Requiring a block would make the published line inexpressible; inventing the estimate would grade the coefficients against arithmetic done with the same coefficients. So the block is OPTIONAL on this leg and unchanged on the other, the self-check runs at the same tolerance wherever a printed estimate exists, and validate.py says at startup - "its coefficients are UNGRADED" - when it does not, naming the two bounds that ran instead. This is TARIFF-15 demonstrating two tariffs end to end rather than three, one card later. 17e also MEASURES the size of that gap: a factor of 0.070 where the contract says 0.100 - 30% of every export payment - passes both bounds, because 0.70x is a pass-through somebody really publishes. ONE PARSER, NOT TWO. `parse_export_formula()` is `parse_price_formula()` with its OPTION NAME as an argument - TARIFF-17's move on `parse_static_profile()`, exactly - and everything that differs between the legs is a string in one `_LEGS` table. A second copy would be a second unit table, a second NaN rule, a second unknown-key rule and a second `to_resolution()`, diverging first on the 23- and 25-hour days. test_price.py 17b pins it two ways: all 32 grammar names are defined in exactly ONE module under app/, with the scope COMPUTED by parsing every file rather than listed; and twelve refusal sentences for the new option are DERIVED from the old option's own sentence rather than transcribed. The four sentences that deliberately differ are named, with what differs, and asserted to differ. NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships ""; `export_price_eur_kwh()`'s body is byte-identical, pinned on the AST rather than on the source text; `export_price_at()` falls through to it for every index value. 17f compares seven option dicts x five index values against the pre-TARIFF-16 expression with `==`. `price_export_formula` joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is derived from that set, and `code_leaks()` refuses a plugin that names it with nothing else edited. ALSO CORRECTED HERE: config.yaml's comment beside `export_price_eur_kwh` still read "no shipped supplier publishes a dynamic injection curve". TARIFF-07 struck that claim from app/price.py and DOCS.md and did not reach config.yaml, where it has been shipping since. 20 mutations, every one red on a named check, ledger in test_price.py below section 17 - including a ZERO-byte one (the two legs transposed in the VAT-asymmetry banner: no decision moves, only the sentence) and a ONE-byte one (`card_required=True` on the injection leg). Three run over all 29 files and red on test_price.py alone. 29/29 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
4dcd9b0fc6 |
Merge pull request 'TARIFF-18: the taxes default is half the real Flemish figure' (#86) from TARIFF-18 into release/1.0
price_taxes_eur_kwh shipped at 0.06 against a real Flemish 0.11736 - Fluvius Antwerpen 5,35 + bijzondere accijns 4,876 + groene stroom/WKK 1,51 + energiebijdrage 0, incl. 6% VAT, from the Eneco residential card for September 2026. Across the eight Fluvius DSOs the range is 0.117-0.131, so the shipped default was roughly half. The components ship as rows in the code, and the exact unrounded sum is used so the check asserts an EQUALITY against them rather than a tolerance. The energiebijdrage is a ZERO ROW, not an absence - it went to 0 EUR/MWh on 2026-08-01 and was merged into the excise, so anyone hand-computing this from an older guide double-counts it; deleting the row changes no sum, so its PRESENCE is graded separately. AN UPGRADE HAZARD THE TICKET DID NOT NAME, found and closed. App.options is a deepmerge of config.yaml's defaults over the stored options, so the moment the default moves, every site STORING 0.06 becomes non-default with nobody having edited anything. Combined with TARIFF-17's new rung - a taxes period table beside a non-default scalar is a startup refusal - a working Walloon or Brussels site with a table would have hit SystemExit(78) on the upgrade, over an inert number the table overrides anyway. Closed with SUPERSEDED_TAXES_DEFAULTS_EUR_KWH: validate.py reads a superseded default as untouched too. The rung is NOT weakened - a genuinely custom 0.1198 beside a table is still refused by name, and the refusal now names both figures. AND THE TICKET'S FRAMING WAS WRONG. I wrote that a site which took the default silently gets a different number. That is not what the deepmerge does: an existing install keeps its stored 0.06 and sees nothing change. The new default reaches a FRESH install, and an existing one only when its options are re-saved. Said plainly in the CHANGELOG, flagged as not re-measured on this branch (the 70-of-71-keys figure is 0.9.0's), with predeploy_check.py --options named as what would confirm it. THE FIGURE IS THE BOTTOM OF THE BAND, DELIBERATELY, and the argument is the agent's rather than the ticket's: the error directions are not symmetric. Set too high, a genuinely negative hour reads positive and the add-on exports into paid disposal - section 17's own regression case. Set too low, it only declines a trade that would have paid. One default standing in for eight DSOs belongs at the bottom, and Antwerpen's 0.11736 is a real card row there rather than a synthesised minimum. A CHECK HAD TO BE REWRITTEN RATHER THAN RE-POINTED. test_price.py section 0 justified its own constants by '0.06 is exactly twice 0.03'. This card ends that coincidence, so the sentence is false and is gone; it now asserts per figure that neither shipped default equals the fixture, which is stronger than the tuple compare it replaced. Recorded rather than quietly dropped, along with three other now-false claims the change created in test_price.py's docstring, MUTATION.md and DECISIONS/TARIFF-15 - which had predicted this card and is annotated as shipped. Evidence: baseline 29/29 before any edit, 29/29 on the committed tree. Ten mutations, all red, every revert sha256-verified and every grade taken in a container with the tree :ro so no .pyc can exist. Two are zero-byte. The interesting one moves the component rows AND the default AND config.yaml together into a coherent West-Vlaanderen card - caught only by the literal typed in the test file and the band check, which is why the sum is graded from two independent sides. What the suite cannot see is stated in the PR: it grades the default against the card as a number, never the card against Belgium. |
||
|
|
9c65ac2cf8 |
TARIFF-18: price_taxes_eur_kwh stops shipping at half the real figure
checks / unit (pull_request) Successful in 7m48s
`price_taxes_eur_kwh` shipped at 0.06 EUR/kWh with no recorded provenance -
no card, no date, no component list - and roughly HALF the true Flemish
all-in non-energy figure. It now ships at 0.11736, and the rows it is made
of are in the code rather than in this message:
Fluvius distribution (Antwerpen) 5,35 c EUR/kWh
Bijzondere accijns, "Alle verbruik" 4,876
Bijdrage groene stroom en WKK Vlaanderen 1,51
Energiebijdrage (0 EUR/MWh since 2026-08-01) 0
------
11,736 c EUR/kWh = 0.11736
Eneco residential card for Flanders, September 2026, incl. 6 % VAT.
`app/price.py`'s FLEMISH_TAXES_COMPONENTS_EUR_KWH carries the rows and
test_price.py section 1b re-adds them - and also compares the total against
the card's own figures typed in the test file, so an edit that moved the
rows and the default together is still caught.
WHAT THIS IS NOT. It is not an arbitrage-correctness fix and it cannot be.
This scalar is a LEVEL component: an additive constant cancels exactly in
`max - min` and leaves the argmin where it was. TARIFF-17 shipped that proof
and it is unchanged - test_price.py 16b drives six flat scalars spanning and
exceeding the whole per-DSO range and gets the identical cheapest hour and
the identical spread to the last digit. DOCS and the CHANGELOG say so in
those words.
WHAT IT IS. A display and guard fix:
* every number an operator reads, which at half the true value made the
add-on look considerably better than it is; and
* §17's negative-price guard in arbitrage.py - the one rung that tests an
ABSOLUTE all-in price rather than a difference. It fires when
`spot < -(markup + taxes)`, so its boundary moves -0.09 -> -0.14736
EUR/kWh of spot. test_arbitrage.py section 2c drives both sides through
the shipped decide() and pins the boundary from below as well.
The default is Antwerpen's real row, near the LOW end of the 0.117-0.131
per-DSO band, and that is deliberate: the error directions are not
symmetric. Too high carries a genuinely negative hour above zero and the
add-on exports into paid-for disposal - §17's own case. Too low only
declines a discharge that would have paid.
TARIFF-17's INTERACTION, WHICH HAD TO BE DECIDED. Nothing in that rung
hardcoded 0.06; it read PRICE_OPTION_DEFAULTS, so moving the default moves
what it refuses. The Supervisor sets `App.options = deepmerge(config.yaml
defaults, stored options)`, so a saved blob carries the key whether it was
typed or not - every stored 0.06 became "non-default" with nobody editing
anything, and a working Walloon or Brussels site with a taxes table would
have met SystemExit(78) on the upgrade over an inert number. price.py now
carries SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,) and validate.py reads a
superseded default as untouched too. The rung is NOT weakened: 0.1198 beside
a table is still refused by name, and the refusal now names both figures.
config.yaml and PRICE_OPTION_DEFAULTS moved together; CFG-03's tripwire
(test_price.py section 1, test_defaults_pin.py) stayed green by agreeing.
test_price.py section 0's fourth check was REWRITTEN rather than re-pointed:
it used to justify the file's own grading constants by observing that the
shipped pair was degenerate (0.06 is exactly twice 0.03). TARIFF-18 ends
that coincidence, so the sentence is no longer true and is not kept.
29/29 test files green. Mutation ledger on the PR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
7796f168a9 |
Merge pull request 'TARIFF-17: a charge that varies by hour gets somewhere to live' (#85) from TARIFF-17 into release/1.0
price.py modelled taxes as one flat scalar. In Flanders that is correct - Fluvius distribution is a single rate - but the Walloon DSOs publish day/night bands on the same card: ORES 13,27 vs 7,39, AIESH 15,17 vs 8,21, ORES IMPACT 16,578 vs 5,090 c EUR/kWh. The bands are ANTI-CORRELATED with what the battery wants to do - cheap network hours coincide with cheap spot hours - so flattening them systematically UNDERSTATES the spread and declines cycles that are genuinely profitable.
The flat scalar was never a wrong number. It sits in the additive bucket and provably cannot change which hour is cheapest; the defect was that a component belonging in the order-changing bucket had nowhere to live. That framing is the ticket, and it is what the regression grades.
Reproduced on one ordinary Belgian winter Thursday, same feed, same markup, taxes the only argument differing: five flat scalars spanning AND exceeding the table's own range - 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000 - all give cheapest hour 13 and spread 0.1500 to the last digit, while the ORES table gives hour 03 and 0.1788. Asserted through the shipped decision, static_tariff.arbitrage().cheapest, not through assemble()'s output. Before this existed the shipped tree answered assemble(..., <table>) -> TypeError.
The fixture differs in exactly ONE way and that is its own precondition - same caps object, same RawPoint list, same markup, by identity - and the flat arm is a FAMILY rather than a single scalar, so what the comparison measures cannot be a level effect. That closes the shape that produced a whole-suite survivor in TARIFF-11, where a fixture varied two things at once.
GRAMMAR REUSED, NOT DUPLICATED. parse_taxes_profile() is parse_static_profile() with option= and means= as default arguments, so every pre-existing call site and sentence is byte-identical. Graded two ways: one check parses every module under app/ and asserts the 14 grammar names are defined in exactly one of them, with an AST pin that the wrapper's whole body is one call; another DERIVES all thirteen refusal sentences for the new option from the old option's own sentence. Copying the tiling check into price.py is red.
A WHOLE CARD TYPED IN CENTS IS ITS OWN RUNG, and not a restatement of the parser's plausibility bound. ORES's 13,27 trips that bound - but SIBELGA'S ENTIRE BRUSSELS CARD (9,39 / 7,10) IS UNDER IT and would have started as a 9.39 EUR/kWh levy. Graded by its own reason, and the mutation is red on the Sibelga line only.
Malformed tables are refused at STARTUP by validate.py across three rungs. If one ever gets past, load_prices() returns a section 11.4 no-data read naming the key and logs an error - no silent fall back to the scalar, no traceback into the tick.
Both plugins that need the framework to add taxes move 13 -> 03 (custom_formula {ENERGY,MARKUP} and generic_dayahead {ENERGY}); manual_csv, which is all-in, stays at 13. Enumerated from PROVIDERS rather than typed.
Evidence: 29/29 green. Thirteen mutations, all red, and no row is an edit-and-revert - each gets a fresh rsync'd copy with no __pycache__, mounted :ro, then deleted, which closes habit 18 structurally rather than by discipline. Hoisting the lookup out of assemble()'s loop (+17 B) keeps the number right while stopping it varying by hour: red on six named checks in test_price.py and GREEN IN THE OTHER 28 FILES.
Three things named rather than rounded off: one mutant is equivalent and unseparable by any fixture, because StaticProfile.at() calls brussels() on whatever instant it is given; DOCS' bucket table is prose that nothing asserts, though the numbers it quotes are printed by the checks on every run; and the card's own 0.2240/0.2422 figures were NOT verified and were not repeated - replaced with a claim that is derivable from the contract price, that the spread is proportional to factor and the offset drops out.
One design point is explicitly the owner's, not the agent's: a table beside a NON-DEFAULT price_taxes_eur_kwh is a refusal, so an operator adopting the table must set that key back to 0.06. The alternative - warn, table wins - is the silent-loser shape this repo refuses everywhere else, so the refusal was taken and flagged rather than quietly chosen.
|
||
|
|
2fac0aaf06 |
TARIFF-17: a price component that varies by hour has somewhere to live
checks / unit (pull_request) Successful in 7m25s
`price.py` modelled the whole non-energy part of a Belgian bill as ONE flat scalar. This adds `price_taxes_profile`, which carries the TAXES component as a period table, and makes `assemble()` evaluate it at each point's own instant. ⚠️ THE FLAT SCALAR WAS NOT A WRONG NUMBER, AND THIS IS NOT A CORRECTION OF ONE. The optimiser reads exactly two quantities off a series - the spread `max - min` over the firm points, and which hour is cheapest - and an additive constant identical in every hour cancels out of the first and leaves the second where it was. It is provably inert. Measured, and printed by test_price.py 16b on every run: with a flat taxes figure of 0.00, 0.0739, 0.1198, 0.1327 or 1.00 EUR/kWh the same 24-hour feed is cheapest at 13:00 in all five cases and its spread is 0.1500 EUR/kWh in all five cases. What was missing is anywhere for an HOUR-VARYING non-energy component to live. This is a missing capability, not a bad constant. Belgium has one. Fluvius distribution is a single rate - its tariff-card row reads `Normaal | Uitsl. nacht`, where exclusive night is a separate physical circuit and not a night register - so nothing changes for the reference site in Flanders. The Walloon DSOs and Sibelga are on the same card with four columns, `Enkelvoudig | Dag | Nacht | Uitsl. nacht`, and the gap is real money (c/kWh): ORES 13,27 / 7,39; AIESH 15,17 / 8,21; Tecteo RESA 12,20 / 7,01; Sibelga 9,39 / 7,10; ORES IMPACT 2026 16,578 / 5,090. ⚠️ AND THE BANDS ARE ANTI-CORRELATED WITH WHAT THE BATTERY WANTS TO DO. The cheap network hours are the cheap spot hours, so flattening them does not shift the curve - it UNDERSTATES the spread and declines cycles that would have paid. Same feed, same markup, the only difference being the taxes argument: flat 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000 -> cheapest hour 13, spread 0.1500 ORES dag 0.1327 / nacht 0.0739 -> cheapest hour 03, spread 0.1788 The regression check grades WHICH HOUR IS CHEAPEST, not the spread: an implementation that reads the table once and adds one number to every point is the LEVEL bucket wearing a period table - right magnitude, wrong shape - and a spread assertion passes on it. Measured: hoisting the lookup out of `assemble()`'s loop (+17 B) is red on six named checks in test_price.py and GREEN in the other 28 files. ONE GRAMMAR, NOT TWO. `static_tariff.py` already shipped the periods, the day selectors, the tiling rule and the DST handling, so `parse_taxes_profile()` is the same parser with the option name and the "what this number means" clause as arguments. A second tiling rule would be a second set of 23- and 25-hour-day bugs. test_static_tariff.py section 10 pins that two ways: by parsing every module under `app/` and asserting the grammar is defined in exactly one of them, and by deriving each of thirteen refusal sentences for the new option from the old option's own sentence rather than transcribing them. `Component.TAXES` semantics, `code_leaks()` and the double-counting guarantee are untouched. The new key joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is derived from that set, and a plugin that names it is refused at `register()`. It reaches both plugins that take taxes - `custom_formula` ({ENERGY, MARKUP}) and `generic_dayahead` ({ENERGY}) - and not `manual_csv`, which is all-in: the table is routed through `capabilities().adds`, exactly as the scalar is. ⚠️ A MALFORMED TABLE IS REFUSED AT STARTUP, NOT AT THE NEXT FETCH. It is read on the ~30 s planning path, and a table that first fails at 03:00 is a site that stops pricing at 03:00, which §11.4 turns into a quiet fallback indistinguishable from a healthy fixed-tariff day. validate.py refuses three things: a table that does not tile the day; a table set beside a NON-DEFAULT `price_taxes_eur_kwh`, because the table wins and the scalar would then be a number the operator typed that decides nothing; and a whole card typed in CENTS, which is how these figures are printed. That last rung is not a restatement of the parser's +-10 EUR/kWh bound - ORES's 13,27 trips the parser, but Sibelga's entire card, 9,39 and 7,10, is under it and would otherwise have started as a 9.39 EUR/kWh levy. Both rungs are graded by the REASON each gives, and mutating them together (TEST-16) moves three rows. NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships "", and the scalar path is unchanged arithmetically rather than merely in intent: test_price.py 16f reproduces the pre-TARIFF-17 expression and compares 12 capability/scalar combinations point for point with `==`. 13 mutations, every one red on a named check, ledger in test_price.py below section 16k with byte deltas and the one equivalent mutant named. 29/29 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
c518007caf |
Merge pull request 'TARIFF-11: the add-on stops flattering itself' (#84) from TARIFF-11 into release/1.0
Four places where the add-on's account of itself was wrong, every one in the direction that flatters it, and none of them pinned by any check because nothing graded the strings a human reads. TariffPlan.words counted points and called them hours - 192 h for 192 quarter-hourly points, which is 48 hours; measured across a 40-hour soak the error ranged 1.79x to 5.33x with the firm/carried mix, and horizon_h was 48.0 at every sample while words never said 48. The section 8.3 refusal did the same and reached the status page through arb.explain(): 'only 1 of 96 hours carry a published price' for 96 points spanning 24. The plan's shape reached no HTTP surface at all - rows['Tariff'] was static_status()'s own sentence and read 'no static profile' at all 802 soak samples, while the plan was provisional for 76% of them and carried mixed 900/3600 s spacing for 70%. And main.py's 'if and only if' comment was false: cycle() returns on missing inputs before self.arb = None, so a site with trading ON rendered 'off (arbitrage_enabled)' - naming an option that was on. A FIFTH instance was found that the card did not list, in price_forecast.py's '%d of %d hours' - carried hours over points - which DOCS quotes verbatim, so leaving it would have re-broken the doc. THE CHECK IS THE DELIVERABLE, not the fixes. It renders Controller.status_payload()['rows'], parses each number out of its OWN slot, and compares it against a figure computed from the fixture's two construction parameters - never from the property the row was built out of. The slot parser is calibrated first against a correct row AND against a row wrong exactly as the defect was. Fixtures are a SET of four plan shapes and the check computes and prints the table of field pairs no fixture separates. origin and product are graded by disagreement over identical point lists, the Arbitrage row's states by inequality, the end-to-end row against the captured document's own length. The two Tariff sources stay distinguishable: one row, two explicitly timestamped halves, with the age taken from tariff_at - which TARIFF-12 deliberately does not move on a kept plan - and measured by the caller rather than by a second clock inside the renderer. The same plan object at 0 s and 4000 s must render differently, and hardcoding the age to 0 is red. FCST-04's Tariff-to-DOCS pin was honoured rather than routed around: wiring the plan in took test_price_history.py red until DOCS carried the pinned sentence, the row's five facts, the two-clock warning and a four-row table of the Arbitrage renderings. The trap fired exactly as designed. Evidence: 29/29 green, re-verified after merging TARIFF-13. Sixteen mutations, no survivors; one is a zero-byte edit transposing firm and carried, and it is red. TWO MUTATIONS BIT THE AGENT'S OWN CHECKS AND BOTH WERE CLOSED. One was a survivor of the whole suite at -190 B: the origin fixture gave the fallback plan a distinct provider string as well as a distinct origin, so the rows differed for two reasons and dropping origin entirely changed nothing observable - habit 4, occurring inside the fix for habit 4. The provider string is now held equal as its own precondition. The other produced a traceback and no named red, because a datetime comparison raised inside an f-string OPERAND rather than inside the condition - FCST-06's finding, one layer over. Left for its own card, named rather than folded in: TARIFF-09's price_feed_url override warning still predicts the wrong degraded state - on a cold store the unpublished hours are ABSENT, not carried and flagged - and absence is the worse state. |
||
|
|
a11597e0b3 |
Merge remote-tracking branch 'origin/release/1.0' into TARIFF-11
checks / unit (pull_request) Successful in 7m20s
# Conflicts: # goodwe_controller/CHANGELOG.md |
||
|
|
610725bed9 |
TARIFF-11: the plan's account of itself, corrected and put on the page
Four defects of one shape - the add-on's report of its own tariff plan was wrong, always in the direction that flattered it - and one check, which is the deliverable. 1. `TariffPlan.words` counted POINTS and called them HOURS. The live add-on logged `dynamic_dayahead from generic_dayahead, 132 h` for 132 quarter-hourly points = 33 hours. Belgium moved to a 15-minute MTU on 2025-10-01, so this was wrong every time it was printed, and NOT by a constant factor: a 40-hour soak measured 1.79x .. 5.33x, because §8.3's carried points are hourly while the firm ones are quarter-hourly. `words` now reports both quantities, plus the firm/carried split and the spacing, in FCST-04's *Price history* wording so the two rows can be compared. 2. §8.3's firm-pair refusal and the flat-tariff refusal did the same, and they reach an operator through `self.arb.explain()`. This is exactly the point<->hour conflation TARIFF-14 removed from `at()`, left standing in the prose. `price_forecast.py`'s §8.3 carry-forward log line carried the third instance - a carried-HOUR count over a POINT count - and DOCS quotes it verbatim, so it is corrected here too. 3. The plan reached no HTTP surface at all. `rows["Tariff"]` was `static_status()`'s 30 s-tick sentence and never saw the provider plan - it read 'no static profile' at all 802 soak samples on a site whose feed was answering. It now carries the plan's shape. ⚠️ The two sources are kept distinguishable: the plan half is stamped with its own age, measured from `tariff_at`, because it can be up to TARIFF_MAX_AGE_S (5400 s) old. FCST-04 pins that row to DOCS as a pair; DOCS moves in this commit. 4. `main.py`'s "if and only if" was false. `cycle()` returns at the input hold before `self.arb = None`, so an armed site read `off (arbitrage_enabled)` - naming an option that is on - and, after one completed cycle, showed a decision up to 15 s stale as a current one. The row now reads the OPTION and separates three facts; `cycle_at` and `arb_at` make "this cycle reached no decision" an observation. THE DELIVERABLE IS THE CHECK. All four survived 29 green suites because nothing graded the strings a human is shown. `test_price.py` section 12 renders through the shipped `status_payload()`, parses each number out of its OWN SLOT and compares it against a figure computed from the fixture's construction parameters - not from the property the row was built out of (habit 4), and not as a substring of a sentence the same file wrote (habit 14). The fixture set is four plan shapes and the check computes and prints the field pairs no fixture separates. The *Arbitrage* row's states are driven through real cycles with a sensor answering None and graded by INEQUALITY. No behaviour changed: no threshold, refusal rung, claim or setpoint moved. MUTATIONS: 16, no survivors, one at +0 B (the risky delta) and red. Two were found by the battery INSIDE THIS CARD'S OWN CHECK: a -190 B survivor whose fixture varied two things at once (habit 4), and a mutation whose red was a TRACEBACK in an f-string operand and therefore named nothing (FCST-06). Both closed; the table is on the PR. All 29 test files green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
b4a3801f68 |
Merge pull request 'TARIFF-13: the count of what was measured is as durable as the measurement' (#83) from TARIFF-13 into release/1.0
A 40-hour soak drove a restart and caught the capacity-tariff row asserting '0 quarter(s) banked' for a month in which 120 quarters had been measured. The peak and its attributed quarter persisted; the count did not. It is the count, not the peak, that tells an operator how much of the billing month the measurement actually covers - a peak of 365 W over 120 quarters and the same peak over 0 quarters are very different claims about the same number, and after a restart the page made the weaker claim while displaying the stronger one's value. Reproduced through a real Controller and P1Ingest with a real restart, never by reading code: 12 -> 0 with the store still holding 12 rows. Fixed durable rather than removed - the count is the useful half of the row. PeakStore.banked_count(month) queries quarter_blocks and `recorded` became a property over it. After the fix the printed count matches the store on both sides, 12 -> 13, and 13 is CORRECT: the restart's restore() banks the stale open block, which the old process-wide counter never saw. TWO DEFECTS THE TICKET DID NOT NAME, both closed by the same change: the count was never month-scoped, so a fresh October reported a quarter banked from September; and it missed blocks banked by restore(). An unreadable store now returns None and renders 'an unreadable number of quarter(s) banked' - rendering it as 0 was rejected because it reads as a real zero, which is TARIFF-01's own disarming-value argument applied to the coverage figure beside the peak. THE TICKET'S SECOND FINDING WAS FALSE AND THE AGENT DISPROVED IT. I wrote that ties at the same wattage are re-attributed to the most recent matching quarter. The upsert is strictly '>', so an exact tie never writes at all - six exactly-equal 365.0 W quarters re-attributed zero times. The soak's drift was a real new maximum below the row's whole-watt display resolution: 365.0000001 W is a new peak to the database and the same '365 W' to the reader. A plausible diagnosis, checked rather than implemented. Tie attribution is nevertheless decided deliberately - EARLIEST wins - on what the row is for rather than on money: wattage is what is billed, and an operator investigating one event needs a stable answer. It is also what recompute_month() already did, and the two readers genuinely diverged: fed newest-first, monthly_peak answered 09:45 and the recompute 09:00, breaking the invariant part 8 exists to prove. peak_w is untouched and SAFETY-07 reads identical watts. Evidence: 29/29 green. Eleven mutations, all red, purged before each with the fingerprint verified after every revert. One was a REAL SURVIVOR at -3 B: banked_count() has two None paths and the first battery reached only one, because a never-opened handle short-circuits before any SQL. Closed by dropping the table out from under an OPEN store, behind a precondition asserting the same call answered 4 first. main.py is untouched - the row picks the fix up unedited because `recorded` is now a property. The residual is named rather than hidden: `recorded` answers for now() while the peak follows checks(when=...). Production has exactly one call site, self.checks() with no argument, where both are the same month, so the live page is correct and durable; they part only under the suite's when= affordance, and that mismatch is strictly smaller than the process-wide counter it replaces. recorded_in(when) is shipped and the row's move onto it is one token. |
||
|
|
a991438e40 |
TARIFF-13: the coverage figure beside the peak stops resetting at a restart
checks / unit (pull_request) Successful in 7m27s
The capacity-tariff status row printed a peak read from SQLite next to a count
of banked quarters read from PROCESS MEMORY - `PeakTracker.recorded`, an `int`
set to 0 in `__init__` and counted up inside `sync()`. A 40-hour soak caught
what that means at a driven restart, on both instances independently:
before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
- 10 % of the 3500 W soft limit; 120 quarter(s) banked
after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
Reproduced here before anything changed, through the real Controller and a real
restart - a second Controller object against the same file on disk - at 12 -> 0
with 12 rows standing in `quarter_blocks`.
The peak is durable and was never wrong. The COUNT is what says how much of the
billing month that peak covers, and 365 W over 120 quarters and 365 W over 0
quarters are different claims about the same number. Mid-month, after every
restart, the page made the weaker claim while displaying the stronger one's
value. That is TARIFF-01's own argument - an unreadable store must not read as a
zero peak, because a zero peak is disarming - one column over.
`PeakStore.banked_count(month)` asks the database, and `recorded` is a property
over it. Two more defects in the same variable fell out once it was driven, and
neither was in the ticket: it was never scoped to a MONTH (on 1 October the row
still showed September's tally beside October's empty month - measured), and it
missed the block `restore()` banks from the checkpoint, which never passes
through `sync()`.
⚠️ NOT 0, AND NOT None, WHEN THE STORE WILL NOT ANSWER. `banked_count()` returns
None and the row renders `an unreadable number of quarter(s) banked`.
`f"{None} quarter(s) banked"` was rejected because it reads as the same zero.
`0 quarter(s) banked` stays true and reachable - it is what a month nothing has
been written to says, and the two are asserted to be different text.
⚠️ THE DEDUPE IS NOT REMOVED WITH THE COUNTER. `_seen` still stops `cycle()`'s
once-a-second `sync()` re-banking a block `P1Ingest.blocks` keeps for a day. The
check that graded it asserted `recorded == 1` - the dedupe and the figure
reporting it were the same variable - and now grades `sync()`'s RETURN, because
counting rows cannot see a re-bank at all (PRIMARY KEY no-op).
THE TIE RULE, DECIDED RATHER THAN EMERGENT: at equal wattage the EARLIEST
quarter keeps the attribution. No bill and no guard moves - `peak_w` is still
only ever raised, the `>` term is untouched, SAFETY-07 reads the same watts.
The two readers DID disagree: the upsert's strict `>` kept whichever tied block
was WRITTEN first while `recompute_month()` has always kept the earliest, the
same only while blocks arrive in time order, and measured to diverge (09:45
against 09:00) fed newest-first.
⚠️ AND THE SOAK'S DRIFTING QUARTER WAS NOT A TIE. Six exactly equal 365.0 W
quarters re-attributed zero times on the shipped tree - `>` is strict. What
moved it was a real new maximum BELOW THE ROW'S OWN RESOLUTION: the row renders
whole watts, so 365.0000001 W is a new peak to the database and the same 365 W
to the reader. Left alone deliberately, and pinned so it is not re-diagnosed.
test_tariff.py parts 11 and 12. 12 mutations, all red, listed on the PR; M2 -
`banked_count()`'s sqlite3.Error arm returning 0 - was a SURVIVOR at -3 B on the
first battery, because a handle that never opened short-circuits before any SQL
runs and nothing reached the except arm. Closed with an open store whose table
is dropped under it, with a precondition that the same call answered 4 first.
main.py is NOT touched - TARIFF-11 holds its status rows. The row picks this up
without editing because `recorded` is an attribute access and is now a property.
The residual is stated in DECISIONS §3: `recorded` answers for now() while the
peak follows `checks(when=...)`, and the shipped install has exactly one call
site - `self.checks()` with no argument, main.py:4359 - where they are the same
month, so the live page is correct and durable. `recorded_in(when)` is shipped
and the row's move onto it is one token, for whoever holds main.py next; part 10
pins the present behaviour against a row count taken from `quarter_blocks` so
the gap cannot be quietly forgotten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
068b83f57c |
Merge pull request 'TARIFF-15: custom_formula, graded against the card it was copied from' (#82) from TARIFF-15 into release/1.0
The owner asked for a custom formula for exotic suppliers. TARIFF-07's research established why it is the only shape that can work: there is no machine-readable source of Belgian tariff coefficients anywhere - CREG's tariff-card pages 404, VREG was unreachable, and every coefficient found came from a supplier's own PDF, one image-only. Coefficients are per-contract and live behind a klantenzone login. So no scraping and no per-supplier plugin can work; the customer is the only party who can see their own numbers, and the card they read them from is the only artefact that can grade them.
The shape is the market's published arithmetic rather than our approximation: both readable cards print the formula, the reference index assumed, AND the resulting price. Through shipped code, Eneco (0.102 x 133.9905 + 1) x 1.06 = 15.54705 against a card printing 15,55; ENGIE (0.1 x 61.96 + 0.9) x 1.06 = 7.52176 against 7,522.
That is also the safety mechanism. validate.py recomputes the card's own worked example at startup and REFUSES TO START unless it matches to 0.01. An offset inflated by network charges cannot pass, because the card's printed estimate is by construction the energy component alone. The refusal names how far out, in which direction, by what ratio, and then names the expensive cause: network charges folded into `offset` bills you for it twice, which is why the check cannot be switched off.
NO EXPRESSION EVALUATION, guaranteed three structural ways - coefficients must be numbers, an unknown field is refused rather than ignored, and there is no evaluator in the module - and checked by an AST walk rather than a grep, because the module's own docstring contains the text eval(s, {"__builtins__": {}}) explaining why that is not a sandbox, and a text scan would fire on the documentation it mandates. That is code_leaks()'s lesson applied to a second file. A mutation plants a real eval in the shipped module and turns it red.
TWO CORRECTIONS TO THE TICKET, both the agent's and both right. The plausibility band's floor was specified on the all-in figure; the shipped 0.06 of taxes is 3x the 0.02 floor, so a hundredfold error (0.0016 -> 0.0616) sails over it. The floor now sits on the ENERGY figure and the ceiling on the all-in. And the ticket claimed the card check catches 10x/100x/1000x errors: it catches a MISMATCH between the two sides, and a unit error applied to both cancels out of a comparison between them. The wording was narrowed everywhere rather than restated, and the band added as a separate refusal on a separate quantity.
Stated limits rather than glossed: the check grades the coefficients against the card, not the card against reality - a consistent-but-wrong pair, both numbers from the wrong product, passes, and DOCS says so in the same sentence as the claim. Only TWO real tariffs could be reproduced end to end, not three: the repo's third real formula (Eneco injection) has no printed price beside it, so there is nothing to grade it against and none was fabricated.
Evidence: 29/29 green, 347 checks in test_price.py. 22 mutations, no survivors. One was a -13 B SURVIVOR on the first attempt - the check graded the preview's heading rather than the row, the third instance this week of a check anchored to something it does not own - and is now graded on a rendered row built from the capture. One mutation passes the all-in check by declaring ALL_IN, exactly as trap 1 says no code can catch; what goes red is the declaration-agreement check plus the card reproductions.
The riskiest edit is flagged rather than buried: test_price.py's summary block had to move, because a section appended after `if fails: sys.exit(1)` would have left every new check's failure unreported with exit 0. Verified on the base that sections 13 and 14 were always before it, so no earlier evidence is affected.
|
||
|
|
ac9854886b |
TARIFF-15: your own tariff card, as two coefficients the card itself grades
checks / unit (pull_request) Successful in 7m14s
A Belgian dynamic contract prices energy as `B x index + A`, and the two
coefficients exist on exactly one artefact: the customer's own tariff card.
There is no machine-readable source for them anywhere - CREG's consumer
tariff-card pages 404, CREG Scan is a JS wizard, the annual report has the
supplier list without per-product coefficients, VREG was unreachable, and
every coefficient found in research came off a supplier's own PDF, one
image-only. So nothing can be scraped and a per-supplier plugin would have no
source for its numbers, which is why TARIFF-07 withdrew three of them. The
customer is the only party who can see their own coefficients; they type them.
New provider `custom_formula`, one new option `price_formula` (a JSON string,
the `static_profile` precedent exactly), and a new module
`app/custom_formula.py`. The index path is `generic_dayahead`'s, not a second
one: the URL/override decision is now one shared `dayahead_feed_url()` both
plugins call, same feed, same Brussels-calendar range, same columnar parser,
same declared egress hosts.
It declares {ENERGY, MARKUP}, NOT ALL_IN. A card's Tariefformule line is the
energy component alone - Eneco tabulates network tariffs and levies
separately, ENGIE states the formula excludes network, distribution,
transport, levies and excise - so ALL_IN would lie for every operator who
typed what their card says and would drop the taxes from every hour
(0.2125 -> 0.1555 EUR/kWh, measured).
THE CARD IS THE GRADER, and that is why this is safe to ship. The framework
already mechanises that a PLUGIN cannot double-count. This introduces a risk
none of those guards can see because it is config, not code: the OPERATOR
double-counting, by folding network charges into `offset` while
price_taxes_eur_kwh carries them too. So validate.py recomputes the card's own
printed price from the operator's coefficients at startup and refuses to start
unless the two agree to within 0.01, naming the discrepancy. An inflated
offset cannot pass: 15.55 becomes 28.27. The same check catches VAT omitted,
VAT doubled, a transposed digit and a unit mismatch between the two sides.
And the limit, in the same sentence as the claim: it grades the coefficients
against the card, NOT the card against reality. A consistent-but-wrong pair
passes. A second refusal covers the one unit error the first cannot see - the
one applied to both sides at once - by bounding the card's own point to
0.02-1.00 EUR/kWh. Its FLOOR is applied to the energy figure rather than the
all-in one because the shipped 0.06 of taxes is three times that floor and
would carry a hundredfold error over it alone; found by writing the mutation,
not by review (M10, +0 B).
The market's own arithmetic, reproduced through the shipped code:
Eneco (0.102 x 133.9905 + 1) x 1.06 = 15.5471 card prints 15,55
ENGIE (0.1 x 61.96 + 0.9) x 1.06 = 7.5218 card prints 7,522
NO EXPRESSION EVALUATION OF ANY KIND. The string is settable over HTTP through
the add-on's own ingress page, so eval on it is RCE in a root container
holding SUPERVISOR_TOKEN and driving an inverter over RS485 - but the deciding
argument is that an expression is UNVERIFIABLE: two coefficients can be graded
against the card, an arbitrary expression cannot be graded against anything,
so it would make the invisible-wrong-coefficient problem strictly worse. An
unknown field is REFUSED rather than ignored, so `"expr"` is told it does not
exist. Future shapes become named fields, never a grammar. test_price.py 15f
walks the module's AST for eval/exec/compile/__import__/literal_eval and
exercises the scanner's red direction on every run.
Refused at startup: card absent or out by >0.01; `units` absent (no default);
factor == 0 (every spread 0, arbitrage silently never fires - the message
points at static_profile); factor < 0 (inverts the market); vat_pct outside
0-30 (0 stays legitimate); non-finite or non-numeric; unknown field, index or
resolution; the plausibility band. A card older than ~6 months WARNS and does
not refuse - ENGIE freezes coefficients for a contract's duration. Negative
output prices are not refused anywhere.
A startup preview logs the next 24 h as `index EUR/MWh -> all-in EUR/kWh` with
min/median/max and the day's spread, through a new default-silent
`PriceProvider.startup_preview()` hook - only the plugin knows what went in to
its own arithmetic. Emitted once per process, and a preview that raises cannot
cost the site its prices.
`custom_formula` joins the config.yaml enum through the DERIVED set
PROVIDERS | RETIRED_PROVIDERS | {"off"}, not a hardcoded list. Both
translations describe the option and the new id. DOCS.md replaces the
"planned" sentence with the shape, a worked example from a real card, a real
startup transcript and the limit of the check stated beside the claim.
22 mutations, one per check added, no survivors - MUTATION.md carries the
ledger. M13 (the preview's index column) was a -13 B survivor of the whole
suite on the first attempt because the check graded the HEADING and not the
row; it is graded on a rendered row now. All 29 suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
aade2c79fd |
Merge pull request 'TARIFF-07: withdraw three plugins that never fetched a price' (#81) from TARIFF-07 into release/1.0
engie_dynamic, totalenergies_dynamic and eneco_eddy had fabricated endpoints. Re-verified before deletion, 2026-09-13: api.engie.be/dynamic/be answers 404 from a Kong gateway with no route; api.totalenergies.be presents a certificate for CN=pge-apigee-dev.direct-energie.com with the host absent from its SAN, so TLS closes before a request is sent; api.eneco.be/eddy/prices answers 404 with a one-byte body. And 'Eddy' is not an Eneco product at all - theirs is 'Zon & Wind Dynamisch', indexed to Belpex/EPEX DAY-AHEAD rather than imbalance.
They are structurally impossible, not merely unfinished: supplier coefficients are per-contract and live behind the customer's klantenzone login, so no public unauthenticated endpoint could ever return a given customer's price. There is no regulator index either - CREG's tariff-card pages 404, VREG was unreachable, and every coefficient found in research came from a supplier's own PDF, one of them image-only. The three invented URLs are the predictable failure mode of a task with no canonical index to fall back on.
THE SCHEMA ENUM KEEPS ALL SIX IDS ON PURPOSE. config.yaml's price_provider is a list(...) enum validated by the Supervisor BEFORE the add-on starts, so removing the three would make an existing config schema-invalid, produce an opaque 'expected one of ...', and validate.py would never run - the refusal would never be seen on exactly the sites it exists for. PROVIDERS is two; the enum stays six; both translations label them withdrawn; the schema check is derived from PROVIDERS | RETIRED_PROVIDERS | {off}; and register() refuses to re-register a retired id so the registry and the retired table cannot both claim one.
The refusal tells a stranded operator the truth rather than aliasing them somewhere: that this is not a typo - the add-on offered the id, accepted it, and said nothing at every startup since; that it has never fetched a price anywhere, with the endpoint and its observed response named; that section 11.4 turned every failure into a silent fallback, so what the site has actually been running is the static profile, or nothing at all when none is set; and what to do instead, stating plainly that generic_dayahead APPROXIMATES a Belgian dynamic tariff and cannot reproduce a supplier's B x spot + A formula. There is deliberately no alias, because aliasing would require the contract's own two coefficients - which are in no public index, so the add-on would have to invent the numbers it prices a battery against.
False claims corrected across DOCS.md, price_forecast.py, price_history.py, static_tariff.py, DECISIONS/FCST-04 and CHANGELOG.md: eneco_eddy was described as a 'Belgian imbalance product' in all of them and is not. The quarter-hourly examples now cite Belgian day-ahead, which genuinely settles per quarter since the 15-minute MTU of 2025-10-01 - the mechanism was right, only the example was invented. export_price_eur_kwh's docstring claimed no shipped supplier publishes a dynamic injection curve; Eneco publishes 0,1 x BELPEX-H - 1,188 on the same card as its consumption formula, identical on the January and September 2026 cards, and it is VAT-exempt while consumption is at 6%. _points_from_json() is deleted: it read a shape no reachable feed publishes and was graded only against fixtures built from the same guess.
PriceProduct.REALTIME and the widening machinery are KEPT although eneco_eddy was their sole declarer, with a DECISIONS note recording that no shipped plugin declares it until one does - otherwise a later reviewer finds a mechanism with zero callers and reasonably deletes the AC2 seam.
ONE CHECK HAD QUIETLY STOPPED BEING ONE. test_price.py 4b proved code_leaks()'s docstring strip is load-bearing by using a real shipped docstring that a raw scan false-positives on - and that docstring belonged to engie_dynamic. Deleting the plugin would have left the check PASSING on a tree with the strip removed. The stimulus is rebuilt inside the test file and the shipped set asserted clean separately.
BACKWARD-INCOMPATIBLE: a config legal on the current version is refused on the next. That is the point of the refusal, and the CHANGELOG says so in the shape PKG-07 established for slew_w.
Evidence: 29/29 green, with the baseline established at
|
||
|
|
f620f7cf80 |
TARIFF-07: three plugins that never fetched a price are withdrawn, and a site configured on one is told so
checks / unit (pull_request) Successful in 7m12s
engie_dynamic, totalenergies_dynamic and eneco_eddy are removed from PROVIDERS and from every surface that listed them. They did not stop working - they never worked. Re-verified by hand on 2026-09-13 before deleting anything: 404 with Kong's "no Route matched with those values"; a TLS handshake that fails because api.totalenergies.be is absent from the certificate's SAN list; 404 with an empty body. All three are also structurally impossible - the coefficients are per-contract and behind a customer login, there is no regulator index, and "Eddy" is not an Eneco product at all. ⚠️ BACKWARD-INCOMPATIBLE. A config legal on the current version is refused on the next. That is the point of the change: §11.4 turned every failed fetch into a silent fall back, so a site on one of these ids has been running on static_profile - or on nothing - since the day it was set, with nothing on its status page ever differing from a working provider that never found a cheap hour. validate.py now refuses a retired id with a message naming the id, the endpoint, what that endpoint answers, which tariff has really been in force, and what to set instead. No version number is picked here; that depends on a fact about the live box. ⚠️ config.yaml's price_provider enum deliberately KEEPS all three ids. The Supervisor validates the options file against that enum before the add-on starts, so removing them would make an existing config schema-invalid and the refusal above would never be reached on exactly the sites that need it. PROVIDERS is two; the enum stays six; test_price.py asserts the schema offers exactly PROVIDERS | RETIRED_PROVIDERS | {"off"}, derived rather than typed, and register() refuses to re-register a retired id. PriceProduct.REALTIME, _WIDE, Uncertainty.REALTIME_PRODUCT and WIDEN_FACTOR's 2.5 are KEPT with no shipped declarer - it is the AC2 seam, and a reviewer finding a mechanism with zero callers would reasonably delete it. Recorded in DECISIONS/TARIFF-07-withdrawing-three-plugins.md and pinned by a check. False claims corrected: every "Belgian imbalance product" claim about eneco_eddy (DOCS.md, price_forecast.py, price_history.py, static_tariff.py, FCST-04, CHANGELOG - the two released entries annotated in place rather than rewritten); the quarter-hourly examples now cite Belgian day-ahead, which really does settle per quarter since the 15-minute MTU; and export_price_eur_kwh's "no shipped supplier publishes a dynamic injection curve", which is false - Eneco publishes 0,1 x BELPEX-H - 1,188 and ENGIE (B x eSpot_15) - A, same two-coefficient shape with its own coefficients and its own VAT treatment, so the scalar is a simplification and not the market. DOCS.md's provider table is two rows and now says honestly what a Belgian dynamic customer should do today, including that generic_dayahead cannot express B x spot + A and that price_markup_eur_kwh: float(0,1) could not hold a negative offset. The formula provider is referenced as planned, not built. _points_from_json() went with the plugins it served: it read a shape no feed anybody here has ever reached publishes. The one place it survives is a negative stimulus, named row_shape_body(). Nine mutations driven, all red; table in the decision record. 29/29 suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
8d09b30f16 |
Merge pull request 'TARIFF-14: the window a point covers is the point's, not an assumed hour' (#80) from TARIFF-14 into release/1.0
_Series.at() hardcoded a one-hour coverage window and returned the FIRST point whose start fell inside it. Belgium moved to a 15-minute MTU on 2025-10-01 and the live feed returns 96 points at 900 s, so on the real series at() answered with the wrong point for 45 of every 60 minutes - and then drifted, because from the second hour on the first match was an earlier point still inside its own one-hour window. Reproduced against shipped code: asking for 03:00 returned the 02:15 point. It reached the trade decision directly. arbitrage._act() calls _point_at(plan, now) -> series.at(now), then tests here.start == cheapest.start / == dearest.start to decide whether this interval is the buy or the sell one. With a stale and drifting point that identity test almost never matches the extremum, so the strategy idled through exactly the interval it exists to trade. It also tested here.provisional and the section 17 negative-price guard on the wrong point's price. Nothing caught it because no test drove at() on a sub-hourly series: the DST tests count points on 23- and 25-hour days but never ask for a quarter-hour, and every price fixture before TARIFF-08 was the invented hourly row shape. static_tariff.py emits hourly points, so static sites are immune - the defect was reachable only by the dynamic path TARIFF-08/09 had just made work. Each point's window now runs to the next point's start, CAPPED AT ONE HOUR. The cap is not cosmetic: uncapped, the rule would have stretched the point before a three-hour FCST-04 hole across the whole hole, introducing hole-filling as a side effect of fixing a lookup. The last point takes the span of the point before it, because a flat one-hour tail answers at(00:30) tomorrow with today's 23:45 price - reaching for a neighbour across a day boundary, which section 11.4 forbids. That tail choice was independently validated by TARIFF-12, which merged during this work. A served plan can now be up to TARIFF_MAX_AGE_S (5400 s) old, and price_horizon_h permits a 1 h horizon - so now can walk past the end of a short series. Under the shipped rule at() raises Absence and the site falls to the static profile; under the rejected flat tail the same stale plan would have gone on trading against an expired :45 quarter for up to 45 minutes. Re-read the rule if TARIFF_MAX_AGE_S is ever widened. covers() shared the bug exactly - it has no rule of its own and delegates to at() - so one change fixes both. window() did not: it grades a start against a half-open range and never asks how long a point lasts. at() now depends on sortedness AND ESTABLISHES IT ITSELF, sorting once in _Series.__init__, because nothing else guaranteed it: _validate_raw() covers only the load_prices() path, _parse_points() only the store read, and PriceRead.series() never enforced anything. A series handed its points reversed now answers identically, as a check. Evidence: 29/29 green on the merged tree. Eight mutations, all red. The killing one restores the shipped rule (+68 B) and is red on 24 named checks across both files, including the one named THE PROPERTY THE DEFECT BROKE - an arbitrage-level check driving the shipped decide() over a 96-point day whose cheapest is a :45 and dearest a :15, asserting as its own check that the old and new rules return different points. The tail mutation is a ZERO-BYTE edit and is caught. It is stated honestly that five of the eight leave test_arbitrage.py green, because those are at()-level properties no arbitrage fixture reaches. Named and not fixed: price_forecast._walk() steps section 8.3's gate in whole hours, so on a sub-hourly series it reads one point per hour and never sees the :15/:30/:45 flags. Unreachable today - FCST-04 carries whole hours and the feed publishes whole days - but that is an assumption about producers, not a property of the gate. |
||
|
|
028c3e53fb |
Merge release/1.0 (TARIFF-12) into TARIFF-14
checks / unit (pull_request) Successful in 7m13s
Second base move while this card was open: |
||
|
|
a6e64fb66e |
Merge pull request 'TARIFF-12: a degradation is not a refresh' (#79) from TARIFF-12 into release/1.0
run_tariff_refresh()'s docstring promised 'A FAILURE DOES NOT CLEAR THE PLAN' and guarded the wrong thing. resolve_tariff() deliberately never raises on a feed failure - load_prices() returns no_data rather than letting a traceback into the planning tick - so a dead feed returned a degraded TariffPlan, the try SUCCEEDED, and the good plan was overwritten. The except branch that promised otherwise was reachable only by unexpected exceptions; a supplier outage, the case the docstring names first, never reached it. With static_profile unset - the shipped default for a dynamic site - a complete 48-hour plan was replaced by origin='none', 0 points. A 40-hour soak drove a 90-minute outage and measured it: the plan was gone at the FIRST failed fetch, ~27 min after the last good one, with TARIFF_MAX_AGE_S (5400 s) governing nothing. There was no serve-stale window; a transient blip had the same effect as a long outage. The fix keeps a plan that is still fresh, via fresh_tariff_plan() - reused rather than transcribed, and pinned on the AST. The §11.4 static fallback does NOT replace a fresh provider plan: every point _degrade() builds is provisional, so firm_points() is empty and arbitrage and cheap_now are already off, while a day-ahead price for a given hour does not change once published - a 30-minute-old observation is exactly as true about 19:00 as one fetched now. The fallback is strictly less actionable and no more current. Deliberately NOT symmetrical: a standing plan that is itself a degradation IS replaced, or a static site's plan would freeze at the window it was first generated for. tariff_at does not move on a kept plan, so TARIFF_MAX_AGE_S now genuinely governs and a kept plan stays distinguishable from a fresh one. Rebased onto TARIFF-10 and re-verified rather than assumed. The interaction was confirmed by driving it: nothing on this path reads arbitrage_enabled, and the static-profile case was driven twice consecutively because a guard that latched there would freeze a site's plan for the life of the install. One honesty fix followed from TARIFF-10 - the kept-plan log said 'arbitrage stops with it', which is false on a site that fetches without trading, and now reads 'arbitrage, where it is armed, stops with it'. Evidence: 29/29 green on the merged tree, including TARIFF-10's new reachability section. Eleven mutations, all re-driven on the merged tree because cached reds from the old base are not evidence about the new one; eleven red, no survivors, every revert fingerprinted after the run as well as before. Two process findings recorded rather than quietly fixed. Two drafts of one check were red against a CORRECT tree because they substring-matched the docstring that states the property - a text search over a function grades its prose as much as its behaviour, which is habit 4 in a third costume; the check now scans AST symbols and non-docstring constants, with positive controls. And the mutation harness was overwritten mid-session by a concurrent agent's harness sharing the scratchpad, pointing it at a different worktree - MUTATION.md habit 18's second bullet almost verbatim. It surfaced as an error rather than a silent wrong result, and the per-run revert fingerprints are what prove the worktree was never left mutated. |
||
|
|
435e1b6f7c |
Merge release/1.0 (TARIFF-10) into TARIFF-14
checks / unit (pull_request) Successful in 7m10s
CI on PR #80 was NOT a test failure. The workflow's "grade the merge, not the branch" step refused the PR because it does not apply, and said so itself: "REFUSED: refs/pull/80/head does not merge into release/1.0. NOTHING HAS BEEN TESTED. This is not a test failure - no test ran." Conflicts were CHANGELOG.md and test_price.py; app/price.py auto-merged. CONFLICTS RESOLVED * CHANGELOG.md - both entries belong under ## Unreleased. TARIFF-10's table row and section come first because it landed first. No version number is picked here; PKG-06/07's note stands untouched. * test_price.py - BOTH cards appended a section "13". Nothing is dropped: TARIFF-10's stays 13, this card's is renumbered 14, and TARIFF-10's temp-directory teardown stays at the end of the file. The two share no fixture, option dict or helper name. WHAT TARIFF-10 CHANGES FOR THIS CARD - nothing in the reasoning, and the check is recorded rather than asserted (decision note §7). In app/price.py the two do not meet: TARIFF-10 added provider_id() and provider_configured() beside price_option() and routed load_prices() through them - the option-reading layer, touching nothing in _Series, at(), covers(), window() or _cover_table(). The merged at() is byte-identical to the one the mutations were driven against. Behaviourally TARIFF-10 makes run_tariff_refresh() run with arbitrage OFF, so a provider-configured site now has a standing plan - and therefore a _Series - where before it had none. It does NOT add an at() consumer to the 30 s tick: static_status() builds a STATIC plan (hourly, immune), main.py calls only plan.words on the fetched plan, and price_forecast.price_permits()/begin_phase() have no caller in main.py at all. So TARIFF-10 neither creates nor closes any exposure this card is about - it enlarges the defect's reach the moment such a site arms trading, which strengthens the fix without changing it. RE-GRADED ON THE MERGED TREE, not carried over * all 29 test files green; * all EIGHT mutations re-driven, purged before each run, each reverted with the fingerprint re-checked. Same verdicts and same red-check counts as before the merge: M1 (the shipped one-hour scan, +68 B) red on 24 named checks across both files; M3 (the ZERO-BYTE tail rule) red on 4; M2/M4/M6 on 1 each, M5 on 2, M7 on 16, M8 on 14. * Clean app/price.py on the merged tree: 90d8ca31f7c0109d, 85953 B. * None of TARIFF-10's 650 new lines of checks moves under any of the eight - they grade resolve_tariff()'s reachability from a configuration, not the lookup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
f5ea39784d |
Merge release/1.0 (TARIFF-10) into TARIFF-12
checks / unit (pull_request) Successful in 7m16s
CHANGELOG.md was the only conflict: both entries belong under the same
`## Unreleased` heading, TARIFF-10's first because it merged first. No
version number picked.
app/main.py auto-merged, and was re-read because of it. TARIFF-10 changed
WHICH installs run `run_tariff_refresh()` (`arbitrage.enabled(opts)` ->
`tariff_refresh_wanted(opts)`); this card changed what that task does on
a failed fetch. Nothing in the keep/replace reasoning changed, and the
two newly reachable configurations are now DRIVEN in test_arbitrage.py
8b rather than reasoned about:
* a provider configured with trading DISARMED is the case the guard
was designed for, and needs no second fixture because nothing on
this path reads `arbitrage_enabled` at all - asserted over the AST's
symbols and non-docstring constants, after two drafts of that check
went red on a CORRECT tree for matching the word "arbitrage" in a
docstring and in a log line;
* `price_provider: off` + a `static_profile` resolves to the
CONFIGURED product (`origin: "static"`), so `standing.from_feed` is
false on every refresh and the guard never latches - driven twice in
a row, because a guard that latched there would freeze a static
site's plan at its first window for the life of the install.
Also: the kept-plan log line said "arbitrage stops with it", which after
TARIFF-10 is false on a site that fetches without trading; it now says
"arbitrage, where it is armed, stops with it". The keep decision is
strictly downstream of `resolve_tariff()`, so the §8.3 carry into
price_history - TARIFF-10's whole point - is unaffected either way.
All 29 suites green on the merged tree, including TARIFF-10's new
test_price.py section 13. All eleven mutations RE-DRIVEN on the merged
tree rather than carried over: eleven red, no survivors, every revert
fingerprinted. M11 is new - making the guard conditional on
`arbitrage.enabled(self.o)` (+86 B) is red on five named checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
7f2c4e20e1 |
TARIFF-14: the point whose own interval covers the instant
checks / unit (pull_request) Failing after 2s
`_Series.at()` asked `s <= u < s + 1 h` of every point IN LIST ORDER and
returned the FIRST match. On the hourly shape every fixture in this
repository carried before TARIFF-08 that is right by accident. Belgium
moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
returns 96 points at 900 s, where it is wrong twice over - measured
against the shipped code at
|
||
|
|
559f0eb022 |
Merge pull request 'TARIFF-10: a configured provider fetches prices, whether or not trading is armed' (#78) from TARIFF-10 into release/1.0
run_tariff_refresh() was created only under `if arbitrage.enabled(opts)`, and it is the sole caller of resolve_tariff(), which is the sole caller of price_history.carry(). arbitrage_enabled defaults to false, so on a shipped install with a provider configured nothing was ever fetched, no plan resolved, /data/price_history.json never written, FCST-04's Price history row read `empty` forever, and TARIFF-09's warning never fired. Every price check constructs its own resolve_tariff() call, so the suites exercised a path production never reached. Measured, not inferred: a 40-hour soak ran two instances side by side - the shipped default made zero feed requests and never created its store across all 802 samples, while an identical instance with arbitrage armed resolved a 132-point plan from the same feed. DOCS.md:1348 has claimed the fixed behaviour all along. The product owner's decision is Option A: a configured provider fetches. Trading stays separately gated, and that is graded directly rather than inferred - the real control loop is driven on a resolved provider plan with arbitrage_enabled the only option moving: off gives +180 W self-consumption, on gives -5000 W at the cheapest hour and +5000 W elsewhere. The armed rows are what make the unarmed row evidence. ONE ACCEPTANCE CRITERION WAS DELIBERATELY NOT IMPLEMENTED LITERALLY, and the deviation is right. The criterion said no fetch task at all when price_provider is off, whatever arbitrage_enabled says. That breaks a configuration validate.py explicitly accepts - provider off, static_profile set, arbitrage armed - by leaving tariff_plan at None forever, silently disarming a strategy the UI reports as running. Driven both ways. The guard is therefore `provider_configured(opts) or arbitrage.enabled(opts)`; the criterion's stated harm is still fully met, because with the provider off resolve_tariff() answers out of the static profile and opens no socket, and the true shipped default starts no task at all. DECISIONS section 3 records the trade. cheap_now() is NOT wired, deliberately. On a provider-only site static_status() returns False on every cycle, so adopting it would take the maintenance cycle's paid grid top-up from never to sometimes - a change to what spends money, and it needs its own decision rather than riding along on this one. Evidence: 29/29 green. 13 mutations over 14 runs, each on a fresh tree copy, purged, :ro, -B. The shipped defect at -4 B is red on 7 named checks. The reachability check grades reachability from a CONFIGURATION rather than from the `if` statement - the `if` is exactly what someone meant to write. The instrument itself was mutated too, both +0 B, both red. One real survivor was found and closed in the same commit: provider_id() dropping .strip() left four price suites green. And one fixture defect was found and fixed - five minutes of P1 telegrams made the peak guard blind depending on where the wall clock sat in the billing quarter, so the armed baseline failed to trade on some runs. |
||
|
|
c81289527f |
TARIFF-12: a returned degradation is not a refresh
checks / unit (pull_request) Successful in 7m12s
`run_tariff_refresh()` promised in its own docstring that "a failure does not clear the plan", and enforced it with `except Exception` - a branch a supplier outage never reaches. `load_prices()` states the reason in its own words: it "RETURNS, NEVER RAISES, for every failure a site can actually have", because a degradation arriving as a traceback into the planning tick would stop the controller regulating over an expired certificate. So a dead feed RETURNS a §11.4 plan, the `try` succeeds, and the assignment below it overwrote the good plan unconditionally. With `static_profile` unset - the shipped default, and the normal state of a dynamic-tariff site - `_degrade()` returns `TariffPlan(no_data, None, "none")`: a complete 48-hour plan replaced by nothing, at the FIRST failed fetch, ~27 minutes after the last good one. `TARIFF_MAX_AGE_S` (5400 s) never governed anything and there was no serve-stale window at all. Fourth card in one family: FCST-04's M4 (`not ok` only), FCST-05's HIGH (`(JSONDecodeError, OSError)` only), FCST-06 (`not self.ok` only), and now `except Exception` only, for a path that never raises. The question is now asked once, after the attempt, from the evidence that exists - FCST-06's answer, one module over. The evidence is `TariffPlan.from_feed` (did this resolution observe the configured feed) and the shipped `fresh_tariff_plan()`, reused rather than transcribed. A degradation replaces a standing observation only once that observation has aged out, so `TARIFF_MAX_AGE_S` is now the serve-stale window it reads like. A §11.4 static fallback does not replace a fresh provider plan either, although it carries real prices: every point `_degrade()` builds is `provisional=True`, so `firm_points()` is empty, `arbitrage()` refuses and `cheap_now()` is False - everything a consumer may act on is already off - while a day-ahead price for a given hour does not change once published. The rule is deliberately not symmetrical: a standing plan that is itself a degradation IS replaced, or an outage would freeze the operator's static profile at the window it was first generated for. `tariff_at` does not move on a kept plan. It is the moment of the fetch that produced the plan standing, `fresh_tariff_plan()` judges the age from it, and an unmoved timestamp is the only thing that tells a kept plan from a freshly fetched one. test_arbitrage.py section 8b drives the whole chain with nothing stubbed but the socket: the transport raises the way aiohttp does, load_prices() returns no_data, _degrade() builds the §11.4 plan, and the shipped run_tariff_refresh() decides. A check that patched resolve_tariff to RAISE would have passed against the defect. Ten mutations, ten reds, no survivors; 29/29 suites green. Out of scope and unchanged: the persisted price store still does not back the plan during an outage (FCST-03), and the status banner and the Arbitrage row's lag are separately carded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
e6c45a61e7 |
TARIFF-10: configuring a price provider is what fetches prices
checks / unit (pull_request) Successful in 7m19s
amain() started the background tariff refresh only when arbitrage_enabled was
on, and that option ships false. run_tariff_refresh is the only caller of
resolve_tariff(), which is the only caller of price_history.carry() - so on a
shipped install with a price provider configured nothing was ever fetched: no
plan resolved, /data/price_history.json never created, the Price history status
row reading `empty` for the life of the install, and TARIFF-09's short-horizon
warning unable to fire because there was no fetch to warn about. Measured over
a 40 h two-instance soak on one feed: 0 requests and `present: false` across all
802 samples, against a resolved 132-point plan on the instance with arbitrage
armed.
git log -S puts the refresh task, its arbitrage.enabled() guard AND the DOCS.md
sentence saying a configured provider still fetches in the same commit - OPT-04,
bdd7f18; config.yaml has carried the claim since TARIFF-02,
|
||
|
|
88c59a9918 |
Merge pull request 'TARIFF-09: ask the feed for the horizon the operator configured' (#77) from TARIFF-09 into release/1.0
generic_dayahead.fetch() accepted since and until and ignored both, requesting the bare URL - so the feed returned one Brussels day against a 48 h horizon. Everything past today was carry-forward, provisional, and dropped from the trade set by 8.3, which means the optimiser never saw tomorrow as firm at any hour of the day. That is not what FCST-04's fill was built for: it covers the pre-publication window until the ~13-14 h CET auction, after which a healthy feed should hand over tomorrow in full. Measured before, 2026-09-10T09:00+02:00 on the live feed: 15 firm hours and 33 carried of 48, firm local days = [2026-09-10] only, buy and sell both on the same day. After, against a real two-day body: 39 firm and 9 carried, firm local days = two, buy 13:45 on 09-09 and sell 19:45 on 09-10. At 23:00 the difference is the whole verdict - before, 1/47 and arbitrage REFUSED at spread 0.0464; after, 25/23 and allowed. The 9 hours still carried are the tail running into a third Brussels day, which no day-ahead market publishes. The range is derived on the BRUSSELS calendar, both ends graded and both driven red by mutation - the API takes dates while since/until are instants, so deriving either end from the UTC date loses the last one or two hours of every day. That is _bdate()'s lesson from FCST-04 one layer up, except that here the hours are never fetched at all rather than misfiled. price_feed_url is an EXACT URL, used verbatim, nothing appended - and the deciding argument came from the codebase rather than from taste: generic_dayahead declares a second egress host, web-api.tp.entsoe.eu, whose date parameters are not start/end. An override to the other DECLARED host is exactly where merging would append parameters that host ignores and produce the short horizon anyway. The cost is made visible by a fetch-time WARNING naming the consequence and the start..end it would have asked for, with a control asserting the default path stays silent, and dated_feed_url() refuses to append to a base that already carries a query string, so blind appending is structurally unreachable. The plugin contract is deliberately NOT enforced yet. Obliging the three unverified-endpoint plugins of TARIFF-07 to pass a range would be satisfied by an invented parameter name - TARIFF-08's defect moved from the response to the request and shipped as a guarantee. The roster of plugins that consume the range is pinned as a literal instead, so the next supplier has to decide. Evidence: 29/29 green. 11 mutations, no survivors, each applied to a fresh copy of the clean tree rather than reverted. Includes the Brussels/UTC boundary pair, one at +1 B, and one at +0 B that changes a single price digit inside the committed capture and is caught by the re-derived sha256. Two live fetches only, 66 s apart, respecting the feed's rate limit; the second returned bytes identical to TARIFF-08's capture, so the pre-publication case is a second recorded URL for the same bytes with the identity re-derived rather than asserted. MUTATION.md habit 20: a fixture grades the response, and nothing about the REQUEST is reachable from one. canned() had recorded the URL since TARIFF-02 and no check ever asserted on it, which is how a plugin that never asked for a date range went unnoticed for four tickets. |
||
|
|
17b7b447c4 |
TARIFF-09: ask the day-ahead feed for the horizon it was handed
checks / unit (pull_request) Successful in 7m10s
`generic_dayahead.fetch()` accepted `since` and `until` and requested the
bare `?bzn=BE`, which answers ONE Brussels day. Everything past today was
then filled from yesterday's profile by FCST-04, flagged provisional, and
§8.3 drops provisional hours from the trade set -- so the optimiser never
saw tomorrow as firm, at any hour of any day, and nothing reported it.
Measured on
|
||
|
|
4257e38e5a |
Merge pull request 'TARIFF-08: read the feed the add-on actually ships pointed at' (#76) from TARIFF-08 into release/1.0
_points_from_json() expected doc['data'] to be a list of {start, price_eur_mwh}. api.energy-charts.info/price?bzn=BE returns a columnar document - parallel unix_seconds and price arrays - so the shipped parser raised SourceUnavailable('the feed carried no data list') against every real response. generic_dayahead had never parsed one. With the three fabricated supplier URLs of TARIFF-07, that left manual_csv as the only working provider in a five-provider table.
It survived to release because every fixture fed the invented shape: test_price.py, test_price_history.py and test_static_tariff.py all built {'data':[{'start':...,'price_eur_mwh':...}]}, so the suite validated the parser against the parser's own assumption - a closed loop that cannot notice the assumption is wrong. Section 11.4 then made the failure silent: a parse error degrades to the static profile, which is correct and says nothing about the cause.
Verified live 2026-09-10, end to end: fetch -> parse -> assemble() -> TariffPlan returns 96 quarter-hourly points at 900 s, origin provider, product DAY_AHEAD, arbitrage allowed with a firm spread of 0.4645 EUR/kWh. First time the pipeline has completed against a real feed. Belgium moved to a 15-minute MTU on 2025-10-01, so FCST-04's H1 fix - the fill was decimating sub-hourly points to one per hour - was never theoretical; it is the shape of the live feed, and it is what makes this deliverable.
The columnar shape is read by a SECOND function, not a sniff, and feeding the row shape to generic_dayahead is now a graded no_data so the sniff cannot return quietly. Equal length refused in both directions with both lengths in the reason; the unit refused if not EUR/MWh, because MAX_PLAUSIBLE_EUR_KWH only bounds prices that are too large and a EUR/kWh switch would otherwise be invisible; nulls dropped with a count, an all-null body named as no_data; a deprecated feed warns while it still works.
Two real bodies captured as fixtures - unmodified bytes, with URL, retrieval time, unit, licence-as-returned and sha256 recorded, and the checksums RE-DERIVED by the replay rather than asserted. The invented fixtures are renamed synthetic_row_feed at every call site so the closed loop cannot re-form. Only one of four network providers has a capture, and a registry-derived check says so in its own sentence: the other three have nothing to capture, which is TARIFF-07.
Evidence: 29/29 green. 12 mutations, no survivors, purged tree and -B per TEST-15. One first-draft line was decoration and the mutation said so - the parser called brussels() per instant while assemble() already renders, so removing it changed nothing. The line was deleted rather than fenced with a check, and the mutation re-aimed at assemble() where the rule lives. MUTATION.md habit 19.
Follow-up filed as TARIFF-09: generic_dayahead ignores the since/until it is handed, so the feed supplies 24 h of a 48 h horizon and tomorrow is never firm.
|
||
|
|
7a5281e97f |
Merge release/1.0 (FCST-06) into TARIFF-08
checks / unit (pull_request) Successful in 7m10s
One conflict, in CHANGELOG.md's `## Unreleased` block, exactly as expected from two branches landing beside each other: both `_this branch_` table rows and both sections are kept, FCST-06's first. Nothing else conflicted - FCST-06's `test_price_history.py` section 5d and this branch's columnar `feed_body()` merged cleanly - and all 29 test files are green on the result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
6eba18968c |
TARIFF-08: the day-ahead feed is read in the shape it actually publishes
checks / unit (pull_request) Failing after 3s
`generic_dayahead` could not read one byte of `api.energy-charts.info`, and
never had been able to. `_points_from_json()` asks for `doc["data"]`, a list of
`{start, price_eur_mwh}` objects; that document does not exist. Fetched live on
2026-09-10 the feed returns COLUMNAR parallel arrays and no `data` key at all -
`unix_seconds` (96 UTC epoch seconds, 900 s apart), `price` (96 floats),
`unit: 'EUR / MWh'`. Driving the shipped parser against that body:
`SourceUnavailable: the feed carried no 'data' list`. It is the only shipped
provider whose endpoint anybody has reached, so dynamic pricing did not work.
AND §11.4 MADE IT SILENT, which is why it survived to release: a parse error
becomes no_data, becomes the static profile, becomes arbitrage off - the
CORRECT degradation, saying nothing about the cause. A site would have sat on
the fallback indefinitely.
AND THE SUITE COULD NOT SEE IT, because every fixture was built from the
parser's own assumption. test_price.py, test_price_history.py and
test_static_tariff.py each built `json.dumps({"data": [...]})`, a shape invented
here and published by nobody, so the checks graded the parser against the
parser's guess - a closed loop no coverage inside it could ever have escaped.
MUTATION.md habit 19 records it as the fourteenth instance of habit 4 and the
first where the fixture and the code under test shared a SHAPE, not a number.
WHAT CHANGED
* `_points_from_columnar_json()` - a SECOND function, not a sniff. A parser that
decides which document it received is one that can decide wrong, and a
wrong-but-parseable response then becomes a price. Feeding the row shape to
`generic_dayahead` is now a graded no_data.
* Unequal arrays are refused in both directions, with the two lengths in the
reason. `zip()` stops at the shorter list silently, and a truncated `price`
array would have produced a real series missing the evening peak - traded on
as complete, where no data at all switches arbitrage off.
* A unit that is not EUR/MWh is refused. The plugin converts with a fixed
0.001, and `MAX_PLAUSIBLE_EUR_KWH` only bounds prices that are too LARGE.
* `unix_seconds` are read as UTC instants explicitly; §9.3's Brussels rendering
stays in `assemble()` rather than being copied into the plugin, where a
mutation could not tell it from a no-op (M4 proved exactly that on a first
draft, and the line was removed rather than fenced).
* A published null is dropped and logged with a count; an all-null body is
no_data named by this parser rather than by the boundary validator.
FIXTURES
Two unmodified response bodies under `scenarios/`, captured by hand on
2026-09-10: quarter-hourly (2026-09-10, 96 points at 900 s) and hourly
(2025-09-10, 24 at 3600 s - the same endpoint before Belgium's 15-minute MTU).
`energy-charts-be-price-captures.json` records the URL, retrieval time, unit,
licence-as-returned and a sha256 of each, and the replay RE-DERIVES the
checksums, so a fixture edited to make a check pass stops being a capture. Data
CC BY 4.0, Bundesnetzagentur | SMARD.de, via Energy-Charts (Fraunhofer ISE),
published without changes - the same BE-zone licence check DATA-02 established.
The surviving invented fixtures are renamed `synthetic_row_feed` at every call
site. `engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy` are untouched
and have no capture: their URLs are unverified and withdrawing them is
TARIFF-07's open decision. Nothing should read their green checks as evidence
that those feeds parse.
MUTATIONS - 12, all red, no survivors. Purged tree, mounted `:ro`, `python3 -B`
(TEST-15). M1 drop the equal-length refusal; M2 make it one-sided; M3 drop the
unit check; M4 `assemble()` stops rendering at the Brussels offset; M5 read
`unix_seconds` as a naive local clock; M6 zero-fill a null instead of dropping
it; M7 drop the all-null refusal; M8 sniff for the row shape; M9 drop the
deprecated warning; M10 drop the dropped-nulls warning; M11 edit one price in
the committed capture; M12 point `generic_dayahead` back at the row parser -
red across all three price suites.
END TO END, LIVE, 2026-09-10: `resolve_tariff()` returns a `TariffPlan`,
`origin='provider'`, 96 quarter-hourly points, spacing [900] s, no decimation,
first point 2026-09-10T00:00:00+02:00 at 0.30608 EUR/kWh all-in. FCST-04's H1
fix was never theoretical. All 29 test files green.
STILL OPEN: `generic_dayahead` asks for no date range, so the default URL
returns one published day - a full 48 h horizon is reached only through §8.3's
carry-forward fill.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
958f5cd218 |
Merge pull request 'FCST-06: cannot-persist is one question, asked after the attempt' (#75) from FCST-06 into release/1.0
The module claimed 'a store that cannot persist records nothing' and enforced it against `not self.ok` only. A store with ok=True whose _save() fails - a full volume, a read-only remount - kept answering yesterday() from memory until the next restart, which is the silently-forgetting cache the docstring rejects, reached by the condition the rule did not name.
Reproduced against real volumes rather than a monkeypatch: /data bind-mounted read-only (EROFS) and a full tmpfs (ENOSPC), with a healthy-/data control asserted either side. Before: yesterday() returned a 24-point series while the disk held {}.
Fixed in the code, not the docstring - weakening the doc would have documented a cache that forgets without saying so as intended.
The design answer is the part worth keeping: 'can this store persist' CANNOT be answered before the attempt, because a directory that was writable a moment ago is not a write that completed, so os.access or a probe write is a third symptom test wearing a predicate's name. It is answered after, once: _save() -> bool, true only if the file now holds what memory holds. The `not self.ok` test in _remember() is GONE rather than joined by a second one, and a check asserts its absence from the source. This is the same shape as FCST-04's M4 and FCST-05's HIGH - a rule enforced on the conditions someone thought of - and it is the first of the three to be closed by removing a test rather than adding one.
What is undone is the MERGE, not the store: a volume that fills at 14:00 leaves the yesterday written at 13:30 still answering. The snapshot is a deepcopy - measured, a shallow copy let a store holding 12 persisted points answer 24.
Evidence: 29/29 green. Six mutations, no survivors, purged tree each run with the mutated line greped off disk. One of them exposed an instrument defect now recorded in MUTATION.md: it printed 1 FAIL when the true figure was 3, because an f-string operand called len(read.points) and .points on a no_data raises Absence by design, killing the harness mid-section. One check earlier and it would have read as a survivor. A mutation's red count is a lower bound until the run is seen to reach the end of its section.
|
||
|
|
c45893ffad |
FCST-06: "cannot persist" becomes one question, asked once, about the outcome
checks / unit (pull_request) Successful in 7m14s
price_history.py's docstring states its rule unconditionally - "A store that
cannot persist now records nothing, and both of its answers say the same
thing" - and FCST-04 enforced it against `not self.ok` only, which is one
SYMPTOM: the durable volume is missing. `ok` is about the DIRECTORY, and a
read-only remount and a full disk both leave it True. Reproduced on
|
||
|
|
5059e28860 |
Merge pull request 'FCST-05: one byte stops stopping the add-on, and the write path gets the read path's standard' (#74) from FCST-05 into release/1.0
Both defects were found by the QA pass on merged FCST-04, and both are the same shape: a corrupt price cache taking the controller with it. HIGH: _load() caught (JSONDecodeError, OSError) and UnicodeDecodeError is neither - it is a ValueError. One non-UTF-8 byte in /data/price_history.json escaped to Controller.__init__, which is unguarded, and the add-on did not start. The except is now (ValueError, OSError) - the content family beside the bytes family - rather than the two members known today, because a third ValueError subclass nobody thought of is exactly how this arrived. The new fixture writes real bytes and asserts the old fixture's string IS valid UTF-8, so the pair cannot collapse back into one. MEDIUM: a malformed `start` under the day the feed was writing raised out of _remember() on every refresh forever - the add-on stayed up while the plan retired after 90 min and arbitrage stayed off, with no self-heal, and `words` reported the store healthy throughout. Read and write paths now share one validator, and the merge key is the validated instant (which also fixes a lexicographic sort putting the 25-hour day's second 02:00 first). Also: the row counts what it says it counts, and two false claims of strengthening in _audit's docstring are corrected - a differential over the realisable domain returns 0 weaker, 0 stronger, and that differential is now a committed check with FCST-03's audit frozen beside it. PROC-01: a right decision with a fabricated reason is still a defect. _audit completeness was NOT attempted, and the reason is measured rather than asserted: the rule as stated takes the horizon checks red on the shipped fill and a healthy feed, because `published` is the whole read while the fill emits only [since, until). The rule is wrong, not merely absent; making it right needs the window in the signature. Separate card. Evidence: 29/29 green. 12 mutations, all red, each on a fresh tree copy fingerprinted against clean. One survivor was found and closed during the work - two rungs reported into one last_error, so a -1 byte deletion on the half of the fix that tells an operator the cache self-healed survived the whole suite. TEST-16's masking pattern, inside a check written to close a false-health defect. Recorded in MUTATION.md. |
||
|
|
375a1e29d2 |
FCST-05: one byte stops stopping the add-on, and the write path gets the read path's standard
checks / unit (pull_request) Successful in 7m10s
HIGH - a non-UTF-8 byte in /data/price_history.json stopped the add-on
from starting. `_load()` caught `(json.JSONDecodeError, OSError)`.
`UnicodeDecodeError` is `ValueError -> UnicodeError -> UnicodeDecodeError`:
neither of those, and `issubclass(UnicodeDecodeError, OSError)` is False. So
it escaped `_load()` -> `_open()` -> `PriceHistory.__init__`, which
`Controller.__init__` does not guard - breaking `_load()`'s own stated
contract, "a corrupt cache must not stop the controller". The handler now
names the ValueError FAMILY (the file's content) beside OSError (its bytes),
because a third subclass nobody has thought of is how this arrived the first
time. The suite could not see it: its "unreadable file" fixture writes
"{ this is not json", which is valid UTF-8 and fails one step later, in
`json`. A fixture holding a genuinely undecodable byte is now beside it,
with the difference between the two asserted as a precondition.
MEDIUM - a malformed `start` under the day the feed is writing to killed the
tariff plan permanently. `_remember()` merged with `{q["start"]: q ...}` and
sorted on `q["start"]`, trusting a value read off /data to be a hashable,
sortable string. An int, null, a dict or a list raised TypeError out of
`carry()` -> `resolve_tariff()` -> `run_tariff_refresh`, which catches
Exception - so the add-on stayed UP while every refresh raised forever, the
plan retiring after TARIFF_MAX_AGE_S with arbitrage and the cheap window off
for good, and no self-heal because `_remember()` raised before `_save()`.
The read path was properly defensive throughout, which is why `yesterday()`
survived and the existing fixture passed - it files its malformed rows under
a day the feed is NOT writing to. Both paths now share one validator,
`_point_of()`. A row that is not a point is dropped, reported, and written
back out clean.
...and the status row said the store was healthy while the plan was dead:
`words` read '2026-01-15 (1 h)'. `_load()` now counts the rows inside a kept
record as well as the record itself, so the store stops reporting plain
health from the moment it is opened; the note names what was FOUND at open
and what was DROPPED by the refresh, and clears once the file is rewritten.
...and the merge key is the validated INSTANT rather than its spelling,
which is what rule 4 already said. The lexicographic sort beside the string
key also put the second 02:00 of the 25-hour October day (+01:00) before the
first (+02:00); stored order only, since `_parse_points()` re-sorts.
LOW - the status row counted POINTS and printed them under the HOUR's name:
'2026-01-13 (96 h)' for a 24-hour day on the quarter-hourly feed this add-on
already supports. It is now '96 points over 24 h' - two facts, each under
its own unit.
LOW - two stated reasons were false. `_audit`'s "WHY IT IS STRICTER" claimed
the rework refused the same published point emitted twice and a carried
point sharing a published hour, "which the old count never looked at". A
differential over the whole domain the audit can express finds ZERO
divergence in both directions: the old `seen` count already refused the
first (one point twice is one hour twice) and the old IDENTITY rule already
refused the second. The reworked audit is EQUAL to FCST-03's, not stronger;
the only behavioural change is the intended relaxation, and it stays. The
differential is committed as a check rather than left as prose.
...and the audit's real limit is named: it does not check COMPLETENESS. Fed
the decimation defect FCST-04 was raised for it ACCEPTS, and it accepts an
empty output. So `_audit` is not what stops that recurring - the count and
identity checks are, and the text now points at them. Not made complete:
`published` is the whole read while the fill emits only [since, until), so
the rule as stated is wrong, and building it takes the 1/12/48 h horizon
checks red on a healthy feed. Measured, not assumed.
12 mutations, purged tree, every one RED, no survivors. All 29 test files
green. Nothing deployed; no option added; config.yaml untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
c2787da539 |
Merge pull request 'FCST-04: the carry-forward fill reaches the plan, and the day it carries survives a restart' (#73) from FCST-04 into release/1.0
Review found 2 HIGH, 4 MEDIUM, 12 LOW - all driven to a concrete failure, all fixed on the branch.
H1 was a regression this PR introduced: fill_from_yesterday keyed the published set as {_utc_hour(p): p}, so a sub-hourly feed lost three points in four. eneco_eddy is the Belgian imbalance product and settles per quarter. Measured on 96 quarter-hourly firm points: 96 -> 15 points, spread 0.143 -> 0.014 EUR/kWh. The fix passes every published point through by identity and carries only whole hours with no coverage at all; _audit's one-per-hour rule became two rules that subsume it, so it caught MORE after the change than before - a repeated published point and a carried point sharing an hour with a real print both now raise, and neither was checked before.
H2 falsified the branch's own 'a broken cache is not a broken controller': a day record that parsed but was not a dict raised out of words(), which is unguarded in Controller.__init__, so the add-on did not start and could not self-heal.
Two of the branch's own checks were decoration and are recorded as such in DECISIONS rather than quietly repaired: the prune mutation survived because the fixture seeded one day and never reached the code it graded, and the AST ordering check compared ast.walk BFS depth rather than source order - shipped and reversed scored identically. Both are now driven by behaviour.
Evidence: 29/29 test files green. 20 mutations driven across the rework, fresh tree copy and __pycache__ purge before each per MUTATION.md/TEST-15. No survivors.
|
||
|
|
b59f5ce31b |
FCST-04: the carry-forward stops decimating a quarter-hourly feed, and a broken cache stops stopping the add-on
checks / unit (pull_request) Successful in 7m22s
Rework of the FCST-04 branch against review. Every finding below was proven by a runnable script before it was touched and re-run after; every check added or repaired was driven red by a mutation, `__pycache__` purged first (MUTATION.md, TEST-15). 29/29 test files green. ⚠️ H1 - A SUB-HOURLY FEED WAS DECIMATED TO ONE POINT AN HOUR, AND THE SPREAD WENT WITH IT. `fill_from_yesterday()` keyed the published set as `{_utc_hour(p.start): p for p in r.points}` - LAST POINT OF THE HOUR WINS, the other three dropped. `eneco_eddy` is the Belgian imbalance product and settles per QUARTER, and `_validate_raw()` accepts any strictly-increasing spacing, so this is a shape the product supports today. Measured on a 96-point quarter-hourly feed - ALL FIRM, FULLY PUBLISHED, nothing to carry: the plan reached the optimiser as 15 points, the arbitrage spread fell from 0.143 to 0.014 EUR/kWh, and the cheapest "hour" became a `:45` point whose `at()` window runs into the next one. Not the windowing effect this card is about; a feed losing 84 % of its own prints, with no flag anywhere reporting it. The fill now decides only WHICH WHOLE HOURS NEED CARRYING: every published point inside the window passes through BY IDENTITY, and a carried point is added only for a whole hour with no published coverage at all. Same feed, same fixture: 60 of 60 firm points kept, spread 0.134 over the hours still ahead. ⚠️ AND `_audit()` GOT STRICTER, NOT LOOSER - that was the condition on doing it at all. The old post-condition refused any repeated `_utc_hour`, a count that cannot express four points in one hour. Two rules replace it and SUBSUME it: a published point must BE one of that hour's published points by identity and may be emitted only once; a carried point may stand only in an hour absent from the published set, must be flagged True, and may not repeat. So the duplicated carried hour it existed to catch still raises, the same published point emitted twice now raises, and a carried point sharing an hour with a real print now raises too - which the count never looked at. Three mutations, three reds. ⚠️ H2 - A DAY RECORD THAT WAS NOT A RECORD STOPPED THE ADD-ON FROM STARTING. `_load()` validated `version` and that `days` is a dict, never that each VALUE is one. `{"version": 1, "days": {"2026-01-14": "oops"}}` loaded as ok=True, last_error=None, and then `rec.get(...)` raised AttributeError out of `words`, `yesterday()` and `resolve_tariff()` alike. `PriceHistory(...)` and `.words` are both unguarded in `Controller.__init__`, so the controller did not come up - and it never self-healed, because `_remember()` raised on the same record before it could reach `_save()`. That falsifies the CHANGELOG's "A BROKEN CACHE IS NOT A BROKEN CONTROLLER" in the one direction that matters. The asymmetry that produced it is visible in the file: `_parse_points()` is carefully defensive about every field of a POINT and `_load()` was not defensive about the record holding them. Unusable records are now dropped and REPORTED (`words` says how many and why), and `words` is total by construction as a second line. M3 - `yesterday()` WAS OFF BY ONE DAY FOR 00:00-00:59 AFTER SPRING-FORWARD. `brussels()` renders at the FIXED offset for that instant, so `brussels(stamp) - timedelta(days=1)` subtracts 24 ABSOLUTE hours - and the Brussels day before the March transition is 23 hours long. At 2026-03-30T00:30+02:00 it returned 2026-03-28's prices as "yesterday", or `no_data` naming a date that is not yesterday. The arithmetic is now on the local DATE, which has no offset to lose, and both transitions have a check - the module's headline claim had none. M4 - WITH NO /data THE STORE STILL FILLED, IN MEMORY. `_save()` short-circuited on `not ok` while `_remember()` populated `self._days` anyway: `words` said "unavailable" and `yesterday()` answered A SERIES that is gone on the next restart. The module docstring rejects exactly that ("a yesterday that silently forgets") as its reason for not calling makedirs, so the code now matches the docstring rather than contradicting it. M5 - THE PRUNE MUTATION SURVIVED BECAUSE THE STIMULUS WAS DEGENERATE. §8's clock-jump check seeded ONE day, so `if len(self._days) <= KEEP_DAYS: return` fired and the body it is named after never ran: pruning by arithmetic against the clock - which empties the store on exactly the morning the fill is for - passed the whole 29-file suite. The fixture is now written to a file and reopened (`_load()` does not prune, which is how a store legitimately holds more than the cap) with KEEP_DAYS + 2 days, and the mutant is red. M6 - THE AST ORDERING CHECK COULD NOT CATCH A REVERSAL, AND TWO DOCUMENTS CITED IT AS A GUARANTEE. `_calls.index("_remember") < _calls.index("yesterday")` grades `ast.walk` BFS DEPTH: `self._remember(...)` is a statement and `self.yesterday(...)` is an ARGUMENT of the fill, one level deeper, so `_remember` came out first whichever statement was written first - shipped and reversed both scored True. It is now line numbers AND, the half that matters, the behaviour: a feed carrying yesterday alongside today into an EMPTY store must fill the whole horizon; reversed, the lookup finds nothing and the plan is only the published hours. Reversing the two statements is red on both. L14/L9 - KEEP_DAYS = 3 IS THE STEADY STATE EXACTLY, so a newest-N cap has no slack: a feed publishing to D+2, or ONE stray point in the wrong year - which sorts LAST and can never age out - pushes yesterday out for good. `_prune()` now PINS the two days a lookup can actually ask for on top of the cap. Pinning is additive, so it can only RETAIN and the WITNESS-03 property (nothing is deleted by clock arithmetic) is preserved by construction; `stamp` stops being a dead parameter; the store is bounded at KEEP_DAYS + 2. L8 - the horizon end was not floored while `since` was, so a 48 h request made at any instant off the hour returned 49 points. §1's count was green only because its NOW was exactly :00:00, a shape production never has; the fixture now sits at :17:43. L10 - a failed `_save()` leaked its temp file; at 48 writes a day a persistent write failure leaked 48 inodes a day onto the volume that was already the problem. L15 - `{"version": 1.0}` passed, since `1.0 == 1`. L16 - the `_remember` tree walk scanned `app/*.py` only and matched a bare string; it now parses `app/`, `tools/`, `scenarios/` and `estop/` and looks for the NAME, so a comment does not fail it and a call in `tools/` cannot pass unseen. CLAIM-ACCURACY DEFECTS, WHICH THIS REPO TREATS AS REAL: * L7. DOCS said "*Tariff* on the status page reads `provisional` through the morning". FALSE - `tariff_words` is assigned only from the HA price-entity branch and from `static_status()`, both on the 30 s tick, and the plan's own sentence reaches `_LOG.info`. The doc now says what an operator can see (the Price history row, the Arbitrage row's own refusal sentence, the refresh log line) and a check pins the PAIRING - wiring the plan into the row is fine, correcting the doc is fine, doing one without the other is red. * L11. "fills EVERY unpublished hour" is conditional: a partial yesterday leaves the local hours it never held out of the series (§11.4 for those hours, and `at()` raises rather than answering). Correct behaviour, overclaimed wording, and untested - all three fixed. * L12. DECISIONS §1 leaned on `TariffPlan.cheap_now()`, which has NO PRODUCTION CALLER on the provider plan (`self.cheap` comes from `static_status()`, which builds its own). The conclusion holds on `arbitrage()`'s firm-pair refusal alone, which does reach control; the evidence is now stated at the reach it has. * L13. §1's "passed through by identity" check compared `eur_per_kwh` between two SEPARATE `resolve_tariff()` calls, where `is` could never have held. The value comparison is kept and now says what it grades; identity is graded on a read the file constructs and still holds. * L17. `PriceHistory.words` and the new status row had no check at all - and `words` is one of the two things H2 crashed. Both now have one. * L18. main.py's inline note carried the "widens what the optimiser can SEE, never what it may act on" guarantee without the windowing caveat that the CHANGELOG, DOCS and `resolve_tariff()`'s docstring all carry. It is the one place a maintainer reads it unqualified. EVIDENCE. All eight reviewer proofs re-run: p1/p1b return 29 March, p4 keeps 60 of 60 firm points at spread 0.134, p5 starts and reports, p6 D leaves no temp, p6 E refuses 1.0, p6 F records nothing, p7 returns 48 for 48. p2's script still scores True/True because it re-implements the OLD algorithm - the reversal is now driven as a mutation instead, and is red. p3's script always showed the shipped code correct; what was wrong was the fixture, and its mutant is now red. Twenty mutations driven, no survivors, tree fingerprinted and `__pycache__` purged before each: decimation (2 suites red), audit x3, unfloored `until` (2), unvalidated day records (7 checks), instant-subtraction yesterday, in-memory fill on a volume-less store, clock-arithmetic prune, cap without the pin, leaked temp, loose schema type, empty `words` (5), reversed `carry()` (2), a `_remember` leak in tools/ spelled two ways, a neighbour-filling fallback, a copy instead of identity (2), the DOCS claim in both directions, and the caveat removed from main.py. Baseline and the unmutated tree both green. No option added, config.yaml untouched, and the CHANGELOG entry still picks no version: PKG-07's rule, and only the deployer knows whether 0.9.0's image has been pulled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |