Merge release/1.0 (0.15.0 + SAFETY-90) into maint-10-local-start-hour
Conflicts: main.py's maintenance import (base added CHARGE, HOLD; branch added site_zone) - both kept. DOCS maintenance table: base's longer drain-rate row kept, start hour row at 19 / local. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -75,3 +75,55 @@ P1 is its section 6 P1, and the pull request lists what was measured.
|
||||
P2's 19.33; Solar first + Cheap hours only 19.57.
|
||||
- Not done in P3: the forecast (P4), and a headroom too short for the run
|
||||
moving the rest to the next cheap periods.
|
||||
|
||||
## P4: the solar forecast and the day plan (2026-09-29)
|
||||
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
|
||||
today; `pv_forecast_tomorrow_entity` optional), read by `app/solarfc.py`.
|
||||
Supported shapes: Forecast.Solar (state only), Solcast (`detailedForecast`)
|
||||
and Open-Meteo Solar Forecast (`wh_period`). The unit is read, not guessed;
|
||||
older than 3 h is stale (one missed hourly poll must not flag it).
|
||||
- Trust: `solar_trust_pct`, default 70 (owner answer 5).
|
||||
- House use: nothing the add-on recorded fitted. `tariff.db` holds grid
|
||||
import per quarter, which the batteries shape. So `dayplan.HouseProfile`
|
||||
keeps grid + battery per local hour for 7 days in the store
|
||||
(`house_profile`), median per hour. It is net of the solar the house used,
|
||||
so the solar sum never takes it below the night median. Solar readings are
|
||||
not used (TEL-11). The first plan needs one day of it.
|
||||
- Verdict (`dayplan.plan()`): trust x sum max(0, forecast - house) x 0.95
|
||||
until the expensive run the cheap run is for. Solar first takes it first.
|
||||
- Leftover solar may then charge from the grid up to min(ceiling, floor +
|
||||
the house's use in the expensive runs / 0.95), less its sun share: only
|
||||
the shortfall.
|
||||
- Cheap hours only ignores the plan and fills to the ceiling (design
|
||||
section 1; review of #156, see the replay below).
|
||||
- Solar first never charges from the grid (owner, 2026-09-29).
|
||||
- The level is kept unless it moves by min(1 kWh, 10 %). With no plan
|
||||
(no, stale or failed forecast, no house profile, no capacity) every mode
|
||||
is P3 option A exactly: `test_modes_p4.py` part 1 pins 12 digests from
|
||||
5a4cdc2 (modes on, with and without an unavailable forecast).
|
||||
- The plan is made with the runs (every 15 min, or at once when the forecast
|
||||
comes or goes). A lost forecast drops the levels at the next cycle.
|
||||
- Not done: the design's scaling by measured solar from 10:00 (it needs the
|
||||
solar readings TEL-11 keeps out), the HA `goodwe_<inv>_plan` sensor, and
|
||||
FCST-01.
|
||||
- Replay (`tools/opt11p4_replay.py`, 16 recorded days, Solar first + Leftover
|
||||
solar, net EUR, lower is better; site solar rebuilt from the recording,
|
||||
forecast errors from Elia's day-ahead Belgian solar forecast):
|
||||
- year-earlier hourly prices: P2 19.33, P3 option A 19.44, old one-period
|
||||
trade 18.14, P4 19.15 (perfect forecast 19.14, 1.5 x too high 19.16,
|
||||
missing 19.44);
|
||||
- the days' real 2026 quarter-hourly prices (held out): P2 32.26, option A
|
||||
32.01, old trade 31.40, P4 29.19 (perfect 29.21, too high 29.28, missing
|
||||
32.01).
|
||||
- Solar first + Cheap hours only, skipping its sun share (the first
|
||||
build): 19.57 (same as P3) and 29.72 against P3's 29.41. The shorter,
|
||||
moved runs left B lower, so later midday top-ups rested A while the sun
|
||||
shone. Cheap hours now ignores the plan, which is P3 exactly.
|
||||
- A forecast of no sun at all gives 19.14 and 29.33. So nearly all of the
|
||||
gain is Leftover solar buying the evening's shortfall in the cheap run;
|
||||
the forecast itself was worth 0.14 EUR in 16 September days.
|
||||
- Bias: the site's solar is rebuilt from the same net use the replay
|
||||
plays, and "recorded" keeps its hourly shape and adds only the national
|
||||
daily error. Both favour P4; "national" (no house leakage) gives 29.25.
|
||||
No threshold was tuned to these days. The 2026-09-24 PV recording could not
|
||||
be replayed: it is only in the live Home Assistant.
|
||||
|
||||
@@ -69,6 +69,8 @@ which does not run the startup gate) the absence costs a shave and never a pack.
|
||||
|
||||
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
|
||||
|
||||
*At N>=2 one pack at its floor no longer stops the others: [[SAFETY-87]].*
|
||||
|
||||
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
|
||||
**is unchanged**, exactly as the card instructed. What changed is the floor it is
|
||||
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
|
||||
|
||||
@@ -35,10 +35,9 @@ direction. Opposite splits stay forbidden.
|
||||
(`tools/safety78_identity.py`, which hashes the claims too).
|
||||
|
||||
**Not changed (measured in the PR #154 review, follow-ups).**
|
||||
- At N=1 a drain (60) outranks the peak floor (50), so the guard does not add
|
||||
to it: a 5000 W house imports 3000 W against a 2500 W limit, a 6000 W house
|
||||
4000 W. Pre-existing, kept by the N=1 identity.
|
||||
- At N=1 a drain (60) outranked the peak floor (50): a 5000 W house imported
|
||||
3000 W against a 2500 W limit. Fixed by [[SAFETY-83]].
|
||||
- At N=2 a maintenance CHARGE in a cheap window has the same stall: the
|
||||
charging pack's `hi = -floor` against the loop asking the other to give is
|
||||
refused on 141 of 261 cycles. The mirror fix is a site `hi`, which can meet
|
||||
the peak floor's `lo` (0 W), so it is not this card.
|
||||
the peak floor's `lo` (0 W), so it is not this card. Fixed by [[SAFETY-84]].
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# SAFETY-83 - the peak guard outranks the maintenance drain at N=1
|
||||
|
||||
**Status: DECIDED 2026-09-29 (owner go, P1).** Follows SAFETY-78's "Not
|
||||
changed" list. Tests: `test_safety83.py`.
|
||||
|
||||
**The defect.** At N=1 the drain is the site SET at `P_MAINTENANCE` (60). The
|
||||
peak floor is a limit at `P_PEAK` (50), and clause 2 applies only limits at or
|
||||
above the winner, so the floor was ignored and the house imported what the
|
||||
drain left. A 5000 W house under a 2500 W limit and a 2000 W drain banked
|
||||
3000 W quarters; on the owner's settings (limit 3500, drain 2500, margin 50) a
|
||||
breach starts at about 5950 W.
|
||||
|
||||
**The fix.** While a drain owns the setpoint at N=1 and the peak floor asks
|
||||
for more than the drain rate, `main.py` (`_peak_over_drain()`) refiles that
|
||||
peak claim at `P_MAINTENANCE`. The arbiter then applies it: the target is the
|
||||
floor, still inside the safety limits, and a safety SET (100) still parks
|
||||
everything. Where the floor is at or below the drain rate nothing changes.
|
||||
|
||||
- The order in effect is now the same at N=1 and N=2: safety limits and the
|
||||
SAFETY-73 refusal > peak guard (with the SAFETY-79 margin) > maintenance >
|
||||
modes > trade > loop (`arbiter.py`, beside the priorities).
|
||||
- The drain keeps counting: the battery discharges at least the drain rate on
|
||||
every cycle, so the cycle is not reset or stalled; it only ends sooner.
|
||||
- Below the drain rate the rest is still exported (owner, 2026-09-29).
|
||||
- Identity: `tools/safety78_identity.py --diff` against 35cdffe changes only
|
||||
cycles where the guard asked for more than the drain; after such an episode
|
||||
the commands are identical again and only the log and the idle loop's
|
||||
integrator differ. N=2 is unchanged.
|
||||
|
||||
**Not changed.**
|
||||
- Between the drain rate and the peak aim, the house still imports what the
|
||||
drain leaves (N=1 behaviour kept by the identity). At N=2 the loop covers it.
|
||||
- A maintenance HOLD (0 W, 120 min, battery full) still outranked the peak
|
||||
guard at N=1. Fixed by [[SAFETY-86]].
|
||||
@@ -0,0 +1,46 @@
|
||||
# SAFETY-84 - a maintenance charge at two inverters
|
||||
|
||||
**Status: DECIDED 2026-09-30.** Follows SAFETY-78's "Not changed" list.
|
||||
Tests: `test_safety84.py`; identity: `tools/safety84_identity.py`.
|
||||
|
||||
**The defect.** In a cheap window the charging pack files its own
|
||||
`hi = -charge` (P_MAINT_SHAPE). At N>=2 the loop's site target sat above it
|
||||
(a house to cover, or its 400 W slew step from 0 W), so the other pack had to
|
||||
give while this one charged. The allocator refuses opposite directions
|
||||
(SAFETY-31), so the site sat at 0 W whatever the house
|
||||
(`tools/safety84_identity.py` on 9d5b7d1: 436 of 480 cycles refused). N=1
|
||||
was fine.
|
||||
|
||||
**The fix.**
|
||||
- While a pack's own limits make it charge at N>=2, `main.py` files a site
|
||||
limit `charge`, `hi` = what that pack's own `hi` places
|
||||
(`allocator.hi_w()`, whole W as the allocator rounds). The site target is
|
||||
never above it, so the other pack only adds power in the same direction.
|
||||
Opposite splits stay forbidden. Mirror of SAFETY-78's `drain` floor.
|
||||
- Peak guard first (owner order, SAFETY-83). The charge is sized to the
|
||||
quarter's headroom under the aim (`charge_headroom_w()`, cap or banked peak
|
||||
minus `peak_margin_w`), so it charges less instead of stopping: the meter
|
||||
sits on the aim and the guard does not need to step in. Where the house
|
||||
alone is over the aim the guard engages, the shaping is suspended as before
|
||||
(`shaping_suspended()`) and no `charge` is filed, so the guard's `lo` and
|
||||
this `hi` never meet in one cycle (no `lo > hi`, no park at 0 W).
|
||||
Without the headroom sizing the aim still held (the suspension did it), but
|
||||
a battery gave power back on 285 of 540 cycles at a 3000 W house
|
||||
(test_safety84.py part 2).
|
||||
- A site stop (a safety SET above P_MAINT_SHAPE) sizes the charge to 0 W:
|
||||
the unit `hi` is a hard limit, so a charge under a 0 W stop needs the other
|
||||
pack to give. It was refused, with an ARBITER line, on most cycles of the
|
||||
stop (test_safety84.py part 4). The stop still parked the site at 0 W.
|
||||
- A pack that cannot take the charge (a refusal, a full pack, a stale link)
|
||||
files no ceiling; the other pack follows the loop.
|
||||
- Not filed at N=1: byte-identical (`tools/safety84_identity.py`, which hashes
|
||||
every claim, in and out of a cheap window and under a banked peak).
|
||||
|
||||
**Not changed.**
|
||||
- At N=1 the charge is not sized to the headroom: the guard still suspends it
|
||||
and the battery gives until the quarter is safe. The same sizing would fit
|
||||
there; that is an owner question, not this fix.
|
||||
- Outside a cheap window (`hi = 0`, solar only) nothing is filed, at any N.
|
||||
- A HOLD (later fixed by [[SAFETY-86]]) and the "Battery is not keeping up"
|
||||
alarm (SAFETY-85) are untouched. In the identity runs that alarm's log line
|
||||
comes as often at N=2 as at N=1 (2 in 480 cycles).
|
||||
@@ -0,0 +1,91 @@
|
||||
# SAFETY-85 - no false "not keeping up" while another owner sets the battery
|
||||
|
||||
**Status: implemented 2026-10-01, for review.** Tests: `test_safety85.py`.
|
||||
|
||||
**The defect.** "Battery is not keeping up" (SAFETY-40, `goodwe_integrator_bound`)
|
||||
fired about every 2.5 min during maintenance (drain, charge, HOLD) and under the
|
||||
peak guard, while the battery gave exactly what it was told. Each warning
|
||||
re-created the HA notification. Rig counts: 24 in an hour (SAFETY-83 F1), 43 in
|
||||
7 episodes (SAFETY-86), 35 in 16 (SAFETY-87), 12 in 6 (SAFETY-84). The page
|
||||
counter counted cycles (861), not episodes.
|
||||
|
||||
**Cause, measured on the real Controller.**
|
||||
- N=1: when a higher claim decides, `cycle()` re-seeds the integrator from the
|
||||
target each cycle, then `compute()` adds `gain * error` for a grid error the
|
||||
loop does not own. Target + kick crosses the rail and sets `i_clamped`. The
|
||||
carried integrator is not wound (SAFETY-48 caps the lead at one slew step),
|
||||
so control comes back one slew step at a time.
|
||||
- N>=2: a maintenance pin is a unit claim. The loop stays the site winner and
|
||||
winds to its rail for watts the pinned battery may not give. When the pin
|
||||
lifts, the site jumps: 2500 W in one cycle when a HOLD ends under a 4500 W
|
||||
house, 1390 W when a charge pin lifts (`s84 n2_charge_dear`, t=1800 s).
|
||||
|
||||
**Decision.** Three rules; the alarm, its text and its id are unchanged.
|
||||
1. **The loop's clamp counts only on a cycle the loop decided.** The winner is
|
||||
the loop. Its number is on the wire, or only a rating (`device`,
|
||||
`supervised`) cut it. No bound held it, and nothing was unmet.
|
||||
(`Controller._loop_alarm_input`.)
|
||||
2. **A battery another owner set to discharge alarms when it gives less than
|
||||
it was told.** This covers every battery on a cycle the loop did not
|
||||
decide, and a battery with its own pin otherwise. The test is more than
|
||||
`saturation_w` short of the least it stood at over `SHORT_CYCLES` cycles
|
||||
and `SHORT_SPAN_S` (the plant's measured settle, SAFETY-13/73), for
|
||||
`saturation_cycles` cycles in a row (`clamp.delivery_short`). It covers a
|
||||
real shortfall during maintenance or under the peak guard. Before, that
|
||||
shortfall was silent unless the kick happened to reach the rail (house
|
||||
1000 W, drain 2000 W, battery gives nothing: 0 warnings before, 4 in 10 min
|
||||
now).
|
||||
3. **At N>=2, while maintenance holds a battery, the loop is bounded** by
|
||||
OPT-11's `loop_bound()` (what the batteries may place). It does not wind,
|
||||
and control comes back one slew step a cycle. Why maintenance only: that is
|
||||
where the jump was measured and where the owner order puts maintenance
|
||||
above the loop. Other N>=2 limits (pack cut-out, SAFETY-73 refusal) keep
|
||||
the old law. Rule 1 still silences them (the target is unmet), so they no
|
||||
longer warn either.
|
||||
|
||||
Fixing the windup was chosen over only ignoring the wound part, because at
|
||||
N>=2 the wound integrator moves real watts at hand-back. At N=1 there was
|
||||
nothing to fix: re-seeding already prevents windup.
|
||||
|
||||
**What SAFETY-03/40 protect is kept.** With the loop in charge, the integrator
|
||||
bound still warns (a runaway, a house above the rating), and a saturated
|
||||
inverter under the loop's own command still does not alarm (SAFETY-03 AC 2):
|
||||
rule 2 grades only batteries another owner set.
|
||||
|
||||
**The counter.** `ClampWatchdog.bound_episodes` counts episodes that had a
|
||||
bound engagement. The status row shows it ("N times since start").
|
||||
`bound` (cycles) stays for the log and the tests.
|
||||
|
||||
**Re-pinned digests.** Every change is intended; nothing at N=1 moves a watt.
|
||||
|
||||
| digest | what changed |
|
||||
|---|---|
|
||||
| s78 `n1_drain_owner` | the false clamp rows are gone from the log; commands identical |
|
||||
| s78 `n2_drain`, s84 `n2_charge`, `n2_charge_banked`, s79 `n2_maint` (test_peak_margin) | loop reason (`mode-bound`), integrator and loop claim while bounded; commands identical |
|
||||
| opt11 `n2_maint` (test_modes) | A drops out at t=1655 s: B follows the house at once, 8 cycles differ (max 920 W) |
|
||||
| s84 `n2_charge_dear` | A's pin lifts at t=1800 s: 400 W a cycle, not a 1390 W jump; the surplus step after it; 98 cycles |
|
||||
|
||||
test_safety86's "without the ceiling the log floods" now needs the ceiling
|
||||
and the bound both removed: either one alone keeps the loop inside what the
|
||||
batteries can give. The `alarmed()` helpers in test_safety84/86 also read
|
||||
SAFETY-81's quarter stamp, because ALLOCATOR rows can push its row out of
|
||||
the 40-row log.
|
||||
|
||||
**Review (PR #163).** The first draft graded each battery against last
|
||||
cycle's command and graded charges too. On plantsim's battery (10 s lag) at
|
||||
the live 2 s meter, every drain or charge start warned once, and an N=2 drain
|
||||
or HOLD under the peak guard with a stepping house warned 15 times an hour. A
|
||||
maintenance charge tapering near full warned 11 times in 50 min, which
|
||||
SAFETY-42 section 7 had ruled out. With the settle window and discharge only:
|
||||
0 in all of those cases. A battery that gives nothing still warns, lagging
|
||||
or not. At a hypothetical 20 s lag, it warns at most twice an hour. The cost:
|
||||
a battery that does not take a charge another owner set is not named by this
|
||||
alarm (the same gap SAFETY-42 states; the charge has its 36 h timeout).
|
||||
`test_safety85.py` part 7.
|
||||
|
||||
**Not changed, open for the owner.**
|
||||
- With the loop in charge and the house above the batteries' total rating,
|
||||
it still warns (SAFETY-40). On the rig's 2 x 2000 W N=2, most of SAFETY-84's
|
||||
count came from houses of 4500 and 6500 W.
|
||||
- TEL-10's `Loop reason` reads `mode-bound` while the maintenance bound holds
|
||||
the loop, with modes off. DOCS lists the value with both meanings.
|
||||
@@ -0,0 +1,47 @@
|
||||
# SAFETY-86 - the peak guard outranks the maintenance HOLD
|
||||
|
||||
**Status: DECIDED 2026-09-30 (owner).** Follows SAFETY-83's "Not changed"
|
||||
list. Tests: `test_safety86.py`.
|
||||
|
||||
**The defect.** The HOLD (battery resting full after the maintenance charge)
|
||||
is a SET of 0 W at `P_MAINTENANCE` (60), above the peak floor (`P_PEAK`, 50),
|
||||
so the floor did not bind. At N=1 a 6500 W house banked 6500 W quarters and
|
||||
'Load too high' fired (SAFETY-83 rig test, check 4). At N=2 only the other
|
||||
battery gave: 4500 W against a 3950 W aim (SAFETY-84 rig test, check 6).
|
||||
|
||||
**The fix** (`main.py`, `_peak_over_hold()`).
|
||||
- N=1: during a HOLD a peak floor above 0 W is refiled at `P_MAINTENANCE`, as
|
||||
for a drain (SAFETY-83). The battery gives the floor; the Why reads "raised
|
||||
for the peak limit".
|
||||
- N>=2: the holding battery's pin is raised to the floor minus what the other
|
||||
live batteries may place (their hard `hi`, or their pin). The others give
|
||||
first, the holding one only the shortfall. Why this split and not the
|
||||
allocator's shares: the hold is maintenance and outranks the others' use
|
||||
(owner order), and the owner rule for an Off battery is the same, "give way
|
||||
only for the shortfall". A battery Off by its mode (P_MODE, below
|
||||
maintenance) therefore still gives before the holding one.
|
||||
- N>=2, while the holding battery gives: a site `hold` ceiling at the floor
|
||||
(P_MAINTENANCE), so the loop does not ask for watts the hold keeps back.
|
||||
Without it the moving pin re-logged the ALLOCATOR "could not be placed"
|
||||
row each cycle and filled the action log (review, PR #161).
|
||||
- Below the aim nothing changes: the floor is 0 W, the hold is 0 W, the rows
|
||||
are identical to before (test_safety86.py part 2).
|
||||
- A site stop (a SET above `P_PEAK`) decides alone: nothing is raised at N>=2,
|
||||
and the refiled floor at N=1 stays under `P_SAFETY`. If another battery is
|
||||
pinned to charge (a SAFETY-73 probe) the pin is not raised, so the site is
|
||||
never split both ways.
|
||||
- The hold resumes by itself: the rule is evaluated each cycle, so when the
|
||||
guard lets go the command is 0 W again.
|
||||
|
||||
**The level dips below the target: the hold finishes anyway.** Owner
|
||||
decision 2026-10-01: the HOLD ends on its clock (`maintenance_hold_min`) even
|
||||
when the peak guard pulls the level down; no top-up. A top-up would re-enter
|
||||
CHARGE (up to 36 h, `CHARGE_TIMEOUT_MIN`) and keep the site's one maintenance
|
||||
slot meanwhile.
|
||||
|
||||
**Not changed.**
|
||||
- The peak guard still grades the emptiest battery (SAFETY-32): at N=2 with
|
||||
B under `peak_soc_floor` the floor is limited and A holds even when it is
|
||||
full (SAFETY-87).
|
||||
- "Battery is not keeping up" during maintenance (SAFETY-85), the N=2 charge
|
||||
flapping (SAFETY-88) and the N=1 charge headroom sizing are untouched.
|
||||
@@ -0,0 +1,83 @@
|
||||
# SAFETY-87 - the peak guard uses every battery that can still give
|
||||
|
||||
**Status: DECIDED 2026-10-01 (owner card).** Follows SAFETY-86's "Not
|
||||
changed" list. Tests: `test_safety87.py`; identity:
|
||||
`tools/safety87_identity.py`.
|
||||
|
||||
**The defect.** At N>=2 the guard graded the emptiest battery against the
|
||||
highest floor ([[SAFETY-32]] §3). One battery under `peak_soc_floor` made the
|
||||
whole site floor-limited: the claim asked for 0 W and the reserve alarm fired,
|
||||
even with the other battery full. Measured: a maintenance charge (A at 11 %)
|
||||
raised the alarm 10 times while both batteries gave 2000 W and the quarter
|
||||
landed on the aim (SAFETY-84 rig test, check 3); a HOLD (A at 99 %, B at
|
||||
15 %) left A at 0 W and banked 4051 W, raising the month peak (SAFETY-86
|
||||
review; rig case 7a: 4563.8 W).
|
||||
|
||||
**The fix.**
|
||||
- `decide()` and its call are unchanged. At N>=2 `Controller._peak_on_able()`
|
||||
then grades each battery on its own floor (`peakguard.reserve_split()`).
|
||||
One at or under it is *held*. The others that are live and not refusing
|
||||
(SAFETY-73) are *able*.
|
||||
- A floor-limited decision is re-taken on the able batteries
|
||||
(`peakguard.place_on_able()`): their own permission (min SoC against max
|
||||
floor, among them), and a floor of `min(need, able_w)`. `able_w` is their
|
||||
rating plus the drain a held battery gives anyway (its maintenance pin).
|
||||
- The held batteries weigh 0 in the split while the site gives
|
||||
(`arbiter.resolve(reserve=)`), so the floor lands on the able ones. During a
|
||||
HOLD, a held battery is not counted among the "others" (`_peak_over_hold()`),
|
||||
so the holding battery covers the need.
|
||||
- Floor-limited now means: some battery is held and the able ones cannot hold
|
||||
the aim. The claim still asks them for `able_w`. The alarm fires only if
|
||||
they miss the real limit at full power for `ALARM_HOLD_S`, the same test as
|
||||
'Load too high' (`_reserve_short()`), so a one-cycle miss at a quarter's end
|
||||
is not an alarm. With nothing able (all held, or the rest refusing or
|
||||
dropped) it is the old state: 0 W, immediate alarm.
|
||||
- The alarm and the row name `held`, each battery graded on its own floor. The
|
||||
old code named batteries against the site's highest floor.
|
||||
- Page: the two peak rows carry `runs`. A red `runs` row gives the banner its
|
||||
title without "Not ready", and the Home page is not shown as held at 0 W.
|
||||
Rows that do stop control still outrank it.
|
||||
|
||||
**How SAFETY-32's concern is still covered.** SAFETY-32 took min(SoC) against
|
||||
max(floor) so the guard never sizes a forced discharge a battery's own BMS
|
||||
stops. Now no battery at or under its own floor is asked for the peak: it is
|
||||
graded on its own floor, and an unread SoC or unknown cut-out counts as held.
|
||||
The min/max pairing still decides permission, over the able batteries. The
|
||||
roster verdict `decide()` echoes (`soc`, `soc_floor`) is kept, so SAFETY-32's
|
||||
call-site and reduction checks still bite. What changed is only that one held
|
||||
battery no longer stops the others. One exception, with battery modes on: see
|
||||
Open.
|
||||
|
||||
**Maintenance pins beside a held battery** (review). A draining battery that
|
||||
may still give is raised from its drain rate towards its rating for the peak
|
||||
floor, after the others (`_drain_over_reserve()`; peak > drain), else the
|
||||
floor's rest landed on the held battery. In a HOLD, a held battery counts its
|
||||
own pin (a drain) among the others, and a held battery pinned to charge stops
|
||||
the raise (never split both ways).
|
||||
|
||||
**Before -> after** (test_safety87.py, rig of 2 x 2500 W, aim 3450 W):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| charge, A 11 %, house 4500 | 3 reserve alarms, quarter 3450 | 0 alarms, 3450 |
|
||||
| HOLD, A 99 % / B 15 %, house 5500 | A 0 W, B 2488 W, 3 alarms | A 2054 W, B 0 W, 0 alarms, 3450 |
|
||||
| HOLD, house 6500 | A 0 W, B 2488 W, 4051 W | A 2500 W, B 0 W, 4014 W, 1 alarm (A alone is short) |
|
||||
| HOLD, month peak 4000, house 6000 | A 0 W, B 2488 W, 3551 W, alarms | A 2054 W, B 0 W, 3950 (peak - margin), 0 alarms |
|
||||
| both under the reserve | alarm | alarm (unchanged) |
|
||||
| N=1, every scenario | | byte-identical |
|
||||
|
||||
**Open, for the owner.**
|
||||
- The loop still draws a held battery (SAFETY-25 option A: the reserve binds
|
||||
the peak guard only). It gives last, after the able ones. So with house 6500
|
||||
in the charge case, B alone cannot hold the aim, the alarm fires as
|
||||
specified, and the loop then lands the quarter on the aim from A. The text
|
||||
says "cannot keep … alone", not "will go over", so it stays true.
|
||||
- Battery modes on, A Off and B held: when B alone can place the floor, OPT-11
|
||||
keeps A resting and B gives it. When B cannot, A gives first. Making Off give
|
||||
before a held battery in the first case needs a third allocator tier. The
|
||||
same at N=3: A Off, B held, C able, floor 4000 W gives C 2500, B 1500, A 0.
|
||||
Until then, with modes on, a held battery can give for the peak.
|
||||
- The notification and row text live in `messages.py`, which has no NL layer
|
||||
yet (UX-19). The Home page text, which has NL, is unchanged.
|
||||
|
||||
**Not changed.** N=1. 'Load too high' (SAFETY-81). SAFETY-85, -88, -89, -90.
|
||||
@@ -0,0 +1,81 @@
|
||||
# SAFETY-90 - the peak guard goes on after a P1 meter gap
|
||||
|
||||
**Status: DEV 2026-10-05, for review.** Tests: `test_safety90.py`.
|
||||
|
||||
**The defect.** A meter gap longer than `meter_max_age_s` leaves seconds the
|
||||
quarter average does not count (`p1.QuarterAverager`, on purpose: a guess must
|
||||
not reach the bill record). `peakguard.blindness()` then called the projection
|
||||
blind for the rest of the quarter, so the floor was 0 W. During a HOLD the
|
||||
battery gave nothing and the quarter went over: SAFETY-86 rig case 7b, N=2,
|
||||
3964.8 W against a 3450 W aim, the month peak raised; N=1, 3701 W (review).
|
||||
The page still read "Keeping the grid near zero". At start-up mid-quarter the
|
||||
same rule left the start-up quarter blind, and a held battery was drawn as last
|
||||
resort (SAFETY-91 test, O2).
|
||||
|
||||
**The fix** (`peakguard.fill_gaps()`, `peakguard.gap_w()`,
|
||||
`Controller._fill_peak_gaps()`).
|
||||
- Once readings are back, every second of a silence longer than
|
||||
`meter_max_age_s` counts at `gap_w = max(the limit, the import just before
|
||||
the gap, the import just after it)`, where "import" is the grid plus the
|
||||
batteries' discharge, i.e. with the batteries at 0 W. The gap's first
|
||||
`meter_max_age_s`, which the averager credits at the last reading, is topped
|
||||
up to `gap_w` too. The guard then grades the quarter as usual, so it is not
|
||||
blind and gives what keeps the quarter at the aim.
|
||||
- **The bound.** The count is at or above the true import as long as, during
|
||||
the gap, the house load (less solar) stayed at or under the higher of the loads seen just
|
||||
before and just after it, or under the limit, and the batteries did not
|
||||
start charging (during a gap they hold their last command, then go to 0 W).
|
||||
It never counts the gap below the limit. A load that rises above all three
|
||||
only inside the gap is the one case it can miss.
|
||||
- **Cost of the bound.** It is conservative: with the house at 6500 W, a 30 s
|
||||
to 5 min gap lands the quarter 100-180 W under the aim (table below), paid in
|
||||
battery energy.
|
||||
- **While the gap lasts** nothing changes: the reading is too old to steer by,
|
||||
the guard is blind (floor 0 W) and the "inputs missing" stop sets 0 W, as
|
||||
before.
|
||||
- **Before the first reading** (start-up or restart mid-quarter) the
|
||||
add-on was down, so the batteries were at 0 W (it sets 0 W when it stops,
|
||||
the ESP32 watchdog after 30 s). Those seconds count like a gap, at
|
||||
`gap_w(the limit, the import seen first)`; after a restart the restored
|
||||
reading's held `meter_max_age_s` is topped up the same way. The bound is
|
||||
the gap's, with only the "after" side known. A load the batteries cannot
|
||||
make up then raises "Load too high", which is true: that quarter is going
|
||||
over. The page says so: the status row "Peak guard: part of this
|
||||
15-minute period was not measured, counted as high use" and the home line
|
||||
"Part of this 15-minute period was not measured." / "Een deel van dit
|
||||
kwartier is niet gemeten." until the quarter ends.
|
||||
*Review:* the first version counted them at the aim, with "nothing is
|
||||
known" as the reason. The first reading does show the load, and with the
|
||||
batteries at 0 W the import was that load, not the aim: a 60 s restart
|
||||
during a 6500 W HOLD landed 3653 W against 3450 W.
|
||||
- The bill record (`quarter_blocks`) is unchanged: it still banks only what
|
||||
was measured. The estimate steers the guard and is never banked.
|
||||
- The action log gets one `PEAK_GUARD_GAP` row per gap, with the seconds and
|
||||
the watts it was counted at.
|
||||
- No option. The threshold is `meter_max_age_s`, the limit and the aim are
|
||||
`peak_cap_w` and `peak_margin_w`.
|
||||
|
||||
**Before -> after** (`test_safety90.py`, quarter as the meter really averages
|
||||
it, gap starting 300 s in; 10 s battery lag; aim 3450 W):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| N=1 HOLD 6500 W, gap 30 s / 60 s | 3456.6 / 5329.2 | 3346.2 / 3272.2 |
|
||||
| N=1 HOLD 5500 W, gap 5 min | 4717.9 | 3330.6 |
|
||||
| N=2 HOLD, B able, 6500 W, gap 30 s / 60 s | 3456.6 / 3865.6 | 3346.3 / 3272.7 |
|
||||
| N=2 HOLD, B able, 5500 W, gap 5 min | 3745.4 | 3288.9 |
|
||||
| N=2 HOLD, B held, 5500 W, gap 60 s | 3254.9, B drawn 2500 W | 3335.4, B 0 W |
|
||||
| gap across the edge, next quarter, N=1 / N=2 | 4100.0 / 3621.2 | 3446.3 / 3382.2 |
|
||||
| house 3000 -> 6500 W inside a 60 s gap, N=1 | 5255.6 | 3368.3 |
|
||||
| house 6500 -> 2000 W late in a 5 min gap, N=1 | 3629.2 | 3145.1 |
|
||||
| start-up 450 s in, N=1, house 5000 W (batteries at 0 W before it) | 5000.0 | 3450.0 |
|
||||
| restart, down 60 s / 150 s, N=1, house 6500 W | 4039.1 / 5483.3 | 3450.0 / 3450.0 |
|
||||
| meter never drops, N=1 and N=2 | | rows identical |
|
||||
|
||||
**Not changed.** The late-reading edge (SAFETY-91 test, O1): a reading late by
|
||||
less than `meter_max_age_s` is not a gap, and the seconds since it are not
|
||||
counted in the projection (on any quarter, not only after a gap). Near a
|
||||
quarter's end the 900/remaining gain turns that into a large negative excess
|
||||
and the floor drops for a cycle or more. Counting those seconds at the held
|
||||
reading would fix it, but it changes every quarter's last seconds on a
|
||||
healthy meter; it is handed back as its own card.
|
||||
@@ -0,0 +1,74 @@
|
||||
# SAFETY-91 - a held battery is drawn only after the others are full
|
||||
|
||||
**Status: DECIDED 2026-10-01 (owner: held battery last, SAFETY-25 option A
|
||||
kept).** Follows SAFETY-87. Tests: `test_safety91.py`.
|
||||
|
||||
**The defect** (SAFETY-87 rig test, F1 and F2). At N>=2 a battery at or under
|
||||
its own peak reserve was held back (weight 0) only on cycles where the peak
|
||||
guard asked for power.
|
||||
- F1: B draining at 1000 W, A held at 15 %, house 5500. Once the grid was
|
||||
near zero the loop set the target, A had its normal weight and B's drain
|
||||
pin bound, so the loop drew A at 2500 W for the whole quarter (15.0 ->
|
||||
10.7 %) while B stayed at 1000 W. In the next quarter A reached its
|
||||
cut-out, and B stayed pinned at the peak floor (~2422 W), not its 2500 W.
|
||||
- F2: in a HOLD (A holding, B held), at a quarter's edge the guard saw a block
|
||||
with no seconds left and asked for nothing for one cycle. The HOLD's
|
||||
ceiling lapsed, A went back to 0 W and the loop's target went to B: 2500 W
|
||||
for 1 s.
|
||||
|
||||
**The fix** (`main.py`, N>=2 only).
|
||||
- `_held_back()`: the batteries at or under their own reserve while another
|
||||
can still give (live, not refusing), graded as in `_peak_on_able()`. They
|
||||
weigh 0 in every split where the site gives, not only for the peak. With
|
||||
nobody able (all held, or the rest refusing or dropped) nobody is held back
|
||||
and the split is as before.
|
||||
Review: once held back, a battery stays so until its SoC reads more than 1
|
||||
point above its reserve. A reading flipping 20/21 % otherwise swapped
|
||||
~1000 W between the batteries on each flip (grid +-500 W with A 10 s and
|
||||
B 3 s lag). The guard's own permission (SAFETY-87) is unchanged.
|
||||
- `_drain_rise()`: while a battery is held back, a draining battery's pin
|
||||
becomes a floor at its drain rate (`lo`), so it rises towards its rating
|
||||
before the held one gives. Not for a held battery's own drain, not under a
|
||||
site stop, not for a battery that is not live.
|
||||
- `tariff.next_quarter()`: past a block's end, before the next block's first
|
||||
telegram, the guard grades the next quarter, projected from the held
|
||||
reading (the averager credits it there). Not when that reading is too old
|
||||
to be credited (`max_hold_s`).
|
||||
|
||||
**What stays.** SAFETY-25 option A: once every able or draining battery is
|
||||
at full power, the loop still draws a held battery (`allocator._fill()`'s
|
||||
last-resort pass). With house 5500 and 2 x 2500 W, A still gives 2500 W
|
||||
after B is full. Nothing is placed on a battery at its cut-out: OPT-09's
|
||||
pack limit closes it (`hi = 0`), and the drain rise lets B take the rest.
|
||||
|
||||
**Before -> after** (`test_safety91.py`, 2 x 2500 W, lagging battery 10 s):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| B drain 1000, A held, house 4000 | A 2500, B 1000 | A 1500, B 2500 |
|
||||
| same, house 5500 | A 2500, B 1000 | A 2500, B 2500 (option A) |
|
||||
| cycles A asked while B not full | 612 of 900 | 0 |
|
||||
| A at its cut-out, house 5500: B | 1572 W mean | 2500 W, A 0 W |
|
||||
| HOLD, late telegram at the edge | A 0, B 2470 for 1 cycle | A 2050, B 0 |
|
||||
| idle, A held, house 2000 | A 1005, B 1005 | A 0, B 2020 |
|
||||
| N=1, every scenario | | byte-identical |
|
||||
|
||||
**Why N>=2 only.** At N=1 there is no other battery to give first. The edge
|
||||
roll would also fix the N=1 one-cycle dip at a quarter's edge (SAFETY-86
|
||||
rig, "A dropped to 0 W for 1 s"), but this ticket keeps N=1 byte-identical;
|
||||
extending it is a one-line change for a later ticket.
|
||||
|
||||
**Re-pinned.** `test_modes.py` `n2` and `n2_off`: from t=1600 battery A is
|
||||
at its 20 % reserve, so B carries the house and A gives last (100 of 540
|
||||
cycles differ). With SAFETY-87's held rule both are a5dddad's again, and the
|
||||
test grades that.
|
||||
Two fixtures changed with it: `test_safety87.py` part 10's "without the
|
||||
raise" run also turns off `_drain_rise()` (else B rises anyway), and
|
||||
`test_peakguard.py` 10g and `test_watchdog.py`'s two-inverter rig give
|
||||
their second battery a cut-out, as rule 8 requires (an unknown cut-out is held, so A alone would carry the cycle).
|
||||
|
||||
**Not changed.** The peak guard's own rules (SAFETY-87). A drain with
|
||||
nobody held stays a pin (SAFETY-78). An Off battery beside a held one
|
||||
(SAFETY-87 Open). The first cycle after start, before any quarter is
|
||||
measured, can still put the loop's first step on a held battery in a HOLD.
|
||||
SAFETY-88, -89, -90, -92, -93, -94.
|
||||
@@ -0,0 +1,60 @@
|
||||
# SAFETY-93 - the maintenance charge end hands back smoothly
|
||||
|
||||
**Status: implemented 2026-10-02, for review.** Tests: `test_safety93.py`.
|
||||
|
||||
**The defect** (SAFETY-85 rig test, check 4; also on 9358781). At N=2, when
|
||||
A's maintenance charge ended, B went 0 -> -1600 W in one cycle and charged
|
||||
from the grid with the house at 6000 W. The HOLD end was already smooth
|
||||
(SAFETY-85).
|
||||
|
||||
**Cause.** During the charge the site target holds A's charge (SAFETY-84's
|
||||
`charge` ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the
|
||||
loop starts its next step from last cycle's site target. The slew limit
|
||||
moves the site total by 400 W (-2000 -> -1600), and the only battery left
|
||||
to place it on is B. The same happens when the charge times out or the
|
||||
battery is switched Off (A free, its -2000 W shared out). With the charge
|
||||
suspended by the guard and A giving under the loop, B took A's share in
|
||||
one step (675 W).
|
||||
|
||||
**The fix** (`main.py`, `_reseed_charge_end()`, N>=2 only). On the cycle a
|
||||
battery leaves CHARGE, the loop's start point (`self.target`, and the
|
||||
integrator with it) is what the other batteries stood at plus that
|
||||
battery's new command: its pin (HOLD or abort, 0 W), or, if it is free, its
|
||||
last discharge (a charge stops). The loop then moves from there one slew
|
||||
step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's
|
||||
pin, as before; it lowers the grid import. The end is kept until the loop
|
||||
next runs, so a missed poll on that cycle (the input hold) does not lose it.
|
||||
|
||||
Review: a charge also ends, for the site, when the battery is still in
|
||||
CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73)
|
||||
drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a
|
||||
1200 W house). So the end is noted after the unit claims are filed: a
|
||||
battery leaves the charge when it leaves CHARGE, is not live, or its own
|
||||
claims contradict. The guard suspending the charge (SAFETY-84) is no end.
|
||||
|
||||
Why a start-point reseed, and not a per-battery slew in the allocator: the
|
||||
jump comes from one stale number at one edge, the kind of edge SAFETY-73
|
||||
(`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed.
|
||||
The change stays at that edge; nothing else at N>=2 moves.
|
||||
|
||||
**Before -> after** (`test_safety93.py`, 2 x 2500 W, 10 s lag, 2 s meter):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| rig case: house 6000, A charges 2000 to the end | B 0 -> -1600, charges 4 cycles | B 0 -> 400 -> 800 ..., never charges |
|
||||
| house 1200 | B 0 -> -1600 (2800 W from the grid) | B steps of 400, never charges |
|
||||
| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 |
|
||||
| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W |
|
||||
| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 |
|
||||
| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 |
|
||||
| N=1, both charge ends above | | byte-identical |
|
||||
|
||||
No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and
|
||||
N=2) are byte-identical to f3e409c; none of their scenarios reaches a
|
||||
charge end.
|
||||
|
||||
**Not changed.** The peak guard's floor still moves the site in one step
|
||||
when it engages (a house step, a quarter edge): the guard outranks the slew
|
||||
(owner order). A HOLD pin still drops the holding battery to 0 W at once
|
||||
and SAFETY-86 gives way to the others first. SAFETY-88 (charge flapping
|
||||
above the aim) is untouched.
|
||||
@@ -0,0 +1,102 @@
|
||||
# SAFETY-94 - a battery that lags or reverses is not frozen or refusing
|
||||
|
||||
**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py`
|
||||
(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open.
|
||||
|
||||
## The defects
|
||||
|
||||
1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last
|
||||
change. After a rest that clock was already past 35 s, so the first read
|
||||
after a new command tripped it, before the register could answer. Live
|
||||
2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a
|
||||
rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was
|
||||
1 s after its command; each register answered within 11 s. This is
|
||||
SAFETY-58's shape.
|
||||
2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES`
|
||||
frozen cycles if the reading was under 100 W at that moment. The frozen
|
||||
cycles include a reversal through the battery's lag. At a 1 s meter the
|
||||
12th cycle comes as a battery reversing out of a charge passes 0 W
|
||||
(SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held
|
||||
0 W, the battery settled at 0 W, never gave 100 W, and waited for the
|
||||
15-minute probe. N>1 already required 22 s in the band, so it was safe
|
||||
at a 10 s lag but not at 20 s.
|
||||
|
||||
## What changed
|
||||
|
||||
- **The gate counts only time the register was asked to move**
|
||||
(`freshness.Freshness._oblige`). A read counts the time since the last
|
||||
read when, at that read, the command is 250 W or more and has moved 250 W
|
||||
from where it stood at the register's last change. A register change
|
||||
(or a SAFETY-57 release) restarts the count. A command flapping in and
|
||||
out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip
|
||||
at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged.
|
||||
- **The bound is unchanged at 35 s and now means what its derivation
|
||||
says.** The floor was always "how long a healthy register takes to answer
|
||||
a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band
|
||||
`[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3,
|
||||
6 and 11 s after the command.
|
||||
- **Refusal evidence is the same at every roster size**
|
||||
(`main.Controller._gave_nothing`, SAFETY-73's count moved into a method).
|
||||
The reading must stay under 100 W, one way, while asked over 200 W, for 12
|
||||
cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and
|
||||
needs it on top of its own freeze test. `None` (compute alone) keeps the
|
||||
old law.
|
||||
- **The evidence restarts when the reading moves 50 W towards the ask**
|
||||
(`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live
|
||||
readings wander by under 25 W at rest. A first-order lag crossing the band
|
||||
gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s.
|
||||
This also covers B, whose reading is 100 W below its power.
|
||||
|
||||
## Bounds and worst-case catch
|
||||
|
||||
| fault | before | after | budget |
|
||||
|---|---|---|---|
|
||||
| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds |
|
||||
| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same |
|
||||
| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) |
|
||||
| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged |
|
||||
| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s |
|
||||
|
||||
Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes
|
||||
the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
|
||||
|
||||
## Before -> after
|
||||
|
||||
- Live replay (part 1): 4 trips -> 0.
|
||||
- Refusing B (its -100 W idle reading): see SAFETY-97.
|
||||
- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs
|
||||
(part 2): before 27 stale trips and 1 refusal, after 0 and 0.
|
||||
- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3
|
||||
refused, N=2 6 / 1; after 0 / 0.
|
||||
- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips
|
||||
answered within 20 s: 21 -> 1. Two of the removed trips were not quick:
|
||||
a register held 351 s under a command hovering at the 250 W travel edge
|
||||
(under 35 s of asking before it moved; survivor 2's shape), and a
|
||||
refusing battery the gate had caught only on a passing ramp (SAFETY-52's
|
||||
refusal covers it).
|
||||
|
||||
## SAFETY-58 and SAFETY-74
|
||||
|
||||
- **SAFETY-58 is fixed** by this change: its card asks for the clock to
|
||||
start with the obligation and for the bound to be re-derived. Both are
|
||||
done above. `test_freshness.py` survivor 5 is now graded as closed.
|
||||
- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly
|
||||
flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2
|
||||
before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a
|
||||
command that moved is exactly what the gate is for; telling "limited"
|
||||
from "frozen" needs a second signal (the meter, or SoC).
|
||||
|
||||
## Known gaps
|
||||
|
||||
- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
|
||||
100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2
|
||||
as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred
|
||||
on each unit's learned idle reading. `SAFETY-97-idle-offset.md`.
|
||||
- A command that asks only part of the time (a fraction d) is caught after
|
||||
35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s).
|
||||
SAFETY-30's per-unit check restarts when its command stops opposing the
|
||||
held reading, which a lapsing ask normally does, so 35 < 85 holds there.
|
||||
SAFETY-31's circulation check (75 s) ignores the command, so its margin
|
||||
holds only while d >= 0.47.
|
||||
- A register frozen under a command that stays near the 250 W travel edge
|
||||
is caught late or not at all (survivor 2).
|
||||
@@ -0,0 +1,118 @@
|
||||
# SAFETY-97 - "gives nothing" is graded against each battery's idle reading
|
||||
|
||||
**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for
|
||||
review.** Owner decision 2026-10-04: the band's centre is clamped to
|
||||
+-75 W (see Bounds). Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`).
|
||||
Closes SAFETY-94 review finding F1.
|
||||
|
||||
## The defect
|
||||
|
||||
B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W
|
||||
at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W.
|
||||
Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing
|
||||
B sat on the band's edge and never latched:
|
||||
|
||||
- N=2: never caught, before or after SAFETY-94.
|
||||
- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared
|
||||
rule (12 cycles in the band in a row) never did (review probe, 1200 s).
|
||||
|
||||
## Design
|
||||
|
||||
- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No
|
||||
new option: a required one is a setup step every client gets wrong, and an
|
||||
optional one is a second source to reconcile. An override can be added
|
||||
later if a site needs one.
|
||||
- **When it learns:** control runs; the unit is not refused (unit or site),
|
||||
stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for
|
||||
120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more
|
||||
than 50 W, and while refused it does not learn, so its refusal cannot be
|
||||
learned as idle. A capped battery under a big ask is not sampled either.
|
||||
- **How:** the first value is the mean of 120 s of samples; then it follows
|
||||
drift with a 30 min time constant. A sample beyond 250 W (`IDLE_SAMPLE_W`)
|
||||
is dropped (a register held at a giving value). The band is centred on
|
||||
the value clamped to 75 W (`IDLE_MAX_W`; owner decision below).
|
||||
- **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each
|
||||
5 W move) and restored, clamped to 250 W. Without it a battery refusing from the
|
||||
start is never quiet, so never learned, so never caught.
|
||||
- **Until learned the idle reading is 0 W**: the rule as it was.
|
||||
|
||||
## Not chosen: the GoodWe LAN integration's DC battery power
|
||||
|
||||
Both inverters also appear through HA's GoodWe integration (B:
|
||||
`sensor.kelder_dyness_battery_power`, 0 W at idle; found read-only on the
|
||||
live HA 2026-10-04). Pointing `batt_entity` at it removes B's offset by
|
||||
configuration, but:
|
||||
|
||||
- the band must be safe without it: other installs may not have it;
|
||||
- it is polled over UDP on the inverter's Wi-Fi, a second channel that can
|
||||
drop while the board keeps reading (HW-01 lost both once); a missing
|
||||
reading stops the site;
|
||||
- SAFETY-37's 35 s bound is derived from the board's 10 s publish; the
|
||||
integration's poll interval (believed 10 s, not checked here) and latency
|
||||
are not measured;
|
||||
- DC power is not 0x0518, which SAFETY-30/31/42 were measured against.
|
||||
|
||||
It stays an install choice to test on the rig, not a code change here. With
|
||||
it, the learned idle reading is simply ~0 W.
|
||||
|
||||
## Where it applies
|
||||
|
||||
Every test of "gives nothing / gives / takes" now uses `reading - idle`:
|
||||
|
||||
| check | change |
|
||||
|---|---|
|
||||
| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred |
|
||||
| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred |
|
||||
| N=1 end in `_refusal_update` | centred |
|
||||
| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once |
|
||||
| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred |
|
||||
| SAFETY-42 "parked" (small ask, gives nothing) | centred |
|
||||
| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level |
|
||||
| SAFETY-57 release | unchanged: it reads the command only |
|
||||
| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone |
|
||||
| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) |
|
||||
|
||||
## Bounds: owner decision 2026-10-04 (review R1)
|
||||
|
||||
Band: `|reading - centre| < 100 W`, centre = idle clamped to +-75 W.
|
||||
|
||||
- **Why a clamp under the band.** A wrong idle reading (taught during a
|
||||
rest while the reading shows something else, e.g. a grid top-up of a unit
|
||||
held at 0 W, or restored onto a swapped inverter) more than about 100 W off
|
||||
hid every refusal on that battery, at N=1 and N=2; with the first +-250 W
|
||||
clamp the rig missed a refusing A whenever the error was 110-250 W. At
|
||||
+-75 W the band always keeps 0 W +-25 W, so a battery reading about 0 W
|
||||
while refusing is caught whatever was learned or restored, and a wrong idle
|
||||
cannot refuse a healthy 0 W battery (its ask is over 200 W).
|
||||
- **The trade-off.** Options: keep +-250 W (covers idle readings to about
|
||||
+-350 W, any 100 W mis-learn hides refusals); clamp to +-75 W (chosen);
|
||||
"old band or centred band" (rejected: a correct B would need to give 200 W
|
||||
to answer its 200 W probe).
|
||||
- **Cost.** A battery whose idle reading is beyond about +-170 W is centred
|
||||
at most 75 W out, so its refusal falls outside the band: it behaves as in
|
||||
0.14.0 (not caught by this rule). B (-95 to -118 W) reads 20-43 W from its
|
||||
clamped centre. B is caught across -150..+90 W of error in its idle
|
||||
reading; learned 110 W or more too high it is missed, as in 0.14.0 at N=2.
|
||||
- A frozen register is still caught by the stale gate at 35 s of asking.
|
||||
- Recovery from a wrong value needs quiet time: tau 30 min.
|
||||
|
||||
## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W)
|
||||
|
||||
| refusing | meter 1 s | 2 s | 5 s |
|
||||
|---|---|---|---|
|
||||
| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) |
|
||||
| A, N=2 | 28 (28) | 28 (28) | 110 (110) |
|
||||
| B, N=1 | 28 (never) | 28 (never) | 65 (never) |
|
||||
| B, N=2 | 24 (never) | 24 (never) | 60 (never) |
|
||||
|
||||
A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once
|
||||
A is cut, so the loop cycles on the 15 s floor; the same run on the PR before
|
||||
SAFETY-97 gives 110 s. B restored after a restart, refusing from the start:
|
||||
24 s; with nothing saved: never.
|
||||
|
||||
## Before -> after
|
||||
|
||||
- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s
|
||||
meters, both register shapes, idle learned or restored (96 runs): 0 stale,
|
||||
0 refused. Reversals, lag 10/20 s, idle restored: 0.
|
||||
- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched).
|
||||
Reference in New Issue
Block a user