Compare commits

..
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 7cde5d862a Merge release/1.0 into SAFETY-18 - CHANGELOG only
MAINT-04 (#10) landed on release/1.0 mid-review, so both branches had
prepended a stanza. config.yaml and DOCS.md auto-merged clean; only the
CHANGELOG needed a hand. Both stanzas are kept verbatim, MAINT-04's
Unreleased above the new 0.5.2 - no text from either author edited.
MAINT-04 is unreleased and 0.5.2 is being cut here, so a release manager
may want to fold the two; that is an editorial call, not a reviewer's.

Full suite green on the merged tree: arbiter, control, loop, maintenance,
p1, validate, watchdog, freeze.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 03:20:26 +02:00
admin b0a17474a3 MAINT-04: refuse a maintenance charge the ceiling cannot deliver (#10)
Rule 6 and the default change verified; four claimed mutations plus six of my own all reproduce red. The cross-file default divergence is now pinned by a check that fails in both directions, including when someone closes it - verified against MaintConfig-only, one-call-site-refactored, both-refactored and one-literal-silently-changed.
2026-08-30 03:01:14 +02:00
glenn schrooyenandClaude Opus 5 ffb8ec6373 MAINT-04 review: pin the default divergence with a check, not a comment
validate.py's comment said "if config.yaml's value moves, move this and
main.py's together" and nothing enforced it - a comment where a check
belonged, in a PR that elsewhere proves it knows the difference.

test_validate.py now reads main.py and maintenance.py directly and asserts
the divergence EXACTLY AS IT STANDS: config.yaml/validate.py at 2000,
main.py's two fallbacks and MaintConfig's dataclass default at 2500. It
records the mismatch rather than demanding it be closed, so it passes today
and goes red if either side moves - including when somebody closes it in
main.py, at which point the check and validate.py's block are deleted
together. Nothing in a file another ticket owns is touched.

The consequence is asserted through the real code as well as the literals:
with the key absent the validator accepts, MaintConfig builds 2500 W against
a 2000 W ceiling, and the arbiter resolves it to 0 W as a contradiction -
the bug rule 6 exists to catch, passing rule 6. Production-unreachable (the
Supervisor always supplies every key) but fully reachable from sim/inject.py
and the tests, which is where anyone debugging maintenance works.

Also: a CHANGELOG section for the upgrade path, covering both the case where
the Supervisor re-derives the default and the case where it persisted the
old one, since which applies was not verifiable without probing the live
install. And maintenance.py's timeout comment said 13.5 kWh where everything
else says 13.8 (300 Ah x 51.2 V, DoD 90); SAFETY-18 does not touch that file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 02:51:40 +02:00
glenn schrooyenandClaude Opus 5 0ba3a200fe SAFETY-18: verify on the real rig, and record what it showed
AC3 says "verified on the rig". The previous commit drove hwsim in-process
because `riglock status` reported UNKNOWN - which is the tool declining to
observe, not an observation. riglock shells out to the local docker CLI; on a
Windows host without DOCKER_HOST that resolves to a Docker Desktop npipe which
is not running, and the result is indistinguishable from a genuinely absent rig.
The rig was up the whole time. With DOCKER_HOST=ssh://homelab@192.168.2.147 it
reads FREE.

Re-run under sim/riglock.py against the real goodwe-sim compose project, with
the shipped modules staged into goodwe-sim-plant so they poll that container's
own hwsim while plantsim drives it. Both phases passed, lock released cleanly.

The rig produced a result the in-process stub could not, and it strengthens the
design rather than merely confirming it: in the NEGATIVE twin - +8000 W of house
load, healthy meter, correctly railed command - `active_power_w` did not move at
all, sitting at exactly 10130.0 W for 26 consecutive polls. The only thing
keeping `unchanged_s` at 0.0 s was the kWh registers advancing.

So the register terms in measurement_fingerprint() are not belt-and-braces for
this detector, they ARE the negative case: fingerprinted on the power figure
alone it would have raised a false alarm on a healthy meter under a real load,
which is precisely how a safety guard gets switched off. Recorded in freeze.py
so nobody simplifies the fingerprint without meeting that run first.

No behaviour change - documentation and the harness note only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 02:49:55 +02:00
glenn schrooyenandClaude Opus 5 7aa1bd052e SAFETY-18: a meter frozen at a wrong-but-fresh value now raises an alarm
A meter that answers 200 OK with a stale document forever is ARRIVING on every
poll, so every freshness mechanism in the stack reads it as healthy while the
loop integrates a constant error and rails: 360/135/75 s at the live install's
max_w: 5000 for a meter frozen at 350/1000/>=3000 W. Fifteen minutes of a 5 kW
unattended import sets the month's billed capaciteitstarief peak.

The detector is a conjunction, not the bare unchanged_s threshold TEL-05
measured and rejected:

    unchanged_s >= meter_freeze_s   AND   |command| within 5 % of max_w

Both terms already existed and were already on the status page, and they fail
for independent reasons: at the converged point the command is nowhere near the
rail, and at the rail against a real load the kWh registers move in seconds. At
the shipped 300 s the relation is structural rather than a margin - the slowest
a healthy 1 Wh register can tick and still look unchanged is 12 W, which is
inside the 15 W deadband, so the command there is parked at 0 W.

Notification only, deliberately: an HA persistent notification with a fixed
notification_id, an ERROR log line, a UI event and a red status row. No claim,
no latch, no 0 W. A false trip parks the battery; a false notification costs an
alert. Only meter_source: homewizard_local is covered - the status page says
NOT COVERED elsewhere rather than pretending the guard is armed.

DOCS.md states the capacity-tariff exposure this does NOT close: peak_cap_w caps
maintenance charging and nothing caps the loop. That cap is SAFETY-07.

test_freeze.py grades both directions and was verified by breaking what it
watches (six mutations, all red): silent over the committed 40-minute converged
capture, silent over an hour of TEL-05's low-power case where the rejected bare
threshold is shown to fire, alarming on a genuinely frozen meter driven over
real HTTP through the real HomeWizardLocalSource (the rig's own hwsim freeze
fault when reachable), silent on the same railed command against a meter that is
still moving, and asserting on the service call that leaves Controller.cycle()
rather than on the watchdog's own flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 02:39:54 +02:00
glenn schrooyenandClaude Opus 5 878a6ce452 MAINT-04: refuse a maintenance charge the ceiling cannot deliver
config.yaml shipped max_w 2000 against maintenance_charge_w 2500. The
cheap-window shaping files Claim.limit(hi = -maintenance_charge_w) against
the supervised SAFETY limit's lo = -max_w, so lo > hi - the arbiter's
clause 3, correctly resolved to 0 W and logged as a CONTRADICTION.
Maintenance silently never charged in a cheap window on a default install,
and the only evidence read like a bug in the arbiter rather than like the
two numbers the operator set.

validate_startup_config() now refuses maintenance_charge_w > max_w, the
same shape as CFG-01's sign_disagree_w >= max_w. Equality is reachable
(-max_w is a single valid point) and is not refused. Demonstrated by
starting the add-on on the pre-fix pair: exit 78 and the refusal banner.

maintenance_discharge_w was checked rather than assumed and is NOT the same
case: the drain phase OWNS the actuator (Claim.set at P_MAINTENANCE), so
the ceiling clamps it to +2000 W and the arbiter's explain() names the
clamp every cycle. Loud, and it still moves energy - warned, not refused.

The shipped maintenance rates drop to 2000 so the defaults pass the rule;
max_w was deliberately not raised, because it is the supervised safety
ceiling. test_validate.py now reads config.yaml itself, so a default the
validator refuses cannot ship again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 02:28:09 +02:00
admin bb64f8fdd7 SAFETY-02 follow-up: persist the latch, and make the gate assertion bite (#9) 2026-08-28 23:09:27 +02:00
11 changed files with 1460 additions and 12 deletions
+134
View File
@@ -1,5 +1,139 @@
# Changelog
## Unreleased
**Cheap-window maintenance charging was unreachable at the shipped defaults, and
now the config that makes it unreachable is refused (MAINT-04).** `config.yaml`
shipped `max_w: 2000` against `maintenance_charge_w: 2500`. The cheap-window
shaping files `Claim.limit(hi = -maintenance_charge_w)` against the supervised
SAFETY limit's `lo = -max_w`, so `lo > hi` — the arbiter's clause 3, correctly
resolved to **0 W** and logged as a CONTRADICTION. **Maintenance silently never
charged in a cheap window on a default install**, and the only evidence read
like a bug in the arbiter rather than like the two numbers an operator set.
Measured against the real arbiter, loop asking +500 W:
```
max_w=2000 hi=-2500 -> +0 W contradiction=True
max_w=2500 hi=-2500 -> -2500 W limited by supervised(max_w)
max_w=3000 hi=-2500 -> -2500 W limited by maintenance(cheap-window charge)
```
`validate_startup_config()` now refuses `maintenance_charge_w > max_w` (rule 6),
the same shape as CFG-01's `sign_disagree_w >= max_w`: a number the operator set,
overridden by a layer they cannot see. Equality is reachable (`-max_w` is a
single valid point) and deliberately not refused. Verified by starting the
add-on on the pre-fix pair: exit 78, the refusal banner, no traceback.
**`maintenance_discharge_w` was checked, not assumed, and it is NOT the same
case.** The shaping is not symmetric. The drain phase does not shape the loop,
it OWNS the actuator — `Claim.set(P_MAINTENANCE, +discharge_w)` — and a SET is
*clamped* by the limits above it rather than contradicted by them. 2500 against
a 2000 W ceiling emits **+2000 W**, and the arbiter's own `explain()` prints
`maintenance -> 2000 W, limited by supervised(max_w)` every cycle. Nothing is
silent and energy still moves; the cycle completes slower (13.8 kWh usable at
2000 W is ~6.9 h against the 720 min drain timeout). **Warned about, not
refused.**
**The shipped defaults changed so the validator does not reject them.**
`maintenance_charge_w` and `maintenance_discharge_w` are now **2000**, at the
shipped `max_w`. `max_w` was deliberately NOT raised instead: it is the
supervised safety ceiling and raising a default upward ships more power to every
new install. Raising `max_w` (the reference install runs 5000) is what unlocks a
faster maintenance cycle — raise it first, then these. `test_validate.py` now
reads `config.yaml` itself and asserts the shipped numbers pass the rule, so a
default that the validator refuses cannot ship again.
### ⚠️ Upgrading an existing default install: the add-on may refuse to start
An install still carrying `maintenance_charge_w: 2500` against `max_w: 2000`
now meets rule 6 and **exits 78 with the refusal banner** instead of starting.
That is the intended behaviour — the configuration was already broken, it was
just silent about it — but an add-on that will not come back after an update
needs the fix stated here rather than only in the log.
**The fix, either way: set `maintenance_charge_w` to `2000` or lower, OR raise
`max_w` to `2500` or higher** (Settings → Add-ons → GoodWe RS485 Controller →
Configuration), then start it. The banner in the log says the same thing and
names both numbers.
Whether you hit this at all depends on whether the Supervisor kept the old
value, and **both cases are covered above because we did not verify which one
applies here.** The Supervisor merges an add-on's *stored* options over the
defaults in `config.yaml`, so:
- **A key the operator never edited** re-derives from the new `config.yaml` and
becomes 2000 on upgrade. Non-event, nothing to do.
- **A key the operator did set** persists at 2500 and the add-on refuses to
start until it is changed. Note this includes anyone who used the
Configuration page's YAML editor, which writes the whole options block back —
so "I never touched that field" is not a guarantee the old value is gone.
⚠️ **This is reasoned from the Supervisor's documented merge behaviour, not
measured on the live install.** The reference install's stored options could not
be read (`/api/hassio/` refuses a long-lived token) and probing it was out of
scope — it regulates a real battery. It runs `max_w: 5000` and is unaffected
either way.
⚠️ **Known, deliberately left, and worth a follow-up:** `main.py` and
`maintenance.py` still fall back to **2500** when the key is absent from the
options dict, while `config.yaml` and `validate.py` now say 2000. Unreachable on
a Supervisor install (every key is always supplied) and reachable only from
tests/`sim`. The real fix is one source of truth for option defaults, which is
bigger than this ticket and touches files another ticket is in.
## 0.5.2
**A meter frozen at a wrong-but-fresh value now raises an alarm. It still is not
stopped.** (SAFETY-18)
A meter that answers `200 OK` with a stale document forever is *arriving* on
every poll, so every freshness mechanism in the stack reads it as healthy:
`meter_max_age_s` is reset by each response, the firmware watchdog is fed by the
heartbeat and by a sample age that never climbs, and the arbiter contributes only
±`max_w`. The loop integrates a constant error and rails — replaying the real
`compute()` at 15 s cycles, in 150/60/30 s at `max_w: 2000` for a meter frozen at
350/1000/≥3000 W, and 360/135/75 s at `max_w: 5000`. **The live install runs
`max_w: 5000`**, which is both the schema maximum and the device rating.
The new detector (`app/freeze.py`) is a **conjunction**, not the bare
`unchanged_s` threshold TEL-05 measured and rejected:
unchanged_s ≥ meter_freeze_s AND |command| within 5 % of max_w
Both terms already existed and were already on the status page. They fail for
independent reasons: at the converged point the command is nowhere near the rail,
and at the rail against a real load the kWh registers move within seconds. At the
shipped 300 s there is also a structural relation, not just a margin — the
slowest a healthy 1 Wh register can tick and still look unchanged is 12 W, which
is inside the 15 W deadband, so the command there is parked at 0 W and the two
halves cannot both be true.
**Notification only, deliberately.** An HA persistent notification (fixed
`notification_id`, so a repeat replaces rather than stacks), an `ERROR` log line,
a UI event and a red status banner. No claim, no latch, no 0 W: a false trip
parks the battery and the operator's next move is to switch the guard off; a
false notification costs an alert. The trip is a separate decision that needs
data from a real install.
- New option `meter_freeze_s` (default 300, range 60–3600, no "off").
- **Only `meter_source: homewizard_local` is covered** — it is the only transport
that sees the meter's own document. Everywhere else the status page says
**NOT COVERED** rather than "armed".
- `DOCS.md` now states the **capacity-tariff exposure** this does *not* close:
`peak_cap_w` caps maintenance charging and nothing caps the loop, so a lying
meter can drive a 5 kW unattended import, and ≥15 minutes of that sets the
month's billed peak (measured: one 4998 W quarter moved a monthly peak from
4832 → 5013 W). That cap is SAFETY-07.
- `test_freeze.py` grades it in both directions: silent over 40 minutes of the
live house's real converged capture (committed as
`scenarios/p1-age-2026-08-26.jsonl.gz`), silent over an hour of TEL-05's
low-power case where a bare threshold is shown to fire, and alarming on a
genuinely frozen meter driven over real HTTP through the real
`HomeWizardLocalSource` — against the rig's own `hwsim` `freeze` fault when the
infra repo is reachable, an equivalent stub otherwise. Its negative twin is the
load-bearing one: the same railed command against a meter that is still moving
must stay silent at the same threshold.
## 0.5.1
**SAFETY-02's latch now survives an add-on restart, which is the difference
+95 -2
View File
@@ -30,6 +30,10 @@ The inverter **holds its last command forever** — it has no meter-timeout. So:
- The **optional RS485 e-stop** is the only thing that covers this machine
dying. Without it, a failed host leaves the battery latched at its last
command until someone intervenes.
- This add-on **raises an alarm — and nothing more —** when the meter keeps
answering with a reading that never changes while the command sits at the
rail. See "The frozen-meter alarm" and, before you rely on it, "Capacity-tariff
exposure of the grid-following loop".
**If anything looks wrong: stop the add-on.** That commands 0 W and the
hardware holds it there.
@@ -414,8 +418,8 @@ help - see "Why the tuning is what it is".
| `maintenance_enabled` | false | Enable the monthly cycle |
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
| `maintenance_discharge_w` | 2500 | Drain rate (exports the surplus) |
| `maintenance_charge_w` | 2500 | Charge ceiling, capped again by peak headroom |
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor |
| `maintenance_soc_target` | 99 | Charge target |
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
@@ -433,6 +437,95 @@ under `peak_cap_w`, and if the forecast goes over the cap the charge-only clamp
is dropped so the battery can shave the peak instead. Money outranks the
maintenance schedule.
#### Capacity-tariff exposure of the grid-following loop
⚠️ **`peak_cap_w` caps MAINTENANCE charging. It does not cap the loop.** The
grid-following loop is bounded by `max_w` and by the device rating, and by
nothing else. That is deliberate — the loop exists to *reduce* what crosses the
meter, so under every healthy condition it lowers the peak rather than raising
it — but it means one thing is true and worth writing down:
**If the meter lies, the loop will happily buy `max_w` from the grid for as long
as the lie lasts, and no cap in this add-on stops it.** A meter frozen at a large
export reads as "the house is exporting hard", the loop charges the battery to
correct it, and the correction is a grid import. At the schema maximum
`max_w: 5000` — which is what this house runs, and is also the device rating —
that is a 5 kW unattended import.
The bill is not the energy. In the Belgian capaciteitstarief the month is billed
on its highest quarter-hour average, so **fifteen minutes of this sets the whole
month's peak.** A measured case: one 4998 W quarter moved a monthly peak from
4832 W to 5013 W, and every day of that month was billed at the higher figure.
What exists today, and what does not:
| | |
|---|---|
| `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude |
| Maintenance charging capped under `peak_cap_w` | ✅ |
| **Loop capped under `peak_cap_w`** | ❌ — not built. This is SAFETY-07 |
| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** |
| Frozen meter *stopped* | ❌ — the alarm does not command anything |
| SoC guard on the loop | ❌ — the BMS is the only energy backstop |
So the frozen-meter alarm below **narrows the window; it does not close it.** On
a capacity-tariff site where an unattended runaway is unacceptable, the mitigation
today is operational: set `max_w` no higher than the loop actually needs (the
tuning was measured at 2000 W and a 1.78 kW load step is tracked fine there), fit
the e-stop, and watch for the alarm.
### The frozen-meter alarm
A meter that has died is easy — it stops answering, the sample age climbs past
`meter_max_age_s`, the reading becomes missing, and the add-on and the ESP32 both
command 0 W. A meter that keeps answering `200 OK` with a document that never
changes is the hard one: **every freshness mechanism in the stack reads it as
healthy**, because it really is arriving on every poll. The loop then integrates
a constant error and winds to the rail, in 75–360 s at `max_w: 5000`.
The add-on alarms when **both** of these are true at once:
- the meter's own document (power figure *and* both kWh registers) has not
changed for `meter_freeze_s` — 300 s by default; **and**
- the command standing on the wire is at the rail, within 5 % of `max_w`.
Neither half is usable alone. A bare "unchanged for N seconds" rule false-trips
at the exact operating point this controller aims for — at a converged −10 W a
1 Wh register legitimately takes about six minutes to move — and a bare "at the
rail" rule fires on any genuinely large load. Together they are safe in both
directions, because a real railed condition means kilowatts still crossing the
meter, and a kilowatt advances a 1 Wh register every 3.6 s.
**It is an alarm and nothing else.** It does not command 0 W, does not latch, and
does not claim the actuator. A false trip here parks the battery; a false
notification costs an alert. Turning it into a trip is a separate decision that
needs data from a real install — and an answer to "what should the loop do
instead", given that 0 W is itself an import of whatever the house is drawing.
When it fires you get a Home Assistant persistent notification
(`goodwe_meter_frozen`, replaced rather than stacked on a repeat), an `ERROR` in
the add-on log, an entry in the UI's event list, and a red banner on the status
page. **The loop keeps running.** If the meter really is stuck, stop control from
the add-on UI.
⚠️ **Only `meter_source: homewizard_local` is covered.** It is the only transport
that reads the meter's own document; on `ha_dsmr`, `mqtt_p1`, `ha_signed` and
`off` there is no signal to threshold and the status page says **NOT COVERED**
rather than pretending the guard is armed.
| option | default | meaning |
|---|---|---|
| `meter_freeze_s` | 300 | Seconds of an unchanged measurement, *while the command is at the rail*, before the alarm |
Why 300 s: over 2399 s of this house at the converged operating point (mean grid
−14.2 W, 1194 polls, 735 of them repeating the previous reading) the largest
`unchanged_s` any sample sees is **8.0 s**, and the longest interval serving no
distinct reading at all is **15.4 s**. There is also a structural reason not to
lower it much: at 300 s the slowest a *healthy* register can tick and still look
unchanged is 12 W, which is inside the 15 W deadband — so the command there is
parked at 0 W and the two halves of the rule cannot both be true. At the schema
floor of 60 s that figure is 60 W and only the margin is left.
### Site
| option | meaning |
+275
View File
@@ -0,0 +1,275 @@
"""SAFETY-18 - the frozen-but-fresh meter detector.
WHAT IT CATCHES, in one sentence: a meter that keeps answering 200 OK with a
document that never changes, which every freshness mechanism in this stack reads
as perfectly healthy while the loop winds to the rail and stays there.
THE FAILURE NEEDS NO FAULT ANYWHERE ELSE. A frozen-but-talking meter is
*arriving* on every poll, so:
- `meter_max_age_s` is structurally blind. Every response resets the arrival
stamp (p1.py, `HomeWizardLocalSource`), so the age never climbs. Age catches
a meter that stops TALKING; this one stops CHANGING.
- firmware layer 1 is fed. Its watchdog keys on setpoint staleness, which the
heartbeat satisfies, and on `sensor.goodwe_p1_sample_age`, which a talking
meter satisfies.
- the arbiter contributes only the device rating and +-`max_w` - it bounds the
magnitude, which is the number this fault drives us to, not away from.
- there is no SoC guard on the grid-following loop at all. `soc_floor` belongs
to the maintenance cycle; the BMS is the sole energy backstop.
The loop reads a constant error and integrates it. Time from freeze to the rail,
replaying the real `control.compute()` at 15 s cycles:
meter frozen at to rail, max_w 2000 to rail, max_w 5000
350 W 150 s 360 s
1000 W 60 s 135 s
>= 3000 W 30 s 75 s
⚠️ THE LIVE INSTALL RUNS `max_w: 5000`, which is both the schema maximum and the
device rating, so the right-hand column is the one that describes this house.
------------------------------------------------------------------------------
THIS HAS A EURO FIGURE, AND IT IS THE REASON THE TICKET IS P1
`maintenance_charge_floor()` caps MAINTENANCE charging against `peak_cap_w`. The
grid-following loop is not capped by anything but `max_w`. A meter frozen
reading a large export drives a 5 kW unattended grid IMPORT, and >= 15 minutes
of that sets the month's billed capaciteitstarief peak: specs.md:583 records a
measured case where a single 4998 W quarter moved a monthly peak from 4832 W to
5013 W and billed the whole month at it.
⚠️ THIS CUT DOES NOT CAP THAT. It notifies. See DOCS.md, "Capacity-tariff
exposure of the grid-following loop", and SAFETY-07.
------------------------------------------------------------------------------
WHY IT IS A CONJUNCTION AND NOT AN `unchanged_s` THRESHOLD
`unchanged_s` (p1.py) already measures exactly the right thing: seconds since
the meter served a DIFFERENT measurement, fingerprinted over the power figure
and both energy registers. It is computed, it is on the status page, and TEL-05
deliberately refused to threshold it - correctly:
this controller regulates grid power toward ~0 W, and at a converged -10 W
the export register takes about six minutes to advance by its 1 Wh
resolution while the power figure legitimately repeats.
So a bare `unchanged_s > N` false-trips at the exact operating point we aim for,
and `p1.py` says as much in a note that ends "whoever wires a freeze detector
must handle the low-power case first". This is that handling, and it is not a
smaller number:
unchanged_s >= hold_s AND |commanded_w| is at the rail
The second term is what TEL-05's case cannot satisfy. At the converged point the
command is nowhere near +-`max_w`; on the 40 minutes of live converged behaviour
in `sim/scenarios/p1-age-2026-08-26.jsonl.gz` the meter never once leaves
+-500 W, so the loop never even approaches a 5000 W rail. And the reverse case -
a command genuinely pinned at the rail by a real 8 kW load - cannot satisfy the
FIRST term, because a real railed condition means kilowatts still crossing the
meter, and a kilowatt advances a 1 Wh register every 3.6 s.
The two terms fail for independent reasons. That is the whole design.
⚠️ AND THE SECOND HALF OF THAT WAS MEASURED ON THE RIG, WITH A RESULT WORTH
READING CAREFULLY. Driving the real transport against the rig's own hwsim under
+8000 W of house load - a genuinely railed command, meter perfectly healthy - the
POWER FIGURE DID NOT MOVE AT ALL: `active_power_w` sat at exactly 10130.0 W for
26 consecutive polls. What kept `unchanged_s` pinned at 0.0 s was the kWh
registers advancing, and nothing else.
So the register terms in `measurement_fingerprint()` are not belt-and-braces
here, they are the entire negative case. A freeze detector fingerprinted on the
power figure alone would have raised a false alarm on that rig run - a healthy
meter, a real load, a correctly railed command - which is the exact failure mode
that gets a safety guard switched off. Anyone tempted to simplify the fingerprint
should read that run first.
------------------------------------------------------------------------------
WHERE `hold_s` COMES FROM
Measured on `sim/scenarios/p1-age-2026-08-26.jsonl.gz` - 2399 s (40 min) of the
live house at the converged operating point, mean grid -14.2 W, 1194 polls:
polls that repeated the previous reading 735 of 1193 (61.6 %)
distinct-reading transitions 458
median gap between distinct readings 6.0 s
p95 gap 6.0 s
LARGEST `unchanged_s` any sample observes 8.0 s
longest interval serving no distinct reading 15.4 s
samples outside +-500 W 0
The default 300 s is 37x the largest `unchanged_s` this house has been recorded
producing while converged, and 19.5x the longest interval in which it served no
distinct reading at all. The two figures differ because the capture has an 11.4 s
hole in its OWN polling; the second is quoted because it is the pessimistic one.
300 s is not fitted to either margin - it is picked as "clearly past any
plausible register-tick interval, and clearly inside the 900 s quarter that bills
the capacity tariff". test_freeze.py re-measures both every run, so this
paragraph cannot quietly stop being true.
⚠️ BE PRECISE ABOUT WHAT THAT CAPTURE PROVES. It is POWER-ONLY - the shipped
fingerprint also covers both kWh registers, so a real `unchanged_s` on that data
can only be shorter, never longer, which makes it a conservative measurement of
the converged case. But it does NOT on its own refute a bare `unchanged_s`
threshold, and saying it did would be exactly the overstatement this project
keeps having to walk back: at 8 s worst, a bare 300 s threshold is silent on it
too. The case that refutes the bare threshold is the one below, which no capture
of THIS meter contains because this meter's power figure jitters.
The floor on the other side: at the rail, the smallest residual grid flow that
still ticks a 1 Wh register inside 300 s is 12 W. Any condition that genuinely
pins this loop at +-5000 W has three orders of magnitude more than that crossing
the meter.
⚠️ AND THOSE TWO NUMBERS DO NOT MERELY HAVE MARGIN - THEY CANNOT BOTH BE TRUE.
The worry the conjunction has to answer is "a HEALTHY meter whose register is
slower than `hold_s`". At 300 s that is |grid| < 12 W. The shipped `deadband_w`
is 15 W, so anywhere the register is that slow the control law is already inside
its deadband and the command is parked - the rail term is not merely unlikely to
be true there, it is 0 W. The two terms are mutually exclusive by construction
at the defaults, not just far apart, and the check re-derives that every run.
That relationship is the one to protect. `hold_s` may be lowered only while
3600/hold_s stays under `deadband_w`; at the schema floor of 60 s that is 60 W
against a 15 W deadband, and the mutual exclusion is gone. A 60 s `meter_freeze_s`
is a supported configuration that gives up this argument and keeps only the
margin - do not set one without a capture that says the margin is enough.
⚠️ WHAT `hold_s` DOES NOT BUY IS DETECTION SPEED. The alert lands at
max(hold_s, time-to-rail) after the freeze, so at the shipped default and
`max_w: 5000` that is 300-360 s. The rail is REACHED first, from 75 s. A
notification is not a brake; see the DOCS.md note.
------------------------------------------------------------------------------
THE RAIL TERM, PRECISELY
abs(commanded_w) >= RAIL_FRAC * max_w
`commanded_w` is the EMITTED setpoint - what the arbiter resolved and
`write_setpoint()` put on the wire - not the loop's raw wish, for the same
reason SAFETY-02 uses the emitted value: the wire carries the emitted number.
RAIL_FRAC is 0.95 and is a constant rather than an option. The command is
quantised to `step_w` (max 100) and clamped to `max_w` (min 100), so at the rail
the emitted value IS `max_w` on every schema-legal combination; the 5 % is slack
for a device rating that clips `max_w` slightly in `write_setpoint()`, not a
tuning knob. Making it configurable would offer a way to widen the one term that
carries the whole no-false-positive argument.
⚠️ NOT GATED ON `following`, and that is deliberate. A maintenance charge
commands against the meter on purpose and can sit at the rail for hours - but it
does so while pushing kilowatts through the meter, so the FIRST term is what
excludes it, and it excludes it on physics rather than on bookkeeping. Gating on
`following` as well would additionally blind the detector to a maintenance phase
running against a meter that really has frozen, which is the same fault with a
worse actuator behind it.
------------------------------------------------------------------------------
NOTIFY, DO NOT TRIP
First cut is an alarm and nothing else: an HA persistent notification, an ERROR
in the log, an event on the status page, and a failed check row. It does NOT
claim the actuator, does NOT file a P_SAFETY claim and does NOT latch.
That is a deliberate ordering of costs, not timidity. A false trip here parks
the battery - on a house that is running this loop precisely to avoid buying
power at the wrong moment - and the operator's response to a parked battery is
to switch the guard off. A false notification costs one alert. The trip is a
separate decision that has to be earned with observed data from a real install,
and it needs an answer to a question this cut does not have one for: what should
the loop do instead, given that 0 W is itself a grid import of whatever the
house is drawing.
⚠️ TRANSPORT-CONDITIONAL. `unchanged_s` exists ONLY on `homewizard_local`,
because it is the only transport that sees the meter's own document. Under
`ha_dsmr`, `mqtt_p1`, `ha_signed` and `meter_source: off` this detector is
silent and the exposure above is uncovered. The caller passes None and gets
False; there is no half-measure available on those paths, and pretending
otherwise would be worse than the gap.
"""
from dataclasses import dataclass
from datetime import datetime
# See "THE RAIL TERM, PRECISELY" above. A constant, on purpose.
RAIL_FRAC = 0.95
@dataclass(frozen=True)
class FreezeWatchdogConfig:
"""Default, not a constant - config.yaml carries `meter_freeze_s`."""
hold_s: float = 300.0
def rail_w(max_w) -> float:
"""The magnitude at or above which a command counts as railed."""
return abs(float(max_w)) * RAIL_FRAC
def looks_frozen(unchanged_s, commanded_w, max_w,
cfg: FreezeWatchdogConfig) -> bool:
"""The predicate, and the ONLY place the conjunction is written down.
Public for the same reason `watchdog.disagrees()` is: the checks that grade
it must assert on THE SHIPPED EXPRESSION. A safety predicate transcribed
into its own test is one copy plus a future disagreement about what the
check means, and the copy that drifts is always the one doing the grading.
`unchanged_s` seconds since the meter served a different measurement, or
None on a transport that cannot know (see the module note).
`commanded_w` the EMITTED setpoint standing on the wire.
`max_w` the configured output bound the rail is measured against.
"""
if unchanged_s is None or commanded_w is None or not max_w:
return False
return (float(unchanged_s) >= cfg.hold_s
and abs(float(commanded_w)) >= rail_w(max_w))
class FreezeWatchdog:
"""One instance per controller, evaluated once per control cycle.
Level-triggered state with an EDGE for the alarm: `evaluate()` returns True
on the cycle the conjunction first becomes true and False on every cycle
after, so the caller raises one notification per episode rather than one
every second. `active` is the level, for the status page.
No latch, deliberately - see "NOTIFY, DO NOT TRIP". A latch exists so a trip
cannot erase its own evidence by working; nothing here acts, so there is
nothing to erase. When the meter starts moving again the alert clears
itself, which is the honest report of the state.
"""
def __init__(self, cfg: FreezeWatchdogConfig | None = None):
self.cfg = cfg or FreezeWatchdogConfig()
self.active = False
self.since: datetime | None = None
self.reason = ""
self.episodes = 0
def evaluate(self, unchanged_s, commanded_w, max_w, now: datetime) -> bool:
"""Advance one cycle. Returns True ONLY on the rising edge.
`now` is the caller's clock so this stays testable and replayable - the
same discipline as `SignWatchdog.evaluate()`.
"""
if not looks_frozen(unchanged_s, commanded_w, max_w, self.cfg):
self.active = False
self.since = None
self.reason = ""
return False
self.reason = (
f"METER_FROZEN: the meter has answered {float(unchanged_s):.0f} s "
f"without its measurement changing while the command stands at "
f"{float(commanded_w):+.0f} W, at the {float(max_w):.0f} W rail "
f"(limits: unchanged over {self.cfg.hold_s:.0f} s, command over "
f"{rail_w(max_w):.0f} W)")
if self.active:
return False
self.active = True
self.since = now
self.episodes += 1
return True
+87
View File
@@ -36,6 +36,7 @@ from .arbiter import (
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_SAFETY, Claim, resolve,
)
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from .freeze import FreezeWatchdog, FreezeWatchdogConfig, rail_w
from .hass import HomeAssistant
from .maintenance import IDLE, MaintConfig, Maintenance
from .mqtt import MqttPublisher
@@ -127,6 +128,18 @@ class Controller:
"once you know why the command opposed the meter.",
self.sign_wd.reason or "(reason not recorded)")
# SAFETY-18. The frozen-but-fresh meter detector - see freeze.py for why
# it is a conjunction and not an `unchanged_s` threshold, and for the
# measurements behind the default.
#
# ⚠️ NOT restored from the store, unlike SAFETY-02's latch, and the
# asymmetry is the point: this one does not latch because it does not
# act. A restart re-derives its state from the meter within one
# `meter_freeze_s` window, and until then the honest report is "not
# alarming yet" rather than an alarm nobody can clear.
self.freeze_wd = FreezeWatchdog(
FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300))))
# P1 ingestion (TEL-01). `meter_source: off` keeps the original
# single-entity meter_entity path, so an existing install is unchanged
# until it opts in.
@@ -393,6 +406,53 @@ class Controller:
self.reason = resolution.explain()
await self.write_setpoint(self.target)
# --- SAFETY-18, and it runs LAST on purpose --------------------------
# Both terms of the conjunction describe the state that now exists: the
# command that has just gone on the wire, and how long the meter that
# chose it has been standing still. Evaluated before write_setpoint()
# it would be reasoning about the PREVIOUS command against THIS cycle's
# meter - the one combination that is true of neither moment.
await self.check_meter_frozen()
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
Notification only - no claim, no latch, no 0 W. freeze.py carries the
reasoning and the euro figure; DOCS.md carries the exposure this does
NOT close.
⚠️ getattr, not an attribute access: `unchanged_s` exists only on
`homewizard_local`, and `p1_source` is None until amain() builds the
transport. Every other meter_source hands the predicate None and gets
False, which is the honest answer - see the transport note in freeze.py.
"""
unchanged = getattr(self.p1_source, "unchanged_s", None)
fired = self.freeze_wd.evaluate(unchanged, self.target,
float(self.o.get("max_w", 2000)),
datetime.now(timezone.utc))
if not fired:
return
self.log_event("METER FROZEN? " + self.freeze_wd.reason)
_LOG.error(
"SAFETY-18 - %s. The grid-following loop is regulating against a "
"number that has stopped moving, and nothing in this stack stops "
"it: the meter is still answering, so the age is fresh, the "
"firmware watchdog is fed and the arbiter only bounds the "
"magnitude. CHECK THE METER. Sustained for a quarter of an hour "
"this sets the month's billed capacity-tariff peak. This is an "
"ALARM ONLY - the loop is still running.", self.freeze_wd.reason)
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
# rather than stacking. An operator who has to dismiss eleven identical
# notifications learns to dismiss the twelfth without reading it.
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_meter_frozen",
"title": "GoodWe: the P1 meter may be frozen",
"message": (
f"{self.freeze_wd.reason}.\n\nThe controller is STILL RUNNING "
f"and still following this reading. Check the meter; if it is "
f"stuck, stop control from the add-on UI."),
})
# -- tasks ---------------------------------------------------------------
async def run_control(self) -> None:
entity = self.o.get("setpoint_entity", "")
@@ -581,6 +641,33 @@ class Controller:
f"{self.p1.parse_errors} rejected"
+ (f", measurement unchanged for {unchanged:.0f} s"
if unchanged is not None else "")})
# SAFETY-18. Reported in both states, for the reason SAFETY-02 is:
# a detector nobody can see is armed is a detector nobody trusts
# when it fires. ok=False/warn=False so the banner goes red - this
# is an alarm, and the banner is the loudest surface we own. It
# changes NOTHING about control; see check_meter_frozen().
if self.freeze_wd.active:
out.append({"ok": False, "warn": False,
"text": "METER MAY BE FROZEN - " + self.freeze_wd.reason
+ ". The loop is STILL FOLLOWING it and is "
"still running. Check the meter. Held here "
"for a quarter of an hour this sets the "
"month's billed capacity peak."})
elif getattr(self.p1_source, "unchanged_s", None) is None:
# Not "armed". Every transport but homewizard_local is invisible
# to this check, and saying "armed" there would be a lie the
# status page tells on behalf of a guard that cannot run.
out.append({"ok": False, "warn": True,
"text": f"freeze detector: NOT COVERED on "
f"meter_source {o.get('meter_source')} - only "
f"homewizard_local sees whether the meter's own "
f"document changes"})
else:
out.append({"ok": True, "warn": False,
"text": f"freeze detector: armed (alerts after "
f"{self.freeze_wd.cfg.hold_s:.0f} s unchanged "
f"while the command is over "
f"{rail_w(self.o.get('max_w', 2000)):.0f} W)"})
rows = [("battery SoC", self.soc, o.get("soc_entity")),
("battery power", self.batt, o.get("batt_entity"))]
if not self.p1_enabled:
+4 -3
View File
@@ -28,9 +28,10 @@ _LOG = logging.getLogger("goodwe.maint")
IDLE, DRAIN, CHARGE, HOLD = "idle", "drain", "charge", "hold"
# A drain of a 13.5 kWh usable pack at 2500 W is ~5 h even with no house load
# helping. 12 h means the battery is not following commands - a fault, not a
# slow day. The charge phase deliberately waits for sun and for cheap hours, so
# A drain of a 13.8 kWh usable pack (300 Ah x 51.2 V, DoD 90) at the shipped
# 2000 W is ~6.9 h even with no house load helping - and less in practice, since
# house load drains alongside the export and soc_floor stops short of empty.
# 12 h means the battery is not following commands - a fault, not a slow day. The charge phase deliberately waits for sun and for cheap hours, so
# it is expected to span a night; 36 h still bounds it.
DRAIN_TIMEOUT_MIN = 720
CHARGE_TIMEOUT_MIN = 2160
+99
View File
@@ -85,6 +85,37 @@ NOT READY banner already names the problem, (b) does not apply.
watchdog: armed". A disarmed guard that advertises itself as armed is the
one thing this project has decided repeatedly it will not ship.
6. `maintenance_charge_w` above `max_w`. [(a)]
The same shape as 5, and found the same way - by checking a worked example
against what actually ships. maintenance's cheap-window shaping files
`Claim.limit(hi=-maintenance_charge_w)` at P_MAINT_SHAPE, against the
`supervised` SAFETY limit's `lo=-max_w`. Both bind (P_SAFETY and
P_MAINT_SHAPE both outrank the loop that wins the SET), so a charge rate
above the ceiling gives `lo > hi` - the arbiter's clause 3, which it
correctly resolves to 0 W and logs as a CONTRADICTION. Measured against
the arbiter, loop asking +500 W:
max_w=2000 hi=-2500 -> +0 W contradiction=True
max_w=2500 hi=-2500 -> -2500 W limited by supervised(max_w)
max_w=3000 hi=-2500 -> -2500 W limited by maintenance(...)
So MAINTENANCE SILENTLY NEVER CHARGES IN A CHEAP WINDOW until max_w is at
or above the charge rate, and the only evidence is a contradiction line
that reads like a bug in the arbiter rather than like the two numbers the
operator set. Equality is fine and deliberately not refused - at
max_w == charge_w the window is a single reachable point, -max_w.
⚠️ `maintenance_discharge_w` IS NOT THE SAME CASE, and this was checked
rather than assumed. THE SHAPING IS NOT SYMMETRIC: the drain phase does
not shape the loop, it OWNS the actuator - `Claim.set(P_MAINTENANCE,
+discharge_w)`. A SET is clamped by the limits above it, not contradicted
by them, so 2500 against a 2000 W ceiling emits +2000 W and the arbiter's
own explain() says `maintenance -> 2000 W, limited by supervised(max_w)`
on the status page every cycle. That is neither (a) - nothing is silent -
nor (b) - it moves energy and the cycle still completes, just slower
(13.8 kWh usable at 2000 W is ~6.9 h against the 720 min DRAIN_TIMEOUT).
It is warned about below, not refused.
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
@@ -102,6 +133,9 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
NOT READY banner.
- `meter_poll_s` above half `meter_max_age_s`. One missed poll costs a stale
reading and a 0 W cycle. Warned about in p1.py, not refused.
- `maintenance_discharge_w` above `max_w`. Clamped, not contradicted, and the
clamp names itself in the arbiter's explain() - see rule 6. Warned about,
not refused.
"""
import logging
@@ -269,6 +303,52 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
f"sign_disagree_w below {max_w:.0f} W (300 W is the measured "
"default), or raise max_w.")
# --- 6. a maintenance charge the ceiling can actually accommodate --------
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
# `Claim.limit(hi=-charge_w)` against `supervised`'s `lo=-max_w`, so
# charge_w == max_w leaves the single reachable point -max_w and works;
# only ABOVE is `lo > hi`, arbiter clause 3, 0 W and a contradiction.
#
# Not scoped to maintenance_enabled, deliberately. The pair is
# contradictory whether or not the schedule is on, and scoping it would
# leave the trap armed for the one operator action - ticking
# maintenance_enabled - that cannot possibly be expected to change what a
# charge rate means.
# ⚠️ THE FALLBACK IS config.yaml's SHIPPED VALUE, and it has to be kept
# equal to it - that is the whole point of AC3. It is reachable only when a
# caller hands over a dict with the key missing (sim/inject.py, a test); the
# Supervisor always supplies every key from config.yaml, so on a real
# install the argument is always present.
#
# ⚠️ KNOWN MISMATCH, LEFT DELIBERATELY AND FLAGGED RATHER THAN FIXED HERE:
# `main.py` still falls back to 2500 in two places
# (`opts.get("maintenance_charge_w", 2500)`), as does maintenance.py's
# MaintConfig dataclass. So a dict with the key ABSENT is accepted by this
# rule at 2000 and then built by main.py at 2500 - the contradiction this
# rule exists to catch, in the one case it cannot see. It is unreachable on
# a Supervisor install and those files are another ticket's right now.
# THE REAL FIX is one source of truth for option defaults; until then the
# divergence is PINNED BY A CHECK rather than by this comment - see
# test_validate.py, "the KNOWN cross-file default divergence". It asserts
# the mismatch as it stands and goes red if EITHER side moves, including
# when somebody closes it in main.py, at which point this block and that
# check both get deleted.
charge_w = _number(opts, "maintenance_charge_w", 2000.0)
if charge_w is None:
problems.append(
f"maintenance_charge_w {opts.get('maintenance_charge_w')!r} is not a number")
elif max_w is not None and max_w > 0 and charge_w > max_w:
problems.append(
f"maintenance_charge_w is {charge_w:.0f} W but max_w is "
f"{max_w:.0f} W, so a maintenance charge in a cheap window can "
"never happen: the shaping asks the arbiter for at most "
f"{-charge_w:.0f} W while the supervised limit allows no less than "
f"{-max_w:.0f} W, which is a contradiction it resolves to 0 W - "
"THE CEILING WINS, and what you would see is an ARBITER "
"CONTRADICTION line that reads like a bug rather than like these "
f"two numbers. Lower maintenance_charge_w to {max_w:.0f} W or less, "
f"or raise max_w to at least {charge_w:.0f} W.")
# --- warnings: real, but not worth refusing over -------------------------
integ = opts.get("integrator_max_w")
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
@@ -280,6 +360,25 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
"saturation. 0 (follow max_w) is the recommended value.",
integ_w, max_w)
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
# drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is
# clamped by the limits above it rather than contradicted by them. So this
# drains at max_w instead of the configured rate, the arbiter says so in
# words on the status page every cycle, and the cycle still completes -
# slower, and eating into maintenance.DRAIN_TIMEOUT_MIN. Loud and it moves
# energy, so it is neither (a) nor (b): warned, not refused.
disch_w = _number(opts, "maintenance_discharge_w", 2000.0)
if disch_w is not None and max_w is not None and max_w > 0 and disch_w > max_w:
_LOG.warning(
"maintenance_discharge_w %.0f W is above max_w %.0f W. Not refused - "
"the drain phase SETS the setpoint and the arbiter clamps it to "
"%.0f W and names the clamp ('limited by supervised(max_w)'), so "
"nothing is silent and the cycle still completes. But it drains at "
"%.0f W, not the %.0f W you configured, and the drain phase has a "
"720 min timeout. Lower it to %.0f W or raise max_w to remove the "
"surprise.",
disch_w, max_w, max_w, max_w, disch_w, max_w)
return problems
+47 -3
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.5.1"
version: "0.5.2"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -114,12 +114,47 @@ options:
sign_disagree_w: 300
sign_disagree_s: 180
# --- SAFETY-18 frozen-but-fresh meter detector -----------------------------
# Seconds the meter may keep answering with an UNCHANGED measurement while the
# command stands at the rail before the add-on raises an alarm. Both halves
# are required: neither one alone is safe, and app/freeze.py carries why.
#
# ALARM ONLY. It does not command 0 W and does not latch. A false trip parks
# the battery; a false notification costs an alert.
#
# ⚠️ Only `meter_source: homewizard_local` can see this at all - it is the
# only transport that reads the meter's own document. On every other source
# the status page says NOT COVERED rather than "armed".
#
# 300 s is measured, not chosen. Over 2399 s of the live house at the
# converged operating point (sim/scenarios/p1-age-2026-08-26.jsonl.gz, mean
# grid -14.2 W, 1194 polls, 735 of them repeating the previous reading) the
# LONGEST run without a distinct reading is 15.4 s (the largest unchanged_s any
# sample observes is 8.0 s). 300 s is 19.5x the pessimistic figure, and
# still inside the 900 s quarter that bills the capacity tariff.
meter_freeze_s: 300
# --- maintenance -----------------------------------------------------------
maintenance_enabled: false
maintenance_interval_days: 28
maintenance_start_hour: 10
maintenance_discharge_w: 2500
maintenance_charge_w: 2500
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
# than the 2500 they shipped as. The cheap-window charge shaping asks the
# arbiter for `hi = -maintenance_charge_w` against the supervised `lo =
# -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to
# 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a
# default install. validate.py refuses that config (rule 6), so these
# defaults had to become self-consistent with the max_w above rather than
# ship a configuration the validator rejects.
#
# The discharge side is clamped rather than contradicted - it drains at max_w
# and the arbiter says so - so it is only warned about; it is 2000 here for
# symmetry and to keep the shipped config warning-free.
#
# Raising max_w (the live reference install runs 5000) is what unlocks a
# faster maintenance cycle. Raise max_w FIRST, then these.
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
maintenance_soc_floor: 11
maintenance_soc_target: 99
maintenance_hold_min: 120
@@ -181,6 +216,15 @@ schema:
sign_disagree_w: int(50,5000)
sign_disagree_s: int(30,1800)
# No "off" value, for SAFETY-02's reason: disarming a safety alarm is not a
# supported configuration. ⚠️ THE FLOOR OF 60 IS NOT A RECOMMENDATION. At 300 s
# the slowest a HEALTHY meter's 1 Wh register can tick and still look unchanged
# is 12 W, which is inside the 15 W deadband, so the two terms of the
# conjunction are mutually exclusive rather than merely far apart. At 60 s that
# figure is 60 W and the argument is gone - only the margin is left. See
# app/freeze.py.
meter_freeze_s: int(60,3600)
maintenance_enabled: bool
maintenance_interval_days: int(1,90)
maintenance_start_hour: int(0,23)
+539
View File
@@ -0,0 +1,539 @@
"""Runnable check for SAFETY-18, the frozen-but-fresh meter detector.
python3 test_freeze.py
Three parts, and the last two are the ones that matter:
1. the predicate's boundaries and the edge-trigger, table-driven;
2. SILENT ON REAL CONVERGED DATA. `scenarios/p1-age-2026-08-26.jsonl.gz` is
40 minutes of this house's live meter at the operating point, committed
here from the infra repo's `sim/scenarios/`. It is replayed through the
REAL `control.compute()` at the real cycle rule, and the detector must not
make a sound. This is the acceptance criterion TEL-05's objection is
about, and it is verified rather than argued;
3. FIRES ON A GENUINELY FROZEN METER, over real HTTP, through the REAL
`HomeWizardLocalSource` - the transport is what computes `unchanged_s`, so
a check that calls the predicate directly would not be testing the thing
that ships. Uses the rig's own `sim/hwsim.py --fault freeze` when the infra
repo is next to this one (or `$GOODWE_SIM`), and an equivalent local stub
otherwise. Both are exercised; the run says which one it used.
⚠️ Part 3 runs in WALL CLOCK - `unchanged_s` is `time.monotonic()` and the
transport really polls - so it drives the detector at `hold_s=10`, not the
shipped 300. The THRESHOLD is config; the MECHANISM is what is under test, and
the mechanism cannot be hurried. Part 2 uses the shipped 300 because that data
is replayed rather than lived.
⚠️ Part 3 also runs its NEGATIVE twin, and that is the load-bearing half: the
same railed command against a meter that is still MOVING, at the same 10 s
threshold. If that one alarms, the detector is a `unchanged_s` threshold with
extra steps and the whole reason for the conjunction is gone.
------------------------------------------------------------------------------
THE RIG RUN THIS STANDS IN FOR
Part 3 is the portable version of a run done on the real rig - the `goodwe-sim`
compose project on 192.168.2.147, under `sim/riglock.py`, with the driver staged
into `goodwe-sim-plant` so it polls that container's own hwsim while plantsim
drives it. Both phases passed there. To repeat it:
DOCKER_HOST=ssh://homelab@192.168.2.147 python sim/riglock.py status
⚠️ WITHOUT `DOCKER_HOST` SET, ON A WINDOWS HOST, `riglock status` REPORTS
`rig: UNKNOWN` - it shells out to the local `docker` CLI, which resolves to a
Docker Desktop npipe that is not running. That is INDISTINGUISHABLE from a
genuinely absent rig, and it is not one: the rig lives on the homelab box and was
up the whole time. `UNKNOWN` is the tool declining to observe, not an
observation. Do not read it as "no rig" (this cost this ticket a wrong caveat).
The rig run also produced a result part 3's stub cannot: under +8000 W of house
load the meter's POWER FIGURE never moved - 10130.0 W for 26 consecutive polls -
and only the kWh registers kept `unchanged_s` at zero. See the note in
freeze.py; it is why the fingerprint's register terms are the negative case
rather than a nicety.
"""
import asyncio
import gzip
import json
import os
import subprocess
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import aiohttp
from app.control import Tuning, compute
from app.freeze import (
RAIL_FRAC, FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_w,
)
from app.main import Controller
from app.p1 import HomeWizardLocalSource, P1Ingest
fails = []
T0 = datetime(2026, 8, 26, 7, 0, 0, tzinfo=timezone.utc)
HERE = Path(__file__).resolve().parent
# The live install's numbers, which are the ones the exposure is written at.
LIVE_MAX_W = 5000.0
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def tuning(max_w=LIVE_MAX_W):
"""The shipped defaults from config.yaml, at the live install's max_w."""
return Tuning(gain=0.6, max_w=max_w, slew_w=1000, deadband_w=15,
target_grid_w=-10, step_w=10, saturation_w=500,
saturation_cycles=3)
# --------------------------------------------------------------------------- #
# 1. the predicate and the edge
# --------------------------------------------------------------------------- #
print("predicate")
cfg = FreezeWatchdogConfig(hold_s=300)
check("both terms true -> frozen", looks_frozen(300, 5000, 5000, cfg))
check("unchanged long, command NOT railed -> silent (TEL-05's case)",
not looks_frozen(3600, -40, 5000, cfg))
check("command railed, meter still moving -> silent (a real 8 kW load)",
not looks_frozen(4, -5000, 5000, cfg))
check("neither -> silent", not looks_frozen(4, -40, 5000, cfg))
check("one second under the hold is still silent",
not looks_frozen(299.9, 5000, 5000, cfg))
check("just under the rail fraction is still silent",
not looks_frozen(3600, 5000 * RAIL_FRAC - 1, 5000, cfg))
check("at the rail fraction exactly, it fires",
looks_frozen(3600, 5000 * RAIL_FRAC, 5000, cfg))
check("the rail scales with max_w, not with a fixed watt figure",
looks_frozen(3600, 2000, 2000, cfg) and not looks_frozen(3600, 2000, 5000, cfg))
check("a charge rail counts too - the runaway can be either sign",
looks_frozen(3600, -5000, 5000, cfg))
check("no transport signal (unchanged_s None) -> silent, never assumed frozen",
not looks_frozen(None, 5000, 5000, cfg))
check("no command yet -> silent", not looks_frozen(3600, None, 5000, cfg))
check("rail_w tracks RAIL_FRAC", rail_w(5000) == 5000 * RAIL_FRAC)
print("the edge")
wd = FreezeWatchdog(cfg)
edges = [wd.evaluate(u, c, 5000, T0 + timedelta(seconds=i))
for i, (u, c) in enumerate(
[(10, 5000), (300, 5000), (305, 5000), (310, 5000),
(2, 5000), (300, 5000)])]
check("rises exactly once per episode", edges == [False, True, False, False,
False, True])
check("two episodes counted", wd.episodes == 2)
check("clearing the condition clears the alarm state",
not FreezeWatchdog(cfg).evaluate(2, 5000, 5000, T0))
check("the reason names both terms",
"unchanged over 300" in wd.reason and "command over 4750" in wd.reason)
# --------------------------------------------------------------------------- #
# 2. silent on 40 minutes of real converged behaviour
# --------------------------------------------------------------------------- #
print("converged replay - scenarios/p1-age-2026-08-26.jsonl.gz")
rows = [json.loads(line) for line in
gzip.open(HERE / "scenarios" / "p1-age-2026-08-26.jsonl.gz", "rt")]
check("the capture loaded", len(rows) > 1000)
# ⚠️ The capture records the POWER figure only. The shipped fingerprint also
# covers both kWh registers, so a real `unchanged_s` on this data can only be
# SHORTER than what is reconstructed here - this replay is the pessimistic
# version of the converged case, not a flattering one.
def replay(hold_s, max_w, cycle_floor_s=15.0):
"""Drive the real control law and the real detector over the capture."""
wd = FreezeWatchdog(FreezeWatchdogConfig(hold_s=hold_s))
t = tuning(max_w)
target, i_w, sat = 0.0, 0.0, 0
prev_pwr, changed_at = None, rows[0]["t"]
last_cycle = None
peak_cmd, peak_unchanged, fired_at = 0.0, 0.0, None
for r in rows:
now, pwr = r["t"], float(r["pwr"])
if prev_pwr is None or pwr != prev_pwr:
changed_at = now
arrival = (prev_pwr is None or pwr != prev_pwr)
prev_pwr = pwr
# main.run_control()'s rule: a cycle per changed reading, or the floor.
if arrival or last_cycle is None or now - last_cycle >= cycle_floor_s:
last_cycle = now
d = compute(prev_w=target, grid_w=pwr, actual_w=target, tuning=t,
sat_count=sat, i_w=i_w)
target, i_w, sat = d.target_w, d.i_w, d.sat_count
# ⚠️ The detector is evaluated on EVERY poll, not only on cycles, and
# that is deliberately harsher than the shipped wiring (which evaluates
# it inside cycle()). On an arrival-triggered cycle `unchanged_s` is 0
# by construction, so grading only there would be grading the detector
# on the one sample it cannot possibly fire on.
unchanged = now - changed_at
peak_cmd = max(peak_cmd, abs(target))
peak_unchanged = max(peak_unchanged, unchanged)
if wd.evaluate(unchanged, target, max_w,
T0 + timedelta(seconds=now - rows[0]["t"])) \
and fired_at is None:
fired_at = now - rows[0]["t"]
return wd, peak_cmd, peak_unchanged, fired_at
for mw in (2000.0, LIVE_MAX_W):
wd_r, peak_cmd, peak_unchanged, fired = replay(300.0, mw)
print(f" max_w {mw:.0f}: worst |command| {peak_cmd:.0f} W (rail "
f"{rail_w(mw):.0f} W), worst unchanged {peak_unchanged:.1f} s "
f"(hold 300 s), episodes {wd_r.episodes}")
check(f"max_w {mw:.0f}: never alarms on the converged capture", fired is None)
check(f"max_w {mw:.0f}: and the rail term had margin to spare",
peak_cmd < rail_w(mw))
# The margin on the OTHER term, as numbers rather than as trust. Both figures
# are quoted in config.yaml and freeze.py, so they are re-measured every run and
# a capture swapped underneath them cannot leave the prose saying the old thing.
_, _, worst_unchanged, _ = replay(300.0, LIVE_MAX_W)
gaps, last, prev = [], rows[0]["t"], None
for r in rows:
if prev is None or r["pwr"] != prev:
gaps.append(r["t"] - last)
last = r["t"]
prev = r["pwr"]
print(f" longest unchanged_s any sample would observe: {worst_unchanged:.1f} s")
print(f" longest interval serving no distinct reading: {max(gaps):.1f} s "
f"(spans an 11.4 s hole in the capture's own polling)")
check("worst observed unchanged_s is the 8 s the docs quote",
7.5 < worst_unchanged < 8.5)
check("longest distinct-reading interval is the 15.4 s the docs quote",
15.0 < max(gaps) < 16.0)
check("300 s is at least 15x the worst of the two", 300.0 >= 15 * max(gaps))
# --------------------------------------------------------------------------- #
# 2b. TEL-05's case, reconstructed, because the capture cannot contain it
# --------------------------------------------------------------------------- #
# ⚠️ BE HONEST ABOUT WHAT THE CAPTURE ABOVE PROVES AND WHAT IT DOES NOT. On this
# house's meter the power figure jitters at 1 W, so the worst `unchanged_s` any
# sample sees is 8 s - which means the capture alone does NOT refute a bare
# `unchanged_s > 300` threshold, and reporting it as though it did would be the
# confident explanation nobody checked. The case that refutes the bare threshold
# is the one p1.py describes and no capture of THIS meter contains: a converged
# meter whose power figure simply repeats, where the 1 Wh export register needs
# 360 s to advance at -10 W and the fingerprint therefore stands still.
#
# So it is constructed, from the operating point the capture measures (mean grid
# -14.2 W) rather than invented: one hour of a perfectly healthy converged meter
# that happens not to move.
print("TEL-05's low-power case - a converged meter that legitimately repeats")
tel05 = FreezeWatchdog(FreezeWatchdogConfig(hold_s=300))
t5 = tuning(LIVE_MAX_W)
tgt, iw, sc = 0.0, 0.0, 0
bare_would_fire = False
tel05_cmd = 0.0
for k in range(3600):
if k % 15 == 0: # cycle_floor_s, the only trigger left
d = compute(prev_w=tgt, grid_w=-14.2, actual_w=tgt, tuning=t5,
sat_count=sc, i_w=iw)
tgt, iw, sc = d.target_w, d.i_w, d.sat_count
tel05_cmd = max(tel05_cmd, abs(tgt))
# The REJECTED design, written out so the case can be shown to be real.
bare_would_fire = bare_would_fire or float(k) >= 300
tel05.evaluate(float(k), tgt, LIVE_MAX_W, T0 + timedelta(seconds=k))
print(f" an hour at -14.2 W: worst |command| {tel05_cmd:.0f} W, "
f"unchanged_s reaches 3599 s, episodes {tel05.episodes}")
check("the bare unchanged_s threshold TEL-05 rejected WOULD fire here",
bare_would_fire)
check("the conjunction does not - this is what the rail term buys",
tel05.episodes == 0)
check("...and the command stayed nowhere near the rail",
tel05_cmd < rail_w(LIVE_MAX_W))
# The structural version of the same point, re-derived rather than asserted in
# prose: the slowest register a HEALTHY meter can have while still being seen as
# unchanged for hold_s is 3600/hold_s watts, and at the shipped defaults that
# figure is inside the deadband, so the command there is parked at 0 W. If
# someone lowers meter_freeze_s or narrows the deadband, this is the check that
# should stop them.
DEADBAND_W = 15.0
slowest_healthy_w = 3600.0 / 300.0
check("a healthy meter slow enough to look unchanged is inside the deadband",
slowest_healthy_w < DEADBAND_W)
_park = compute(prev_w=0.0, grid_w=-slowest_healthy_w, actual_w=0.0,
tuning=tuning(LIVE_MAX_W), sat_count=0, i_w=0.0)
check("...and the shipped law really does park the command there",
_park.target_w == 0.0)
# ⚠️ THE PROOF THAT THE REPLAY IS CAPABLE OF FIRING AT ALL. Without this, "the
# converged capture is silent" is satisfied by a detector that is wired to
# nothing. Drop the hold to 1 s and the rail to nothing, and the SAME replay
# must alarm - so the silence above is the data's doing, not the harness's.
wd_hot, _, _, fired_hot = replay(1.0, 20.0)
check("...and the same replay DOES alarm once both terms are made reachable",
fired_hot is not None and wd_hot.episodes > 0)
# --------------------------------------------------------------------------- #
# 3. a genuinely frozen meter, over real HTTP, through the real transport
# --------------------------------------------------------------------------- #
def find_hwsim():
"""The rig's own fake meter, if the infra repo is reachable."""
cands = []
if os.environ.get("GOODWE_SIM"):
cands.append(Path(os.environ["GOODWE_SIM"]) / "hwsim.py")
cands.append(HERE.parents[1] / "goodwe" / "sim" / "hwsim.py")
for c in cands:
if c.exists():
return c
return None
STUB = r'''
import http.server, json, sys, threading, time
FROZEN = {"active_power_w": 700.0, "active_power_l1_w": 700.0,
"total_power_import_kwh": 1234.567, "total_power_export_kwh": 890.123}
moving = len(sys.argv) > 2 and sys.argv[2] == "moving"
t0 = time.monotonic()
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
d = dict(FROZEN)
if moving:
# A REAL railed condition: a big steady import, so the power figure
# jitters and the 1 Wh registers advance the way physics makes them.
el = time.monotonic() - t0
d["active_power_w"] = 7000.0 + (int(el * 10) % 7)
d["active_power_l1_w"] = d["active_power_w"]
d["total_power_import_kwh"] = round(1234.567 + 7000 * el / 3.6e6, 3)
raw = json.dumps(d).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def log_message(self, *a): pass
http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever()
'''
HWSIM_DRIVER = r'''
import sys, threading, time
sys.path.insert(0, sys.argv[3])
import hwsim
hwsim.serve(int(sys.argv[1]))
mode = sys.argv[2]
hwsim.set_grid(7000.0)
if mode == "freeze":
hwsim.fault = "freeze" # exactly `hwsim.py --fault freeze`
while True:
# The plant keeps moving underneath either way. Under `freeze` hwsim's own
# set_grid() returns early, which is the whole point of that fault.
hwsim.set_grid(7000.0 + (int(time.monotonic() * 10) % 7))
time.sleep(0.2)
'''
async def live_run(port, mode, hold_s=10.0, seconds=16.0, max_w=LIVE_MAX_W):
"""Poll a real HTTP meter through the real transport and drive the detector."""
ingest = P1Ingest(phases=1, max_age_s=30)
wd = FreezeWatchdog(FreezeWatchdogConfig(hold_s=hold_s))
t = tuning(max_w)
target, i_w, sat = 0.0, 0.0, 0
fired_at, polls, worst_unchanged, worst_cmd = None, 0, 0.0, 0.0
async with aiohttp.ClientSession() as session:
src = HomeWizardLocalSource(session, ingest, "127.0.0.1", port=port,
poll_s=1.0)
start = time.monotonic()
while time.monotonic() - start < seconds:
if await src.poll_once():
polls += 1
grid = ingest.net_w
d = compute(prev_w=target, grid_w=grid, actual_w=target,
tuning=t, sat_count=sat, i_w=i_w)
target, i_w, sat = d.target_w, d.i_w, d.sat_count
worst_unchanged = max(worst_unchanged, src.unchanged_s or 0.0)
worst_cmd = max(worst_cmd, abs(target))
if wd.evaluate(src.unchanged_s, target, max_w,
datetime.now(timezone.utc)) and fired_at is None:
fired_at = time.monotonic() - start
await asyncio.sleep(1.0)
return fired_at, polls, worst_unchanged, worst_cmd
def with_server(argv, port, fn):
proc = subprocess.Popen([sys.executable, "-c"] + argv,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
try:
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
try:
import socket
with socket.create_connection(("127.0.0.1", port), 0.3):
break
except OSError:
time.sleep(0.1)
else:
raise RuntimeError("the fake meter never came up")
return fn()
finally:
proc.kill()
proc.wait()
hw = find_hwsim()
print(f"frozen meter over HTTP - using {'the rig hwsim at ' + str(hw) if hw else 'the local stub'}")
if hw:
def argv_for(mode):
return [HWSIM_DRIVER, "8791" if mode == "freeze" else "8792", mode,
str(hw.parent)]
else:
def argv_for(mode):
return [STUB, "8791" if mode == "freeze" else "8792",
"frozen" if mode == "freeze" else "moving"]
fired, polls, unch, cmd = with_server(
argv_for("freeze"), 8791,
lambda: asyncio.run(live_run(8791, "freeze")))
print(f" freeze: {polls} polls, worst unchanged {unch:.1f} s, "
f"worst |command| {cmd:.0f} W, alarm at "
f"{'never' if fired is None else f'{fired:.1f} s'}")
check("the transport really polled a live server", polls >= 10)
check("a frozen meter raises the alarm", fired is not None)
check("...and only after the hold, not on the first railed poll",
fired is None or fired >= 10.0)
fired2, polls2, unch2, cmd2 = with_server(
argv_for("moving"), 8792,
lambda: asyncio.run(live_run(8792, "moving")))
print(f" moving: {polls2} polls, worst unchanged {unch2:.1f} s, "
f"worst |command| {cmd2:.0f} W, alarm at "
f"{'never' if fired2 is None else f'{fired2:.1f} s'}")
check("the moving meter also drove the command to the rail", cmd2 >= rail_w(LIVE_MAX_W))
check("a REAL railed condition against a live meter stays silent", fired2 is None)
check("...because the meter kept moving", unch2 < 10.0)
# --------------------------------------------------------------------------- #
# 4. the wiring, because a correct detector nobody calls is not a detector
# --------------------------------------------------------------------------- #
# ⚠️ EVERYTHING ABOVE PASSES WITH `app/freeze.py` IMPORTED BY NOTHING. That is
# the shape of defect this project keeps finding: the mechanism is right, the
# suite is green, and the shipped path never reaches it. So the real
# `Controller.cycle()` is driven here, against the real arbiter and the real
# control law, with a stand-in transport that reports `unchanged_s` the way
# HomeWizardLocalSource does - and the assertion is on the SERVICE CALL that
# leaves the process, not on the watchdog's own flag.
print("the wiring - Controller.cycle() actually raises the notification")
class _Store:
data: dict = {}
def set(self, *a):
pass
def get_time(self, *a):
return None
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class _Hass:
def __init__(self, grid):
self.grid = grid
self.calls = []
self.writes = []
# The plant tracks the command instantly, as in test_loop.py. Without
# it the saturation freeze pins the command short of the rail and the
# runaway under test never happens - which would have made this a check
# that passes for the wrong reason.
self.batt = 0.0
async def limits(self, entity):
return (-5000.0, 5000.0, 1.0)
async def number(self, entity, invert=False):
return {"sensor.grid": self.grid, "sensor.soc": 50.0,
"sensor.batt": self.batt}.get(entity)
async def set_number(self, entity, value):
self.writes.append(float(value))
self.batt = float(value)
return True
async def call(self, domain, service, data):
self.calls.append((domain, service, data))
return True
class _Source:
"""Only the attribute the controller reads. Duck-typed like the real one."""
def __init__(self, unchanged_s):
self.unchanged_s = unchanged_s
def wired(unchanged_s, grid, cycles=60, **over):
async def go():
hass = _Hass(grid)
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": -10,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15},
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -5000.0, 5000.0
c.p1_source = _Source(unchanged_s)
for _ in range(cycles):
await c.read_inputs()
await c.cycle()
return hass, c
return asyncio.run(go())
hass_f, ctl_f = wired(3600.0, 4000.0)
notes = [d for dom, svc, d in hass_f.calls
if (dom, svc) == ("persistent_notification", "create")]
# The status row lives behind `p1_enabled`, which is what a homewizard_local
# install has. Flipped here rather than plumbing a whole P1 transport in - the
# row's own content is what is under test.
ctl_f.p1_enabled = True
rows = [c for c in ctl_f.checks() if "METER MAY BE FROZEN" in c["text"]]
print(f" frozen at +4000 W: command ran to {ctl_f.target:.0f} W, "
f"{len(notes)} notification(s), {len(rows)} red status row(s)")
check("the shipped cycle() raises exactly one HA notification", len(notes) == 1)
check("...with a fixed notification_id so repeats replace, not stack",
notes and notes[0].get("notification_id") == "goodwe_meter_frozen")
check("...and it says the controller is still running",
bool(notes) and "STILL RUNNING" in notes[0]["message"])
check("the status page carries a failed row saying so",
len(rows) == 1 and not rows[0]["ok"] and not rows[0]["warn"])
check("the event log names it", any("METER FROZEN" in e for e in ctl_f.events))
# ⚠️ NOTIFY, NOT TRIP. If a later cut turns this into a trip it must come here
# and change this expectation deliberately, not discover it in the field.
check("and it does NOT command 0 W - this cut is an alarm only",
abs(ctl_f.target) >= rail_w(LIVE_MAX_W))
hass_n, ctl_n = wired(3600.0, -14.2)
check("wired up, TEL-05's converged case still raises nothing",
not [1 for dom, svc, _ in hass_n.calls if svc == "create"])
hass_m, ctl_m = wired(2.0, 8000.0)
check("wired up, a real railed load against a moving meter raises nothing",
not [1 for dom, svc, _ in hass_m.calls if svc == "create"]
and abs(ctl_m.target) >= rail_w(LIVE_MAX_W))
check("an uncovered transport (no unchanged_s) raises nothing, and says so",
not [1 for dom, svc, _ in wired(None, 4000.0)[0].calls if svc == "create"])
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")
+173 -2
View File
@@ -26,9 +26,15 @@ def check(name, cond):
def base(**over):
"""A configuration that must start. Everything else is this, spoilt."""
# ⚠️ The maintenance rates are named EXPLICITLY, at the values config.yaml
# ships (MAINT-04). They are not decoration: with the key absent, rule 6
# falls back to main.py's 2500 W, which against this 2000 W ceiling is a
# genuine refusal - so a base() without them would be a config that does
# not start, and every check below would be measuring the wrong thing.
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off"}
"meter_source": "off",
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
o.update(over)
return o
@@ -151,6 +157,170 @@ check("with meter_source off the number governs nothing and is not refused",
check("the constant is the firmware's, not a rounded guess",
FIRMWARE_INGEST_VETO_S == 30.0)
# --------------------------------------------------------------------------- #
print("6. a maintenance charge the ceiling can actually accommodate (MAINT-04)")
# ⚠️ THE SHIPPED BUG. config.yaml shipped max_w 2000 against
# maintenance_charge_w 2500, so the cheap-window shaping asked for hi=-2500
# against the supervised lo=-2000 - lo > hi, arbiter clause 3, 0 W. Maintenance
# silently never charged in a cheap window on a default install.
p = validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
-5000, 5000)
check("maintenance_charge_w above max_w is refused", len(p) == 1)
check("...and the message names the charge rate", why(p, "2500"))
check("...and the ceiling", why(p, "2000"))
check("...and says which one wins", why(p, "THE CEILING WINS"))
# ⚠️ Equality is REACHABLE, not a near miss: lo=-2000, hi=-2000 is the single
# point -2000 W. Refusing it would be exactly the trivia that gets a validator
# deleted, and it is the boundary the rule is written `>` for.
check("maintenance_charge_w exactly at max_w starts",
validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
-5000, 5000) == [])
check("below it starts",
validate_startup_config(base(max_w=2000, maintenance_charge_w=1500),
-5000, 5000) == [])
# Not scoped to maintenance_enabled: the pair is contradictory either way, and
# scoping would leave the trap armed for the one action - ticking the box - that
# cannot be expected to change what a charge rate means.
check("...and it is refused with maintenance_enabled off, as shipped",
why(validate_startup_config(
base(max_w=2000, maintenance_charge_w=2500,
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
# ⚠️ THE SIBLING, CHECKED RATHER THAN ASSUMED. The shaping is NOT symmetric:
# drain files Claim.set at P_MAINTENANCE and owns the actuator, so the ceiling
# CLAMPS it (+2000 W) instead of contradicting it, and the arbiter's explain()
# names the clamp every cycle. Loud, and it still moves energy - so warned, not
# refused. If someone later "fixes the asymmetry" by making this a refusal,
# this check goes red and they have to justify it.
check("maintenance_discharge_w above max_w WARNS but starts",
validate_startup_config(base(max_w=2000, maintenance_discharge_w=2500),
-5000, 5000) == [])
# The demonstration the rule is derived from, asserted rather than described.
from app.arbiter import ( # noqa: E402
Claim, P_LOOP, P_MAINTENANCE, P_MAINT_SHAPE, P_SAFETY, resolve,
)
def _charge(max_w, charge_w):
return resolve([Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"),
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-charge_w,
reason="cheap-window charge"),
Claim.set("loop", P_LOOP, 500.0, "grid")])
def _drain(max_w, disch_w):
return resolve([Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"),
Claim.set("maintenance", P_MAINTENANCE, disch_w, "drain"),
Claim.set("loop", P_LOOP, 500.0, "grid")])
r = _charge(2000, 2500)
check("arbiter: the refused pair really does emit 0 W",
r.target_w == 0.0 and r.contradiction)
r = _charge(2000, 2000)
check("arbiter: the permitted equality really does charge",
r.target_w == -2000.0 and not r.contradiction)
r = _charge(3000, 2500)
check("arbiter: raising max_w above the rate charges at the rate",
r.target_w == -2500.0 and not r.contradiction)
r = _drain(2000, 2500)
check("arbiter: the drain SET is clamped, not contradicted",
r.target_w == 2000.0 and not r.contradiction)
check("...and the clamp names itself, which is why it is not refused",
"supervised" in (r.bound_by or "") and "limited by" in r.explain())
# --------------------------------------------------------------------------- #
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
# ⚠️ Shipping a default the validator refuses would be worse than the bug it
# refuses. Read config.yaml itself rather than a transcription of it - a
# transcription is what lets the two drift apart silently. Regex, not PyYAML:
# the add-on image has no PyYAML (Dockerfile installs py3-aiohttp/py3-paho-mqtt
# only) and a check that skips on ImportError is not a check.
import os # noqa: E402
import re # noqa: E402
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
with open(_CFG, encoding="utf-8") as _fh:
_txt = _fh.read()
_shipped = {k: float(v) for k, v in re.findall(
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w): "
r"(-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
check("config.yaml really was read (all four defaults found)",
set(_shipped) == {"max_w", "maintenance_charge_w",
"maintenance_discharge_w", "sign_disagree_w"})
check(f"the shipped defaults are accepted {_shipped}",
validate_startup_config(base(**_shipped), -5000, 5000) == [])
check("...and the shipped charge rate is at or under the shipped ceiling",
_shipped["maintenance_charge_w"] <= _shipped["max_w"])
check("...and the shipped discharge rate too, so it warns about nothing",
_shipped["maintenance_discharge_w"] <= _shipped["max_w"])
# --------------------------------------------------------------------------- #
print("the KNOWN cross-file default divergence, pinned rather than commented")
# ⚠️ WHAT THIS GUARDS, AND WHY IT ASSERTS THE BUG RATHER THAN THE FIX.
# config.yaml and validate.py now default maintenance_charge_w to 2000, while
# main.py's two `opts.get(..., 2500)` fallbacks and MaintConfig's dataclass
# default still say 2500. So an options dict with the key ABSENT is ACCEPTED by
# rule 6 at 2000 and then built by main.py at 2500 - which is a contradiction:
#
# validate_startup_config -> [] (accepted)
# MaintConfig().charge_w -> 2500.0
# arbiter -> 0.0 W, contradiction=True
#
# That is the bug rule 6 exists to catch, passing rule 6. It is unreachable on a
# Supervisor install (every key is always supplied from config.yaml) but fully
# reachable from sim/inject.py and from tests - which is exactly where anyone
# debugging maintenance works.
#
# The fix is ONE SOURCE OF TRUTH for option defaults. That is a bigger change
# than this ticket and it lands in main.py, which another ticket is live in - so
# the divergence is RECORDED here instead of closed. The check therefore passes
# on the mismatch and FAILS when either side moves, in EITHER direction:
#
# - config.yaml moves -> FAIL: re-decide what the fallback should be
# - main.py is fixed to match -> FAIL: the divergence is gone, DELETE this
# check and the ⚠️ block in validate.py
#
# A comment cannot do that, and "a comment where a check belonged" is this
# repo's most repeated defect.
_APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app")
def _read(*parts):
with open(os.path.join(*parts), encoding="utf-8") as fh:
return fh.read()
# Every literal fallback for this key in main.py, whatever the call shape.
_main_falls = [float(m) for m in re.findall(
r'\.get\(\s*"maintenance_charge_w"\s*,\s*(-?\d+(?:\.\d+)?)\s*\)',
_read(_APP, "main.py"))]
# ...and MaintConfig's own dataclass default, which is the one that bites when
# main.py is bypassed entirely (a Maintenance built straight from defaults).
_dc_falls = [float(m) for m in re.findall(
r"^\s*charge_w:\s*float\s*=\s*(-?\d+(?:\.\d+)?)\s*$",
_read(_APP, "maintenance.py"), re.M)]
check(f"main.py's fallbacks are still found and still 2500 {_main_falls}",
len(_main_falls) == 2 and set(_main_falls) == {2500.0})
check(f"MaintConfig's default is still found and still 2500 {_dc_falls}",
len(_dc_falls) == 1 and set(_dc_falls) == {2500.0})
check(f"...and config.yaml still ships 2000, so the divergence is UNCHANGED "
f"(shipped {_shipped['maintenance_charge_w']:.0f}, code 2500)",
_shipped["maintenance_charge_w"] == 2000.0)
# ⚠️ And the consequence itself, through the REAL code rather than through the
# numbers above - so that if someone "fixes" this by changing a literal without
# understanding it, the thing that goes red is the behaviour, not the transcript.
from app.maintenance import MaintConfig # noqa: E402
_absent = {k: v for k, v in base().items() if k != "maintenance_charge_w"}
_built = MaintConfig().charge_w
check("with the key ABSENT the validator accepts...",
validate_startup_config(_absent, -5000, 5000) == [])
check(f"...while the code would build {_built:.0f} W against a "
f"{_absent['max_w']:.0f} W ceiling...", _built > _absent["max_w"])
_r = _charge(_absent["max_w"], _built)
check("...which the arbiter resolves to 0 W as a contradiction - the hole, "
"asserted so it cannot widen unnoticed",
_r.target_w == 0.0 and _r.contradiction)
# --------------------------------------------------------------------------- #
print("max_w must be a positive magnitude")
# Schema-blocked on a real install, but validate_startup_config() is also
@@ -168,7 +338,8 @@ check("...and the message explains it would read as a trip, not a mistake",
# threshold the ceiling can actually reach, and the interaction gets its own
# check below.
check("the smallest sane max_w still starts",
validate_startup_config(base(max_w=1, sign_disagree_w=0.5), -5000, 5000) == [])
validate_startup_config(base(max_w=1, sign_disagree_w=0.5,
maintenance_charge_w=1), -5000, 5000) == [])
check("...but that same 1 W ceiling with the DEFAULT sign threshold is refused",
why(validate_startup_config(base(max_w=1), -5000, 5000), "never fire"))
+7 -2
View File
@@ -145,8 +145,13 @@ check("300 W under a 2000 W ceiling is accepted", p == [])
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 2000},
-5000, 5000)
check("a threshold AT max_w is refused", len(p) == 1 and "never fire" in p[0])
p = validate_startup_config({**base, "max_w": 500, "sign_disagree_w": 3000},
-5000, 5000)
# ⚠️ maintenance_charge_w is named here only to keep this case about the SIGN
# rule. MAINT-04 added a second rule against the same ceiling, and a 500 W
# max_w is also below the 2000 W shipped maintenance charge - so without this
# the dict is refused twice, for two correct but different reasons, and the
# `len(p) == 1` below would be measuring the interaction rather than this rule.
p = validate_startup_config({**base, "max_w": 500, "sign_disagree_w": 3000,
"maintenance_charge_w": 500}, -5000, 5000)
check("a threshold above max_w is refused", len(p) == 1 and "never fire" in p[0])
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 1999},
-5000, 5000)