PKG-18-cut-0.12.0
312
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
20f9f4969d |
TEST-16: pin the two one-sided arbitrage rungs, and un-mask them from each other
checks / unit (pull_request) Successful in 3m51s
SAFETY-26's AC4 gave the same tolerance mutation to every price and quantity
boundary in arbitrage.py and static_tariff.py. Everything in arbitrage.py went
red - `price < 0`, `soc <= floor_pct`, `soc >= ceiling_pct` are each red under
+/-5. Two survived, both in static_tariff.py, and this card closes them.
D `spread == 0.0` (:912) weakened to `spread <= 0.001` SURVIVED at +2 B.
F `len(firm) < 2` (:901) weakened to `len(firm) < 1` SURVIVED at +0 B - the
delta MUTATION.md names as the risky one.
THE DEFECT IS NOT EITHER ROW, IT IS THE INTERACTION. D and F MASK EACH OTHER. A
single firm hour also has a spread of 0.0, so with F weakened the flat rung
refuses the stimulus anyway; with D weakened the small-but-real spread is still
refused, only for a lie. Each rung's failure is absorbed by the other, so BOTH
can be weakened together and a suite grading `kind == "refused"` reports
nothing. That is structurally invisible to single-mutation testing, which is how
this pair came through four rounds of review on TARIFF-03 and FCST-03 - and it
is the arrangement in which the next edit to either rung opens a real hole with
nothing to report it.
D is not cosmetic either. With the tolerance in place a series priced 0.20000 /
0.20050 - a REAL 0.0005 EUR/kWh spread - flips ALLOWED to REFUSED at
min_spread_eur_kwh=0.0, a value arbitrage()'s own docstring documents ("pass 0.0
to mean any positive spread, explicitly") and its validator at :861 accepts on
purpose. At a shipped threshold the outcome does not move, but the operator
reads the false sentence "the tariff is flat - every one of the 2 firm hours
costs 0.2000 EUR/kWh" about a tariff that is not flat.
WHAT WAS ADDED. test_static_tariff.py section 9, and the shape of it is the
argument:
* THE REASON IS GRADED, NOT THE VERDICT. The masked case refuses CORRECTLY
while reporting the wrong sentence, so a check asserting only "refused"
passes on a broken tree. A `rung9()` helper names which refusal answered,
from the sentence the operator would read; an unrecognised refusal reports
itself as UNATTRIBUTED rather than quietly matching nothing.
* EACH RUNG IS GIVEN A STIMULUS THE OTHER CANNOT ABSORB, or the pinning is
circular. The flat rung gets a THREE-firm-hour flat series, which no
`len(firm) < N` a person would write can reach. The firm-pair rung gets a
ONE-FIRM-HOUR series, which the flat rung CAN reach - so its attribution is
the whole check, and that is why it is written as a rung name rather than a
verdict.
* AC1 is a sweep of small-but-real spreads (0.0005 down to 0.000001) all
ALLOWED at min_spread_eur_kwh=0.0, with the exactly flat series on the other
side still refused BY THE FLAT RUNG - without that mirror the sweep would
also pass for a rung deleted outright. 1e-6 is the floor because the shipped
line rounds the spread to 6 decimals; that quantisation is graded in the
open as its own row rather than left unstated.
* AC3 is a five-row table graded on rung attribution, which denies the masking
from both sides at once.
* The firm-pair count is also pinned from ABOVE (`< 3`) and BELOW (`< 0`),
which the criteria do not ask for but which is what stops this section being
one-sided in exactly the way it was written to fix.
EVIDENCE. Eight rounds through TEST-15's tools/mutation_run.sh, used UNCHANGED:
recursive __pycache__ purge before each run, python3 -B, the tree re-copied FROM
DISK between rounds, the mutated line grepped out of the file before the run,
byte delta printed, test_static_tariff.py and test_arbitrage.py graded each
time. Baseline green.
baseline, shipped +0 B PASS
D1 `spread == 0.0` -> `<= 0.001` +2 B RED, 3 checks
D2 `spread == 0.0` -> `<= 0.000001` +5 B RED, AC1 alone
D3 `spread == 0.0` -> `< 0.0` (rung gone) +0 B RED (section 2 as well)
F1 `len(firm) < 2` -> `< 1` +0 B RED, 3 checks
F2 `len(firm) < 2` -> `< 3` +0 B RED, 6 checks
F3 `len(firm) < 2` -> `< 0` (rung gone) +0 B RED (ValueError)
D1+F1 BOTH AT ONCE - THE TICKET +2 B RED, 5 checks
Four of the seven are +0 B, the delta MUTATION.md names as the one stale
bytecode can hide. All are REDS, the direction that defect cannot fabricate -
but they were run under the purge anyway, because "it went red so the cache
cannot have lied" is a conclusion and not a procedure.
NO SHIPPED BEHAVIOUR CHANGED. app/ is untouched; both rungs were correct all
along, and the card is explicit that a rung found actually wrong is a stop-and-
report rather than an edit. Also updated: MUTATION.md gains a short section on
masking as a SECOND way a survivor lies, with the two rules that fall out of it
(grade the reason, and mutate suspected pairs together); and test_arbitrage.py's
SAFETY-26 AC4 note is marked closed rather than left reading as an open finding.
UNSURE ABOUT. The 5e-7 row asserts the shipped `round(spread, 6)` quantisation.
That is a real property of the line and worth stating, but it does pin a
rounding the ticket never named - if a reviewer would rather that stayed
unpinned, it is one check to drop and nothing else depends on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
b68f288fef |
TEST-11: pin the clamp guard to the write it reads
checks / unit (pull_request) Successful in 3m40s
check_output_clamp() sits one line below `await self.write_setpoint(self.target)` in cycle(), and moving it above the write left test_clamp.py, test_freeze.py and test_loop.py ALL GREEN. Measured on the move itself before this commit; measured again after, where test_clamp.py part 6 goes red and the other two are still green - the gap was real and it was only ever in this file. WHAT IS ACTUALLY LOST. The guard has two evidence sources. `self.loop_clamped` is set from the Decision well above the write and survives the move, and on the 10.3 runaway replay it is the source of every engagement the alarm reports (0 write-path engagements against 2 loop-side ones) - which is exactly what makes the move nearly invisible. `self.clamp_last` is assigned by write_setpoint() and by nothing else, is None until it has run, and is what `engaged_write` reads at main.py:856. Moved above the write, that consumer goes quietly false and a DEVICE-bound clamp - max_w above the inverter's own rating, SAFETY-20's install - is reported by nothing. A guard whose second evidence source can be silently disconnected is one refactor from being half a guard. WHAT THE CHECK PINS, AND WHAT IT DELIBERATELY DOES NOT. Not the position: the comment at that line warns against tidying SAFETY-03's ordering and SAFETY-18's into one rule, and a check a rename breaks is worse than none. It pins the CONSEQUENCE - what `self.clamp_last` held each time the guard consulted the watchdog - using a spy subclassed from the shipped ClampWatchdog over a slew-limited ramp where every cycle's command is a different number, so "this cycle's evidence" and "last cycle's" are distinguishable at all. The spy hooks `clamp_wd.note()`, the consumer, rather than the guard's name: that is what keeps a rename legitimate. The same assertions are re-run through a controller whose guard is renamed and whose write lives in a helper, and were verified against main.py itself - renaming the method throughout AND moving both calls into a new `_emit_and_guard()` helper keeps part 6 green; swapping the two calls turns it red on a line that names clamp_last. WHY AC 3 NEEDED A NEW STIMULUS, AND WHY IT IS NOT DRIVEN THROUGH cycle(). Through the real cycle() the write-path half cannot engage: the arbiter files the device range and max_w as P_SAFETY LIMIT claims, so resolution.target_w is already inside both bounds before write_setpoint() re-applies them. That duplication is deliberate defence in depth; part 6 now MEASURES the zero engagements rather than leaving it as a claim in a comment, and then drives the write path directly at max_w 5000 against a +-2500 W device - the wire carries 2500 W, every loop-side flag stays clean, and the alarm fires on clamp_last alone. With the two calls swapped the same stimulus clamps the same value and the alarm is silent, which is the ticket in one pair of runs. main.py gains one comment sentence naming the check, mirroring the one SAFETY-18's block already carries for test_freeze.py part 5. No behaviour changes. Full suite: 17/17 green in goodwe-addon-ci. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e8fb596b99 | Merge pull request 'TEST-10: pin max_w beside the meter_freeze_s key TEST-06 already pinned' (#35) from TEST-10 into release/1.0 | ||
|
|
d0b053b150 |
TEST-10: pin max_w beside the meter_freeze_s key TEST-06 already pinned
checks / unit (pull_request) Successful in 3m43s
max_w and the LIVE_MAX_W literal beside it were the two survivors left
after TEST-06 pinned meter_freeze_s: changing max_w 2000 -> 4500 in
config.yaml passed every check in test_freeze.py and test_tariff.py
GREEN, and LIVE_MAX_W = 5000.0 was a bare literal that silently
disagreed with the shipped max_w (2000) with nothing in the file
saying so. For contrast, deadband_w 15 -> 5 already went RED 2 + 1,
so the pinning technique was known to work - it just was not applied
to the key either side of it.
Three changes, each measured:
1. test_freeze.py gains a check comparing Tuning()'s code default
(control.py, 2000.0) against SHIPPED_MAX_W, read from config.yaml
by extending the file's existing _cfg_default() regex reader
rather than reimplementing test_arbitrage.py's _shipped. Broken
and confirmed red: max_w: 2000 -> 4500 in config.yaml now fails
exactly "the code default and the shipped default agree on max_w
too" and nothing else in either file; reverting returns both files
to all-green. (AC 1, AC 4)
2. LIVE_MAX_W is renamed LIVE_INSTALL_MAX_W in both test_freeze.py
and test_tariff.py, each with a comment stating plainly that 5000
is the live install's own override, not config.yaml's shipped
max_w (2000) - the defect was a literal that could be misread as
the shipped default, not the number itself. (AC 2)
3. test_freeze.py states PINNED_KEYS = ("meter_freeze_s", "deadband_w",
"max_w") in one place as the complete set of config keys this
suite pins. Deliberately excludes maintenance_* - CFG-03 (merged
into release/1.0 since this branch started, merged in here) gave
those keys a separate single source of truth in app/maintenance.py
and this ticket has no reason to duplicate that pin. Not every key
in config.yaml is pinned here on purpose: meter_freeze_s,
deadband_w and max_w are the three this file's mutual-exclusion
arithmetic (section 2b) actually reasons about together. (AC 3)
Full 17-file suite run green under goodwe-addon-ci after the merge
with release/1.0 (
|
||
|
|
f630ed949b | Merge pull request 'CFG-03: give maintenance option defaults one source of truth' (#34) from CFG-03 into release/1.0 | ||
|
|
d5b1ef19b3 |
CFG-03: give maintenance option defaults one source of truth
checks / unit (pull_request) Successful in 3m44s
Option defaults for the maintenance cycle were declared in three places that
could disagree, and MAINT-04 proved they already did. config.yaml shipped
`maintenance_charge_w: 2000`; main.py carried TWO `opts.get(..., 2500)`
fallbacks; MaintConfig's dataclass carried a third `2500.0`. So an options dict
with the key ABSENT was ACCEPTED by validate.py's rule 6 - which read 2000 -
and then built by main.py at 2500, against a 2000 W ceiling:
options dict with maintenance_charge_w ABSENT, max_w=2000
validate_startup_config -> [] (accepted, no problems)
MaintConfig().charge_w -> 2500.0
arbiter.resolve() -> 0.0 W, contradiction=True
That is the bug rule 6 exists to refuse, passing rule 6. It is unreachable on a
Supervisor install, which always supplies every key from config.yaml, and fully
reachable from sim/inject.py and from the test suite - which is where anyone
debugging maintenance actually works.
MAINT-04 shipped a TRIPWIRE and said so: test_validate.py pinned all three
literals and asserted the consequence through the real arbiter, so the
divergence could not silently widen or be half-closed. Its PASSING CONDITION
WAS THE BUG. Both it and validate.py's comment said to delete each other when
the divergence was closed. It is closed, so both are deleted.
THE DIVERGENCE WAS WIDER THAN ONE KEY. `maintenance_discharge_w` had the
identical three-way split (config.yaml 2000, MaintConfig 2500) and the card
named only the charge side. Closing one and leaving its twin would have
satisfied the numbers and left three sources of truth one line down, which is
the state that produced the bug. Both are closed, and so is the whole family:
all eight `maintenance_*` keys go through the same mechanism.
WHAT THE SINGLE SOURCE IS. config.yaml's `options:` block - it is what the
Supervisor reads and what the operator edits, so it wins by definition. There
is now exactly one in-code copy, `maintenance.MAINT_OPTION_DEFAULTS`, which is
TARIFF-02's `PRICE_OPTION_DEFAULTS` and SAFETY-24's `PEAK_OPTION_DEFAULTS`
pattern applied to the family that named the card - validate.py's rule 8 comment
already called that "CFG-03 read as a rule rather than as a complaint", so this
makes the rule hold where it was first broken rather than inventing a fourth
mechanism for one option family.
A MIRROR RATHER THAN A RUNTIME READ, and the reason is mechanical: the
Dockerfile copies `app/` and `run.sh` into the image and NOT config.yaml. The
running add-on cannot open the file it is configured by, so there is nothing to
read at 03:00 on a client's Pi, and a YAML-subset parser that could fail at
import is a new way for the add-on not to start. What makes the copy safe is
that test_maintenance.py pins every entry by READING the shipped file - drift is
a CI failure, not a comment.
EVERY OTHER SITE NOW DERIVES FROM IT OR HAS CEASED TO EXIST:
* MaintConfig's eight dataclass defaults are indexed out of the mirror. No
numeric literal survives in the class that held the third opinion.
* `MaintConfig.from_options()` is the ONLY place in the add-on that reads a
`maintenance_*` option. main.py's eight inline `opts.get(key, <literal>)`
calls are gone: moving the read next to the mirror means the fallback cannot
be anything but the mirror, because there is no literal in sight of it to
disagree with. It uses `key in opts` rather than truthiness, so a shipped
`false` or `0` is honoured instead of being quietly replaced by the default
- this card's own bug in a different costume.
* main.py's SECOND fallback site - the cheap-window charge floor at the
P_MAINT_SHAPE claim - reads `self.maint.cfg.charge_w` instead of re-opening
the options dict with its own literal. The Controller had already parsed
this once in __init__; asking the object that owns it cannot drift from it,
and the rate the shaping asks for can no longer be a different number from
the rate the state machine drains at on the same install.
* validate.py's rule 6 and its `maintenance_discharge_w` warning take their
fallbacks from the mirror.
WHERE THE RANGE LIVES, STATED AND CHECKED. config.yaml's `schema:` block carries
`maintenance_charge_w: int(500,5000)` - a fourth declaration site of a different
kind. It is a BOUND, not a default, so it is not this bug; but leaving the
question open is how a fifth site arrives. The decision recorded in config.yaml
and in maintenance.py is: bounds live in the schema block ONLY, because the
Supervisor is what enforces them and the Supervisor reads only that file, so a
Python copy of `int(500,5000)` would be a place to disagree and nothing else.
test_maintenance.py checks both halves of that: every mirrored key must have a
schema entry - without one the Supervisor drops the option and the add-on
silently runs on the mirror instead of the operator's value - and every shipped
default must lie INSIDE its own range, since a default the schema rejects is a
config that cannot be re-entered once it is edited. What the schema grammar
cannot express is a relation BETWEEN keys (`maintenance_charge_w` against
`max_w`), which is exactly why rule 6 is written in Python and why it does not
belong in the mirror either.
THE REPLACEMENT CHECKS, AND HOW THEY WERE VERIFIED. test_validate.py's new
section exercises the same four consumers the defect ran through, the same way:
the validator ACCEPTS a dict with every maintenance key missing; a REAL
Controller built from that same dict holds 2000 W (through main.py, not through
a regex over its source); `MaintConfig()` answers 2000 W with no dict at all;
and the real arbiter emits `-2000 W, limited by supervised(max_w)` instead of
`0 W, contradiction=True`. test_maintenance.py pins the mirror to config.yaml in
both directions - equal values, and full coverage of every `maintenance_*` key
the file ships, so a ninth key cannot arrive unmirrored - and asserts the
consumers move as a RELATION rather than against numbers, reading MaintConfig's
defaults back out of `dataclasses.fields()`.
Each new check was verified by BREAKING the thing it watches and observing red,
this project's rule:
1. config.yaml -> 1500, mirror left at 2000: test_maintenance 1 FAILED,
test_validate 4 FAILED.
2. config.yaml AND the mirror -> 1500: green again, and every consumer moved
together - MaintConfig() 1500 W, the Controller 1500 W, and the arbiter
`-1500 W, limited by maintenance(cheap-window charge)` where it had said
`-2000 W, limited by supervised(max_w)`. That is AC4: change the single
source and watch the consumers follow.
3. `charge_w: float = 2500.0` retyped into the dataclass: 2 FAILED in each of
the two files.
4. `opts.get("maintenance_charge_w", 2500)` restored in main.py: 1 FAILED,
naming the file and the literal it found.
5. `maintenance_hold_min` deleted from config.yaml's options block: 3 FAILED.
Full suite 17/17 in goodwe-addon-ci, twice, once under parallel load.
Stale prose that asserted the now-closed divergence as current is corrected in
app/watchdog.py, test_watchdog.py and test_peakguard.py - watchdog.py's example
still uses 1500 W, but because the point of the example is a charge strictly
inside the ceiling, not because 2500 is what ships.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
b7267eb51a | Merge pull request 'SAFETY-25: say what peak_soc_floor bounds, and measure what it does not' (#31) from SAFETY-25 into release/1.0 | ||
|
|
e498dd4c1c |
SAFETY-25: pin the claim-source set, so the tripwire is one
checks / unit (pull_request) Successful in 3m53s
The durability claim this PR shipped was FALSE, and the reviewer of #31 disproved it by building the fix. Part 10 said, in four committed places, that it "goes RED the day a loop-side floor is built, so the exposure cannot change size under a green suite". The reviewer implemented the memo's own recommended Option B in main.py - a `Claim.limit("reserve", P_LOOP, hi=0.0)` filed under a `loop_soc_floor` - and the ENTIRE SUITE STAYED GREEN. Part 10 green. Mutation 13 went on printing "NOTHING PREVENTS IT, ON PURPOSE" and the same 21 % -> 10.0 %, on a tree where something DID prevent it. Pushed to an absurd always-binding 95 % reserve it was caught only by SAFETY-24's pre-existing part 7 wiring checks; part 10 was still green. THE CAUSE IS STRUCTURAL, not a missing assertion. `replay_loop()` hand-builds main.py's claim list as a literal `[supervised, loop, peakguard.claim(...)]`, so it is blind to any claim main.py ADDS - which is exactly where AC 2 would be built. That is this repository's named failure mode: a check that observed something other than what it claimed to observe, shipped as a guarantee inside the document that recommends the change. THE FIX IS 10f: THE CLAIM-SOURCE SET, PINNED, two ways because the two fail differently. * STATICALLY, over `Controller.cycle()`'s own AST. Every `claims.append(...)` in it must be one the scan can read - an unreadable append FAILS rather than counting as nothing, which is how a scan like this goes quietly vacuous - and the sources it can file must be exactly {device, supervised, failsafe, safety, peak, arbitrage, maintenance, loop}. A ninth takes part 10 red on the line it is written, in every configuration, whether or not any fixture here reaches the state that files it. * DYNAMICALLY, through REAL `Controller.cycle()`s with main.py's OWN `resolve` spied - not a list this file built and hoped matched - swept over the WHOLE 0..100 % SoC range in the two states 10a-10c replay. Idle: exactly {device, supervised, loop}. Floor-limited: exactly {device, supervised, loop, peak}. A claim conditioned on a low pack, which is the shape a loop-side floor has, cannot hide from a sweep that visits 0 %. And the fold is checked rather than assumed: main.py's two P_SAFETY rating limits are both symmetric LIMITs, so replay_loop() modelling them as one `supervised` claim at the tighter bound is exact. MEASURED AGAINST THE COUNTEREXAMPLE. Reproduced first on the unfixed tree (green, as reported). On this tree the reviewer's Option B at its recommended default takes part 10 RED on 3 checks, and the 95 % variant on 6. Nine mutations were run in all, each against a freshly copied tree with no `__pycache__` present, mounted `:ro`; all nine red, no survivors, no `+0 B` delta. MUTATION.md records why a second round was needed: the first round's mutations were all aimed at DISARMING the guard, while the claim part 10 actually made was about the guard being FIXED. The four false sentences are replaced with what is now true, and each says WHICH half catches WHICH shape - 10f for an arbiter claim (Options B and C), 10a-10c for a clamp inside control.compute() or the guard (Option D), which the replay drives directly. AC 2 IS STILL OPEN AND NOTHING HERE BUILDS IT. The loop-side floor was a test mutant, reverted before commit. The memo still says "Status: OPEN. This is a memo, not a decision." Also, non-blocking, from the same review: the NOT READY banner had inherited the new scope sentence and become a ~550-character paragraph that the row three lines below repeated verbatim. A checks() row may now carry an optional short `banner` form; the floor-limited row does, and `status_payload()` prefers it. THE ROW IS UNCHANGED - shortening that would have been weakening AC 3 - and rows without the key are unaffected. Graded, and mutated: dropping the short form goes red. Merged origin/release/1.0 first (CI-02), keeping both tickets' CHANGELOG entries. Full suite green: 17/17 in goodwe-addon-ci (test_ci_gate.py needs `git`, which CI-02 added to .gitea/ci/Dockerfile but the local image lacks; run with it installed and it passes). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3cb9d3b7d9 |
Merge origin/release/1.0 into SAFETY-25
CI-02 landed on release/1.0 after this branch was opened and both tickets add a block under `## Unreleased`, so CHANGELOG.md collided. Both entries are kept, newest-first: SAFETY-25's, then CI-02's, then SAFETY-20's. Nothing else conflicted. |
||
|
|
99321892cb | Merge pull request 'CI-02: grade the merge, and stop a cancelled run reading as a test failure' (#30) from CI-02 into release/1.0 | ||
|
|
182102eeaf |
SAFETY-25: say what peak_soc_floor bounds, and measure what it does not
checks / unit (pull_request) Successful in 3m41s
peak_soc_floor reads like a pack-wide reserve and is not one. The
grid-following loop has no SoC guard of any kind, so on a sustained-load
evening it discharges straight past that number and keeps going until the
pack's own BMS cut-out stops it.
The gap has now been written down four times without being filed -
SAFETY-18's finding (freeze.py:18), SAFETY-24's module note, its PR body,
and its honestly-uncaught 13th mutation - and never once measured. It is
measured here, on the committed 2026-08-20 capture, driving the SHIPPED
control.compute(), peakguard.decide(), peakguard.claim() and the real
arbiter.resolve() in main.py's order:
19:00-22:00 UTC, 830 W mean, minimum 701 W, zero export in the window
reference pack (300 Ah x 51.2 V x DoD 90) started at 21 %, one point
above peak_soc_floor
ends at 10.0 % - the bms_soc_floor cut-out
1525 Wh delivered by the LOOP over 587 cycles
549 cycles below peak_soc_floor, 343 of them still discharging
0 cycles on which the peak guard filed any floor at all
AND IT IS A BEHAVIOUR, NOT A CONFIG VALUE. The same evening ends at
9.9715 % at peak_soc_floor = 5, 11, 20 and 60 - the shipped value and both
ends of the schema range - identical to four decimal places. The option an
operator would reach for cannot move the number by any amount at all. On
the same day's high-load block the two states coexist: 49 cycles reporting
PEAK GUARD FLOOR-LIMITED and, on all 49 of them, the arbiter simultaneously
granting the loop up to 2000 W of discharge.
WHY SAFETY-24 COULD NOT CLOSE IT. The peak guard files a `lo` limit - a
LOWER bound on the setpoint. That asymmetry is exactly what makes it
trustworthy: a reviewer proved across 19,800 combinations that it can never
lower a target, cap a discharge or contradict the arbiter. The same property
makes it structurally incapable of stopping a discharge the loop itself
wants. A floor on the guard cannot become a floor on the pack by being
raised, and the sweep above is that sentence measured.
WHAT THIS COMMIT CHANGES IS THE HONESTY, on the four surfaces an operator
actually reads. That half is required whichever way the open question is
answered, and it is the real misstatement on the live system today:
* config.yaml, beside the option itself - it BOUNDS THE PEAK GUARD ONLY
and is NOT A PACK-WIDE RESERVE, with the replayed evening and the sweep;
* DOCS.md, in the options table and in a new section carrying the figures;
* the STATUS-PAGE ROW for a floor-limited guard. This is the one line a
human reads at the moment the guard stops discharging, and read alone it
says "the battery is being held at peak_soc_floor" - which the replay
shows is false in every cycle that row is rendered;
* the PERSISTENT NOTIFICATION, which outlives the page. It now names what
has stopped (the guard buying a lower bill) and what has not (the pack
being spent).
peakguard.py and freeze.py carry the measurement beside the findings they
already recorded, so the next reader does not have to take the caveat on
trust.
THE PRODUCT DECISION IS NOT TAKEN HERE, and must not be. Whether the loop
gets a reserve floor of its own decides whether this house's battery may
discharge to the BMS floor on a heavy evening; that moves real energy and it
is the owner's call. DECISIONS/SAFETY-25-loop-soc-floor.md is the memo AC 1
asks for: the options, what each costs, what each protects, weighed against
MAINT-01 (a pack repeatedly at the BMS floor is the SoC-drift condition the
maintenance cycle exists to correct, and an over-tight reserve fights it from
the other side) and against the fact that this would be the FIFTH SoC key -
OPT-04's arbitrage_soc_floor fired SAFETY-24's "revisit if a third floor
appears" trigger unremarked, so SAFETY-27's naming decision should land
first. It carries a recommendation and says plainly that it is not a
decision.
It also records a trap found while writing it, and now CHECKED through the
real arbiter rather than described: the obvious shape of a loop-side floor
is a `hi` limit at P_LOOP, and filed while the peak guard is asking for a
positive floor that is `lo > hi` - which arbiter.py resolves to a
CONTRADICTION at 0 W, the battery PARKED in the one quarter of the month
that sets the bill. It is the same crossing-limits hazard
shaping_suspended() already exists to avoid. The ordering
`bms_soc_floor < loop_soc_floor <= peak_soc_floor` removes it provably,
because wherever the reserve binds the guard is already floor-limited and
files lo = 0; both halves are run in part 10d.
test_peakguard.py part 10 is a TRIPWIRE, not a guarantee. It records a
defect and goes RED the day somebody builds a loop-side floor, so the
exposure cannot change size under a green suite and closing it has to be
deliberate. Part 11's mutation 13 stays honestly NOT CAUGHT - nothing
prevents the loop discharging past this floor - but it now reports a number
instead of a sentence.
Verified: 16/16 suites green in the CI image. Eight mutations, each against
a tree copied fresh with __pycache__ excluded and mounted :ro, so no .pyc
existed to go stale (MUTATION.md's structural immunity, the same one it
records for CI); byte deltas +61, -8, -465, -430, -1088, -46, file deleted,
and -95; none is +0 B. All eight go red on the named check:
M1 peakguard.claim() also files hi=floor_w -> 7 red, incl. 10c
M2 control.compute() gain forced to 0 -> 6 red, incl. 10a/10b
M3 scope sentence removed from the status row -> 10e red
M4 scope sentence removed from the notification -> 10e red
M5 scope paragraph removed from config.yaml -> 10e red
M6 scope bullet weakened in DOCS.md -> 10e red
M7 the decision memo deleted -> 10e red
M8 arbiter clamps crossing limits instead of
reporting a contradiction -> 5 red, incl. 10d
AC 2 is left explicitly OPEN, pending the owner's answer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
1e4bad8aa7 |
CI-02: grade the merge, and stop a cancelled run reading as a test failure
checks / unit (pull_request) Successful in 3m44s
Two things a green from `checks / unit (pull_request)` was claiming without having observed. Both were measured on a deliberately conflicted throwaway pull request (#29, now closed and its branch deleted) rather than read off the workflow, because the Actions log endpoints 404 on this Gitea and a step's own conclusion is the only channel out of a job. CASE 1, AND THE TICKET'S MECHANISM IS WRONG IN A WAY THAT MAKES IT WORSE. The card says a conflicted pull request loses its merge ref, so that git fetch -q --depth 1 origin "$GITHUB_REF" \ || git fetch -q --depth 1 origin "$GITHUB_SHA" falls through to the second half and quietly grades the bare branch head. The symptom is real - PR #14 reported success while `mergeable: false` - but that is not how. Asking one question per step on the conflicted probe (runs 558, 559): GITHUB_REF == refs/pull/<n>/head P01 passed GITHUB_REF != refs/pull/<n>/merge P02 failed GITHUB_SHA == the pull request head sha P03 passed fetching GITHUB_REF succeeds P04 passed <- even conflicted ...and it lands on the bare head P05 passed refs/pull/<n>/merge does not exist P06 failed `git ls-remote origin 'refs/pull/*'` on this server advertises only `/head`, for open and closed pull requests alike. There are no merge refs here at all. The first fetch has therefore always succeeded and the fallback has never fired once; both halves name the same commit anyway. So the gate did not lose merged content on conflicted pull requests - it has never had merged content, on any pull request in this repository's history. Green has meant "this branch passed on its own", and with `block_on_outdated_branch` off on release/1.0 the base has usually moved underneath it. Reproduced end to end on one pull request, same conflict, gate swapped: run 567 the ORIGINAL gate -> success, "Successful in 3m38s" run 566 the gate below -> failure in 2s, merge step red, both test steps `skipped` The checkout now fetches the base tip and the pull request head, merges them, and refuses if the merge does not apply - as its own NAMED step, because /actions/runs/<id>/jobs carries a per-step conclusion and that is what lets a reader tell "nothing was tested" from "the tests spoke". `--depth 1` is gone: a merge needs a merge base, and a full fetch of this repository measured 6.8 MB and 1.1 s. The fallback survives only on the workflow_dispatch path, which has no pull request to merge and now announces which bare ref it took. CASE 2, AND `cancel-in-progress` IS NOT THE KNOB. Runs 531 and 533 on PR #14 were cancelled at 0s, each at the exact second the next run on that pull request was created: 531 created 17:08:19 `Waiting to run`, cancelled 17:10:44 = 532's creation 533 created 17:45:11 `Waiting to run`, cancelled 18:11:22 = 534's creation Both have `started_at` at the Unix epoch. A cancelled run posts `failure` on the required context, described `Canceled after 0s`; branch protection compares state, not description, so "nothing ran" and "your tests are broken" arrive as the same red wanting opposite responses. Three settings were measured, each with one run holding the single runner slot and two further pushes behind it: no concurrency block at all runs 561 and 562 both queued, both executed group, cancel-in-progress:false run 564 sat `Blocked by required conditions` and was cancelled at 0s the second 565 existed A shared group cancels a run that has not started regardless of that flag - GitHub documents it and Gitea 1.27 implements it. Having no group is the only configuration measured to leave a queued run alive, so the block is gone and the measurement is written above it. The cost is stated in the file: a developer who pushes four times occupies the shared runner four times. That is CI-01's own trade written the other way round - a delayed verdict, never a destroyed one - and a destroyed verdict is worse here because it is indistinguishable from a real failure. A sha-keyed group was considered and rejected: the only cancellations it still permits are same-sha ones, whose red lands on a commit that IS still the head. WHAT IS NOT ESTABLISHED, SAID PLAINLY. Nothing here makes a cancelled run distinguishable at the gate BY STATE; that is Gitea posting the status, not this workflow, and no edit to this file can change it. What ships removes the cause instead of relabelling the symptom. And a commit that never had a run carries an empty `/statuses/<sha>` list, while a queued one carries `pending / Waiting to run` within seconds and a concurrency-held one `pending / Blocked by required conditions` - those were already distinguishable at the API. `/actions/tasks`, which is what was polled when this was first reported, lists only tasks that started and cannot answer the question at all. test_ci_gate.py grades the gate by RUNNING it. It extracts the checkout and merge steps out of checks.yml and executes the shipped commands against two throwaway local repositories it builds - one whose pull request merges, one whose does not - and pins that the old one-liner exits 0 on the unmergeable one while the new steps refuse and say nothing was tested. Part 1 pins the two tokens that assemble `checks / unit (pull_request)`: renaming `name:` or the `unit:` job key would leave every open and future pull request waiting forever on a context that never arrives, with no error anywhere in that sequence, and admin override blocked. Seven mutations of checks.yml were each confirmed red, including both renames and a restored `--depth 1`. .gitea/ci/Dockerfile gains `git` for it; the fixtures are local paths and no network is touched. 17/17 green in the checks image. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6ea79f1043 | Merge pull request 'SAFETY-20: measure the freeze rail against the reachable ceiling, not max_w' (#28) from SAFETY-20 into release/1.0 | ||
|
|
29fa39eda7 |
Merge origin/release/1.0 into SAFETY-20
checks / unit (pull_request) Successful in 3m36s
# Conflicts: # goodwe_controller/CHANGELOG.md |
||
|
|
d64790b3e2 | Merge pull request 'TEST-15: purge the bytecode cache before a mutation run, not after' (#27) from TEST-15 into release/1.0 | ||
|
|
83911ed637 |
SAFETY-20: measure the freeze rail against the reachable ceiling, not max_w
checks / unit (pull_request) Successful in 3m37s
SAFETY-18's conjunction needs `abs(commanded_w) >= 0.95 * max_w`, but the
emitted command is clipped to the setpoint device's own [dev_min, dev_max]
range as well as to max_w - in clamp_to_rating()'s second stage and again as
the arbiter's `device` P_SAFETY limit claim. Measured on the shipped predicate
at the ticket's numbers, max_w 5000 on a +-4700 W inverter:
looks_frozen(unchanged_s=3600, commanded_w=4700, max_w=5000) -> False
The command is pinned at the hardware's rail, the meter has been frozen for an
hour, and term 2 is unsatisfiable by construction because 4700 < 4750. Any
device rating at or below 0.95 * max_w disarms the conjunction outright, and
the status page then printed `armed (... command is over 4750 W)` - a guard
that cannot fire, advertising that it can, which is the failure SAFETY-15,
SAFETY-19 and validate.py rule 5 all exist to prevent.
The rail is now measured against min(max_w, |dev_min|, |dev_max|), through a
new `rail_ceiling_w()` threaded into looks_frozen(), FreezeWatchdog.evaluate()
and both call sites in main.py. An unread rating leaves the old behaviour
exactly, and correctly: hass.limits() returns None whenever ESPHome is not up,
and on that path no device clamp is applied either, so max_w really is the
ceiling. A rating larger than max_w cannot raise the rail - this is a min(),
not a swap.
THE DELIBERATE CHOICE, since SAFETY-03 has landed. The arithmetic was kept
rather than substituting Decision.i_clamped / Decision.clamped. i_clamped is
the integrator bound (control.py:208, `bounded != i_w`), not "the command is
at the device rating", and `clamped` is the max_w output bound, unreachable on
a default install - neither is the device rail. More decisively, both answer
"did the LOOP ask for more than a bound", while the rail term asks "is the
command standing at the ceiling", and those coincide only when the loop is the
claimer that won. A maintenance SET or a peak-guard floor reaches the ceiling
through arbiter LIMIT claims with no clamp engaging at all, so a boolean rail
would go False exactly during a maintenance phase running against a frozen
meter - which freeze.py's own docstring names as the same fault with a worse
actuator behind it. The predicate is also public with two call sites that pass
DIFFERENT command values (self.target, and `standing` = last_write); a
per-cycle boolean cannot be passed per call site, so substituting it would
have silently decided the pre-clamp-versus-wire question that PM recorded as
still open. AC 1 governs, and it is satisfied literally.
The smaller of the two device bounds, not a per-sign pair. A per-sign rail is
tighter and is not wrong, but it is two rails needing two coverage verdicts on
a status row that carries one number, and the only configuration that
distinguishes them is refused at startup anyway. The symmetric ceiling can only
lower the rail, which widens term 2 rather than disarming it; term 1 carries
the whole no-false-positive argument and is untouched.
A ceiling of 0 W is NOT COVERED, not "armed at 0 W". A device that can command
nothing in one direction would make `abs(command) >= 0` true of every command
including a parked one - term 2 degenerately TRUE, collapsing the conjunction
into the bare unchanged_s > N threshold TEL-05 refused for false-tripping at
this controller's own operating point. rail_unreachable() returns the reason,
looks_frozen() stays silent, and the page uses the NOT COVERED row the
unsupported-transport case already has - a second reason to use that surface,
not a new mechanism.
AC 4, decided rather than assumed: NO NEW validate.py RULE. The configuration
is max_w above the device rating, which rule 2 (CFG-01) already refuses on the
identical predicate, re-run by run_control() once the real rating is known:
validate_startup_config({... max_w: 5000}, -4700, 4700)
-> ['max_w is 5000 W but number.sp accepts at most 4700 W - THE DEVICE
WINS ...']
A rail-specific rule would fire on a strict subset of that (0.95 * max_w >
dev_max implies max_w > dev_max) and could never be the rule that stopped a
start. The residue is the state where hass.limits() answered (None, None) -
ESPHome not up at boot, range checks skipped rather than guessed at, rating
never re-read - and there is nothing to refuse there by construction, which is
why the answer is a runtime row. Recorded in validate.py's "deliberately not
refused" list and pinned by four checks in test_validate.py, so the decision's
premise goes red if rule 2 is ever softened back to a warning.
HOW REACHABLE THIS IS, stated rather than glossed. Because rule 2 refuses the
configuration whenever the rating is known, min(max_w, dev_max) IS max_w on
every install that reaches the control loop with a rating it could read, and
this change is arithmetically dead there. That is clamp.py's argument for its
own second stage word for word: the case where it bites is the case where the
startup gate did not run or no longer holds, and a guard that quietly assumes
the gate ran is a guard that reports armed in exactly the state it cannot fire
in.
Verified by breaking it, per this project's rule. Reverting rail_w() to bare
max_w fails nine checks; deleting the NOT COVERED handling fails five. The one
that mattered: shipping the corrected predicate while NOT passing the rating
from check_meter_frozen() left the entire suite green, because nothing graded
the caller - so test_freeze.py part 5b now drives the real cycle() on a +-4700 W
device and asserts on the notification that leaves the process, and the
unwiring mutation fails three checks there. The peak-guard call site's wiring
is still ungraded (behind p1_enabled, needs a fed averager for `frozen` to have
any observable consequence) and that gap is named in the file rather than
implied.
Full suite: 15/15 in goodwe-addon-ci.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
7b4877d56d |
TEST-15: purge the bytecode cache before a mutation run, not after
checks / unit (pull_request) Successful in 3m35s
FCST-03's developer reverted `self.provisional` to `here.provisional`, re-ran, and reported the mutation as a survivor. It was not one. CPython validates a cached `.pyc` on (source size, source mtime truncated to whole seconds), and that edit moved neither number - same length, same second - so the interpreter loaded the bytecode it already had. Disassembly settled it at the time: the source read `here`, the running code loaded `self`. A mutation run had graded code that was not there, with no error and exit status 0. This does not make the results already on the record wrong. It makes them UNVERIFIED, and that distinction is the whole card - so nothing here re-runs a campaign and nothing here quietly assumes the old numbers hold. REPRODUCED, THEN CLOSED. test_bytecode.py builds a real package whose two return values are the same length, imports it in a real subprocess, rewrites it inside the same second (retrying until both writes land in one whole second - milliseconds, no sleep) and reads back which value ran. Part 1 shows the interpreter running SELF off a file that says HERE. AND THE TWO OBVIOUS MITIGATIONS DO NOT WORK. Measured, not read off documentation: `python3 -B` and PYTHONDONTWRITEBYTECODE=1 are write-side only. Neither stops an EXISTING stale .pyc being read, and a mutation run always has one, because the previous run wrote it. Part 2 grades both still running the old code. The advice in test_control.py's coverage audit led with `-B`; it is corrected here - the purge is the half that works, and `-B` earns its place by keeping THIS run from leaving a cache for the NEXT edit to inherit. NO SLEEP ANYWHERE. One-second mtime resolution is the mechanism, not the enemy. Waiting it out would slow every run down and would still only cover the edit somebody remembered to slow down. THE HARNESS. tools/mutation_run.sh purges, prints a sha256/byte fingerprint of every app/*.py it is about to grade - so a mutation report can say WHICH source produced it, which is the reviewer's addition to this card - runs each suite under `python3 -B`, refuses a tree with no test_*.py (CI-01's rule, for CI-01's reason), and fails if a cache appears during the run. It takes the tree as an argument so the infra repo's replay harnesses can call it without being moved. WHERE IT IS SAID (AC 2). MUTATION.md carries the mechanism, the measurement, the reporting rules and the survey, and every one of the 16 suites now points at it from its docstring. That list is DERIVED FROM THE TREE by test_bytecode.py part 5, so a new suite that joins this directory without the pointer is red - the same failure shape CI-01's file glob exists to prevent. CI WAS ALREADY IMMUNE, AND IS DESCRIBED THAT WAY (AC 4). Verified rather than reasoned: with the checkout bind mounted `-v "$WS:/w:ro"` the interpreter writes no __pycache__ into the tree at all, and it fails SILENTLY - no error, exit status 0 - while the same probe on a writable mount produces one. Each job also rm -rf's its workspace and clones afresh, so nothing crosses between jobs on the shared runner. PYTHONDONTWRITEBYTECODE=1 is added as belt and braces only: the `:ro` is one character wide and the next person debugging a write will be tempted by it. Part 6 grades both ON THE DOCKER COMMAND LINE rather than on the file - the first draft searched the whole workflow and stayed green after the flag was deleted, because the comment explaining it still named it. THE SHIPPED ADD-ON IS NOT AFFECTED and the question is closed: goodwe_controller/Dockerfile has set PYTHONDONTWRITEBYTECODE=1 since it was written. There is no deploy-side finding. THE SURVEY (AC 3), in MUTATION.md, scoped BY THE PROPERTY. The defect is one-directional - a mutation that never took leaves the suite green, so stale bytecode manufactures SURVIVORS and cannot manufacture a red - which puts every "all red, no survivors" claim in the window out of scope, along with mutations executed in-process (SAFETY-07's B1/B2/B3, test_peakguard parts 7 and 10, where no source file is edited) and everything run after the purge discipline landed on 2026-08-31 (SAFETY-24, SAFETY-26, OPT-04, FCST-03's re-run). What is left is the recorded SURVIVORS from source-edit runs before that date. Two were re-run here under the harness and both still stand: TARIFF-01's record_block() ignoring its policy argument, and SAFETY-03's abs() on the clamp limit - 15 suites green each, on the tree as it stood. The rest are labelled still-unverified rather than assumed or re-run; each is one harness run to close, and the moment to spend it is when somebody next touches that module. OUT OF SCOPE AND RECORDED: the infra repo's sim/ replay harnesses import the add-on and write bytecode into both trees (goodwe/sim/__pycache__ exists today), so SAFETY-02's headline replay carries the same exposure. mutation_run.sh takes a tree argument for exactly that, but the change belongs in that repo and some of those harnesses want the rig. RED AND GREEN. Every new check was broken and watched fail: the purge commented out of the harness turns part 4 red (and its non-vacuity probe already proves the fixture would mis-grade without it); one docstring pointer removed turns part 5 red naming the file; `:ro` and the `-e` flag removed from the workflow turn part 6 red. Full suite green, 16 files, in the CI image on a read-only mount - the shape the runner uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
358fbb0172 | Merge pull request 'TEST-17: grade the status page's peak row at a stated month, not at today's' (#26) from TEST-17 into release/1.0 | ||
|
|
504e8daac3 |
TEST-17: grade the status page's peak row at a stated month, not at today's
checks / unit (pull_request) Successful in 3m35s
release/1.0 went red at 00:00 on 2026-09-01 on a commit nobody had touched. `Controller.checks()` read the capacity-tariff peak with `self.peak.peak()` - no argument - so tariff.py resolved the month from `datetime.now()`, while test_tariff.py section 10 banks its quarter at the fixed fixture 2026-08-30. From 1 September the page was correctly asked for September, correctly answered NO_RECORD, and the row read "no completed quarter recorded yet" - a string containing no "peak", which the section's own filter required. Four checks failed. ISOLATED BY MOVING ONLY THE CLOCK. Same commit, same shipped-deps image, libfaketime: green at 2026-08-30, four failures at 2026-09-01. Identical at |
||
|
|
f6ecd5ff03 |
DATA-01: thirty days of the real meter, and what AC 3 actually measures (#25)
Thirty days of the house's own P1 meter, exported from HA's recorder with a committed exporter, closing the dataset gap that made TARIFF-01's and SAFETY-07's AC 3 unmeetable. 2879 closed quarters, 485841 samples, 99.950 % covered, 13 gaps over 30 s and nothing interpolated. Reviewed independently. The re-export is byte-for-byte identical after decompression on both previously committed days (865024 B / 15600 samples and 894387 B / 16351 samples), verified by decompressing and comparing rather than by reading --verify; the only difference is the gzip mtime field, and writing 0 rather than a wall clock makes this exporter reproducible where the 2026-08-25 one-off was not. The committed CRLF is reproduced rather than tidied because reproducing it IS the verification, and .gitattributes marks *.gz binary so git can never drift it - confirmed with git check-attr. All 30 manifest sha256s match. replay_ac3.py --expect 189/190 reproduces test_peakguard part 6 exactly: 5029.8 -> 3485.3 W, 189/190, 878 Wh, 110 graded + 5 blind - every one a figure the reviewer had already derived independently on a different harness while reviewing TARIFF-01 and SAFETY-07. AND THE RESULT IS A FINDING ABOUT THE CRITERION. Over the month the guard takes the peak from 5029.8 W to 4107.3 W (-18.3 %, still over the 3500 W soft limit) while moving the interval count from 98.89 % to 98.99 %. The UNCONTROLLED month already passes the 95 % bar, with 112 quarters of slack, and the guard contributes 3 - a signal-to-threshold ratio of 0.021. The criterion counts intervals while the capacity tariff bills the month's maximum, so it cannot separate a working guard from an absent one. AC 3 is met on the letter and uninformative on the substance; it should not be read as evidence the guard works, and if rewritten it should be on the monthly peak. The failed-read discipline was verified by producing all three states: a typo'd entity and an unreachable recorder both exit 1 having written nothing, and a day whose read fails is recorded FAILED with file null, no .gz, days_failed 2 and empty_days [] - so an empty response cannot look like a quiet hour. The 30 days sit in a subdirectory and are glob-invisible to both suites' non-recursive scenarios/p1-net-*.jsonl.gz; neither suite had a check added, removed or altered. Full suite green, 15 files. |
||
|
|
04292c4a08 |
DATA-01: thirty days of the real meter, and what AC 3 actually measures
checks / unit (pull_request) Successful in 3m36s
TARIFF-01 and SAFETY-07 both merged with AC 3 - "monthly peak held under
the soft limit in >= 95 % of intervals over a 30-day simulation replay" -
honestly unmet, because two days of capture existed and it was believed
nothing longer could be produced. That belief was a true statement about
the past read as a claim about the future: HA's recorder still held the
whole month, and the reason nobody found out is that the 2026-08-25
export was a one-off script that left no artefact.
So the exporter is committed and runnable now.
scenarios/p1-net-30d-2026-08/ is 2026-08-01 to 2026-08-30, 485,841
samples, 30/30 days read, 0 failed, 4.13 MB gzipped, in the schema the
two committed days already fixed.
VERIFIED AGAINST THE DAYS THAT EXIST. `--verify` re-fetches 2026-08-20
and 2026-08-23 by this exact route and reproduces both committed files
BYTE FOR BYTE after decompression (865,024 B / 15,600 samples and
894,387 B / 16,351 samples). The gzip containers differ by design: this
exporter writes mtime 0 so its output is reproducible, where the one-off
wrote a wall clock.
A FAILED READ IS NOT AN OBSERVATION. A day whose read fails writes no
file, is recorded "read": "FAILED", and the run exits non-zero; the
entity is proved to exist through /api/states before any empty day is
believed, because a typo would otherwise answer 200 [] thirty times and
produce a consistent dataset of nothing. Nothing is interpolated, so the
month's 13 gaps (1291 s, 99.95 % covered) and its 3 `unavailable`
samples stay visible as holes.
SIZE: COMMITTED, DELIBERATELY, AND MEASURED. A git bundle of the whole
history goes from 1.58 MB (release/1.0 @
|
||
|
|
86c43eba24 |
DATA-02: twelve months of real Belgian day-ahead prices, closing OPT-04 AC4 (#24)
8760 hourly EPEX BE clearings from the Energy-Charts API (Fraunhofer ISE), CC BY 4.0 from Bundesnetzagentur | SMARD.de, committed with attribution and a fetcher that refuses to write if the licence stops saying CC BY 4.0. Raw wholesale with all_in false and the markup/levy/VAT assumption labelled as an assumption; the additive terms cancel out of every daily spread, so trade counts turn on the VAT multiplier alone. DST handled by iterating in UTC and rendering through price.brussels(): 25 points on 2025-10-26, 23 on 2026-03-29. app/ diff is zero lines - nothing tuned. Section 7B asserts that the real year cannot grade section 17, so the synthetic year stays. |
||
|
|
d57122ec74 |
DATA-02: a real Belgian price year, so AC4 stops being unmeetable
checks / unit (pull_request) Successful in 3m36s
OPT-04 reported AC4 - "net-positive across 12 months of replayed prices" - as NOT MET and replayed a synthetic year, because scenarios/ held only P1 meter captures. Right call, wrong diagnosis: the criterion was being carried beside a 30-day METER criterion as though both needed waiting. EPEX BE day-ahead is public data, not a capture. It needed a fetch. 8760 hourly points, 2025-09-01 to 2026-09-01 Brussels-local, canonical three fields. Licence checked BEFORE committing: Energy-Charts publishes the BE zone under CC BY 4.0 from Bundesnetzagentur | SMARD.de and other zones under private-use-only terms, so this one is redistributable with attribution. The fetcher refuses to write a file whose per-response license_info stops saying so. Cross-checked against SMARD's own 4996/DE: 96 of 96 quarter-hours, max difference 0.00 EUR/MWh. Raw wholesale, and the file says so. all_in: false, with the markup/tax assumption carried as numbers to APPLY rather than numbers applied - TARIFF-02's trap in data form. Section 7B applies it in one visible place and asserts arithmetically that its additive terms cancel out of every daily spread, so the trade count turns on the VAT multiplier alone; the raw replay is run too, so how much rests on the guess is reported (110 vs 88). DST by construction: iterate in UTC, render through price.brussels(). 2025-10-26 is 25 points, 2026-03-29 is 23, verified by counting per Brussels day rather than asserting 24, and by checking October's 02:00 appears twice at two offsets with two prices while March's never appears. Both bugs the point-and-gap accounting caught were real: a UTC-bounded window made the first day 22 hours, and an inclusive-end off-by-one dropped the last day silently. A day nobody asked for is not a day with no prices. THE RESULT, AT THE SHIPPED DEFAULT, WHATEVER IT IS. At 0.10 widened 1.5x to 0.1500: 110 round trips, +24.88 EUR net of the cycle cost. Unwidened, 173. Nothing tuned - the threshold and the 2.5/1.5 factors are untouched. And what the owner's decision now has under it: the four reports that a static ~0.0840 spread does not clear 0.10 all STAND. What was missing is that a dynamic site clears the widened requirement on 121 of 365 days, median all-in spread 0.1130, so a median day would clear at 0.0754 - 156 trips, +35.42 EUR. Reported, not applied. The synthetic year stays, because the real one cannot grade section 17: not one of 365 days has a negative DEAREST hour, so a zero there counts something that could not happen. 7B asserts that degeneracy rather than reporting the zero. Size measured, not assumed: 893 KB plain but 74.6 KB as a git object against 56.5 KB gzipped. Git already compresses; 18 KB is not worth making the provenance block - the licence claim a reviewer must check - unreadable in a diff. Nothing wired into the add-on; generic_dayahead is still the live path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
4601682815 |
SAFETY-24: an energy floor under the peak guard's forced discharge (#21)
SAFETY-07 could command a discharge to hold a billed quarter down and nothing below it consulted the state of charge - the BMS was the sole energy backstop. decide() now takes soc and soc_floor, and discharge_permitted() is the one place the question is answered: public, no default arguments, and consulted only AFTER a forced discharge is actually being asked for. The denied action is a 0 W floor - one-directional, forbids charging, structurally cannot cap the discharge the loop is already doing. Reviewed independently. The trade-off is ruled honest and was made a check rather than a footnote: on a pack deliberately sized so the floor must bind, the worst quarter goes 4338 -> 5030 W while end SoC goes 9.6 -> 20.0 % and cycles pinned at the BMS cut-out go 1227 -> 0. Reproduced on the reviewer's own replay, including the 304.2 Wh withheld against 292.8 Wh between the two floors - half a cycle at the rail apart, so the floor is doing the arithmetic it claims. The guard's central guarantee was re-derived across 19,800 combinations of load x elapsed x battery x SoC x floor: 0 negative floors, 0 claims with hi other than +inf, 0 decisions both floor-limited and saturated, 0 arbiter resolutions lowered or contradicted. Rule 8's <= is right: at equality the EMS floor could never be the thing that stops the discharge. Eleven author mutations plus nine reviewer ones, all red under python -B with a purged __pycache__ and real byte deltas. One survivor is an equivalent mutant and provably so: a float(soc or 100) defaulting above the call site is dead code, because a Controller with no SoC never reaches decide() at all - inputs_ok is False and cycle() stops at the safety claim first. That property is now asserted on the service call, replacing an assertion whose sentence was wider than its check. Merged after two rebases. The second one broke a grader neither card could have seen alone: test_peakguard graded the FIRST .decide( in main.py and required exactly one, which stopped being true when OPT-04 added arbitrage.decide(). It failed loudly rather than grading the wrong call. Fixed by selecting peakguard.decide by qualified name and requiring exactly one of that, verified still red on a decoy and on the maintenance_soc_floor swap. Follow-ups filed as SAFETY-27, not addressed here: arbitrage_soc_floor has no rule-8 twin against bms_soc_floor, and this card's own 'revisit if a third floor appears' trigger has fired. |
||
|
|
9234016419 |
Merge release/1.0 (OPT-04) into SAFETY-24, and fix one grader the merge broke
checks / unit (pull_request) Successful in 3m36s
OPT-04 landed after the previous rebase. Conflicts were CHANGELOG.md and one IMPORT LINE in validate.py; both refusal blocks and both option sets survive untouched, and DOCS.md, main.py and config.yaml auto-merged. ⚠️ AND THE MERGE BROKE A CHECK THAT NEITHER CARD COULD HAVE SEEN ALONE. test_peakguard.py graded "the first `.decide(` call in main.py" and asserted there was exactly one. That was true when SAFETY-24 was written; OPT-04 added `arbitrage.decide(...)`, so the merged tree had two and the assertion failed. It failed loudly rather than grading the wrong call - the guard did its job - but it cannot ship red. Fixed by selecting the call by QUALIFIED NAME (`peakguard.decide`) instead of by position, and requiring exactly one of THAT. Other modules may now call their own decide(); this grader can no longer be moved by one of them. Verified still discriminating: a decoy `peakguard.decide` added to main.py turns it red on 7 assertions, and the `maintenance_soc_floor` swap still turns it red on 6. Also folded in the review's wording finding. An assertion read "a CONTROLLER whose SoC entity does not answer hands decide() None" while driving no Controller. The wiring property is stronger than the one claimed, and is now asserted on the SERVICE CALL: a Controller with no SoC never reaches the guard at all, because `inputs_ok` is False and cycle() stops at the safety claim first - so a `float(soc or 100)` defaulting line above the call site cannot disarm the floor. The original decide()-level check is kept, reworded to say what it actually grades. Full suite green on the merged tree: 15 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
b7272169b1 |
SAFETY-26: pin the negative-price boundary from below, not just from above (#23)
Closes the survivor found reviewing OPT-04: price < 0 weakened to < -0.001 left every suite green while a -0.0005 EUR/kWh sell hour discharged. Pinned as a sweep rather than a point - five magnitudes spanning >10000x down to -0.000001, the last place the canonical 6-decimal format expresses - with the well-formedness of each stimulus asserted first so section 17 is the only rung that can refuse, and the same five sign-flipped asserted to discharge so it is a boundary at exactly zero. app/ untouched. |
||
|
|
2b8b8cb97f |
SAFETY-26: pin the negative-price refusal from below, not only from above
checks / unit (pull_request) Successful in 3m15s
OPT-04's reviewer weakened `if price < 0` to `if price < -0.001` and every suite stayed green while a -0.0005 EUR/kWh sell hour discharged. The shipped code was right the whole time; the CHECK could not tell correct from nearly-correct. The only probe near the boundary sat at 0.0000, which catches a `<=` and says nothing about a tolerance invented on the other side - and a tolerance on the other side is §17's named regression walking through with the suite passing. A dearest hour at -0.0005 is an ordinary Belgian day-ahead print, not a fuzz value. `app/` IS NOT TOUCHED. `if price < 0` is already correct and changing it would be the defect; this card closes the check, not the behaviour. The 0.0000 probe stays and is now labelled as the half that pins the boundary from above. The new probe is a SWEEP rather than a point, because one probe at -0.0005 closes `-0.001` and nothing else - a mutation reaching for `-0.01` would be the identical defect one decimal further out. Five prices span four orders of magnitude down to -0.000001, the last place the canonical six-decimal format can express; each is asserted to survive that rounding as strictly negative, to still be the DEAREST hour, and to still clear the widened threshold, so §17 is the only rung that can refuse. The same five magnitudes with the sign flipped all discharge, so this is a boundary at exactly zero rather than a refusal of small numbers. AC4, the look: every price/quantity boundary in arbitrage.py and static_tariff.py was given the same tolerance mutation, both directions where both exist. Two survive, both in static_tariff.py and both NAMED RATHER THAN FIXED - they are TARIFF-03's rungs and want probes in its own file. `spread == 0.0` loosened to `<= 0.001` flips a real 0.0005 spread from allowed to refused at min_spread_eur_kwh=0.0, a documented supported value, and otherwise prints a false reason about a tariff that is not flat. `len(firm) < 2` loosened to `< 1` survives at +0 bytes - TEST-15's risky delta - and is masked only by the flat rung, so the two mask each other and a later edit to either opens a hole nothing reports. Mutation verified under `python -B` with a recursive __pycache__ purge, byte delta printed (+5 B), the mutated line asserted changed, and - per the reviewer's addition to TEST-15 - the tree re-read from disk and asserted to be the tree that was mutated before each run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
8e589fee3f |
OPT-04: price arbitrage, at priority 3, shipped switched off (#22)
app/arbitrage.py consumes TARIFF-02s canonical prices, TARIFF-03s Arbitrage verdict and FCST-03s firm/provisional split rather than re-implementing them. An idle decision files NO claim - a 0 W set at P_ARBITRAGE would out-rank the loop and kill self-consumption. Peak shaving is honoured twice: TARIFF-03s required peak_guard_active interlock, and P_PEAK 50 clamping P_ARBITRAGE 30 in the arbiter. The fetch lives in run_tariff_refresh(), asserted on the shipped AST to be absent from cycle() and read_inputs(). Selling into a negative-price hour is refused. arbitrage_enabled ships false. |
||
|
|
bdd7f18a54 |
OPT-04: charge cheap, discharge expensive - and only when it pays
checks / unit (pull_request) Successful in 3m16s
Four cards built the price stack and nothing traded on it. app/arbitrage.py is §10.1's priority 3, and it consumes all four rather than re-deciding anything: whether a trade is permitted at all, on which two hours, against which widened threshold is TariffPlan.arbitrage()'s answer, and that answer cannot be obtained without first saying whether the peak guard is holding. §9.1's "peak shaving outranks arbitrage" is therefore honoured structurally - there is no path to a spread that skips the question - and again in the arbiter, where P_PEAK (50) clamps a claim filed at P_ARBITRAGE (30). A guard that never ran is not a guard that is clear. On meter_source off the peak guard is never evaluated; that third state is carried as None and refuses, because charging at the rail creates the billed quantity while nothing measures the quarter. validate.py refuses that configuration too, along with arbitrage armed with no price source and a SoC floor at or above the ceiling. §17: never export into a negative price. The guard is on the hour being sold into, not on the spread - a day priced -0.30 .. -0.05 clears any threshold and its dearest hour is still negative. Buying in a negative hour stays allowed. resolve_tariff() may open an HTTPS connection and the tick feeds the ESP32 heartbeat, so the fetch runs in its own task at a 30 min cadence and the tick reads an attribute and an age bound. test_arbitrage.py section 8 asserts that structurally on the shipped AST: a fetch put back on cycle() is red. AC4 is NOT met and is not restated - this repository holds no price history. Section 7 replays a synthetic year and asserts the property that does not need real prices: every executed round trip cleared its own widened requirement, and zero discharges landed in the 522 negative-priced hours. At the shipped 0.10 EUR/kWh threshold a realistic Belgian day/night site does not trade at all. Reported, not tuned: that number and FCST-03's widening factors are one product decision and it is the owner's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
a1376e44e4 |
Merge release/1.0 (FCST-03) into SAFETY-24 - CHANGELOG only
checks / unit (pull_request) Successful in 3m28s
FCST-03 landed while this was in review, so both branches had prepended an Unreleased stanza. Only CHANGELOG.md conflicted, exactly as predicted; app/, config.yaml, validate.py and DOCS.md all auto-merged, and this card touches neither price_forecast.py nor static_tariff.py. Both stanzas kept verbatim, SAFETY-24's above FCST-03's - no text from either author edited. Full suite green on the merged tree: 14 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
e709554857 |
SAFETY-24: an energy floor under the peak guard's forced discharge
checks / unit (pull_request) Successful in 3m29s
SAFETY-07 can command a discharge to hold a billed quarter down, and nothing below it consulted the state of charge. On the 2026-08-20 replay it asks for 878 Wh in a day. Relying on the BMS is not absurd - it is a real floor and it works - but it is the pack's protection rather than the operator's, and a month of aggressive quarters leaves the battery repeatedly at that cut-out with no EMS-side record of why. The first symptom is a pack never available when wanted, which is indistinguishable from the SoC drift MAINT-01 exists to correct. decide() now takes soc and soc_floor, both required and neither defaulted, and discharge_permitted() is the one place the question is answered. Above the floor nothing changes. At or below it the guard files a 0 W floor instead of a sized one - one-directional, so charging stays blocked and no discharge is capped - and reports floor_limited with the reason it bound. It is not a trip: every stop in 11.2's table still outranks the claim, and the floor is never negative, so 9.1's asymmetry survives as a property of the object (0 negative floors over 1296 swept combinations, SoC included). The SoC is graded only after `wanted > 0` is established, so it is never evaluated where it governs nothing. An unreadable SoC is floor-limited, not a full battery - a full battery is exactly the value that would disarm the floor, and this project shipped that defaulting once already. Failing to shave is not silent: one notification per episode (goodwe_peak_soc_floor), distinct from the saturation alarm because the two ask for opposite operator actions, plus its own status row and an action-log line. Verified by replay. The 2026-08-20 capture is driven against a modelled pack sized from the day's own measured appetite, so it genuinely exhausts: without the floor it reaches the BMS cut-out and holds there for 1227 guard cycles; with it the pack stops at 20.0 % and never touches the BMS. The energy withheld equals the energy between the two floors to within one cycle at the rail. The honest half is reported too - the worst quarter goes 4338 W -> 5030 W, because a floor-limited guard shaves less on purpose. Two new options, mirrored once in peakguard.PEAK_OPTION_DEFAULTS (TARIFF-02's pattern), imported by validate.py and main.py rather than typed as literals and pinned to config.yaml by a check that reads the file - so CFG-03 still tracks three places. validate.py rule 8 refuses peak_soc_floor <= bms_soc_floor at startup, in CFG-01/MAINT-04's manner. maintenance_soc_floor is deliberately not reused and not renamed: it is a drain target, not a spend limit, and the rename is recorded as a decision to revisit if a third floor appears. Eleven mutations of the shipped source were run against the checks; all eleven go red and there are no survivors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
a30bde1ab1 |
FCST-03: provisional prices - yesterday flagged as yesterday, and unable to move anything (#20)
app/price_forecast.py fills unknown hours from yesterday, flagged provisional by four independent mechanisms: _carried() has no provisional parameter, published points pass through by identity, _audit() re-derives every flag and raises rather than repairs, and an AST check refuses a non-literal provisional. Gate blocks irreversible actions on provisional hours. Arbitrage refusal 3 rewritten to fewer-than-two-FIRM-hours so filling does not switch arbitrage off every morning, with the 11.4 fallback still refused by a truer route. Real-time products get a measured 2.5x wider threshold, following the declared PriceProduct. |
||
|
|
71cd7849e4 |
Merge release/1.0 (SAFETY-07) into FCST-03, and close two degenerate stimuli
checks / unit (pull_request) Successful in 3m13s
CHANGELOG.md was the entire collision - SAFETY-07 lands in peakguard.py, main.py and the arbiter, none of which this branch touches. Both entries kept. X1. `_firm_or_die` guarding only `cheapest` survived, because the check passed the SAME provisional point as both cheapest and dearest: a guard on either role alone raises, so the stimulus could not tell "the buy hour is guarded" from "both are". Now one call per role with a firm point in the other seat, plus the positive arm so the two refusals are discriminating. X6. `firm_points()` returning the carried hours too survived, because its two consumers - the §11.4 refusal and cheap_now - guard provenance independently and both keep answering correctly. The accessor is now graded directly: exactly the published points, by identity and in order. Both are the degeneracy rule this file states at the top, in a new dress - a stimulus that cannot distinguish the two things it grades. ⚠️ AND THE REASON THE M9 MUTATION "SURVIVED" IN MY EARLIER REPORT: a stale __pycache__. Reverting `self.provisional` to `here.provisional` changes neither the file size nor, at one-second resolution, the mtime, so CPython kept the MUTATED bytecode and a green run afterwards proved nothing about that line. Every mutation here now clears __pycache__ first; re-run that way, M9 is caught. The committed source was always correct - the checks were not being run against it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
e7c95bfd8a |
SAFETY-07: cap the loop before it sets the month's billed capacity peak (#18)
The first thing in this system that can STOP a runaway rather than alarm on one. Each cycle it projects the open 900 s block from P1 telegrams and, over the ceiling, files one arbiter claim: a lower bound at P_PEAK, sized as excess = 900*(projected-ceiling)/remaining added to the MEASURED battery power. No writes, no I/O, no clock, no state between calls. Replayed independently by the reviewer on a separate harness: 2026-08-20's billed quarter 5029.8 W -> 3485.3 W, both days 189/190 -> 190/190 under the 3500 W soft limit, 878 Wh modelled discharge on the failure day and 0 Wh with 0 engagements on the healthy one. Verified as properties rather than witnesses: the lo-limit claim cannot cap discharge or export (hi is inf; 0 of 54 loop/floor pairs lowered the target; no floor in 0..5000 W reduces a full-rate discharge; decide() emitted 0 negative floors over 630 input combinations), and the 900/remaining gain is exactly 1.0 at the top of the block, growing, bounded at the end by the achievable_w clamp so it can never file lo > hi. Round one held this PR on three wiring mutations that left all twelve suites green while a default install parked at 0 W in the billed quarter - including the suppression re-gated on the optional peak_forecast_entity, verbatim the historical defect this card's own comment described. All three are closed. The conjunction moved out of main.py into peakguard.shaping_suspended() so the check can assert on the shipped expression, and part 7 now executes the mutations against the real Controller.cycle(). The reviewer confirmed the harness cannot go vacuous: re-inlining the conjunction in main.py with behaviour-identical code turns B1 and B2 RED rather than green, because they assert the mutation takes effect. Eight further reviewer mutations this round, all red, no survivors. One note recorded, not blocking: the parsed-syntax grade on decide(batt_w=...) is defeated by a decoy .decide() call or by overwriting self.batt before the call, and it is the behavioural checks beside it that catch both. AC 3 remains TARIFF-01's and is NOT met - do not close the card as AC 3 met. SAFETY-24, the missing SoC backstop on the forced discharge, is named in peakguard.py with the 878 Wh figure and must land before this reaches a client install. |
||
|
|
dd40c56ab4 |
FCST-03: yesterday's prices, flagged as yesterday's, and unable to move anything
checks / unit (pull_request) Successful in 2m43s
§8.3 fills the hours a day-ahead feed has not published yet with yesterday's profile and flags them provisional, and forbids any irreversible action on such a price. The whole risk of this ticket is one line - a carried point and a published point are three fields and a bool, so `provisional=y.provisional` copies yesterday's False onto today's guess and produces stale data presented as current. That is SAFETY-18's frozen-but-fresh meter one layer up. So the flag is never passed through. Carried points come from a constructor with no provisional parameter; published points pass through BY IDENTITY, so no copy exists that could lose one; an audit re-derives the expected flag for every emitted point and raises rather than repairing; and the check walks the module's own AST so the pass-through idiom cannot come back looking ordinary. A published point may itself be provisional (eneco_eddy's imbalance print is), so the rule for a published hour is "whatever the feed said", never False. The fill keys on the Brussels wall clock rather than t-24h, so the 23- and 25-hour days are right; a late publication rebuilds the series, moving the number and the flag together. AC2 and AC3 landed on TARIFF-03's existing verdict surface rather than beside it. Refusal 3 became "fewer than two FIRM hours" - refusing on "any point is provisional" would have switched arbitrage off every morning on every dynamic site - and refusal 5 is new: the spread must clear a threshold widened by the widest active uncertainty source (max, never a product). The permitting variant now refuses to be constructed around a provisional point at all, and the cheap-window flag became per-hour for the same reason. The irreversible action is a value carrying the hours it commits, derived from maintenance.py's own DRAIN_TIMEOUT_MIN; the gate blocks a maintenance drain whose window reaches into a carried hour, and the phase machine stays idle. It is the price-justified entry point only - a gate that blocked on no_data would stop the monthly cycle on every default install. Zero new config options; TEST-14's signature scan ported to cover both Gate's and Arbitrage's constructors, closing _Refused.reason's open hole. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
c2ed509565 |
Merge release/1.0 (TARIFF-03) into SAFETY-07
checks / unit (pull_request) Successful in 3m12s
The anticipated conflict again, and only it: both cards prepend to CHANGELOG.md's Unreleased section. Both entries kept, SAFETY-07 first. app/main.py and DOCS.md auto-merged, so the overlap claim on #18 still holds - config.yaml and validate.py remain untouched by this card even after TARIFF-02 and TARIFF-03 widened both. Full suite green on the merge result, test_price.py and test_static_tariff.py included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
2d570d84ce |
SAFETY-07 review: defend the three wiring mutations that survived
The reviewer's finding was correct and the shape of it matters: everything the
first cut graded was in peakguard.py, and grading the module is not the same as
grading the guard. Three one-line changes to main.py's CALL SITE left all twelve
suites green while a default install parked at 0 W in the quarter that sets the
month's bill.
B2 was the serious one. My own comment described that defect - "that entity is
optional and empty on a default install, so until now the suspension could not
fire on the site that needed it" - and nothing could regress it. A comment that
documents a fix nobody can test is the same shape as a corrected comment nobody
tests, which this project has already been caught by once.
WHAT CHANGED
The suppression conjunction moved out of main.py and into
peakguard.shaping_suspended(), for the reason watchdog.loop_is_following() and
freeze.looks_frozen() live where they do: the check that grades a safety
predicate must assert on THE SHIPPED EXPRESSION, not on a transcription that can
drift apart from it in silence. Its docstring carries both terms and why neither
is sufficient alone. main.py's call site now says READ THAT FUNCTION FIRST, and
records that a reviewer mutated it back to its historical broken form with every
suite staying green.
test_peakguard.py part 7 gains a section that EXECUTES the three mutations
rather than describing them. Each one is put in place on the shipped module and
the REAL Controller.cycle() is driven with it, on the dangerous fixture: a
default install (no peak_forecast_entity, so peak_fc is None and peak_at_risk is
False forever) with maintenance in its CHARGE phase, so a P_MAINT_SHAPE `hi <= 0`
limit is live, and the guard genuinely engaged on the measurement. That the
mutations are monkeypatched onto the shipped module is itself load-bearing: if
main.py inlined either expression again the patch would do nothing and these
checks would pass vacuously.
SHIPPED suspended=True loop -> 1498 W, limited by peak +1497.8 W
B1 suspended=False CONTRADICTION between limits 0.0 W
B2 suspended=False CONTRADICTION between limits 0.0 W
B2b (forecast term dropped instead) - caught by its own check
B3 is graded three ways, because the single-cycle form is only INFLATED and the
failure is what happens on the next cycle:
* the value main.py actually hands decide() is recorded off the real call, with
the measured battery at 0 W and the command standing at +3000 W;
* the one-cycle difference is asserted to be exactly the standing command - the
whole defect in one line, watts that were asked for and never arrived counted
as if they had;
* both forms are then run forward against the REAL controller's own projection
with an inverter that never follows:
intent-sized floor 4482 5000 5000 5000 ... SATURATED at cycle 2
delivered-power floor 1482 1482 1482 1482 ... stationary
* and the call site is graded on parsed syntax, so a rename cannot quietly
reintroduce it: `decide(batt_w=...)` must unparse to `self.batt`.
SAFETY-24 is now named in peakguard.py, in its own section, with the number that
makes it real - 878 Wh of forced discharge on the 2026-08-20 replay - and the
statement that this must not go to a client install before that ticket lands. A
ticket nobody can find from the code is a ticket nobody reads before shipping.
RED AND GREEN
Fifteen mutations run against the shipped modules, each reverted after: the six
new wiring ones (B1, B2, B2b, B3, B3b, and deleting the SAFETY-24 pointer) and
the eight from the first round, re-run to confirm they still bite. All fifteen
turn test_peakguard.py red. Full suite green, test_price.py and TARIFF-03's
included.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
|
||
|
|
cf5094c608 |
TARIFF-03: the static day/night profile as a first-class product and the 11.4 fallback (#19)
app/static_tariff.py: StaticProfile derives STATIC vs FIXED from its own prices, one generator serves both the configured product and the degradation fallback differing only in the provisional flag. TariffPlan.arbitrage() is a refusal type behind a required keyword-only peak_guard_active, and a flat tariff is refused by arithmetic rather than by matching PriceProduct.FIXED. main.py touched only in read_inputs() else-branch, one field, one status row - nothing in cycle(), the arbiter or the peak path; an empty profile is bit-for-bit the old behaviour. Rule 7 tripwire re-aimed rather than silenced. |
||
|
|
9f8b721a4d |
Merge release/1.0 (TARIFF-02) into SAFETY-07
checks / unit (pull_request) Successful in 3m9s
Conflict was the one anticipated: both cards prepend to CHANGELOG.md's Unreleased section. Both entries kept, SAFETY-07 first. Nothing else of TARIFF-02's surface (app/price.py, validate.py rule 7, config.yaml, DOCS.md) overlaps this card - config.yaml and validate.py are untouched here, so the CFG-03 check still reads a config.yaml that only TARIFF-02 has widened. Full suite green on the merge result, test_price.py included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
303525cfc5 |
TARIFF-03: static day/night and fully-fixed tariffs, first-class
checks / unit (pull_request) Successful in 2m43s
§9.2 puts a large share of the Belgian installed base on a static profile, and
an EMS that only works under dynamic pricing is an EMS for a minority. A new
`static_profile` option describes a two-period day/night tariff - or a single
fixed price - and app/static_tariff.py turns it into the same canonical
{start, eur_per_kwh, provisional} series the fetched providers emit.
Not a sixth provider plugin, for two mechanical reasons. capabilities() is a
classmethod, so a plugin's PriceProduct is fixed when the class is written -
but §9.2 makes STATIC and FIXED two different products and which one a site is
on is a property of the operator's data; StaticProfile.product derives it from
the number of distinct prices. And the plugin framework exists to stop a
FETCHED figure being marked up twice, which a number typed as all-in cannot be.
So the profile sits where §11.4 already says it does - under the providers, as
the fallback. Configured product and feed-is-down are one generator differing
in one flag, so the degradation path cannot rot unused.
"No arbitrage" is a type. TariffPlan.arbitrage() returns a variant whose
ALLOWED arm carries the spread and the cheapest/dearest hours and whose REFUSED
arm carries a reason and nothing else - .spread_eur_kwh on a refusal raises
rather than answering 0.0, so an optimiser cannot compute a trade from one.
Four refusals, all derived: peak guard holding, no price data, plan
provisional, or the series is FLAT. The last is AC2: a fixed tariff disables
arbitrage by arithmetic, not by matching on an enum. peak_guard_active is a
required keyword argument, so §10.1's priority 2 over 3 cannot be skipped.
Time-window shifting reaches the one shiftable load that exists: main.py's
cheap-window flag falls back to the static profile, and the UI gained a Tariff
row. Cheap is STRICTLY below the seven-day mean - `<=` would call every hour of
a flat tariff cheap, and 48 h taken on a Friday is two-thirds weekend.
A profile must tile every day type exactly once, checked at startup; a gap is
refused with the range named rather than filled in. `22:00-07:00 mon-fri` does
not cover Saturday morning, and that trap is what the check catches.
validate.py rule 7's stated reason expired exactly as TARIFF-02 predicted: the
test_price.py section 12 tripwire went red naming static_tariff.py. The message
now says what is true instead, and the tripwire is re-aimed rather than removed
- consumer set pinned, expired phrase asserted gone. Rule 7 also grew a second
refusal for static_profile alongside the HA price pair, the same silent-loser
conflict and a live one.
CFG-03 is not widened: static_profile went into price.py's PRICE_OPTION_DEFAULTS
- the one in-code mirror pinned to config.yaml by a check - and the new module
carries no defaults dict of its own.
Measured on the two committed days of this house's real P1 meter. 2026-08-20
(Thursday): 15.220 kWh imported, 9.606 day / 5.614 night, EUR 4.7185 under
day/night vs EUR 4.6725 flat, shifting saves EUR 0.8069 = 9.606 kWh x the
0.0840 spread, derived twice. 2026-08-23 (Sunday): 0.728 kWh, all of it
night-priced, which is the weekend row proving itself. §17 names six Belgian
scenarios; this is the seventh, and it is two days rather than a season - the
file says so rather than implying an annual figure.
All 12 suites green. Eleven mutations of static_tariff.py were each caught by
at least one check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
|
||
|
|
616b62d3e9 |
SAFETY-07: cap the loop before it sets the month's billed capacity peak
checks / unit (pull_request) Successful in 3m7s
TARIFF-01 measured the quarter and said the consumer would be this card. It is
one new module, app/peakguard.py, and one new claim.
THE MECHANISM. Every cycle the guard projects the open 900 s block to its
end-of-interval average - from P1 telegrams only, never from the command - and
compares it to a ceiling. Over the ceiling it files ONE arbiter claim: a lower
bound on the setpoint at P_PEAK. That single shape is both behaviours 10.1 asks
for, a hard clamp on charging and pre-emptive discharge, and it cannot cap
discharge or export, because a floor cannot. 9.1's asymmetry becomes a property
of the object instead of a rule to remember.
excess = 900 x (projected - ceiling) / seconds remaining
floor = MEASURED battery power + excess
The 900/remaining gain is 1.0 at the top of the quarter and grows, so the guard
is gentlest where its projection rests on the least evidence. The floor is sized
from delivered battery power, not the standing command: a floor built on intent
re-adds its own unmet ask every cycle and winds to the rail whenever the inverter
fails to follow, and test_peakguard.py part 1 shows the two diverging.
IT TRIPS, AND THE CAPTURES DECIDED THAT. On the healthy reference day it engages
on zero of 95 quarters. The action is level-triggered, unlatched and re-derived
every cycle, so a false engagement costs one cycle of discharge rather than a
parked battery - the cost SAFETY-18 could not bound and this one can. Replayed on
2026-08-20 the billed quarter goes from 5029.8 W, above the inverter's own 5000 W
rating, to 3485 W, under the soft limit and without saturating. Quarters under
the limit across both committed days: 189/190 -> 190/190. Two days is not thirty
and AC 3 remains TARIFF-01's open item.
BLINDNESS IS CHECKED BEFORE THE PROJECTION IS GRADED, and it is a different
action. A fresh averager projects a confident 0 W, which is under every ceiling
there is; a meter gap longer than meter_max_age_s makes the projection read LOW;
a frozen meter reading export projects 0 W at full coverage. All three become a
floor of 0 W - no importing while the billed quantity is unobservable - which is
one-directional and cannot cost pack energy. That is the backstop SAFETY-18 could
not be: its alarm arrives at 300 s of a 900 s quarter and commands nothing. The
frozen term is freeze.looks_frozen() itself, on this cycle's standing command.
THE CEILING IS peak_cap_w, RAISED ONLY BY AN OBSERVED READING. NO_RECORD and
NOT_OBSERVED both leave it at the cap, so an unreadable peak store makes the
guard stricter and never looser. The gate is the reading's STATE, not whether a
number happens to be attached: today a `peak_w is not None` test behaves
identically, and depending on that coincidence is how a future last-known-peak
field would raise the ceiling off a read that failed. headroom_w() is
deliberately not consumed - cap minus peak turns a zero peak into FULL freedom.
TWO DEFECTS FOUND BUILDING IT, both in the interaction rather than the
arithmetic, and both caught by running the thing rather than by reading it:
* A LIMIT does not out-vote another LIMIT. arbiter.resolve() applies every
limit at or above the winner's priority and takes the most restrictive, so
the peak floor (lo >= 0) and the maintenance charge-only shaping (hi <= 0)
resolved to a CONTRADICTION and 0 W - the battery parked in the exact quarter
it should shave. Fixed by suppression, not priority: main.py drops the
maintenance shaping while the guard is engaged, which is what that branch
already did for the optional peak_forecast_entity - a key empty on a default
install, so until now the suspension could not fire on the site that needed
it.
* Thresholding `excess > 0` released the claim the instant it began working,
producing seventeen engage/release cycles in one 900 s quarter and leaving
that quarter 49 W over the limit it had spent fifteen minutes holding. That
is 10.2's oscillation. The threshold is now the floor itself, which is the
same arithmetic read as a level rather than as a switch.
PeakTracker.project() no longer reads a private name with a default.
getattr(averager, "_last_net", 0.0) failed quiet AND disarming - a rename credits
0 W held, the projection reads low, the money guard does not engage.
QuarterAverager.held_net_w is a documented public property now, read without a
default, and Projection carries it. TEST-13 item 4.
NO NEW OPTION AND NO FOURTH DEFAULTS SITE. The ceiling is peak_cap_w, the
coverage threshold is meter_max_age_s, the freeze predicate is meter_freeze_s,
and the achievable discharge comes from max_w and the device rating. config.yaml
and validate.py are untouched; 14's capacity_peak_limit_kw stays declined. On
meter_source: off there are no telegrams, so the guard does not run at all and
the status page says NOT COVERED - running it there blinded it permanently and
stopped every legacy install from charging, which is what the first cut did until
test_clamp.py's export case caught it.
test_peakguard.py: eight parts, all figures derived from the shipped config.yaml
and the committed captures. Part 8 runs seven one-line defeats of the guard and
reports which check bites; six do, and the seventh is reported as NOT caught,
because max(cap, peak) makes a zero peak the strictest ceiling there is and there
is nothing left to catch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
|
||
|
|
91ea676af4 |
TARIFF-02: price provider plugins + the canonical all-in price format (#16)
Five providers behind a registry, canonical all-in {start, eur_per_kwh, provisional}. Plugins return RawPoints and declare capabilities().sources; assemble() adds the complement once. register() refuses at import any plugin whose class code mentions a framework key, PRICE_OPTION_DEFAULTS, price_option or _o - closing the three routes review used to double-count. PriceRead.series/no_data as an absence type; all-zero is a series, no_data forbids arbitrage. Additive only: main.py untouched, zero deletions.
|
||
|
|
862f0fa574 |
Merge release/1.0 (TARIFF-01) into TARIFF-02
checks / unit (pull_request) Successful in 2m39s
TARIFF-01 (#17) landed on release/1.0 first, so this branch was two commits behind and Gitea reported the PR unmergeable. Integrating rather than rebasing: the two reviewed commits stay addressable, and the branch is protected against force-push anyway. ONE CONFLICT, AND IT IS THE BORING HALF OF THE ONE REVIEW PREDICTED. Review of #16 expected collisions in four places - the tail of `options:`, the tail of `schema:`, the rule list inside validate_startup_config(), and the tail of validate.py. None of them happened: TARIFF-01 touched neither config.yaml nor validate.py. It added app/tariff.py, test_tariff.py, two P1 scenario captures, and edits to main.py and p1.py, none of which this branch goes near. The only conflict was CHANGELOG.md, where both branches prepended a block under the same `## Unreleased` heading. Both blocks kept, TARIFF-02 above TARIFF-01 since it lands second. Nothing was dropped or reworded on either side, and the resolution adds no line of its own beyond the blank one between them. Checked rather than assumed after the merge: - all ELEVEN test files pass, including TARIFF-01's own test_tariff.py (which this branch had never run) and the 93 s test_loop.py; - the 24-mutation battery still has 0 survivors; - no semantic overlap in config: TARIFF-01's peak tracker reads the peak_cap_w / peak_forecast_entity options that already existed, so the price options added here neither shadow nor duplicate anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
e102cde841 |
TARIFF-02 review: close the three other doors to the markup, and stop overclaiming
checks / unit (pull_request) Successful in 2m34s
Review of PR #16 wrote a double-counting plugin four ways. PluginOptions blocked one. The one that decided it touched nothing but PRICE_OPTION_DEFAULTS - a public dict in the module every plugin must already import for register() - so "the number is not in scope" was simply false, and an absolute guarantee a reviewer trusts is worse than a stated limit, because they stop checking. register() now refuses, AT IMPORT, any plugin whose own code mentions a framework-owned name: FRAMEWORK_KEYS plus PRICE_OPTION_DEFAULTS, price_option and _o. code_leaks() parses the class and drops every docstring node before searching, because every plugin is REQUIRED to name its sourced components in prose - engie_dynamic says "markup" in its docstring and a raw text scan false-positives on the documentation the same file mandates. A check that fires on required documentation is a check somebody deletes. test_price.py asserts that a raw scan really would have false-positived, so the strip is proven load-bearing rather than asserted. It fails OPEN if inspect.getsource() cannot find the .py, which the add-on image always can. Refusing to start a battery controller because a static analysis could not run would trade a real outage for a hypothetical accounting error - so instead the checks assert source was found for all five shipped plugins, and a build that lost it is a red check rather than a quietly disarmed guard. All three reachable attacks are written out as plugins in the suite and each must be refused at registration. The cost is measured, not asserted: honest wholesale 0.188, every attack 0.219 - one markup high, every hour, invisible in every log this add-on writes. The guarantee is now stated at its true size everywhere it appears: the framework adds the complement exactly once, GIVEN A CORRECT sources DECLARATION. A plugin that declares {ENERGY} while its feed is all-in still doubles while touching no framework name, and no code can catch that - the declaration is an unverifiable claim about somebody else's tariff. Measured in the suite (0.276) so the limit is a number rather than a caveat. The docstring/declaration check verifies that a plugin's two claims AGREE, not that either is true; that now says so. The three surviving mutations: M3 - _NoData's reason taking a default. This is the identical survivor left open on RIG-01's _Unread.cause: the shape was borrowed from sim/riglock.py including its blind spot, which is what borrowing a shape does if nobody re-runs the mutations against the copy. Closed by SIGNATURE - no constructor or handler of an outcome type may carry a default - so the next one is red without anyone writing a new case. M5 - the ENERGY-required guard was untested. Its consequence is that adds becomes {ENERGY, TAXES} and the emitted price silently omits the energy it is pricing. M7 - deleting the TLS refusal went green AND the check named for it still passed, because the old parser stripped a fixed len("https://") and mangled http://api.energy-charts.info/p into the host pi.energy-charts.info, which then failed the ALLOW-LIST. An assertion that does not test what it names, inside a security check. Fixed on both sides: the scheme is parsed as a scheme, so an http URL now resolves to its real and declared host and only the TLS rule can refuse it; and the check asserts the REASON. Fixing it surfaced M7b - the no-scheme guard was survivable the same way - found by mutating my own fix, and closed the same way. Also carried forward: rule 7's refusal is correct forever but its stated reason ("the provider decides nothing") expires the moment TARIFF-03 or OPT-04 lands. test_price.py section 12 now greps app/ by AST for a load_prices consumer and goes red when one appears, telling whoever lands it to update the message in the same commit. The first draft of that tripwire used a substring and matched the comment in validate.py explaining the tripwire - the same false-positive class as the plugin docstrings, hit twice in one ticket, and fixed the same way. Documented rather than coded around: vars(read).get("points", []) and bool(read) both still get past the absence type, and neither is worth code - defining __bool__ would turn a line that reads like a guard into one that IS a guard by accident, which is how .get(k, default) got here in the first place. Stated plainly in DOCS/CHANGELOG/PR: every fixture is synthetic, so the five feed URLs and JSON shapes are verified against a belief about what those suppliers publish rather than against the feeds. A wrong shape degrades to no-price-data, not to a wrong price. Review's own DST verification recorded: 149,040 hourly instants, 2020-2036, zero mismatches against zoneinfo. The residual is political, not arithmetic, and the docstring now says so. 24 mutations driven through the suite, 0 survivors. Whole add-on suite green. No deleted lines outside this ticket's own files; main.py still untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
0dbd485b5a |
TARIFF-01: measure the month's billed capacity peak, and make it survive a restart (#17)
The instrument SAFETY-07 will read: the highest clock-aligned 15-minute average offtake of the calendar month, persisted in /data/tariff.db across two retention tiers, with a monthly peak that recompute_month() proves has not diverged from the 15-minute tier. Files no claim, applies no clamp, commands nothing.
AC 1 was already built and unwired - p1.QuarterAverager on the base already does clock-aligned 900 s blocks, time-weighted over irregular spacing, offtake-only, per-phase, with the max_hold_s gap rule; verified on
|
||
|
|
f4914a174a |
TARIFF-01: measure the month's billed peak, and make it survive a restart
checks / unit (pull_request) Successful in 2m38s
The 15-minute block average was already built and unwired. p1.QuarterAverager computes clock-aligned 900 s blocks, time-weighted over irregular telegram spacing, offtake-only and per-phase; P1Ingest.blocks was a 96-entry list in memory that a restart erased. So this is the persistence and the monthly reduction, not a second accumulator - a second one would be a second source of truth for the exact quantity the money depends on. BLOCKS, NOT A SLIDING WINDOW. The card says "rolling"; 9.1 bills "the highest 15-minute average offtake" and states its own worked case per QUARTER - one 4998 W quarter moving a monthly peak from 4832 to 5013 W. A trailing window reports a peak the meter never bills, and reports it HIGH: a burst straddling :15 fills the window completely while contributing to two block averages at partial weight. DELIVERED, NOT INTENDED. tariff.py only ever sees P1Sample - the meter's own reading - and never a setpoint. SAFETY-20's open question does not arise for a capacity tariff: the utility integrates the current that flowed. AN UNREADABLE STORE IS NOT A ZERO PEAK. peak() answers OBSERVED / NO_RECORD / NOT_OBSERVED with peak_w None unless observed, which is riglock.holder()'s fix applied to something that spends money. A peak read as 0 W hands SAFETY-07 maximum headroom and disarms the guard at the moment its own storage breaks. Restart mid-block resumes the accumulator through a new snapshot()/restore() pair, checkpointed every 15 s and cleared in the same breath as a block close. The held sample keeps its ORIGINAL timestamp, so an outage is integrated under the same max_hold_s rule a running process would apply. quarter_start is the primary key and a re-bank may only ever RAISE a figure. Calendar months are LOCAL: a quarter starting 22:15 UTC on 31 August bills in September. No new option (the limit is the existing peak_cap_w), so config.yaml and validate.py are untouched and CFG-03 is not widened. No second peak entity: peak_forecast_entity stays the input to maintenance_charge_floor(), because repointing that consumer changes what moves energy and belongs to SAFETY-07. AC 3 is NOT met. It asks for 30 days of replay; two days of this house's real meter exist and are committed here. test_tariff.py part 9 replays them, reports the percentage next to the sample size in days, and asserts nothing about 95 %. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
852fc7ced6 |
TARIFF-02: one canonical price format, and a seam that keeps supplier names out of the optimiser
checks / unit (pull_request) Successful in 2m33s
Five provider plugins - generic_dayahead, engie_dynamic,
totalenergies_dynamic, eneco_eddy, manual_csv - all emitting exactly
{"prices": [{"start", "eur_per_kwh", "provisional"}]}, tz-aware at the
Brussels offset, EUR/kWh, all-in. Nothing consumes it yet; the seam
lands first, per the PM's ordering.
It is built in goodwe_controller/app/ rather than at specs.md 2.2's
ems/ingest/prices/, per the product owner's decision of 2026-08-30. The
behaviour 9.3 / 8.3 / 11.4 specifies is unchanged.
Three things shape the design, and each is the mechanism rather than a
comment saying to be careful:
Double-counting the markup is unreachable. A plugin does not return a
price - it returns the figure its source published and declares which of
{energy, markup, taxes} that figure already contains. assemble() adds
the complement, once, in the framework. A plugin is never handed
price_markup_eur_kwh: PluginOptions raises FrameworkOption on it, so the
line that adds the margin a second time cannot be written. Engie and
TotalEnergies publish a retail figure with their margin inside; adding
the configured 3 c/kWh on top would have made every arbitrage decision
wrong by that amount with nothing reporting it.
The product type is declared, not inferred from the plugin's name.
eneco_eddy is real-time/imbalance and gets 8.3's widened thresholds
through capabilities().product and volatility_regime(), never through a
name comparison - which is AC2. test_price.py asserts mechanically that
no plugin id appears anywhere in the framework's code, and drives a
sixth provider defined inside the test file to the right regime with
price.py untouched.
"No price data" is a type, not an empty list - the same rework
sim/riglock.py took under TEST-04. A fetch returns PriceRead.series(...)
or PriceRead.no_data(reason), and only the first carries fields. An
empty series is not constructible; no_data has no .points, no .at(), and
.get("points", []) raises rather than returning the default. A day of
free electricity and a day with no prices are different instructions -
arbitrage is pointless under one and forbidden under the other - and an
implementation returning [] cannot tell them apart. Every site-level
failure returns no_data; nothing raises into the control tick.
Also: injection stays a separate scalar (export_price_eur_kwh, AC4),
negative prices survive the boundary validator (17's regression case),
egress is allow-listed against each plugin's own declaration (16), and
no tzdata is used - the shipped Alpine image has none, so Brussels
offsets come from the EU rule and instants are carried as UTC.
validate.py grows rule 7: an unknown provider id, manual_csv with no
path, and - the HA overlap the PM told us to decide about -
price_provider set alongside price_now_entity/price_avg_entity, which is
two price sources where the HA pair silently wins. The HA sensors are
kept and still work alone; TARIFF-03 and OPT-04 are what replace them.
CFG-03 is not widened: the price defaults have ONE in-code mirror,
PRICE_OPTION_DEFAULTS, which validate.py imports rather than restating
and which test_price.py pins to config.yaml by reading the file.
test_price.py is runnable, assert-free in the house style, and every
verdict it prints is derived. It grades the markup arithmetic with
0.031/0.057 rather than the shipped 0.03/0.06, because 0.06 is exactly
twice 0.03 and a degenerate pair would make double-counting
indistinguishable from correct addition - and it asserts that
non-degeneracy as its first section. The DST checks run on the 23-hour
and 25-hour days, not on a normal day where a fixed 24-slot
implementation and a correct one agree. Thirteen mutations were driven
through it; all thirteen go red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
|
||
|
|
9aadf469bc |
TEST-06: pin the cycle() ordering, the NOT COVERED string and the shipped meter_freeze_s (#14)
Closes the three mutations that survived SAFETY-18 suite green. Ordering pinned by consequence (N8 red above the assignment, N3 green below it); NOT COVERED asserted in both directions; shipped meter_freeze_s and its schema bounds pinned. notes_of()/all_notes_of() split so this guard and the whole notification surface are separately nameable, with the three surface-wide claims decided per scenario after SAFETY-03. app/ change is comment-only (AST-identical). |
||
|
|
4b521b2df0 |
ci: trigger a run on the rebased head
checks / unit (pull_request) Successful in 2m33s
No content change. The gate never created a run for |
||
|
|
0942228d65 |
Merge release/1.0 (SAFETY-03) into TEST-06
checks / unit (pull_request) Canceled after 0s
Two conflicts, both predicted, both mine as the later merger. app/main.py - #12 inserted `await self.check_output_clamp()` on the line immediately above `await self.check_meter_frozen()`, so its hunk abuts my comment correction. Resolved so EACH CALL CARRIES ITS OWN BOUNDARY, because the two guards do not share one: - check_output_clamp() reads `self.clamp_last`, which is None until write_setpoint() has run, so its boundary IS the write; - check_meter_frozen() grades `self.target`, assigned further up, so its boundary is the assignment. Letting my correction generalise across both would have been worse than the vague comment it replaces, because it would read as deliberate. The AST of app/main.py is identical to merged release/1.0 (+1251 bytes, all comment). test_freeze.py - #12 independently added `frozen_notes()` keyed on the same id. `notes_of()` subsumes it: same id, plus the title fallback that keeps the id's own check independently falsifiable, plus `all_notes_of()` for claims about the whole surface. THE THREE SURFACE-WIDE ASSERTIONS, DECIDED ONE AT A TIME. Measured on the merged tree: scenario surface ids freeze frozen meter, railed command 2 frozen + clamp 1 TEL-05 converged, -14.2 W 0 - 0 moving meter, a real 8 kW load 1 clamp 0 uncovered transport, no unchanged_s 1 clamp 0 - the wiring scenario STAYS SURFACE-WIDE and now asserts exactly TWO alarms with BOTH IDS NAMED. The answer to "is this still meant to raise one alarm" is no, it raises two, and a third guard has to come back to this line. That is the one assertion in the file that accounts for the controller's entire alarm output; - the CONVERGED case stays surface-wide and stays silent: at the operating point the whole add-on should have nothing to say, that is still true, and narrowing it would give away a real property; - the other two NARROW to notes_of(). The clamp alarm there is correct and is SAFETY-03's subject: a real 8 kW load does drive the loop past the rating, and an uncovered transport does not stop it. Asserting whole-surface silence would make this file fail whenever another guard correctly does its job. Each still owns the load-bearing half - THE FREEZE DETECTOR SAID NOTHING - and the surface is PRINTED so a reader sees what else fired without this file grading it. Re-verified on the merged tree: M1 RED 4, M2 RED 5, M3 RED 3 + RED 2, N6 RED 1, N3 green (correctly), N8 RED 4, R1 RED 2, R2 RED 1. All nine suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |