Compare commits

..
4 Commits
Author SHA1 Message Date
glenn schrooyenandClaude 2bbf88862a MAINT-20 part 2: main.py keeps the FCST-04 windowing caveat a test pins
checks / unit (pull_request) Successful in 25m56s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-01 04:00:00 +01:00
glenn schrooyenandClaude 3d31b679eb MAINT-20 part 2: main.py: keep whole sentences, no truncated warnings; refresh archive
checks / unit (pull_request) Failing after 24m55s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-01 03:33:54 +01:00
glenn schrooyenandClaude 6889667fcd MAINT-20 part 2: main.py comments and docstrings cut to a line and a pointer; DECISIONS MAIN-orchestration and its archive
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-01 03:32:54 +01:00
glenn schrooyenandClaude 0fb4c2b5e2 MAINT-20 part 2: same-code script checks -OO code, added/removed files, allowed paths, docstring list
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-01 03:27:45 +01:00
21 changed files with 3924 additions and 5359 deletions
+6 -114
View File
@@ -1,121 +1,13 @@
# Changelog
## 0.20.0
### Not in a release yet
- When Home Assistant passes readings on late, control no longer swings on them: it waits until the battery shows its last power request, and the peak limit no longer asks for full power on them. The page says so, and if the readings stay too late every inverter goes to 0 W ([[SAFETY-128]]).
**A minor bump: a Forecast tab, a night grid charge sized to what the sun will not give, a kept evening forecast, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options). It is a minor bump because Leftover solar now buys different amounts from the grid and the night charge keeps its level when the forecast goes quiet; every saved setting still starts.
**Existing installs need no option change:** options that start 0.19.2 also
start 0.20.0. The changes below apply with one inverter or two.
**A Forecast tab.** A new tab next to Home and Batteries shows today and tomorrow hour by hour: the solar forecast, the sun measured so far, the house's expected use, and each battery's level with its planned charge and give blocks. It shows what the add-on plans and changes no command.
**Leftover solar buys what the sun will not give.** On a day/night tariff the night grid charge now covers the expensive hours' use less the sun counted for them, where it used to buy for the whole day. A forecast with only a daily total, such as Forecast.Solar, is no longer counted several times over by day, so Leftover solar no longer charges too little because of it.
**The evening forecast is kept for the night.** Forecast.Solar-type sensors stop updating in the evening. The last fresh reading is now kept until 06:00, so the night cheap run is judged with the forecast's price gap and Leftover solar still plans its charge. A newer reading replaces it at once, and a charge that is running keeps its planned level if the figure ends.
**No false peak alarm during the night grid charge.** "Battery stopped limiting the peak", and its notification, no longer appear while the 15-minute period stays within 10 W of the aim.
Contents of this release, in merge order since 0.19.2 (`f1c9733`):
| | |
|---|---|
| `a1d0703` | Home now draws a grid charge only where the add-on will take it, with battery modes on or off; before, it could show the rest of a refused cheap run, or a charge after the last dear period. The price texts say "lowest price left" and "highest price left", and no command changes ([[UX-53]], #209) |
| `f5afb42` | The day planner's hour loop now always ends on the night the clocks go forward. Without time-zone data it could go round forever and freeze the add-on; the add-on's own image has that data ([[SAFETY-123]], #211) |
| `4a1f6cc` | A new Forecast tab shows today and tomorrow hour by hour: the sun, the house and each battery's level, with the charge and give blocks Home would draw. It shows the plan and changes no command; notes say when a part is not known yet ([[UX-54]], #210) |
| `6be7e41` | The add-on's test files now run six at a time in the checks, so a check takes about 15 to 25 minutes, where one by one it ran past an hour. No change in behaviour ([[CI-15]], #215) |
| `b6be312` | The day plan counts the sun right: a forecast with only a daily total is no longer counted several times over. On a day/night tariff the night charge for Leftover solar covers the expensive hours' use less the sun counted for them, shared between batteries by capacity, and the screens say when another battery or the grid covers the rest ([[OPT-23]], [[OPT-28]], #218) |
| `cc9bbeb` | When a solar forecast sensor stops updating in the evening, the night plan uses its last fresh reading, for the day it is for only. One missed reading no longer drops the forecast at once, and the pages say when a figure is kept ([[OPT-27]], #219) |
| `deee98d` | The night grid charge under the peak limit no longer raises a false "Battery stopped limiting the peak" alarm when the 15-minute period stays within 10 W of the aim. A real overrun still alarms; a steadily rising load alarms a little later, and with a peak margin under 10 W a period can end up to 10 W over the limit without it ([[SAFETY-126]], #221) |
| `8afadbd`, `cdafa2c` | The kept evening figure now counts until 06:00, not until 03:00, and a charge that is running keeps its planned level when the figure ends. A newer reading replaces the evening figure at once and for good, so it does not come back later in the night ([[OPT-34]], #222, with its fix #225) |
| `d4c01da` | Comments and docstrings in the peak guard and the price files were cut to a line and a pointer to the decision notes. No change in behaviour: a check shows the code itself is the same ([[MAINT-20]] part 1, #223) |
The merge that brought 0.19.1 and 0.19.2 into this branch (`f1b7244`, #220) adds nothing else; the Forecast tab uses the same price gap as control since it.
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s. This is about the guard's discharge only; a grid charge is cut back since 0.19.2 ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
- A steady overload on a 1 s meter can warn, and send the Home Assistant notification, 2 or 3 times at the start of each 15-minute period instead of once. The peak limit is still kept ([[SAFETY-122]]).
- A fixed tariff is priced once per hour, so a price window that starts or ends at :15, :30 or :45 gets the wrong price for part of that hour. Windows that start and end on the hour are right ([[TARIFF-23]]).
- Step 6 of the setup wizard stays empty because of an error in the page, in English and in Dutch ([[UX-51]]).
- On Dutch pages, refusal texts, the last-event line under the Home buttons, the installer details and the trading status sentences are still English, and a few Dutch texts read badly, such as "1 metingen geweigerd" ([[UX-52]]).
- Some price texts say "hour" where the add-on acts on a 15-minute period, such as "cheapest hour" ([[UX-47]]).
- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]).
- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]).
- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]).
- Control acts on Home Assistant readings that arrive late. With readings 10 s behind, the give run can swing and send up to 1.9 kW to the grid for about 50 s in a rig test; with readings 30 s behind, a battery can charge from the grid in an expensive period. Also in 0.19.2 ([[SAFETY-128]]).
- In the last second of a 15-minute period, being a few watts over the aim can make the peak guard ask a battery for full power for one second. When a charge ends on a period edge, the last-second give can fade out over about 10 s, and a rig test exported up to 7.4 kW for about 8 s ([[SAFETY-124]]).
- During the night grid charge, a 15-minute period can end 6 to 14 W over the peak aim, because the battery charges harder than asked. This was seen in simulation; a rig test saw 2 to 8 W ([[OPT-35]]).
- The test rig could not bring back the false peak alarm that [[SAFETY-126]] fixes, so that fix is shown in simulation only. Watch the notifications for a few nights ([[TEST-35]]).
- The night cheap run is sized for the batteries' full charging power, not for what the peak limit leaves. Under a tight limit the run can end before the batteries reach their level, and Home can show a level they do not reach (79.9 % against a shown 95 % in a rig test) ([[OPT-25]]).
- By day, a Leftover solar plan that has only a daily forecast figure counts the sun from now, not from when the figure was read. It counts about 22 % too much for a figure one hour old; the night plan is not affected ([[OPT-30]]).
- The night charge can buy too much in a house that uses more at night than by day, such as one with a car charger or heat pump, and on a sunny day, because spare sun in one expensive hour does not count for the later ones ([[OPT-31]]).
- A cheap run that has already started can overlap a new give run after a re-plan, so a battery can charge from the grid at the dearest price for part of an hour. This was seen in a test case, not on a site. Also in 0.19.2 ([[OPT-32]]).
- If the solar forecast sensors stop updating before 17:00, as in winter or further north, there is no forecast from three hours after their last update until 20:00 ([[OPT-33]]).
- With a forecast that gives only a daily total, the sun is spread an hour off on the two clock-change days after the change. The day's total is right ([[OPT-24]]).
- The add-on learns the house's use only once a solar forecast is set, so a site that adds a forecast later waits one more day for its first plan ([[OPT-26]]).
- Home says "No trading on price yet: no prices have arrived" when every battery is on Solar first, though the prices are shown ([[UX-55]]).
- The Home plan header can say "Charges in the cheapest hour, gives power in the dearest" when Home draws no charge, and it says "hour" for 15-minute prices ([[UX-56]]).
- While the peak guard holds back charging, Home hides the rest of the cheap run and gives no reason. The charge starts again when the guard lets go ([[UX-57]]).
- With battery modes on and selling on, Home can draw a "Sells up to" block when the add-on judges the trade not worth it, and then it will not sell ([[UX-58]]).
- Home, the plan and the Forecast tab stop drawing a battery's give at the trading floor, though control goes on giving down to the battery's cut-out ([[UX-59]]).
- During a price outage, the Batteries screen says "Prices differ too little" and Home says "The price gap is too small" ([[UX-60]]).
- With battery modes on and no meter, or a meter that is a Home Assistant entity, a grid charge is not possible and nothing says so ([[UX-61]]).
- On the Batteries screen, a charge the add-on refused in a cheap run reads as "Saves its power for ...", for up to 15 minutes ([[UX-62]]).
- A Leftover solar battery with no capacity set never charges from the grid when a solar forecast is set, and nothing warns about it ([[UX-63]]).
- The "How the modes work" box on the Batteries screen does not describe Cheap hours only ([[UX-64]]).
- With battery modes on, Settings still shows "Trade on the price spread", which the modes ignore. A site with modes on and no price source starts, and the Batteries screen says "No prices, so it follows the house" without pointing to Settings ([[UX-65]]).
- With battery modes off and several equal prices in a row, Home and the Forecast tab draw one price quarter, while the add-on trades every quarter of the run ([[UX-67]]).
- Late in the day, the Forecast tab's "now" line and last bars sit over the axis labels at the right edge ([[UX-68]]).
- The Forecast tab's level curve charges at each battery's full rating. Under a peak limit the levels it draws are too high and a charge block can end early (54.6 % shown against 39.7 % in a rig test) ([[UX-70]]).
- The peak forecast check says maintenance charging is not kept under the peak limit when no peak forecast is set, but since 0.19.2 it is ([[UX-73]]).
- The peak guard row can show the same figure twice, such as "at this rate 3 500 W, over 3 500 W". Also in 0.19.2 ([[UX-74]]).
- If the inverter's setpoint entity appears in Home Assistant after the add-on has started, the page keeps saying "Inverter control: not found in Home Assistant" and the batteries say "An alarm stopped control" until a restart, though control runs ([[UX-75]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.19.2
**A patch bump: one fix for charging from the grid under the peak limit, and no option changes.**
### Not in a release yet
- The night grid charge no longer raises "Battery stopped limiting the peak" when the 15-minute period stays on the aim. A steadily rising load now alarms a little later, and with a peak margin of 0 a period can end up to 10 W over the limit without this alarm ([[SAFETY-126]]).
- A night grid charge for *Leftover solar* no longer stops at 03:00 when the solar forecast sensors went quiet in the evening. The evening's figure is used until 06:00, and a charge that has started goes on to its level ([[OPT-34]]).
**A patch bump: two fixes, one for charging from the grid under the peak limit and one for the night charge of Leftover solar, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options).
**Existing installs need no option change:** options that start 0.19.1 also
start 0.19.2. The change below applies with one inverter or two.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
# MAIN - why app/main.py is built the way it is
**Status: SHIPPED.** Written down by MAINT-20 part 2 from the long comments and
docstrings of `app/main.py`, so the module can keep one line and a pointer. The
comments that were shortened but not rewritten here are kept word for word in
[[MAIN-orchestration-archive]], by their line in `app/main.py` at release/1.0
`d4c01da`. Related: [[SAFETY-07-peak-guard]], [[FCST-04-the-day-that-is-carried]],
[[SAFETY-30-per-unit-sign]], [[SAFETY-73-refusal-per-inverter]],
[[SAFETY-121-bound-every-quarter]], [[TARIFF-10-fetching-is-not-trading]],
[[TARIFF-24-modes-need-the-tariff-task]], [[OPT-34-the-night-charge-keeps-its-level]].
## Controller construction
- **Per-write budget (DEV-02).** Each write is dispatched concurrently and bounded, so one hung `hass.call()` cannot eat a sibling's layer-1 budget (the ESP32 zeroes after about 30 s, and a fault on A could zero a healthy B). The number is derived: `heartbeat_s` max 25 s, layer-1 veto `validate.FIRMWARE_INGEST_VETO_S` 30 s, so 25 + 4 < 30. test_inverter.py grades the inequality, so raising the heartbeat ceiling goes red instead of taking an inverter to zero. Below hass.py's 10 s HTTP timeout on purpose: abandon the write and re-issue it next tick, since `last_write` moves only on an accepted write.
- **Tariff refresh (OPT-04).** `resolve_tariff()` may do an HTTPS fetch. The tick feeds the ESP32 heartbeat, so a supplier's TLS timeout on the tick would push the add-on into layer 1. The tick never awaits a fetch. The 30 min cadence is not about price change (day-ahead publishes once at 14-16 h CET and 8.3's carried hours go firm then); it picks the switch up within the hour at 48 fetches a day. The age bound answers another question: a failed refresh still stores a plan (the 11.4 static fallback), the bound covers the refresh task dying while a plan from hours ago stands as current. Three missed refreshes, for `p1.max_age_s`'s reason.
- **Status rows (TARIFF-11).** The Tariff row used to be `static_status()`'s sentence alone and never saw the provider plan (read 'no static profile' at all 802 samples of a 40 h soak with a live feed). `tick_words` and `plan` are two clocks and the row says so; the plan is stamped with its own age from `tariff_at` (TARIFF-12 does not move it on a kept plan), and `age_s` is the caller's measurement. The Arbitrage row used to be `"off (arbitrage_enabled)" if self.arb is None`, under a comment saying `arb is None` iff trading was off; false, since `cycle()` returns at the input hold before `self.arb = None`: `arbitrage_enabled: true` plus a sensor answering None rendered 'off (arbitrage_enabled)', naming an option that is on, and past the first cycle the previous decision stood as current. Three states: off, armed with no decision this cycle, a decision with its age (always printed: a row that reports staleness only once stale cannot be told from one that never does).
- **`stop` is required (review catch).** `stop=None` made a Controller that cannot stop itself the default: on the late-refusal path in `run_control()` it logged the banner, returned and left the process alive forever with no loop and no output.
- **Action log first.** The store migration can log to it (15: how an operator finds a latch was re-armed); an AttributeError on the way to saying so would stop the add-on booting.
- **Roster and migrations (DEV-02, SAFETY-44, MAINT-05).** Every per-device value lives on the roster objects (app/inverter.py); a `Tuning` and an integrator are deliberately not per device (6.2: one integrator on P1 above the split; a second regulating the same meter is the two-writers failure). The SAFETY-02 key migration runs before the first `Inverter` reads its latch, else a tripped install comes up armed with a green banner. SAFETY-44: it is handed `roster_names(opts)[0]`, never `UNIT_A` (on a declared roster the flat latch landed under `unit/inverter-a/`, read back under the name it was handed, stamping the marker); `roster_names()` returns `[UNIT_A]` on the flat shape so today's path is byte-identical. `misdirected` is unreachable from that call site (`unit` is a roster member by construction; dropping the `roster` argument survives 28/28), so the claim that the call site buys the refusal is gone; the argument stays for new callers and is graded in test_maintenance.py. The maintenance migration targets `self.inverters[0].name`, not `UNIT_A`: with `UNIT_A` the history would sit under a name on no roster (a nameless entry falls back to a positional `inverter-1`). Every two-pack fixture once named its first entry `inverter-a`, so swapping them left 19 files green; `migrate_maint_keys()` now grades the namespace it wrote against the names the machines read, and test_maintenance.py sections 9 and 11 use `["garage", "shed"]`.
- **CFG-04 disclosure.** A declared `inverters:` roster ignores `soc_entity`, `batt_entity`, `batt_invert` and `max_w`, which the UI still shows. Rule 9(e) refuses the dangerous one (a flat `setpoint_entity`); the rest cannot be refused (the schema has no blank value) so one action-log line at every start names the roster and the ignored keys.
- **Site bound.** Summed from the roster, not `opts["max_w"]`: it is the integrator bound and rail of the site loop, then split. Identical at N=1; a per-unit `max_w` binds each share through its `supervised` claim.
- **SAFETY-39 slew fallback** is 0 ("follow the rail"); it was 1000, a stale third copy. `control.slew_limit_w()` is the one reader.
- **CFG-03 in main.py.** Eight `opts.get(key, <literal>)` calls used to live in the maintenance setup and two disagreed with config.yaml (2500 against 2000). `MaintConfig.from_options()` reads them. A ninth key goes in config.yaml `options:`/`schema:` and `maintenance.MAINT_OPTION_DEFAULTS` only.
- **Watchdog thresholds (SAFETY-30).** The per-unit pair is re-derived at two inverters, not carried (it grades a command against one unit's own power). The site pair is used only at N>=2: the 300 W threshold carries (meter noise, deadband), the window does not (110 s at one inverter, 215 s at two, against a 180 s hold).
- **Latches (SAFETY-02, -31, -34, -42, -18).** SAFETY-02: one per actuator, restored from namespaced keys; graded on every start (a latch that failed to move namespace, or was taken by an older add-on in a rollback, is a disarmed guard, so a disagreement re-arms by hand and shouts). SAFETY-31: site-level, flat keys (circulating power is defined over the roster), restored because it acts. SAFETY-42's delivery latch does not act, so it is restored only so the ALARM survives (the 2026-09-02 outage went unseen 6 h 25 m with the detector correct). SAFETY-18 is not restored (does not act). SAFETY-30's site check is armed only at N>=2.
- **PV (TEL-11).** Instrumentation only; wiring it in would be an unrequested behaviour change and would destroy the measurement.
- **Peak guard setup (SAFETY-07, TARIFF-01, FCST-04).** See SAFETY-07-peak-guard.md and FCST-04. The windowing caveat for FCST-04: hours already elapsed leave the spread, so a cheap night that has gone no longer sets the day's cheapest hour.
## Cycle state and latch repair
- **Allocation record (SAFETY-28).** The tick that mints a record increments once per resolution, not per loop iteration. A cycle with no resolution (the "inputs missing, not yet stale" hold) must not bump it: that hold returns before the allocator so one missed poll does not zero the inverter, and the heartbeat re-affirms `self.target` through the write path; bumping would make that heartbeat unauthorised and drop the site to 0 W. The record is superseded, never expired.
- **`self.arb` (TARIFF-11).** See "Status rows" above; `arb_at < cycle_at` is the derived "the latest cycle did not reach the arbitrage step".
- **Shadow (SAFETY-10, SAFETY-47).** `shadow_would_w` is what a live controller would have put on the wire; `self.target` is what is standing on the wire (held at `shadow.WIRE_W`, re-seeded each shadow cycle). Folding them shows an operator a number nothing is doing. The "sized from the billed-quarter meter" flag is recorded where the decision was taken (the page runs on its own task); False, not None, before the first shadow cycle.
- **ARB-03.** `units` is a property derived from the roster so a site cannot claim two actuators while holding one writer. At N=1 (6.2) the grid connection and the only inverter are one actuator; a second entry makes per-inverter claims stop being site claims (ALLOC-01).
- **DEV-02 latch guard (`_check_sign_latch...`).** Runs on every start: gating on `rep.ran` swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip in the window, upgrade; the controller came up untripped while state.json said `sign_tripped: true`; a firmware rollback path was performed on this site on 2026-09-02). The gate could go because `handle_action()` clears the flat keys with the namespaced ones and the trip mirrors them. SAFETY-44 second half: the question is whether a namespaced latch THAT A MACHINE READS is set. An earlier version accepted any `unit/*/sign_tripped`, which blinded the guard on exactly the installs the broken migration had put the latch under `unit/inverter-a/` (a key nothing reads). The first blunt fix (every off-roster latch is litter) re-armed a healthy A off B's mirrored flat key (test_inverter.py DEV-02 restart case). `UNIT_A` is the discriminator: the only name the broken migration wrote. A single flag written for two intents was defeated by one removed unit's key (roster ['garage','shed'] with `unit/old-shed/sign_tripped` drove on a state file reading `sign_tripped: true`; `tools/rehearse_roster.py` exit 1); the fixtures now hold both arrangements. Residual: a first unit really named `inverter-a`, latched and removed from the roster is re-armed on `inverters[0]` (conservative). The printed reason is the flat mirror's (whatever tripped last), so on a multi-unit site one inverter's evidence may appear under another's name; test_maintenance.py's check that the reason is not the removed unit's depends on that fixture's flat string, not on this code.
- **Maintenance views (MAINT-05).** `maints` is one FSM per roster entry; `maint`, `maint_unit` and the button target are views of the one that holds the slot (`maintenance.arbitrate()` allows at most one out of IDLE). validate.py rule 6 grades `maintenance_charge_w` against the weakest cap on the roster.
- **N=1 views.** Properties, not fields, and they refuse at N>=2 (6.2's identity is false there). Nothing inside main.py may read them (test_inverter.py AST scan).
- **`_arm_sign_watchdogs` (SAFETY-30).** Called every cycle because fixtures append an `Inverter` after construction; a detector frozen at construction would be graded against the meter at a roster of two. `arm()` touches no timer or latch.
- **Per-inverter refusal (SAFETY-73).** The probe ask is PROBE_W, not `saturation_w`: a unit's share is often small (a 400 W night load split two ways) and SAFETY-43 would name the empty battery as not delivering. The site target is restarted from the watts on the wire when a unit reopens (phase A: -1059 W).
## Write path and cycle
- **The write gate (SAFETY-28).** specs 1.5 makes two writers on one actuator a non-goal. `len(validate.ACTUATOR_WRITE_KEYS) == 1` is a fact about the options schema, not the writes, and goes false when CFG-04 adds per-inverter config. So the enforcement is on the write: a number reaches the wire only if the single allocator placed it, on this tick, for this unit (`Allocation.authorises()`); a second writer must file a Claim. 0 W is always authorised and the test for zero comes first and reads nothing: the failsafe branches write 0 W while the allocator has refused (`Allocation.ok` False, no shares), and a gate asking its permission would deadlock the failsafe behind the component it answers; at S = 0 broadcast and allocation coincide, so there is no over-command (the hazard is N-times loop gain from broadcast, TEST-19's `N=2 gain 1.0` limit cycle). The predicate is `value == 0.0`, not a `failsafe=True` flag, which a second writer would pass; -0.0 == 0.0 and a nan never gets here. DEV-02: the unit is an argument, not the N=1 view; the default refuses at N>=2. The refusal sits above the clamp and below the non-finite coercion.
- **Clamp (SAFETY-03)** is on the only path to the wire, so no optimizer, maintenance or manual path routes around it. A non-finite value is loss of input (11.1), coerced to 0 W. Every engagement goes to the action log undeduplicated; the notification is deduplicated.
- **Shadow (SAFETY-10)** suppresses in the same method, below the gate and the clamp, as a substitution not a return. It is not a second writer.
- **Reads (DEV-02, TEL-12).** Each inverter's readings are gathered concurrently across units and sequentially within one; PV entities ride in the same gather (a slow sensor must not push a healthy battery toward layer 1's veto). P1 has no inverter-side fallback: the inverter's AC power correlates 0.998 with battery power and 0.09 with the real meter.
- **Stale reading (SAFETY-37/56/57).** The power reading goes through the freshness gate, the SoC does not (the power feeds the sign and delivery checks, which a frozen register fools). SAFETY-57 rewrote the escape clause: "it clears itself when the register reports a different value" was false for the state announced, because the 0 W it announces is what stops the value changing. The release is reported, never silent (it withdraws a SITE STOP).
- **TARIFF-03.** The one shiftable load is the maintenance cycle's cheap-window top-up; the static window is synchronous and I/O-free, and validate.py refuses `static_profile` together with the price entities.
- **Cycle ordering (SAFETY-02/31/42).** The watchdog evaluations run every cycle, before the input hold, so a run of held cycles cannot leave their timers resuming from a start that predates the gap. Reason first, flag second (each `set` writes the file): a crash between persists a reason nothing reads, not a latch with no explanation. The per-unit trip mirrors the legacy flat keys (store.py rule 2 covered only the latch present at migration time). The site check is flat-keyed, armed only at N>=2. SAFETY-31's input is MEASURED power (`inv.batt`), never the allocation record, which has sign-coherence by construction.
- **Maintenance FSMs (MAINT-05).** Each pack is ticked with its own SoC (a minimum would abort a cycle on a sibling's state, an average would declare a pack full that is not); `arbitrate()` runs first. A deferral is carried to the status page because the edge-logged line scrolls out of the 40-row log.
- **Aggregates.** SUM for the site bound, achievable rating and the battery power the site loop regulates against (`compute()`'s saturation test compares against the site target; one unit's power trips the freeze permanently, up to 510 W of split-dependence at gain 0.2, test_alloc.py part 8). MINIMUM for the discharge permission (the emptiest pack). MEASURED power for circulating power. Unknown bounds stay unknown when any unit is silent.
- **Trade sizing (OPT-09).** `trade_sizing` uses arbitrage's own SoC bounds, not the BMS cut-out (SAFETY-25): sizing is the trade's business, the pack envelope is the pack's. Weights are a preference, so a pack between its arbitrage floor and its cut-out can still take part of a sell; fixing it needs a per-pack envelope depending on which claim won the tick.
@@ -34,13 +34,6 @@ known, so the add-on does not depend on it.
midnight, so a 09:05 reading, unusable since 21:05, does not come back for five
minutes after midnight. Same-day figures and the daytime rule keep 12 h and 3 h.
4. A fresh figure replaces the kept one at once, as before.
*Rig F-1 fix:* once every today sensor has given a fresh reading after
midnight, the evening figure is no stand-in for that day any more
(`Hold.replaced()`). When that reading goes stale (3 h), it is kept or not
as any today reading (OPT-27), so it is "no forecast" at night and the latch
keeps the level it set; the older 17:05 figure never comes back. Rig: A
stopped at 69.5 % after a brighter 02:00 figure, then charged back to 94.4 %
from 05:15 when the 17:05 figure returned. Test: `test_opt34.py` F-1.
**Step 1 alone is enough for the owner's shape, steps 2-3 are still needed.**
With the window put back to midnight + 3 h the owner's night still ends as a
@@ -66,4 +59,4 @@ A fresh figure that LOWERS the level during a charge still lowers it, as before.
**Mutants** (all red in `test_opt34.py`): no latch; window back to midnight + 3 h;
15 h without a limit; no "usable at midnight" rule; the latch without the narrow
gap, without its save, or not kept through the next run; the window 6 h after
midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off or shown without a cheap run; no expiry check; no pruning of a changed mode; no night bound; a left-the-plan battery keeping its level; no `replaced()` check (F-1); `replaced()` with one of several today sensors (any, not all).
midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off or shown without a cheap run; no expiry check; no pruning of a changed mode; no night bound; a left-the-plan battery keeping its level.
@@ -1,84 +0,0 @@
# SAFETY-128 - a reading that does not answer the last command is not current
**Status: FIXED on release/1.0 (after 0.20.0).** Code: `app/lateness.py`,
`control.compute(hold=, hold_w=)`, `peakguard.cap_floor()`,
`Controller._readings_late()`, `Controller.read_inputs()` (feeds the fits).
Tests: `test_safety128.py` (`tools/safety128_rig.py`, TEST-23's plant).
**Cause** (SAFETY-127 report). Home Assistant can deliver readings late but
still changing. Every gate judges arrival (SAFETY-01) or a value that stops
changing (SAFETY-18, SAFETY-37), so a late reading passes them all. The loop
then corrects, each cycle, an error its last command has already fixed: a
loop with a dead time far longer than its cycle, which swings and grows. The
peak guard projects the quarter from the same late figures and pushes to the
inverter limit in its last seconds (OPT-34 rig F-2).
**Rule.**
1. *Find late readings* (`Answer.watch()`, every 2 s, from the 1 s reads).
- **Batteries** (`Delay`): fit one `d` so that each battery's reading minus
its own request at `t - d` (averaged over 2 s for the battery's lag and
the read) is flattest. The house does not move that reading, so a
repeating load cannot line up with it (review round 1, B1). Needs 5 new
readings in 60 s and the request and the reading each to move 300 W.
Late from 9.5 s, after 2 late fits in a row. A late fit must explain 75 %
and beat every fit up to 6.5 s by 2x; the shortest delay that fits about
as well (within 2x, or within 2 500 W^2) is taken instead, since a swing's
period fits again. A slow battery (3-10 s lag) answering at once gives no
late fit; at a 15-20 s lag some fits read late (see "What it costs").
No late battery fit counts for 60 s
after a battery gave nothing under its ask: an emptied battery lined its
old movement up as 85 s late.
- **Meter** (`MeterFit`, round 1's fit): `meter(t) + request(t - d)` is the
house alone at the right `d`. Quick, but confounded by the house. Needs
16 new values in 60 s, so only a meter of about 3.5 s or faster: at 5 s
it gave false delays. Late over 8 s.
- A meter fit says late only with evidence from the batteries' side too:
a request more than 100 W from the battery reading took 3 s or more to
move a reading towards it (`SLOW_START_S`; on time they start within
0-1 s in the recorded house at 1 and 2 s ticks), and no battery fit in
the last 60 s said on time (under 9.5 s). An ask during which a battery
gave nothing under its ask is no slow evidence. A repeating load fools
the meter fit but not these. No meter verdict in the first 150 s.
- On time again on a battery fit under 6.5 s, or a meter fit within 8 s
unless a battery fit said late (hysteresis). With no fit for 120 s the
verdict is dropped.
2. *Hold.* While late, a request more than 100 W from the battery reading is
pending until that reading comes within 100 W of it, or half way. Meanwhile
the loop may not lead the reading by more than 100 W that way, and the peak
guard's floor is capped at the request that stands. The loop is not held
while a battery gives nothing under its ask or refuses (SAFETY-52/94).
3. *Late verdict.* Pending for `LATE_S` (23 s) with no reading moving 100 W
towards it: control holds, then after `stale_input_s` goes to 0 W
("readings late"), as for a missing input.
4. *Seen.* While late an amber row says "Home Assistant readings show requests
about N seconds late, so control holds the batteries." (NL too). N is the
batteries' fitted delay: Home Assistant's lag plus the battery's own
answer. While only the meter has shown it the row gives no figure. One
WARNING (`READINGS_LATE`) per episode and one line when it ends.
**With readings on time** no verdict was reached in any reviewer scenario
(owner-shaped house, ovens 20/40 to 60/60 s, clouds, a 300 W/s battery, 6-8 s
dead times, HA jitter 0-4 s, an EV following the meter, capped batteries, HA
restarts, meter pauses; 1, 2 and 5 s meters) nor in 2 days of the recorded
house at 1, 2 and 5 s ticks, so the commands are the same as without the gate.
The fits run all the time; the pull request has the counts.
**What it costs.**
- Found 36-68 s after a lag starts at 1-2 s meters, about 3 minutes at a 5 s
meter (battery fit only; the meter fit needs a faster meter). Until then control is as before. At 5 s and 30 s
late the export is cut to about a third, not removed.
- A battery that answers slowly is read as late readings and held: a dead
time of 10 s or more (8 s reads 7-8; 10 s reads 10 at 1-2 s meters, 9 at
5 s), or a 15-20 s lag (review round 2). The GoodWe measured settles in 6 s
(`freshness.PLANT_SETTLE_S`); the row then blames Home Assistant wrongly.
- The row can stay up to 180 s after the readings are on time on a steady
site. While only the meter has shown the delay it gives no figure.
- A battery that cannot follow while the readings are late (at a limit, not
reading 0 W) is held, then set to 0 W after 38 s.
- The fits cost a few ms every 2 s (x86, measured after 3 idle days); the
history they read is bounded (60 s of readings, 150 s of requests).
**Left out.** The setpoint -> Home Assistant round trip: the fitted delay gives
the figure from readings the add-on already has. The hold's step-and-pull-back
at extreme lag and the arbitrage charge's sizing on late readings
(`charge_room_w()`) are follow-up cards.
-13
View File
@@ -298,9 +298,6 @@ PROBE_S = 34.0
# command: every battery that may move this way is at its mode's limit.
MODE_BOUND = "mode-bound"
# SAFETY-128. The loop's reason while it waits for the readings to answer.
WAITING = "waiting for readings"
@dataclass(frozen=True)
class Tuning:
@@ -489,8 +486,6 @@ def compute(
bound: tuple | None = None,
gave_nothing: bool | None = None,
idle_w: float = 0.0,
hold: int = 0,
hold_w: float | None = None,
) -> Decision:
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
@@ -511,9 +506,6 @@ def compute(
`idle_w` SAFETY-97: what `actual_w` reads while the batteries give
nothing (app/idle.py). The refusal's band and its end are
centred on it. 0 is the law as it was.
`hold` SAFETY-128: +1 / -1, the batteries have not answered the last
command that way (app/lateness.py), so the loop may not go
past `hold_w` (None: `prev_w`) that way. 0 is the law as it was.
"""
reason = "tracking"
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
@@ -630,11 +622,6 @@ def compute(
i_w = min(moved, i_w)
else:
i_w = max(moved, i_w)
# SAFETY-128: no further than `hold_w` until the readings answer the ask.
held_at = float(prev_w if hold_w is None else hold_w)
if hold and hold * (i_w - held_at) > 0:
i_w = held_at
reason = WAITING
# OPT-11: the integrator does not wind past what the modes let the
# batteries place. Only a side tighter than the rail is left in `bound`
-373
View File
@@ -1,373 +0,0 @@
"""SAFETY-128: a reading that does not answer the last command is not current.
When Home Assistant delivers readings late but still changing, every gate that
judges arrival (SAFETY-01) or an unchanged value (SAFETY-18, SAFETY-37) passes
them, and the loop corrects again, each cycle, an error its last command has
already fixed. The readings show it: they answer each power request late.
`Answer.watch()` (each 1 s read) fits two delays every FIT_EVERY_S:
- `Delay`: how long after its request each battery's reading shows it. The
house does not move that reading, so a repeating load cannot fool it. It
needs the request to move FIT_MOVE_W; one reading per 10 s makes it slow.
- `MeterFit`: how long after the site request the meter shows it. Quick at a
1-3 s meter, none at a slower one, and a house load that repeats can fool it.
The meter counts only when an ask was slow to start and no battery fit said
"on time"; with hysteresis (ON_S / OFF_S) the result is `late_seen`. Then (`observe()`, each cycle):
- a new command more than ANSWER_W from the battery reading is PENDING until
that reading comes within ANSWER_W of it, or half way;
- while pending the loop may not lead the battery reading by more than
ANSWER_W that way (`hold`, `hold_w`; not while `exempt`: a battery that
gives nothing under its ask or refuses is SAFETY-52/94's case) and the peak
guard may not push past what stands (`peak_cap`). Pending for LATE_S with
no reading moving towards it is LATE: control holds, then 0 W after
`stale_input_s`, as for a missing input.
Not `late_seen`, nothing here acts. DECISIONS/SAFETY-128-late-readings.md.
"""
import bisect
from collections import deque
from . import freshness
from .control import PROBE_ANSWER_W
# Within this of the command the reading has answered it (a probe's answer).
ANSWER_W = PROBE_ANSWER_W # 100
# The slowest healthy battery answer: SAFETY-75's 6 s dead time, the 6 s plant
# settle, one 10 s publish and one 1 s read.
LATE_S = (2 * freshness.PLANT_SETTLE_S + freshness.PUBLISH_PERIOD_S
+ freshness.READ_PERIOD_S) # 23.0
# Each battery reading's delay is fitted over this much history, up to this.
FIT_S = 60.0
MAX_DELAY_S = 90
FIT_EVERY_S = 2.0
# A fit needs this many new readings (all batteries) and this much movement
# of the reading and the request.
FIT_ROWS = 5
FIT_MOVE_W = 3 * ANSWER_W # 300
# Late from ON_S, on time again under OFF_S (hysteresis).
ON_S = 9.5
OFF_S = 6.5
# ...after this many late fits in a row.
ON_FITS = 2
# With no fit for this long the evidence is dropped (a steady site shows none).
FIT_GONE_S = 120.0
# A fit counts only if the commands explain this share of the reading's swing.
FIT_SHARE = 0.5
# A late delay must explain this share.
FIT_SHARE_LATE = 0.75
# ...and a late delay only if it fits this much better than any fresh one.
FIT_BETTER = 0.5
# A shorter delay within this factor of the best fit is taken instead.
FIT_NEAR = 2.0
# ...or within this of it (W^2): two fits this good are both exact.
FIT_FLOOR_W2 = (ANSWER_W / 2) ** 2 # 2 500
def per_second(cmds, first, last):
"""The request standing at each whole second from `first` to `last`."""
ts = [t for t, _c in cmds]
return [cmds[max(0, bisect.bisect_right(ts, k) - 1)][1]
for k in range(first, last + 1)]
def pooled(sets, d):
"""Pooled variance over [(rows, c, first, sign)] of value + sign *
c[t - d], each set about its own mean."""
tot, n = 0.0, 0
for rows, c, first, sign in sets:
e = [v + sign * c[int(t) - d - first] for t, v in rows]
m = sum(e) / len(e)
tot += sum((x - m) ** 2 for x in e)
n += len(e)
return tot / n
class UnitFit:
"""One battery's power requests and readings, as they were seen."""
def __init__(self):
self.cmds = deque() # (t, request) when it changed
self.reads = deque() # (t, reading) when it changed
def add(self, now, cmd, reading):
if cmd is not None and (not self.cmds or self.cmds[-1][1] != cmd):
self.cmds.append((now, float(cmd)))
if reading is not None and (not self.reads
or self.reads[-1][1] != reading):
self.reads.append((now, float(reading)))
while self.reads and self.reads[0][0] < now - FIT_S:
self.reads.popleft()
while (len(self.cmds) > 1
and self.cmds[1][0] <= now - FIT_S - MAX_DELAY_S):
self.cmds.popleft()
def series(self):
"""(rows, request each second, first second) for the fit, or None:
too few new readings, or a reading or a request that has not moved
FIT_MOVE_W. The request is averaged over the 2 s before each second:
the battery's own lag and the read."""
rows = [r for r in self.reads
if self.cmds and r[0] >= self.cmds[0][0] + MAX_DELAY_S]
vals = [v for _t, v in rows]
if len(rows) < 3 or max(vals) - min(vals) < FIT_MOVE_W:
return None
cs = [c for _t, c in self.cmds]
if max(cs) - min(cs) < FIT_MOVE_W:
return None
first, last = int(rows[0][0]) - MAX_DELAY_S - 2, int(rows[-1][0])
c = per_second(self.cmds, first, last)
avg = [c[0], c[0]] + [(c[k - 2] + c[k - 1] + c[k]) / 3
for k in range(2, len(c))]
return rows, avg, first
class Delay:
"""How long after a power request the batteries' readings show it.
A reading follows its battery's own request and the house does not move
it, so `reading(t) - request(t - d)` is flattest at the right `d`. Each new
reading counts once, at the second it was seen, so the 10 s publish adds no
delay. One `d` for every battery (Home Assistant delays them all). None
with too few readings, or when the requests explain too little of them (a
battery that does not follow); not late unless it fits clearly better than
any fresh delay, and then the shortest delay that fits about as well."""
def __init__(self):
self.units = {}
def add(self, now, unit, cmd, reading):
self.units.setdefault(unit, UnitFit()).add(now, cmd, reading)
def estimate(self):
ser = [x for x in (u.series() for u in self.units.values()) if x]
if sum(len(rows) for rows, _c, _f in ser) < FIT_ROWS:
return None
sets = [(rows, c, first, -1.0) for rows, c, first in ser]
r_var = pooled([(rows, [0.0] * len(c), first, -1.0)
for rows, c, first in ser], 0)
pure = [pooled(sets, d) for d in range(0, MAX_DELAY_S + 1)]
# The shortest that fits about as well: a swing's period fits again.
best = next(d for d, v in enumerate(pure)
if v <= FIT_NEAR * min(pure) + FIT_FLOOR_W2)
if r_var <= 0 or pure[best] > (1 - FIT_SHARE) * r_var:
return None
if best < ON_S:
return float(best)
if (pure[best] > (1 - FIT_SHARE_LATE) * r_var
or pure[best] > FIT_BETTER * min(pure[:int(OFF_S) + 1])):
return None
return float(best)
# The meter's delay: fitted over this much history, from this many new
# values (a meter of about 3.5 s or faster: a slower one gives too few to
# fit without false delays); late over M_LATE_S (the plant's 6 s settle and one 2 s meter tick),
# but only when the batteries' own readings have not just shown them on time.
M_FIT_S = 60.0
M_ROWS = 16
M_LATE_S = freshness.PLANT_SETTLE_S + 2.0 # 8.0
M_SHARE, M_BETTER = 0.4, 0.7
# A meter fit counts only after an ask that took this long to start moving a
# reading: on time they start within 1-2 s (the recorded house, at 1-5 s).
SLOW_START_S = 3.0
class MeterFit:
"""How long after a site request the meter shows it (round 1's fit).
`meter(t) + request(t - d)` is the house alone at the right `d`. Quick,
as the meter reads every 1-5 s, but a house load that repeats can line
up with an earlier request, so it only counts while the batteries' own
fit is not on time (`Answer.watch`)."""
def __init__(self):
self.cmds = deque() # (t, request) when it changed
self.grid = deque() # (t, meter) new values only
def add(self, now, cmd, grid):
if cmd is not None and (not self.cmds or self.cmds[-1][1] != cmd):
self.cmds.append((now, float(cmd)))
if grid is not None and (not self.grid or self.grid[-1][1] != grid):
self.grid.append((now, float(grid)))
while self.grid and self.grid[0][0] < now - M_FIT_S:
self.grid.popleft()
while (len(self.cmds) > 1
and self.cmds[1][0] <= now - M_FIT_S - MAX_DELAY_S):
self.cmds.popleft()
def estimate(self):
if len(self.cmds) < 2:
return None
rows = [(t, g) for t, g in self.grid
if t >= self.cmds[0][0] + MAX_DELAY_S]
cs = [c for _t, c in self.cmds]
if len(rows) < M_ROWS or max(cs) - min(cs) < ANSWER_W:
return None
first, last = int(rows[0][0]) - MAX_DELAY_S, int(rows[-1][0])
c = per_second(self.cmds, first, last)
g_var = pooled([(rows, [0.0] * len(c), first, 1.0)], 0)
pure = [pooled([(rows, c, first, 1.0)], d)
for d in range(0, MAX_DELAY_S + 1)]
best = min(range(len(pure)), key=lambda d: (pure[d], d))
if g_var <= 0 or pure[best] > (1 - M_SHARE) * g_var:
return None
if best <= M_LATE_S:
return float(best)
if pure[best] > M_BETTER * min(pure[:int(M_LATE_S) + 1]):
return None
return float(best)
class Answer:
"""The answer gate. `observe()` once per cycle, before the loop."""
def __init__(self):
self.ask = None # the pending command, W (site, + = give)
self.from_w = None # the battery reading when it was asked
self.from_grid = None # the meter when it was asked
self.since = None # when it was asked
self.started = False # a reading has moved towards it
self.late_seen = False # the batteries' readings run late
self.late_at = None # when the late verdict started
self.exempt = False # a battery that cannot follow explains it
self._was = None # (command, reading, meter) at the last call
self.delay = Delay()
self.meter = MeterFit()
self._batt_fit = None # (when, d): the batteries' last fit
self._b_run = 0 # late battery fits in a row
self._start_age = None # (when, how long the last ask took to start)
self._exempt_at = None # when a battery last gave nothing under its ask
self._ask_exempt = False
self._start = None
self.delay_s = None # the last fitted delay (either fit), s
self.shown_s = None # the batteries' last late delay, for the page
self.fits = 0 # fits made (instrumentation)
self._fit_at = self._fit_ok = None
@property
def active(self) -> bool:
return self.ask is not None and self.late_seen
@property
def hold(self) -> int:
"""+1 / -1: the loop may not push further that way. 0: no hold."""
if not self.active or self.exempt:
return 0
return 1 if self.ask > self.from_w else -1
@property
def hold_w(self):
"""How far the loop may go while held: ANSWER_W past the reading."""
if not self.hold:
return None
return self._was[1] + self.hold * ANSWER_W
def peak_cap(self, standing_w):
"""The most the peak guard may ask, or None."""
return max(float(standing_w), 0.0) if self.active else None
def late_s(self, now):
"""Seconds the late verdict has stood, or None."""
return None if self.late_at is None else max(0.0, now - self.late_at)
def watch(self, now, units, standing_w=None, grid_w=None):
"""Each read (1 s): `units` = [(name, request on its wire, its battery
reading)], the site request and the meter. Every FIT_EVERY_S, fit both
delays. Late on ON_FITS battery fits from ON_S, or on a meter fit
over M_LATE_S when an ask was slow to start (SLOW_START_S) and no
battery fit within FIT_S said on time: a house load that repeats can
fool the meter fit, not these. On time again on a battery fit under
OFF_S, or a meter fit within M_LATE_S unless a battery fit said late.
"""
if self._start is None:
self._start = now
for name, cmd, reading in units:
self.delay.add(now, name, cmd, reading)
self.meter.add(now, standing_w, grid_w)
if self._fit_at is not None and now - self._fit_at < FIT_EVERY_S:
return
self._fit_at = now
b = self.delay.estimate()
if (b or 0.0) >= ON_S and self._exempt_at is not None \
and now - self._exempt_at <= FIT_S:
b = None # a battery that stopped following: no late fit
m = self.meter.estimate()
if b is not None:
self._batt_fit = (now, b)
self.shown_s = b if b >= ON_S else None
recent = (self._batt_fit is not None
and now - self._batt_fit[0] <= FIT_S)
b_fresh = recent and self._batt_fit[1] < ON_S
b_late = recent and self._batt_fit[1] >= ON_S
self._b_run = self._b_run + 1 if (b or 0.0) >= ON_S else 0
d = b if b is not None else m
if d is None:
if self._fit_ok is None or now - self._fit_ok > FIT_GONE_S:
self.late_seen = False # nothing to tell it by
return
self.fits += 1
self.delay_s, self._fit_ok = d, now
warm = now - self._start >= MAX_DELAY_S + FIT_S
m_late = (m or 0.0) > M_LATE_S
slow = (self._start_age is not None
and now - self._start_age[0] <= M_FIT_S
and self._start_age[1] >= SLOW_START_S)
if self._b_run >= ON_FITS or (
m_late and warm and slow and not b_fresh):
self.late_seen = True
elif (b is not None and b < OFF_S) or (
m is not None and m <= M_LATE_S and not b_late):
self.late_seen = False
def observe(self, now, standing_w, batt_w, *, grid_w=None, exempt=False,
idle_w=0.0):
"""`standing_w` the site command on the wire, `batt_w` the site
battery reading (None: unknown, nothing changes), `grid_w` the meter
(+ = import), `idle_w` what the batteries read while giving nothing.
`exempt`: a battery gives nothing under its ask, or refuses."""
if batt_w is None or standing_w is None:
return
cmd, r = float(standing_w), float(batt_w) - float(idle_w)
g = None if grid_w is None else float(grid_w)
self.exempt = bool(exempt)
if self.exempt:
self._exempt_at = now
was, self._was = self._was, (cmd, r, g)
if self.ask is None:
if was is None or cmd == was[0] or abs(cmd - was[1]) < ANSWER_W:
return
self.ask, self.from_w, self.from_grid = cmd, was[1], was[2]
self.since, self.started = now, False
self._ask_exempt = False
self.ask = cmd
# A battery that gives nothing under its ask explains a slow start.
self._ask_exempt = self._ask_exempt or self.exempt
age = now - self.since
gap = self.ask - self.from_w
way = 1.0 if gap > 0 else -1.0
batt_to = (r - self.from_w) * way
grid_to = (-1e9 if g is None or self.from_grid is None
else (self.from_grid - g) * way)
if not self.started and max(batt_to, grid_to) >= ANSWER_W:
self.started, self.late_at = True, None
self._start_age = (now, 0.0 if self._ask_exempt else age)
elif (not self.started and age >= SLOW_START_S
and not self._ask_exempt):
self._start_age = (now, age)
if (self.ask - r) * way < ANSWER_W or batt_to >= abs(gap) / 2:
self._clear() # answered
elif abs(gap) < ANSWER_W:
self._clear() # withdrawn
elif exempt:
self.late_at = None
elif (self.late_at is None and self.late_seen and not self.started
and age >= LATE_S):
self.late_at = now
def _clear(self):
self.ask = self.from_w = self.from_grid = self.since = None
self.started, self.late_at = False, None
+1200 -3809
View File
File diff suppressed because it is too large Load Diff
-37
View File
@@ -644,31 +644,6 @@ def row_controller_stalled(age_s, veto_s) -> str:
f"stuurmodule zet de omvormer na {seconden(veto_s)} op 0 W.")
def row_readings_late(delay_s, zero=False) -> str:
"""SAFETY-128: readings show the power request late; `delay_s` the
batteries' fitted delay (their own answer included), or None when only
the meter has shown it; past the hold, 0 W."""
if delay_s is None:
if zero:
return Bi("Home Assistant readings show requests late, so every "
"inverter is at 0 W.",
"Metingen uit Home Assistant tonen vermogensvraag te "
"laat, omvormers op 0 W.")
return Bi("Home Assistant readings show requests late, so control "
"holds the batteries.",
"Metingen uit Home Assistant tonen vermogensvraag te laat, "
"dus de batterijen wachten.")
if zero:
return Bi(f"Home Assistant readings show requests {seconds(delay_s)} "
f"late, so every inverter is at 0 W.",
f"Metingen uit Home Assistant tonen vermogensvraag "
f"{seconden(delay_s)} te laat, omvormers op 0 W.")
return Bi(f"Home Assistant readings show requests about {seconds(delay_s)} "
f"late, so control holds the batteries.",
f"Metingen uit Home Assistant tonen vermogensvraag zo'n "
f"{seconden(delay_s)} te laat, dus de batterijen wachten.")
def row_meter_waiting(limit_s) -> str:
"""UX-10: no reading yet since the start, and still inside the limit."""
return Bi(f"Meter: waiting for the first reading, limit "
@@ -1295,7 +1270,6 @@ WHY_WINNER = {
"circulation": "Safety stop, inverters worked against each other",
"stopped": "Control is stopped",
"inputs missing": "A reading is missing",
"readings late": "Home Assistant readings arrive late",
"no claim was made": "Nothing asked for power",
# UX-10: the claim reasons main.why_key() can return for a safety or
# failsafe winner. test_messages.py part 7 finds every one in the code.
@@ -1346,11 +1320,6 @@ def why_missing(what, limit_s) -> str:
return f"No usable {what} reading for over {seconds(limit_s)}: 0 W"
def why_late() -> str:
"""SAFETY-128: readings arrive late, so control holds the batteries."""
return "Home Assistant readings arrive late, control holds"
def why_waiting(missing_s) -> str:
"""A reading went missing a moment ago: control holds and waits."""
return f"A reading is missing for {seconds(missing_s)}, control waits"
@@ -1368,8 +1337,6 @@ HINTS = {
"roster": "Inverter list read: {n} inverters",
"maint_history": "Maintenance history did not move over cleanly",
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
"readings_late": "Home Assistant readings arrive late, control holds",
"readings_back": "Home Assistant readings are on time again",
"battery_stuck": "{unit}: battery power reading stopped changing",
"battery_back": "{unit}: battery power reading is used again",
"battery_refuses": "The battery refuses to give power, the add-on asks 0 W",
@@ -1521,10 +1488,6 @@ def status_samples() -> list:
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
("row_meter_stale", RED, row_meter_stale(40, 10)),
("row_meter_waiting", AMBER, row_meter_waiting(30)),
("row_readings_late", AMBER, row_readings_late(30)),
("row_readings_late", AMBER, row_readings_late(30, True)),
("row_readings_late", AMBER, row_readings_late(None)),
("row_readings_late", AMBER, row_readings_late(None, True)),
("row_meter", GREEN, row_meter(-350, 2, 3, 340)),
("row_meter_frozen", RED, row_meter_frozen()),
*(("row_freeze_off", AMBER, row_freeze_off(k)) for k in FREEZE_OFF),
-13
View File
@@ -446,19 +446,6 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
return decision is not None and decision.engaged
def cap_floor(decision, cap_w):
"""SAFETY-128: the floor no higher than `cap_w` (the command standing while
the readings have not answered it), or `decision` unchanged when None.
DECISIONS/SAFETY-128-late-readings.md."""
if (cap_w is None or decision is None or decision.floor_w is None
or decision.floor_w <= cap_w):
return decision
return replace(
decision, floor_w=float(cap_w), saturated=False,
reason=decision.reason + f", held at {float(cap_w):+.0f} W until the "
"readings answer the last power request")
def claim(decision: PeakDecision) -> Claim | None:
"""The decision as one arbiter claim (`lo` only, `hi` stays `+inf`), or None."""
if not decision.engaged or decision.floor_w is None:
+3 -13
View File
@@ -38,8 +38,7 @@ midnight, if it is at most `KEPT_MAX_AGE_S` old when used.
Never beyond that, and never for another day: a today figure is only today's,
a tomorrow figure is tomorrow's until midnight and then, until the bell starts
(`BELL_FROM_H`, 06:00 local), today's (OPT-34: at most `KEPT_MORNING_MAX_AGE_S`
old, it must have been usable at midnight, and no fresh today reading may
have replaced it since).
old, and it must have been usable at midnight).
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md,
DECISIONS/OPT-34-the-night-charge-keeps-its-level.md.
@@ -352,13 +351,6 @@ class Hold:
evening = _local_hour(now) >= BELL_TO_H and now < _bounds(day0)[1]
return (day, at, True) if evening else None
def replaced(self, entities, day0) -> bool:
"""OPT-34 F-1: every today sensor has given a fresh reading for
`day0` (so after midnight, newer than any evening figure). The
evening's tomorrow figure is then no stand-in for today any more,
also once those readings are stale."""
return all(("today", e, day0) in self._r for e in entities)
def dump(self) -> dict:
return {"v": 1, "r": [
[kind, ent, d.day0.isoformat(), d.kwh, at.isoformat(),
@@ -500,8 +492,7 @@ def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
where `Hold.stand_in()` allows it (the forecast then has `kept` set when
that took a reading older than `FRESH_S`). After midnight, if the today
side is still short, the tomorrow figures read before midnight are
today's, until the bell starts (`stand_in()`), unless fresh today
readings have replaced them (`Hold.replaced()`). Without a `hold` this is
today's, until the bell starts (`stand_in()`). Without a `hold` this is
exactly as before."""
if not today:
return None, NOT_SET, ()
@@ -546,8 +537,7 @@ def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
# day; all of them are needed, as for any sum.
subs = [hold.stand_in("tomorrow", ent, day0, now, as_today=True)
for ent, _st in tomorrow]
if all(r is not None for r in subs) and not hold.replaced(
[ent for ent, _st in today], day0):
if all(r is not None for r in subs):
days = [r[0] for r in subs]
ats = [r[1] for r in subs]
gaps = [g for g in gaps if g[0] != "today"]
-1
View File
@@ -476,7 +476,6 @@
"stopped": "Control is stopped. Every inverter is at 0 W."
},
"attn": {
"late": "Readings arrive late",
"home_assistant": "Home Assistant is not answering",
"controller": "Control is not running",
"meter": "Meter reading",
-1
View File
@@ -397,7 +397,6 @@
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
"attn": {
"late": "Metingen komen te laat",
"home_assistant": "Home Assistant antwoordt niet",
"controller": "Regeling is niet actief",
"meter": "Meting van de meter",
+1 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.20.0"
version: "0.19.2"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
-95
View File
@@ -36,8 +36,6 @@ DECISIONS note and the dev report.
and before a charge has started, up and down;
e. the last hour, after the kept figure has ended at 06:00: the charge under
way goes on to its level, also over a restart; the screens say so.
f. F-1 (rig): one fresh reading at 02:00, then silent again: when it is
stale (05:00) the 17:05 figure it replaced does not come back.
3. The text, EN and NL.
4. `_latch_charge()` alone: a battery that leaves the plan leaves the latch.
"""
@@ -195,47 +193,6 @@ for label, now in (("01:00", at(4, 1, 0)), ("04:00", at(4, 4, 0))):
"17:05 figure again", fr is not None and fr.kept
and fr.today.kwh == 12.0 and fr.at == at(3, 17, 5))
# F-1 (rig): a fresh today reading at 02:00 replaces the 17:05 figure; once
# that reading is stale (05:00) the 17:05 figure must not come back.
for label, kwh in (("brighter", 40.0), ("darker", 1.0)):
hn = hold_of(at(3, 17, 5))
f2 = rs(st(kwh, at(4, 2, 0)), st(kwh, at(4, 2, 0)), at(4, 2, 1), hn)[0]
f4 = rs(GONE, GONE, at(4, 4, 59), hn)[0]
snap = json.loads(json.dumps(hn.dump()))
f5 = [rs(GONE, GONE, t, hn)[0] for t in (at(4, 5, 1), at(4, 5, 59))]
f5s = [rs(st(kwh, at(4, 2, 0)), st(12.0, at(3, 17, 5)), t, hn)[0]
for t in (at(4, 5, 1), at(4, 5, 30))]
check(f"F-1 {label} 02:00 reading: used fresh at 02:01 and kept for "
"a missing sensor to 04:59 (3 h)",
f2 is not None and not f2.kept and f2.today.kwh == kwh
and f4 is not None and f4.today.kwh == kwh and not f4.kept)
check("... and from 05:01 (stale) to 05:59: no forecast, never the "
"17:05 figure (sensors gone, or showing their old readings)",
all(x is None for x in f5 + f5s))
back = solarfc.Hold(snap)
check("... also after a restart at 03:00 (the saved hold keeps the "
"02:00 reading)", rs(GONE, GONE, at(4, 3, 0), back)[0] is not None
and rs(GONE, GONE, at(4, 5, 1), back)[0] is None)
# Two today sensors, only one read fresh: the sum never had a fresh figure,
# so the evening one still stands in (as before).
h2 = solarfc.Hold()
td2 = [("sensor.t1", st(0.2, at(3, 17, 5))),
("sensor.t2", st(0.2, at(3, 17, 5)))]
tm2 = [("sensor.m1", st(6.0, at(3, 17, 5))),
("sensor.m2", st(6.0, at(3, 17, 5)))]
gone2 = [(e, GONE) for e, _s in td2], [(e, GONE) for e, _s in tm2]
solarfc.read_sum(td2, tm2, at(3, 17, 10), h2)
solarfc.read_sum([("sensor.t1", st(9.0, at(4, 2, 0))), td2[1]], tm2,
at(4, 2, 1), h2)
g5 = solarfc.read_sum(*gone2, at(4, 5, 1), h2)[0]
check("two today sensors, only one read fresh at 02:00: the evening sum "
"(12.0) still stands in at 05:01", g5 is not None and g5.kept
and g5.today.kwh == 12.0)
solarfc.read_sum([td2[0], ("sensor.t2", st(9.0, at(4, 2, 10)))], tm2,
at(4, 2, 11), h2)
check("... and once both have read fresh, it does not",
solarfc.read_sum(*gone2, at(4, 5, 20), h2)[0] is None)
# DST: the 25 h autumn day and the 23 h spring day. The window ends at 06:00
# LOCAL, not 6 h after midnight.
for (y, mo, d), label in (((2026, 10, 25), "25 h day 2026-10-25"),
@@ -474,19 +431,6 @@ SCEN = {
soc={"a": 65.0, "b": 95.0},
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 45)]),
}
# F-1 (rig 4v): silent from 17:05, one fresh reading at 02:00, silent again.
# It is stale at 05:00, before the 06:00 bell: the 17:05 figure it replaced
# must not come back. Brighter (A's level drops), with a restart at 03:00,
# and darker (the level rises).
F1_MARKS = [at(4, 1, 55)] + [at(4, 2, 15) + timedelta(minutes=15 * i)
for i in range(19)]
for k, kwh, rst, hw in (("f1_bright", 30.0, (), 700.0),
("f1_bright_rs", 30.0, (at(4, 3, 0),), 700.0),
("f1_dark", 1.0, (), 500.0)):
SCEN[k] = dict(start=at(4, 1, 30), hours=5.5, house_w=hw,
soc={"a": 50.0, "b": 50.0}, fresh_from=at(4, 2, 0),
fresh_until=at(4, 2, 0), fresh=(kwh, kwh),
restarts=list(rst), marks=F1_MARKS)
t0 = time.time()
with ProcessPoolExecutor(max_workers=min(6, os.cpu_count() or 2),
mp_context=get_context("fork")) as pool:
@@ -619,45 +563,6 @@ check("(control: the same latch, still valid until tomorrow 07:15, does "
f"charge both: A {sl_['soc'][0]:.1f}, B {sl_['soc'][1]:.1f} %)",
sl_["soc"][0] > 11.0 and sl_["soc"][1] > 11.0
and sl_["marks"]["22:20"]["state"] == "latched")
for k in ("f1_bright", "f1_bright_rs"):
fb = R[k]
mk = fb["marks"]
lo_t = mk["02:15"]["target_a"]
after = [mk[h] for h in sorted(mk) if h >= "02:15"]
print(f" {k}: A level 01:55 {mk['01:55']['target_a']} -> 02:15 "
f"{lo_t}; A {fb['soc_at']['02:15'][0]:.1f} % at 02:15, "
f"{fb['soc_at']['06:55'][0]:.1f} % at 06:55; lines "
+ " | ".join(sorted({x['line'] for x in after})))
check(f"F-1 {k}: a brighter fresh figure at 02:00 lowers A's level "
"below its SoC and A stops",
lo_t is not None and mk["01:55"]["target_a"] is not None
and lo_t < mk["01:55"]["target_a"] - 10.0
and lo_t < mk["02:15"]["soc_a"])
check(f"... after 02:00 the screen never says 'read 17:05' and from "
"05:15 there is no forecast, with the LOWERED level",
all("17:05" not in x["line"] and not (x["kept"] and
not x["fc_none"])
for x in after)
and all(mk[h]["fc_none"] and near(mk[h]["target_a"], lo_t, 0.5)
for h in mk if "05:15" <= h < "07:00"))
check(f"... so A stays at its lowered level to 07:00 "
f"({fb['soc_at']['02:30'][0]:.1f} -> "
f"{fb['soc_at']['06:55'][0]:.1f} %)",
fb["soc_at"]["06:55"][0] < fb["soc_at"]["02:30"][0] + 1.0)
fd = R["f1_dark"]
md = fd["marks"]
print(f" f1_dark: A level 01:55 {md['01:55']['target_a']} -> 02:15 "
f"{md['02:15']['target_a']}; A {fd['soc_at']['06:55'][0]:.1f} % "
"at 06:55")
check("F-1 darker fresh figure at 02:00: the level rises, is held after "
"05:00 with no forecast (never 'read 17:05'), and A charges to it",
md["02:15"]["target_a"] is not None
and md["01:55"]["target_a"] is not None
and md["02:15"]["target_a"] > md["01:55"]["target_a"] + 3.0
and all("17:05" not in md[h]["line"] for h in md if h >= "02:15")
and all(md[h]["fc_none"] for h in md if "05:15" <= h < "07:00")
and near(fd["soc_at"]["06:55"][0], md["02:15"]["target_a"], 1.5))
tb = R["tail_bfull"]
check("B full: the run ends at 06:33 before A is at its level, so the next "
"slice is only A's, planned after the figure has ended with A's "
+2 -2
View File
@@ -3080,13 +3080,13 @@ _unit_sources_unrouted = [
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
print(f" {len(_direct)} direct appends, each read at its own line: "
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
check("all EIGHTEEN of `Controller.cycle()`'s DIRECT appends route through "
check("all SEVENTEEN of `Controller.cycle()`'s DIRECT appends route through "
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
"of EVERY one of them. An append that computed its actuator, or omitted "
"the argument, or hand-wrote a string, fails HERE rather than being "
"silently skipped - which is how a scan like this goes quietly vacuous, "
"and 10f is in this file because that already happened once",
len(_direct) == 18
len(_direct) == 17
and all(a is not None and a.startswith("self.actuator_of('")
for _s, a, _ln in _routing))
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
-493
View File
@@ -1,493 +0,0 @@
"""Runnable check for SAFETY-128: control does not act on late readings.
`python3 test_safety128.py`
When Home Assistant runs behind, its readings arrive late but still change, so
every staleness gate passes them. The loop corrected again, each cycle, an
error its last command had already fixed, and the evening give run swung with
growing export (SAFETY-127 rig: 10 s late, commands -450..+3 790 W, grid to
-1 947 W). The peak guard pushed to the inverter limit on the same late figures
(OPT-34 rig F-2). Now a command the battery reading has not answered holds the
loop and caps the peak guard (app/lateness.py), and readings found late hold,
then go to 0 W. DECISIONS/SAFETY-128-late-readings.md.
The REAL Controller on TEST-23's plant (tools/safety128_rig.py). "Without the
gate" is the same run with `Controller._readings_late` returning None, which
is the tree before this card (the rig runs unchanged on 77fe31b).
1. The give run (two batteries, house 1 800 W), readings 10 s late for 150 s
and 30 s late for 300 s: without the gate the swing grows to export below
-1 kW; with it the command stays near the last good one, no export below
-200 W, and it settles once the lag stops. N=1 too.
2. The peak guard in a quarter's last 30 s (a charge at the peak aim, readings
30 s late, the house steps): it does not go to the inverter limit.
3. Seen: the late hold says so on the page (EN and NL) and logs once per
episode.
4. Nothing else moves: no-lag runs at 1, 2 and 5 s meters, a load step on a
battery with a 6 s dead time (SAFETY-75), start-up, a battery at its
cut-out level: within the stated tolerance of the run without the gate.
5. `Answer` and `compute(hold=)` at their edges.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import sys
from datetime import timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
import safety128_rig as R # noqa: E402
import app.main as app_main # noqa: E402
from app import control, lateness, messages # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def no_gate(fn):
"""`fn()` with the answer gate off: the tree before SAFETY-128."""
real = app_main.Controller._readings_late
app_main.Controller._readings_late = lambda self, standing_w: None
try:
return fn()
finally:
app_main.Controller._readings_late = real
def window(rows, t0, t1):
return [r for r in rows if t0 <= r[0] < t1]
def last_good(rows, t):
"""The site command standing just before `t`."""
return [r for r in rows if r[0] < t][-1][2]
def settled_at(rows, t0, band=150.0):
"""First second from `t0` after which the grid stays within `band` W."""
out = None
for r in rows:
if r[0] < t0:
continue
if abs(r[1]) > band:
out = None
elif out is None:
out = r[0]
return out
def swing(rows, t0, t1):
w = window(rows, t0, t1)
return max(r[1] for r in w) - min(r[1] for r in w)
# --------------------------------------------------------------------------- #
print("1. the give run with readings late")
ON = 300.0
CASES = ((10.0, 150.0), (30.0, 300.0))
for p1, units in ((2.0, ("a", "b")), (2.0, ("a",)), (1.0, ("a", "b")),
(5.0, ("a", "b"))):
house = 1800.0 if len(units) == 2 else 1500.0
for lag, span in CASES:
off = ON + span
seen = []
new = R.Rig(house=house, lag_s=lag, lag_on=ON, lag_off=off,
units=units, p1_s=p1)
new.run(off + 120.0, lambda rig, c, t: seen.append(
(t, c.answer.late_seen)) if c.answer.late_seen else None)
old = R.Rig(house=house, lag_s=lag, lag_on=ON, lag_off=off,
units=units, p1_s=p1)
no_gate(lambda: old.run(off + 120.0))
n = f"N={len(units)}, {p1:.0f} s meter, {lag:.0f} s late for {span:.0f} s"
wo, wn = window(old.rows, ON, off), window(new.rows, ON, off)
good = last_good(new.rows, ON)
half = ON + span / 2
red = min(r[1] for r in wo) < -1000.0
found = min([t for t, _l in seen] or [None])
print(f" {n}: without the gate cmd {min(r[2] for r in wo):.0f}.."
f"{max(r[2] for r in wo):.0f} W, grid {min(r[1] for r in wo):.0f}"
f"..{max(r[1] for r in wo):.0f} W (swing {swing(old.rows, ON, half):.0f}"
f" then {swing(old.rows, half, off):.0f}); with it cmd "
f"{min(r[2] for r in wn):.0f}..{max(r[2] for r in wn):.0f} W "
f"(last good {good:.0f}), grid {min(r[1] for r in wn):.0f}.."
f"{max(r[1] for r in wn):.0f} W, found late "
f"{None if found is None else found - ON} s after the start, "
f"settled {settled_at(new.rows, off) - off:.0f} s after")
if len(units) == 2 and p1 == 2.0:
check(f"{n}: without the gate the export goes below -1 kW and the "
f"swing grows (red on 77fe31b)",
red and swing(old.rows, half, off) > swing(old.rows, ON, half))
if red:
check(f"{n}: found late while the lag lasts", found is not None
and found < off)
if p1 < 5.0:
# Found within about a minute: at 2 s the brief's bounds; at 1 s
# the swing grows faster before it is found, so twice them.
lim = 250.0 if p1 == 2.0 else 500.0
check(f"{n}: with it the command stays within {lim:.0f} W of the "
f"last good one and no export below -{lim - 50:.0f} W",
all(abs(r[2] - good) <= lim for r in wn)
and min(r[1] for r in wn) >= -(lim - 50.0))
elif red:
# 5 s meter: only the batteries' fit runs (too few meter values),
# so it is found later; the export is cut to under a third.
check(f"{n}: with it the export is under a third of the run "
f"without the gate",
min(r[1] for r in wn) >= min(r[1] for r in wo) / 3.0)
check(f"{n}: settled within 30 s after the lag stops",
settled_at(new.rows, off) is not None
and settled_at(new.rows, off) - off <= 30.0)
# --------------------------------------------------------------------------- #
print("\n2. the peak guard in a quarter's last 30 s")
# A charge sized to the peak aim (arbitrage on a cheap hour, the shape of the
# owner's night charge), readings 30 s late from 300 s; the house steps
# +2 000 W 100 s before the quarter ends (900 s).
PTS = [(R.BASE + timedelta(hours=h), 3600, 0.02 if h < 2 else 0.40)
for h in range(-3, 30)]
def peak_run(lag, gate=True, step_at=800.0):
seen = []
def sample(rig, c, t):
now = rig.start + timedelta(seconds=t)
if int(t) % 300 == 0:
c.tariff_plan = rp.plan_at(now, PTS)
c.tariff_at = now
g = c.peak_guard
if g is not None and 870.0 <= t < 900.0 and g.engaged:
seen.append((t, g.saturated, float(g.floor_w or 0.0),
float(c.target)))
r = R.Rig(house=lambda t: 1000.0 + (2000.0 if t >= step_at else 0.0),
lag_s=lag, lag_on=ON, lag_off=2000.0, soc=30.0,
extra={"arbitrage_enabled": True,
"cycle_cost_threshold_eur": 0.03})
(lambda f: f() if gate else no_gate(f))(lambda: r.run(900.0, sample))
return seen, r
RATING = sum(R.MAXW.values())
for lag in (10.0, 30.0):
pg_new, _r = peak_run(lag)
pg_old, _r = peak_run(lag, gate=False)
top_new = max([s[2] for s in pg_new] or [0.0])
top_old = max([s[2] for s in pg_old] or [0.0])
print(f" {lag:.0f} s late: the guard's largest ask in the last 30 s "
f"without the gate {top_old:.0f} W ({sum(s[1] for s in pg_old)} "
f"cycles at the limit), with it {top_new:.0f} W "
f"({sum(s[1] for s in pg_new)})")
if lag == 30.0:
check("without the gate the guard goes to the inverter limit on the "
"late projection (red on 77fe31b)",
top_old >= RATING and any(s[1] for s in pg_old))
check(f"{lag:.0f} s late: with it the guard never goes to the limit and "
f"never says it is saturated",
top_new < RATING and not any(s[1] for s in pg_new))
# --------------------------------------------------------------------------- #
print("\n3. the page and the log say so")
rows_seen = []
def watch(rig, c, t):
rows_seen.extend((t, r) for r in c.checks() if r.get("id") == "late")
late = R.Rig(lag_s=30.0, lag_on=ON, lag_off=ON + 300.0)
c = late.run(ON + 300.0 + lateness.FIT_GONE_S + 60.0, watch)
log = [e for e in c.events if "readings late" in e or "readings on time" in e]
n_late = sum(1 for e in log if "readings late" in e)
n_back = sum(1 for e in log if "readings on time" in e)
first = min([t for t, _r in rows_seen] or [None])
lastr = max([t for t, _r in rows_seen] or [None])
print(f" 30 s late for 300 s: {n_late} late episode(s), {n_back} end(s), "
f"the row from {first} to {lastr} s ({len(rows_seen)} s); "
f"{rows_seen[0][1]['text'] if rows_seen else ''}")
_ok_texts = {str(messages.row_readings_late(None))} | {
str(messages.row_readings_late(d)) for d in range(20, 33)}
check("found late within 70 s of the lag starting, and an amber row says "
"so in plain words, EN and NL, with the batteries' delay once they have "
"shown it (20-32 s for 30 s late; never a figure from the meter alone)",
first is not None and first - ON <= 70.0
and all(r["warn"] and not r["ok"] for _t, r in rows_seen)
and {str(r["text"]) for _t, r in rows_seen} <= _ok_texts
and rows_seen[-1][1]["text"].nl.startswith(
"Metingen uit Home Assistant tonen vermogensvraag"))
rows5 = []
late5 = R.Rig(lag_s=30.0, lag_on=ON, lag_off=ON + 300.0, p1_s=5.0)
late5.run(ON + 300.0, lambda rig, c, t: rows5.extend(
str(r["text"]) for r in c.checks() if r.get("id") == "late"))
print(f" 5 s meter, 30 s late: {sorted(set(rows5))}")
check("at a 5 s meter (the batteries' fit) the row names the delay: about "
"20-32 s for 30 s late",
rows5 and set(rows5) <= _ok_texts and any("about" in x for x in rows5))
check("logged once for the episode, and once when it ends, not per cycle",
n_late == 1 and n_back == 1)
check("the row goes once a fit shows the readings on time, or 2 minutes "
"after the last fit, at most 160 s after the lag (a steady site shows "
"nothing to fit)",
lastr is not None
and lastr - (ON + 300.0) <= lateness.FIT_S + lateness.FIT_GONE_S
and not [r for r in c.checks() if r.get("id") == "late"])
# The late verdict through the real cycle(): readings found late (30 s), then
# the gate is frozen with its verdict 5 s old (held) or 20 s old (past
# stale_input_s, 15 s).
seen_zero = {}
def verdict(after_s):
def at(rig, c, t):
if t == 500.0:
c.answer.observe = lambda *a, **k: None
c.answer.late_seen, c.answer.late_at = True, c._now_mono - after_s
seen_zero[after_s] = [c.target]
if t == 506.0:
seen_zero[after_s] += [
c.target, c.reason, [r["text"] for r in c.checks()
if r.get("id") == "late"], c.why_words]
v = R.Rig(lag_s=30.0, lag_on=ON, lag_off=2000.0)
v.run(507.0, at)
verdict(5.0)
verdict(20.0)
held, zero = seen_zero[5.0], seen_zero[20.0]
print(f" late verdict 5 s old: {held[:3]}; 20 s old: {zero[:3]}")
check("the late verdict holds the request until stale_input_s, then every "
"inverter goes to 0 W, and the row says so (EN and NL)",
held[1] == held[0] and held[2].startswith("readings late")
and zero[1] == 0.0 and zero[4].startswith(
"Home Assistant readings arrive late: 0 W")
and len(zero[3]) == 1 and "every inverter is at 0 W" in zero[3][0]
and "omvormers op 0 W" in zero[3][0].nl)
on_time = R.Rig(house=lambda t: 600.0 + (2500.0 if t >= 200.0 else 0.0))
c = on_time.run(600.0)
check("readings on time, a load step: no late episode, no row",
not [e for e in c.events if "readings late" in e]
and not c.answer.late_seen)
# --------------------------------------------------------------------------- #
print("\n4. nothing else moves")
STEP = dict(house=lambda t: 600.0 + (2500.0 if t >= 200.0 else 0.0))
def compare(name, tol_w, **kw):
a = R.Rig(**kw)
a.run(500.0)
b = R.Rig(**kw)
no_gate(lambda: b.run(500.0))
diff = max(abs(x[2] - y[2]) for x, y in zip(a.rows, b.rows))
sa, sb = settled_at(a.rows, 200.0), settled_at(b.rows, 200.0)
print(f" {name}: largest command difference {diff:.0f} W, settled "
f"{sa - 200.0 if sa else None} s after the step (without the gate "
f"{sb - 200.0 if sb else None} s)")
return diff, sa, sb, a
for p1 in (1.0, 2.0, 5.0):
for units in (("a", "b"), ("a",)):
d, sa, sb, _a = compare(f"no lag, {p1:.0f} s meter, N={len(units)}, "
f"house 600 -> 3 100 W", 0.0, p1_s=p1,
units=units, **STEP)
check(f"{p1:.0f} s meter, N={len(units)}: settles within 10 s of the "
f"run without the gate",
sa is not None and sb is not None and sa - sb <= 10.0)
d, sa, sb, a75 = compare("SAFETY-75 shape, 6 s dead time", 0.0, dead_s=6.0,
**STEP)
check("6 s dead time (SAFETY-75): the same commands as without the gate, and "
"no late episode (the fit says 5-7 s, under 8 s)",
d == 0.0 and not any("readings late" in e for e in a75.c.events))
d, sa, sb, a0 = compare("start-up, the house at 2 000 W from the first second",
0.0, house=2000.0)
check("start-up: the first 120 s reach the house within 30 s, as without the "
"gate, and no late episode",
settled_at(a0.rows, 0.0) is not None and settled_at(a0.rows, 0.0) <= 30.0
and not any("readings late" in e for e in a0.c.events))
for on, off in ((45, 60), (30, 30)):
house = (lambda t, on=on, off=off:
400.0 + (2000.0 if t % (on + off) < on else 0.0))
oven = R.Rig(house=house, noise=80.0)
oven.run(1500.0)
oven0 = R.Rig(house=house, noise=80.0)
no_gate(lambda: oven0.run(1500.0))
check(f"an oven cycling 2 kW {on} s on, {off} s off (2 s meter, readings "
f"on time): never taken for late readings ({oven.c.answer.fits} "
"fits), the same commands as without the gate",
oven.c.answer.fits > 100 and not any(
"readings late" in e for e in oven.c.events)
and [r[2] for r in oven.rows] == [r[2] for r in oven0.rows])
# One battery that empties to its cut-out level under an oven (1 s meter):
# its fit lined the old movement up as 85 s late (round 2, before the fix).
emp = R.Rig(house=lambda t: 400.0 + (2000.0 if t % 105 < 45 else 0.0),
noise=150.0, soc=10.6, units=("a",), p1_s=1.0)
emp.run(1800.0)
check("one battery emptying to its cut-out level under a cycling oven (1 s "
"meter): not taken for late readings",
not any("readings late" in e for e in emp.c.events))
emp2 = R.Rig(house=lambda t: 400.0 + (2000.0 if t % 60 < 30 else 0.0),
noise=150.0, soc=10.2, units=("a",), p1_s=2.0)
emp2.run(1800.0)
check("one empty battery under an oven cycling 30/30 s (2 s meter): its "
"requests do not start, but it gives nothing, so that is no evidence "
"for the meter fit",
not any("readings late" in e for e in emp2.c.events))
# The recorded house (2026-09-02, its first 2 hours, modes off) at a 2 s
# meter: the meter fit alone read it as up to 65 s late here (review, B1).
from datetime import datetime as _dt, timezone as _tz # noqa: E402
_tick, rp.TICK = rp.TICK, 2.0
_late = []
try:
_st, _rc = rp.run("modes_off", rp.load_house(["2026-09-02"]),
rp.epex_points(),
_dt.fromisoformat("2026-09-02T00:00:00+02:00")
.astimezone(_tz.utc), 7200.0,
check=lambda c, h, now, pr: _late.append(
c.answer.late_seen))
finally:
rp.TICK = _tick
check(f"the recorded house, 2 hours at a 2 s meter: {_rc.answer.fits} fits, "
"never taken for late readings",
_rc.answer.fits > 100 and not any(_late))
import safety94_rig as s94 # noqa: E402
slow = [(n, tau, tick) for n in (1, 2) for tau in (10.0, 20.0)
for tick in (1.0, 2.0)
if any("readings late" in e for e in s94.run(
n, s94.reversal(300.0, -2500.0, 800.0), 600.0, tau=tau,
tick=tick)["c"].events)]
check("a slow battery (10 or 20 s lag, SAFETY-85/94's rig) reversing out of a "
"charge, readings on time: never taken for late readings, N=1 and N=2, "
f"1 and 2 s meters (taken: {slow})", not slow)
for name, kw in (("at their cut-out level", dict(soc=10.5)),
("A refusing from 200 s",
dict(cut=lambda u, t: u == "a" and t >= 200.0))):
x = R.Rig(house=1800.0, **kw)
x.run(600.0)
y = R.Rig(house=1800.0, **kw)
no_gate(lambda: y.run(600.0))
check(f"readings on time, batteries {name}: not taken for late "
f"readings, the same commands as without the gate",
not any("readings late" in e for e in x.c.events)
and [r[2] for r in x.rows] == [r[2] for r in y.rows])
# --------------------------------------------------------------------------- #
print("\n5. the rules at their edges")
check("LATE_S is SAFETY-75's slowest healthy answer: 6 + 6 + 10 + 1 = 23 s",
lateness.LATE_S == 23.0)
check("ANSWER_W is a probe's answer (100 W)",
lateness.ANSWER_W == control.PROBE_ANSWER_W == 100.0)
check("late from ON_S 9.5 s, on time again under OFF_S 6.5 s",
(lateness.ON_S, lateness.OFF_S) == (9.5, 6.5))
def fit(delay, *, follows=True, step_every=20, dur=300, tau=0.0, units="ab"):
"""`Delay` on batteries whose readings, published every 10 s at their own
phase, show each request `delay` s late (through a `tau` s lag), or never
(`follows` False)."""
import math
import random
rng, d = random.Random(7), lateness.Delay()
edges = [0]
while edges[-1] < dur:
edges.append(edges[-1] + step_every * rng.uniform(0.5, 1.5))
cmd = lambda t: 1500.0 + (600.0 if sum(e <= t for e in edges) % 2 else 0.0)
act, shown = 1500.0, {}
for t in range(0, dur):
want = cmd(t - delay) if t >= delay else 1500.0
act = want if not tau else act + (want - act) * (1 - math.exp(-1 / tau))
for k, u in enumerate(units):
if (t + 4 * k) % 10 == 0:
shown[u] = round(act + rng.uniform(-3, 3)) if follows else 900
d.add(float(t), u, cmd(t), shown.get(u))
return d.estimate()
got = {k: fit(k) for k in (0, 6, 10, 30, 60)}
print(f" fitted delays: {got}; one battery {fit(10, units='a')}")
check("the fit finds the readings' delay (0, 6, 10, 30, 60 s) to within 2 s "
"(it reads the request over the 2 s before), also with one battery",
all(v is not None and abs(v - k) <= 2.0 for k, v in got.items())
and abs((fit(10, units="a") or 0.0) - 10.0) <= 2.0)
slow = [fit(0, tau=tau, step_every=s) for tau in (3.0, 10.0, 20.0)
for s in (10, 20, 40)]
check("a battery that does not follow gives no fit, nor does a request that "
f"does not move, and a slow battery (3, 10, 20 s lag) is never late "
f"({slow})",
fit(10, follows=False) is None and fit(10, step_every=10_000) is None
and all((d or 0.0) < lateness.ON_S for d in slow))
def gate(steps, exempt=(), late_seen=True):
"""[(t, command, battery reading, meter)] through one Answer, its fit
replaced by `late_seen`; after each: (pending, hold, late s, peak cap)."""
a = lateness.Answer()
a.late_seen = late_seen
def one(t, cmd, batt, grid):
a.observe(float(t), cmd, batt, grid_w=grid, exempt=t in exempt)
return (a.ask is not None, a.hold, a.late_s(float(t)),
a.peak_cap(cmd))
return a, [one(*x) for x in steps]
STEP = [(0, 1000, 1000, 500), (2, 1500, 1000, 500)]
HELD = STEP + [(t, 1500, 1000, 500) for t in range(4, 40, 2)]
a, out = gate(HELD, late_seen=False)
check("readings on time: an unanswered ask holds nothing, caps nothing and "
"is never late (a battery at its limit)",
out[-1][0] and all(o[1] == 0 and o[2] is None and o[3] is None
for o in out))
a, out = gate(STEP + [(4, 1500, 1000, 500), (6, 1500, 1450, 60)])
check("readings late: the ask holds the loop up and caps the peak guard at "
"what stands until the battery reading answers",
out[1][1:] == (1, None, 1500.0) and out[2][1:] == (1, None, 1500.0)
and out[3] == (False, 0, None, None))
a, out = gate(HELD)
check("...and with no move towards it for 23 s it is late (control holds, "
"then 0 W)",
[o[2] for o in out].index(0.0) == len([t for t, *_ in HELD
if t - 2 < 23]))
a, out = gate(HELD + [(40, 1500, 1000, 380)])
check("...which ends when a reading starts towards the ask",
out[-2][2] is not None and out[-1][2] is None and out[-1][0])
a, out = gate(HELD, exempt=set(range(2, 40, 2)))
check("exempt (a battery gives nothing under its ask, or refuses): no hold "
"and no late verdict, the cap stays",
all(o[1] == 0 and o[2] is None for o in out) and out[-1][3] == 1500.0)
a, out = gate([(0, 1000, 1000, 500), (2, 1090, 1000, 500),
(4, 1000, 1000, 500)])
check("a move under 100 W is not an ask", not any(o[0] for o in out))
a, out = gate(STEP + [(4, 1050, 1000, 500)])
check("an ask taken back within 100 W of the reading is withdrawn",
out[2][0] is False)
T = control.Tuning(max_w=5000.0)
base = dict(prev_w=1500.0, grid_w=800.0, actual_w=1000.0, tuning=T,
i_w=1500.0)
check("compute(hold=+1): the loop may not push past the standing command, "
"but may come back",
control.compute(**base).target_w > 1500.0
and control.compute(**base, hold=1).target_w == 1500.0
and control.compute(**base, hold=1).reason == control.WAITING
and control.compute(**dict(base, grid_w=-800.0), hold=1).target_w
< 1500.0)
check("compute(hold=-1) mirrors it, and hold=0 is the law as it was",
control.compute(**dict(base, grid_w=-800.0), hold=-1).target_w == 1500.0
and control.compute(**base, hold=0) == control.compute(**base))
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")
-4
View File
@@ -61,9 +61,6 @@ def without_fix(fn):
"""Run `fn` with the gate's old clock and the old refusal evidence."""
real_f, real_c = app_inverter.Freshness, app_main.compute
real_p = app_main.REFUSED_PROGRESS_W
real_l = app_main.Controller._readings_late
# SAFETY-128: that tree had no late-readings gate either.
app_main.Controller._readings_late = lambda self, standing_w: None
app_inverter.Freshness = Before94
app_main.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None))
app_main.REFUSED_PROGRESS_W = float("inf")
@@ -72,7 +69,6 @@ def without_fix(fn):
finally:
app_inverter.Freshness, app_main.compute = real_f, real_c
app_main.REFUSED_PROGRESS_W = real_p
app_main.Controller._readings_late = real_l
# ============================================================================ #
-4
View File
@@ -68,14 +68,10 @@ def without(fn):
Controller._held_out_w = lambda self, live: 0.0
Controller._reseed_drop = lambda self, live: setattr(
self, "_live_was", frozenset(live))
real_late = Controller._readings_late
# SAFETY-128: that tree had no late-readings gate either.
Controller._readings_late = lambda self, standing_w: None
try:
return fn()
finally:
Controller._held_out_w, Controller._reseed_drop = fixed, real_drop
Controller._readings_late = real_late
def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None,
+75 -10
View File
@@ -5,12 +5,19 @@
For each FILE (default: every goodwe_controller/app/*.py that differs from
BASE_REF, default release/1.0) the ast.dump() of the tree with docstrings
removed must be identical at BASE_REF and in the working tree. Also prints
code lines vs comment-only + docstring lines (whole lines, via tokenize) before
and after. Run from the repository root. Exit 1 on any code difference.
removed must be identical at BASE_REF and in the working tree, and so must
the `python -OO` compiled code (a wholly removed docstring leaves a no-op
there). An added or removed app/*.py file is a code difference. The diff
against BASE_REF may touch only goodwe_controller/app/*.py,
goodwe_controller/DECISIONS/*.md and this script. Also prints code lines vs
comment-only + docstring lines (whole lines, via tokenize) before and after,
and the docstrings removed entirely or added. Run from the repository root.
Exit 1 on any difference.
"""
import ast
import io
import os
import re
import subprocess
import sys
import tokenize
@@ -53,29 +60,87 @@ def counts(src):
return len(code), len(doc) + len(com - code - doc)
def qualnames(src):
out = {}
def walk(node, prefix):
for n in ast.iter_child_nodes(node):
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef,
ast.ClassDef)):
q = prefix + n.name
out[q] = docstring(n) is not None
walk(n, q + ".")
out["<module>"] = docstring(ast.parse(src)) is not None
walk(ast.parse(src), "")
return out
def code_sig(co):
consts = tuple(code_sig(c) if hasattr(c, "co_code") else c
for c in co.co_consts)
return (co.co_name, co.co_code, consts, co.co_names, co.co_varnames,
co.co_freevars, co.co_cellvars, co.co_flags, co.co_argcount,
co.co_stacksize, co.co_exceptiontable)
def oo_same(old, new):
return (code_sig(compile(old, "f", "exec", optimize=2))
== code_sig(compile(new, "f", "exec", optimize=2)))
def git(*a):
return subprocess.run(["git", *a], check=True, capture_output=True,
text=True).stdout
def exists(base, f):
return subprocess.run(["git", "cat-file", "-e", f"{base}:{f}"],
capture_output=True).returncode == 0
ALLOWED = re.compile(r"^goodwe_controller/(app/[^/]+\.py|DECISIONS/[^/]+\.md|"
r"tools/maint20_same_code\.py)$")
def ratio(m, c):
return f"{m / c:.2f}" if c else "n/a"
def main(argv):
base = argv[0] if argv else "release/1.0"
files = argv[1:] or [
f for f in git("diff", "--name-only", base, "--",
"goodwe_controller/app").split()
if f.endswith(".py")]
bad = 0
print(f"base {base} = {git('rev-parse', base).strip()}")
changed = git("diff", "--name-only", base).split()
for f in changed:
if not ALLOWED.match(f):
print(f"FAIL: diff touches {f}, outside app/*.py, DECISIONS/*.md "
"and this script")
bad += 1
files = argv[1:] or [f for f in changed if re.match(
r"^goodwe_controller/app/[^/]+\.py$", f)]
for f in files:
in_base, in_head = exists(base, f), os.path.exists(f)
if in_base != in_head:
print(f"CODE DIFFERS {f} "
f"{'removed' if in_base else 'added'} file")
bad += 1
continue
old = git("show", f"{base}:{f}")
new = open(f, encoding="utf-8").read()
same = dump(old) == dump(new)
oo = oo_same(old, new)
(c0, m0), (c1, m1) = counts(old), counts(new)
print(f"{'SAME CODE' if same else 'CODE DIFFERS'} {f} "
f"code {c0}->{c1} comment+doc {m0}->{m1} "
f"ratio {m0 / c0:.2f}->{m1 / c1:.2f}")
bad += not same
print("OK: no code change" if not bad else f"FAIL: {bad} file(s) differ")
f"ratio {ratio(m0, c0)}->{ratio(m1, c1)} "
f"-OO {'same' if oo else 'DIFFERS'}")
bad += (not same) + (not oo)
qo, qn = qualnames(old), qualnames(new)
for q in sorted(q for q in qo if qo[q] and not qn.get(q, True)):
print(f" docstring REMOVED: {f}:{q}")
for q in sorted(q for q in qn if qn[q] and not qo.get(q, True)):
print(f" docstring ADDED: {f}:{q}")
print("OK: no code change" if not bad else f"FAIL: {bad} problem(s)")
return 1 if bad else 0
-284
View File
@@ -1,284 +0,0 @@
#!/usr/bin/env python3
"""SAFETY-128: the REAL Controller with Home Assistant readings arriving late.
python3 tools/safety128_rig.py # prints a few runs' summaries
The plant is TEST-23's (SAFETY-127 rig, t127_plant.py): two batteries
(A 5 000 W, B 4 600 W) that ramp at 5 000 W/s and then follow a 1 s lag; each
battery reading is published every 10 s (A at :x6, B at :x1.6); the meter every
2 s; the house is `house` W plus seeded +-`noise` W, new each second.
`p1_s` sets the meter's period, `dead_s` a command dead time (SAFETY-75's
laggy battery), `units` one battery ("a") or two.
Home Assistant runs `lag_s` seconds behind (a number, or a function of t) between `lag_on` and `lag_off`:
every reading the add-on gets (meter, battery power, battery level) is the
one published `lag_s` seconds earlier. When the lag starts, nothing new comes
for `lag_s` seconds, then the backlog; when it stops, the readings jump to
now. Commands reach the plant at once (t127_shim.py's lag mode).
The add-on reads every 1 s and runs a cycle on a new meter value or after
`cycle_floor_s`, as `run_control()` does. Uses only the Controller API that
release/1.0 77fe31b has, so the same file runs on the tree before SAFETY-128.
"""
import asyncio
import math
import os
import random
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
# 18:00 local (CET) on a weekday: the evening give run.
BASE = datetime(2026, 11, 4, 17, 0, tzinfo=timezone.utc)
UNITS = ("a", "b")
MAXW = {"a": 5000.0, "b": 4600.0}
CAPWH = {"a": 15360.0, "b": 20480.0}
PHASE = {"a": 6.0, "b": 1.6}
BPER = 10.0
P1PER = 2.0
RAMP = 5000.0
LAG = 1.0
def options(units=UNITS):
o = dict(OPTIONS)
o["inverters"] = [
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
"max_w": int(MAXW[u]), "bms_soc_floor": 10,
"capacity_kwh": CAPWH[u] / 1000.0} for u in units]
return o
OPTIONS = {
"meter_entity": "sensor.grid", "meter_source": "ha_signed",
"p1_net_entity": "sensor.grid", "meter_phases": 1, "meter_max_age_s": 30,
"peak_cap_w": 3500, "peak_margin_w": 50, "peak_soc_floor": 20,
"gain": 0.6, "slew_w": 0, "target_grid_w": -10, "deadband_w": 15,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"cycle_floor_s": 15, "saturation_w": 500, "saturation_cycles": 3,
"auto_start": True, "battery_modes_enabled": False,
"arbitrage_enabled": False, "bms_soc_floor": 10,
}
class _Store:
def __init__(self):
from app.store import DEFAULTS
self.data = dict(DEFAULTS)
def set(self, k, v):
self.data[k] = v
def get_time(self, k):
v = self.data.get(k)
return datetime.fromisoformat(v) if v else None
def set_time(self, k, when):
self.data[k] = when.isoformat() if when else None
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class Plant:
"""The batteries, their registers and the meter, in 0.1 s steps."""
def __init__(self, house, noise, seed, soc, cut=None, units=UNITS,
p1_s=P1PER, dead_s=0.0):
self.house_fn, self.noise, self.seed = house, noise, seed
self.units, self.p1_s, self.dead_s = units, p1_s, dead_s
self.cut = cut or (lambda u, t: False)
self.cmd = {u: 0.0 for u in units}
self.queue = [] # (t, unit, command), dead time
self.tgt = {u: 0.0 for u in units}
self.act = {u: 0.0 for u in units}
self.soc = {u: float(soc) for u in units}
self.reg = {u: 0.0 for u in units}
self.slot = {u: None for u in units}
self.p1 = None
self.p1_n = 0
self.t = 0.0
self.house = float(house(0.0))
self.hist = [] # (t, p1 value, p1 index, {reg}, {soc})
def grid(self):
return self.house - sum(self.act.values())
def advance(self, t):
while self.t < t - 1e-9:
self.t = round(self.t + 0.1, 6)
sec = int(math.floor(self.t + 1e-9))
rs = random.Random(self.seed * 1000003 + sec)
self.house = (float(self.house_fn(self.t))
+ rs.uniform(-self.noise, self.noise))
while self.queue and self.queue[0][0] <= self.t + 1e-9:
_t, u, v = self.queue.pop(0)
self.cmd[u] = v
for u in self.units:
want = max(-MAXW[u], min(MAXW[u], self.cmd[u]))
if self.cut(u, self.t):
want = 0.0
if self.soc[u] <= 10.0 and want > 0:
want = 0.0
if self.soc[u] >= 100.0 and want < 0:
want = 0.0
d = want - self.tgt[u]
self.tgt[u] += math.copysign(min(abs(d), RAMP * 0.1), d)
self.act[u] += ((self.tgt[u] - self.act[u])
* (1 - math.exp(-0.1 / LAG)))
self.soc[u] = max(0.0, min(100.0, self.soc[u] - self.act[u]
* 0.1 / 3600.0 / CAPWH[u] * 100.0))
slot = math.floor((self.t - PHASE[u]) / BPER + 1e-9)
if slot != self.slot[u]:
self.slot[u] = slot
self.reg[u] = float(round(self.act[u]))
if abs(self.t / self.p1_s - round(self.t / self.p1_s)) < 1e-6:
self.p1 = round(self.grid(), 1)
self.p1_n += 1
self.hist.append((self.t, self.p1, self.p1_n, dict(self.reg),
{u: round(v, 2) for u, v in self.soc.items()}))
del self.hist[:-3000]
def view(self, t, lag_s, lag_on, lag_off):
"""What Home Assistant shows at `t`: (p1, p1 index, regs, socs)."""
lag_s = lag_s(t) if callable(lag_s) else lag_s
if lag_s and lag_on <= t < lag_off:
want = max(lag_on, t - lag_s)
for row in reversed(self.hist):
if row[0] <= want + 1e-9:
return row[1:]
return self.hist[0][1:]
return (self.p1, self.p1_n, dict(self.reg),
{u: round(v, 2) for u, v in self.soc.items()})
class _Hass:
def __init__(self, rig):
self.rig = rig
async def limits(self, e):
return (-MAXW[e[-1]], MAXW[e[-1]], 1.0)
async def call(self, *a):
return True
async def state(self, e):
return None
async def number(self, e, invert=False):
_p1, _n, regs, socs = self.rig.seen
u = e[-1]
if e.startswith("sensor.soc"):
return socs[u]
if e.startswith("sensor.batt"):
return regs[u]
return None
async def set_number(self, e, v):
p = self.rig.plant
p.queue.append((p.t + p.dead_s, e[-1], float(v)))
self.rig.writes.append((self.rig.plant.t, e[-1], float(v)))
return True
class Rig:
def __init__(self, house=1800.0, noise=50.0, seed=90, soc=60.0,
lag_s=0.0, lag_on=0.0, lag_off=0.0, extra=None, cut=None,
start=BASE, units=UNITS, p1_s=P1PER, dead_s=0.0):
house_fn = house if callable(house) else (lambda t, h=house: h)
self.plant = Plant(house_fn, noise, seed, soc, cut, units, p1_s,
dead_s)
self.lag = (lag_s, lag_on, lag_off)
self.o = dict(options(units), **(extra or {}))
self.start = start
self.writes = []
self.rows = []
self.seen = None
def run(self, dur, sample=None):
"""Run `dur` s. Rows: (t, true grid, site command, site actual,
loop reason, controller reason). `sample(rig, c, t)` per second."""
async def go():
db = os.path.join(tempfile.mkdtemp(prefix="s128-"), "tariff.db")
c = Controller(self.o, _Hass(self), _Store(), _Pub(),
stop=asyncio.Event(), peak_db_path=db)
for i in c.inverters:
i.dev_min, i.dev_max = -MAXW[i.name[-1]], MAXW[i.name[-1]]
c.auto = True
self.c = c
real_dt = m.datetime
clock = [self.start]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = _DT
floor = float(self.o.get("cycle_floor_s", 15))
last_grid, last_cycle, last_n = object(), None, None
try:
t = 0.0
while t < dur:
self.plant.advance(t)
now = self.start + timedelta(seconds=t)
clock[0] = now
self.seen = self.plant.view(t, *self.lag)
p1, n = self.seen[0], self.seen[1]
if p1 is not None and n != last_n:
last_n = n
imp, exp = split_signed(p1)
c.p1.submit(make_sample(
"ha_signed", imp, exp, phases=1, ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
if (c.grid != last_grid or last_cycle is None
or t - last_cycle >= floor):
last_grid, last_cycle = c.grid, t
await c.cycle()
self.rows.append((
t, self.plant.grid(), sum(self.plant.cmd.values()),
sum(self.plant.act.values()), c.loop_reason,
c.reason))
if sample is not None:
sample(self, c, t)
t += 1.0
finally:
m.datetime = real_dt
return c
return asyncio.run(go())
def summary(rows, t0, t1):
"""Over [t0, t1): (min cmd, max cmd, min grid, max grid, s of export
below -200 W, s below -1 000 W)."""
sel = [r for r in rows if t0 <= r[0] < t1]
return (min(r[2] for r in sel), max(r[2] for r in sel),
min(r[1] for r in sel), max(r[1] for r in sel),
sum(1 for r in sel if r[1] < -200), sum(1 for r in sel
if r[1] < -1000))
if __name__ == "__main__":
for lag in (0.0, 10.0, 30.0):
r = Rig(lag_s=lag, lag_on=300.0, lag_off=450.0)
r.run(600.0)
print(f"lag {lag:4.0f} s: before {summary(r.rows, 200, 300)}")
print(f" during {summary(r.rows, 300, 450)}")
print(f" after {summary(r.rows, 480, 600)}")