Compare commits

..
18 Commits
Author SHA1 Message Date
glenn schrooyenandClaude 66d8777b39 SAFETY-135: a young 15-minute period does not cost the night charge the whole period
checks / unit (pull_request) Successful in 41m54s
infra-pin / fresh (pull_request) Successful in 2s
With battery modes on, a grid charge the peak guard (or a measured headroom)
stops in the first 60 s of a period no longer latches off for the period: it
may start again once the period is 60 s old, if the guard is clear and the
headroom is over START_W. A stop after 60 s latches as before. In a young
period no charge starts while the batteries' give is still falling (over
START_W within 15 s), since the meter may not show that drop yet. The guard
itself is unchanged, so a real over-aim load is cut on the same cycle.

DECISIONS/SAFETY-135-young-period.md, test_safety135.py.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-10 17:43:28 +01:00
admin a8ecf171d6 Merge pull request #244: SAFETY-136 a gentler loop on a slow meter until the last move shows
infra-pin / fresh (push) Successful in 2s
2026-11-09 22:35:21 +01:00
goodwe-agent 0f9c119bcf SAFETY-136 round 2: tests for override and direction, true wording
checks / unit (pull_request) Successful in 26m2s
infra-pin / fresh (pull_request) Successful in 2s
F1 the on-change case is tested as slow, F2 override and direction checks,
F3 the 3.75 s rule in the texts, F4 the quarter figures and the live note.
No behaviour change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 20:51:06 +01:00
goodwe-agent c50e2279dd SAFETY-136: a gentler loop on a slow meter until the last move shows
checks / unit (pull_request) Successful in 26m18s
infra-pin / fresh (pull_request) Successful in 2s
On a meter whose readings come 4 s or more apart, the loop runs at half its
gain after a move of 150 W or more, until the meter has fallen by half of the
move or 2 cycles have passed. With readings 10 s late on a 5 s meter the export
falls 44 % over oven, kettle and hob, owner shape, steps; 1-2 s meters are
untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 14:49:26 +01:00
admin 8508ac33f0 Merge pull request 'MAINT-21: split app/main.py Controller into ctl_*.py mixins' (#243) from MAINT-21 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-11-09 08:06:31 +01:00
glenn schrooyenandClaude Opus 5.5 8f0b8477e6 MAINT-21 review: two source scans read the split files too
checks / unit (pull_request) Successful in 31m44s
infra-pin / fresh (pull_request) Successful in 1s
test_safety118's "size rule is gone" and test_validate's "no numeric
maintenance_* fallback" read app/main.py only; the code they guard can now
sit in app/ctl_*.py, where a regression passed unseen. Both read
main_files.source() for main.py now. Tests only; no app change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:15:14 +01:00
glenn schrooyenandClaude 58b6fa35f5 MAINT-21 round 2: keep the move pure - revert the added DECISIONS text, restore cut comments
checks / unit (pull_request) Successful in 26m32s
infra-pin / fresh (pull_request) Successful in 1s
Router T241: the comments commit had put new rationale into
MAIN-orchestration.md and cut comment lines that the note at 56fc41f did not
hold.

- DECISIONS/MAIN-orchestration.md is the 56fc41f text plus the one sentence
  that the Controller is split into app/main.py and app/ctl_*.py.
- Comments whose content is not in a DECISIONS note at 56fc41f are back in the
  code verbatim: MAINT-05 one config, the SAFETY-02, SAFETY-42/34,
  SAFETY-30 site half, SAFETY-31 and SAFETY-18 latch blocks in __init__, the
  _grade_store_migration docstring and comment, the trade_power_w docstring,
  the rows.py docstrings.
- Kept: cuts of text already in the note, the SMA-10 / SAFETY-137 / SMA-2
  pointers, the SAFETY-32 line fix.

No code change: maint21_same_ast OK, maint20_same_code -OO same.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 05:48:00 +01:00
glenn schrooyenandClaude 902da3f723 MAINT-21: comments only - trim restated notes, add DECISIONS pointers
checks / unit (pull_request) Successful in 25m17s
infra-pin / fresh (pull_request) Successful in 1s
No code change: tools/maint21_same_ast.py and tools/maint20_same_code.py HEAD~
both print OK, and the -OO code objects are the same.

- Trims 56 comment and docstring lines (2088 -> 2032) in app/main.py and the
  new files that only restated their MAIN-orchestration.md bullet, each cut to
  its first sentence and a pointer. What the cut comments said and the bullet
  did not is now in the bullet: why a SAFETY-02 latch is restored, why the
  SAFETY-30 site check is not armed at one inverter, the SAFETY-18 re-derive,
  the MAINT-05 one-config rule, the OPT-09 sizing rules, the "no plan" row,
  the 5 400 s age bound, and the guard being the only latch repair.
- Adds the missing DECISIONS pointers on the SMA-10, SAFETY-137 and SMA-2
  comments (FALLBACK_BUDGET_S, write_setpoint, _await_inflight,
  _release_last, the retry wait, the failed-release branch, release_zero_sent,
  the in-flight write, the stop_event hand-back, the SMA-2 read and driver).
- DECISIONS/SAFETY-32: the stale `main.py:932-936` now names
  Controller.pack_floor_soc.
- main.py's docstring and import block say where the Controller's parts are.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 00:41:34 +01:00
glenn schrooyenandClaude 27a867f34b MAINT-21: app/ctl_tasks.py - Controller methods that run as tasks
Moves the methods named in the card and the constants only they read out of app/main.py unchanged. TasksMixin: run_control, run_tariff_refresh, fresh_tariff_plan and the store-migration grade. tools/maint21_same_ast.py: OK.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 00:30:04 +01:00
glenn schrooyenandClaude 66952beb53 MAINT-21: app/ctl_status.py - Controller methods that build the status and MQTT output
Moves the methods named in the card and the constants only they read out of app/main.py unchanged. StatusMixin: checks, status_payload, forecast_payload, publish, mqtt_command, handle_action, why_words. tools/maint21_same_ast.py: OK.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 00:30:00 +01:00
glenn schrooyenandClaude d3504fbbe7 MAINT-21: app/ctl_alarms.py - Controller methods that raise and clear alarms
Moves the methods named in the card and the constants only they read out of app/main.py unchanged. AlarmsMixin: the check_* alarms and the loop-alarm helpers. tools/maint21_same_ast.py: OK.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 00:29:56 +01:00
glenn schrooyenandClaude 3939211176 MAINT-21: app/ctl_cycle.py - Controller.cycle, whole
Moves the methods named in the card and the constants only they read out of app/main.py unchanged. CycleMixin: cycle() and _shadow_settle(). cycle() is not cut up. tools/maint21_same_ast.py: OK.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 00:29:52 +01:00
glenn schrooyenandClaude f7c2d45957 MAINT-21: app/ctl_trade.py - Controller methods for trading
Moves the methods named in the card and the constants only they read out of app/main.py unchanged. TradeMixin: trade sizing, the mode runs, the day plan, the peak floors, drain and charge limits, pack actions. tools/maint21_same_ast.py: OK.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 00:29:48 +01:00
glenn schrooyenandClaude 058ecb7caa MAINT-21: app/ctl_writes.py - Controller methods that write, refuse and hand back
Moves the methods named in the card and the constants only they read out of app/main.py unchanged. WritesMixin: write_setpoint, write_refusal, _write_unit, _hand_back, the refusal evidence, heartbeats and the site release/zero commands. tools/maint21_same_ast.py: OK.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 00:29:44 +01:00
glenn schrooyenandClaude fb0730fe6f MAINT-21: app/ctl_inputs.py - Controller methods that read the inputs
Moves the methods named in the card and the constants only they read out of app/main.py unchanged. InputsMixin: read_inputs, _read_unit, live_units and the other readers of the meter, packs, PV and forecast. tools/maint21_same_ast.py: OK.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 00:29:40 +01:00
glenn schrooyenandClaude 1b6bb05fb4 MAINT-21: app/rows.py - the status-row and Why-line module functions
Moves why_raised, why_key, tariff_plan_row and arbitrage_row out of app/main.py unchanged. main.py imports them, so app.main still exports them. tools/maint21_same_ast.py: OK.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 00:29:37 +01:00
glenn schrooyenandClaude 0d49027daa MAINT-21: tests read and patch the Controller through tools/main_files.py
Prepares the split of app/main.py's Controller into files by job, with no app
change. tools/main_files.py gives a test the text and the AST of main.py plus
the files split from it, and `live`, which sets a global in every file whose
functions read it (and raises when none does). Tests that read main.py's
source, and tests and replay tools that patch a main global (datetime,
compute, resolve, _mono, WRITE_BUDGET_S, ...), go through it, so they pass on
this layout and on the split one. test_split_patches.py fails when a test or
tool patches an app.main attribute that main.py no longer reads, or that
another file also reads. tools/maint21_same_ast.py proves every function,
method and module-level assignment of main.py at the base is found unchanged
in its new file.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 00:27:07 +01:00
admin 56fc41fffa Merge pull request 'MAINT-20 part 2: comments and docstrings in main.py' (#224) from MAINT-20-2 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-11-08 22:16:27 +01:00
70 changed files with 6630 additions and 5109 deletions
@@ -4,7 +4,8 @@
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]],
`d4c01da`. Since MAINT-21 the Controller's methods are split by job into
`app/main.py` and `app/ctl_*.py`; a comment keeps its text and moves with its code. 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]].
@@ -71,7 +71,8 @@ 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; at a 5 s meter 30 s late
is found within 100 s after it starts (88 s measured) (the loop first moves 100 W after 60 s,
SAFETY-129) with the export above -200 W, and 10 s late is not found. Until
SAFETY-129) with the export above -200 W, and 10 s late is not found (SAFETY-136 calms
the loop at a slow meter instead). Until
then control is as before. In a quiet house the "readings late" row stays
about 2 minutes longer after the lag ends (126 s against 32 s on base); no
harm measured, and it clears as early as on base when the load changes.
@@ -0,0 +1,74 @@
# SAFETY-135 - a young 15-minute period does not cost the night charge the whole period
**Status: for review.** Code: `Controller._mode_roles()` and
`Controller._give_falling()` (`app/ctl_trade.py`), `modetrade.decide(young=)`,
`YOUNG_S`, `GAVE_S` (`app/modetrade.py`). Tests: `test_safety135.py`. Battery
modes only. No option, no text change, no change to the peak guard.
**Problem.** In the first seconds of a period the guard's projection is almost
only the last reading (`held_w`). Two cases seen on TEST-23:
- *A spike at a period's start* (UX-77 I-1, x10): a 1-3 s spike, or a charge
still at full power from the end of the last period, makes the guard project
4.3-6.6 kW and cut the night charge. The cut is right. But a cut charge
stays off to the period's end (the OPT-11 latch), so one 2 s spike cost 15
minutes of charge, about 0.16 kWh per trip on the rig.
- *A start at the tariff edge* (SAFETY-136 rig F-4, x1): a give ends at 01:00
and the cheap run starts. The battery reading drops at once, the meter
reading a few seconds later. In between, grid + batteries reads the house
too low, so the charge room is too big. The charge starts and the guard cuts
it 4-61 s later, and the latch then holds it off for the period.
**Decision.** Two small rules, both only in a young period (its first
`YOUNG_S` = 60 s):
1. *A stop does not latch.* A charge the guard or a measured headroom stops in
a young period may not start again until the period is 60 s old; then the
normal start rule (guard clear, over `START_W` of headroom) decides. A stop
after 60 s latches to the period's end as before, so there is at most one
restart per period and the OPT-11 flip guard keeps its job.
2. *No start while a give is still falling.* While the batteries give over
`START_W` less than they did in the last `GAVE_S` = 15 s, no charge starts:
the meter may not show that drop yet. 15 s covers a 5 s meter twice plus
Home Assistant's own delay. A give that falls by `START_W` or less does not
hold the start: the error it leaves is under the smallest charge worth
starting.
The guard itself is not touched: its claim, floor and projection are the same
code, so a real over-aim load is cut on the same cycle as before. A running
charge is never held by rule 2.
**Options weighed.**
- *A minimum data window before the latch* (no latch in the first N s): this
is rule 1. Alone it lets the edge start flap once more.
- *No start in a young period within 60 s of a give or a guard action*: also
stops the edge flap, but delays every run start that follows a give. In the owner-shape night sim it
charged 0.018 kWh (N=1) and 0.003 kWh (N=2) less than base. Rule 2 holds the
start only while the give is still falling, and charges the same as base.
- *Cutting the young charge room by the recent give* (instead of holding the
start): it double-counts once the meter catches up, cut a running charge 3 s
after its start in the 1 s meter edge run, and so flapped where base did not.
- *Re-evaluation each cycle with no latch*: brings back the 13 flips in 4
minutes the latch was made for (2026-09-09 replay).
- *A hysteresis band on the start*: does not help at the edge. The error there
is the readings' age, not a small room.
**Numbers** (`scratch/SAFETY-135/dev/s135_sim.py`, real `cycle()`, TEST-23-like
lag, 1 s cycle, two 2.5 kW batteries, cap 3 500 W, aim 3 450 W; grid
average over each period as the sim saw it):
| Scenario | Base: paused s, kWh, flaps, top period | Head |
|---|---|---|
| (a) 4 s spike at 01:30 and 01:45, house 2.8 kW | 1 197 s, 0.330 kWh, 1, 3 458 W | 135 s, 0.537 kWh, 0, 3 458 W |
| (b) 01:00 edge after a give, 5 s meter, house 3.0 kW | 882 s, 0.006 kWh, 1, 3 009 W | 21 s, 0.119 kWh, 0, 3 464 W |
| (b) the same, house 2.4 kW | 0 s, 0.266 kWh, 0, 3 461 W | 11 s, 0.266 kWh, 0, 3 461 W |
| (c) +4 kW for 5 min from 01:30:20 | cut 01:30:22, 0.387 kWh | cut 01:30:22, 0.492 kWh |
| Owner-shape night, N=1 / N=2 | 5.510 / 6.048 kWh, top 3 454 / 3 453 W | 5.510 / 6.048 kWh, 3 454 / 3 453 W |
| Owner-shape day, N=1 / N=2 | top 331 / 145 W | the same |
The add-on's own period averages in `test_safety135.py`: 3 450.0 W in the
spike period (base 2 807.9 W, the charge lost), 3 450.0 W in the 01:00 period
(base 2 996.5 W at 3.0 kW). Over the aim by a few watts in the sim's grid
average is the landing error base has too (SAFETY-126).
**Not changed.** With battery modes off, `arbitrage.decide()` has no period
latch and sizes the charge to the headroom each cycle (SAFETY-45). Nothing
here touches it.
@@ -0,0 +1,81 @@
# SAFETY-136 - a slow meter gets a gentler loop until the last move shows
**Status: for review.** Code: `app/settle.py`, a call in `Controller.cycle()`
(`app/ctl_cycle.py`). Tests: `test_safety136.py`. No option, no text change.
**Problem.** On a 5 s meter, Home Assistant readings 10-15 s late are not
found (SAFETY-129): they look like a slow battery (R2-1), and a hold does not
help (a perfect one cut the export of the oven by 7 %, SAFETY-129 review).
With moving loads the loop answers the same stale error at each cycle and
overshoots: an oven, a kettle and hob, the owner's day or a step export 3-10
times what they do with no lag. The fix is a calmer loop, not a detector.
**Decision.** After a move of 150 W or more, a loop on a slow meter runs at half
its gain until the meter has fallen by half of that move, or for 2 cycles.
Slow is judged from the readings that arrive: the usual gap
between them, over the last 60 s and at least 5 readings, is over 3.75 s (the
limit of SAFETY-128's fast meter fit). It counts arrivals, not changes of
value: on the recorded day at 1-2 s the value-based test (`MeterFit.slow()`)
called 24 % of the cycles slow, because the recorded house repeats a value
for 5 s or more, and the commands then differed from base. So there is no
option. At a meter with a usual gap of 3.75 s or less (1-3 s) it is false, the tuning is never touched and the
commands are those of the tree before. A meter that Home Assistant passes on
only when its value changes looks as slow as its changes are far apart, as it
does to SAFETY-128's fits: a 1-2 s meter with a value that holds for 5 s or
more is then treated as slow, and the loop is gentler for it (the cost below).
With no lag the meter shows a move at the next reading, so the wait ends at
once.
**Candidates** (SAFETY-129 sim, real `cycle()`, 5 s meter, sum over oven,
kettle and hob, owner shape over the oven and over the heat pump, step down,
step up, flat; export Wh, lag 0 / 10 s / 15 s; base 64 / 340 / 363):
| Candidate, only at a slow meter | 0 s | 10 s | 15 s | Import Wh, 0 / 10 / 15 s |
|---|---|---|---|---|
| gain 0.4 | 97 | 201 | 299 | 97 / 224 / 301 |
| gain 0.3 | 128 | 185 | 322 | 132 / 232 / 293 |
| step limit 500 W | 118 | 285 | 293 | 110 / 325 / 324 |
| cut fast, raise slowly (0.6 / 0.3) | 55 | 140 | 226 | 140 / 332 / 345 |
| ignore 1 cycle after a move | 93 | 216 | 325 | 90 / 240 / 291 |
| ignore 2 cycles after a move, unless the meter showed half | 69 | 206 | 277 | 62 / 240 / 354 |
| **half gain for 2 cycles after a move, unless the meter showed half** | **67** | **189** | **296** | **62 / 223 / 288** |
A lower gain for good, a step limit and a fixed ignore all make a 5 s meter
with no lag worse (export +30-110 Wh, quarter peak up to 150 W): that is the
common case. Cutting fast and raising slowly moves export to import and peak.
Waiting only while the last move has not shown costs nothing with no lag.
Ignoring the meter (the 2-cycle line) was worse than half gain where the plant
is slow, not the readings late: a 12 s dead time with one inverter went from
250 to 841 Wh import and a quarter peak of 206 to 1 509 W, so the loop keeps
following at half gain.
**What it gives** (5 s meter; the pull request and the report have each
scenario). Export summed over the seven houses: 10 s late 340 -> 189 Wh
(-44 %, import 363 -> 223), 15 s late 363 -> 296 Wh (-19 %, import 281 ->
288), together -31 %. At 15 s the oven and the owner's oven are not better
(92 / 39 Wh against 91 / 37), the kettle and the steps are. No lag: export
64.4 -> 66.5 Wh (kettle +2), import and peak the same.
**What it costs.** The loop follows a real step more slowly: half gain for up
to two cycles after each move of 150 W or more. Of the 136 sim runs (every
SAFETY-129 review scenario and its 15 harm runs), 56 at 4-10 s meters differ
from base. Together their export is 10 363 -> 9 559 Wh (-8 %) and import
8 389 -> 7 835 Wh (-7 %). Only 5 export more than 1 Wh and 14 import more:
export by at most 11.5 Wh (a 10 s battery lag, +10 %), import by at most
17 Wh (a 12 s dead time, one inverter, +7 %), and the quarter average by at
most 35 W, except two import quarters: +73 W (10 s meter, 10 s dead time,
export -59 Wh) and +40 W (`dead10_p6`, -6 to 34 W, export -68 Wh). Slow
batteries with a dead time of 8-10 s export 5-39 % less; a 12 s dead time is
the same. `READINGS_LATE` holds are the same seconds in every run.
**Live, a 1-2 s meter can be judged slow.** Through Home Assistant on change,
a value that holds for 4-10 s makes a 1 s meter slow. In 20 rig scenarios the
commands were those of base. On the recorded day (1 s, N=1, 1 day, repeats
dropped; its values hold about 5 s) it was slow in 33 % of cycles, half gain
in 83 cycles, 15 422 commands in both trees, 4 016 with another value (at
most 190 W apart), cost 0.61 EUR in both (review of SAFETY-136).
**Does not change.** Meters of 1-2 s: the same commands in all 63 runs (34
at 1 s, 29 at 2 s), and the recorded-day digests (see the report). The late
verdict, the hold and the freeze tests. 15 s late is only partly helped, and
30 s late is the hold's (SAFETY-129).
@@ -709,7 +709,7 @@ rather than smuggled in.
**Also still not in this card, and named by the shipped code already:** the
charge *ceiling* is the mirror image — `pack_floor_soc` is the wrong reduction
for it, because a ceiling is the **fullest** pack and `arbitrage`'s `soc_ceiling`
takes one number. `main.py:932-936` says so, and it belongs to CFG-04/SPIKE-04's
takes one number. The `Controller.pack_floor_soc` docstring in `app/main.py` says so, and it belongs to CFG-04/SPIKE-04's
per-pack bound. Nothing here touched it.
## 6. What was changed, and one thing that was found on the way
+461
View File
@@ -0,0 +1,461 @@
"""Controller methods that raise and clear the alarms.
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
import logging
from datetime import datetime, timedelta, timezone
from . import allocator
from . import control as _ctl
from .circulation import opposing
from .clamp import (
REASON_CODE as CLAMP_REASON, delivery_short, shortfall_note,
write_clamp_note,
)
from . import messages
from .mqtt import unit_slugs
from . import peakguard
from .price import brussels
from .price_chain import fixed_tariff_beside_service
from .ctl_writes import REFUSED_SPAN_S
from .ctl_inputs import _mono
_LOG = logging.getLogger("goodwe")
# SAFETY-34: a Core-only restart wipes persistent_notification without
# restarting this add-on, so a latched alarm is re-asserted this often.
REANNOUNCE_S = 300.0
# SAFETY-85: a battery another owner set is graded against the least it was
# told over the plant's settle time, so a battery still settling is not short.
SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S
# SAFETY-119/121: after a hand-back the loop's bound is hidden for
# HANDBACK_CYCLES / SHORT_SPAN_S, and across hand-backs on at most
# ROLLING_BOUND_CYCLES cycles per ROLLING_S
# (DECISIONS/SAFETY-121-bound-every-quarter.md). Without a monotonic clock the
# budget never refills.
HANDBACK_CYCLES = 4
ROLLING_S = 900.0
ROLLING_BOUND_CYCLES = 11
class AlarmsMixin:
def _loop_alarm_input(self, resolution) -> None:
"""SAFETY-85. The loop's clamp counts only on a cycle the loop decided
(a rating may cut it). A battery another owner set to discharge counts
when it gave less than it was told for `saturation_cycles` cycles in a
row. DECISIONS/SAFETY-85-no-false-not-keeping-up.md"""
d = self._loop_decision
if d is None:
self._short_run = 0
self._asked_hist = {}
self._wire_lost, self._handback = False, None
return
unmet = self.allocation.unmet_w if self.allocation is not None else 0
cut = (resolution.bound_by or "").split("(")[0]
owns = (resolution.winner == "loop" and not d.held and not unmet
and (resolution.target_w == d.target_w
or cut in ("device", "supervised")))
# Graded against the least that stood on the wire (0 W in shadow
# mode) over SHORT_CYCLES cycles and SHORT_SPAN_S seconds.
now, asked = self._now_mono, {}
for inv in self.inverters:
h = self._asked_hist.setdefault(inv.name, [])
h.append((now, inv.standing_w()))
while len(h) > SHORT_CYCLES and (
now is None or h[0][0] is None
or now - h[0][0] > SHORT_SPAN_S):
h.pop(0)
asked[inv.name] = min(w for _t, w in h)
others = [inv for inv in self.inverters if not owns
or allocator.pin_w(resolution.unit_bounds.get(inv.name))
is not None]
short = [(inv, asked[inv.name], inv.batt) for inv in others
if inv.batt is not None
and delivery_short(asked[inv.name], inv.batt,
self.tuning.saturation_w)]
self._short_run = self._short_run + 1 if short else 0
settling = self._settling(owns, now)
if owns and self.loop_clamped[0] and not (
settling and not self.loop_clamped[2]):
return
engaged = self._short_run >= self.tuning.saturation_cycles
named = len(self.inverters) > 1
self.loop_clamped = (engaged, shortfall_note(
[(inv.name if named else None, a, g) for inv, a, g in short])
if engaged else "", False)
self.loop_clamp_facts = ()
def _settling(self, owns, now) -> bool:
"""SAFETY-119: does the loop's bound on this cycle count? Not within a
settle (HANDBACK_CYCLES cycles and SHORT_SPAN_S) after another owner
handed the wire back; across all hand-backs the bound is hidden on at
most ROLLING_BOUND_CYCLES cycles in any ROLLING_S.
DECISIONS/SAFETY-121-bound-every-quarter.md"""
def aged(t0, span):
return now is None or t0 is None or now - t0 >= span
if not owns:
self._wire_lost, self._handback = True, None
return False
if self._wire_lost:
self._wire_lost, self._handback = False, (now, 0)
if self._handback is None:
return False
tc, n = self._handback
n += 1
self._handback = (tc, n)
if aged(tc, SHORT_SPAN_S) and n >= HANDBACK_CYCLES:
self._handback = None
return False
if self.loop_clamped[0] and not self.loop_clamped[2]:
kept = tuple(t for t in self._hidden if now is None or (
t is not None and now - t < ROLLING_S))
if len(kept) >= ROLLING_BOUND_CYCLES:
self._hidden = kept
return False
self._hidden = kept + (now,)
return True
async def check_output_clamp(self) -> None:
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle. Runs
AFTER write_setpoint() (the clamp is arithmetic done inside the write).
⚠️ PUBLISHING IS NOT IN THE WRITE PATH: a stalled supervisor must never
delay or drop a frame. ⚠️ THIS IS NOT A TRIP: no claim, no latch, 0 W
is not commanded (§11.2: clamp and alarm); turning it into one is a
deliberate change. (DECISIONS/MAIN-orchestration-archive.md)"""
# ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT: `clamp_last` is on
# the Inverter, `ClampWatchdog` is one per controller (an anti-flood
# counter), so any unit that clamped is an engagement and the reason
# names it. (DECISIONS/MAIN-orchestration-archive.md)
clamped_units = [inv for inv in self.inverters
if inv.clamp_last is not None and inv.clamp_last.engaged]
engaged_write = bool(clamped_units)
engaged_loop, loop_note, loop_bug = self.loop_clamped
engaged = engaged_write or engaged_loop
# SAFETY-40: only an over-rating is a bug; the integrator bound is not.
bug = engaged_write or loop_bug
if engaged_write:
_named = len(self.inverters) > 1
reason = write_clamp_note([(inv.name if _named else None,
inv.clamp_last)
for inv in clamped_units])
facts = [messages.Clamp(inv.name if _named else None,
inv.clamp_last.requested_w,
inv.clamp_last.applied_w,
inv.clamp_last.bound)
for inv in clamped_units]
else:
reason = loop_note if engaged else ""
facts = self.loop_clamp_facts if engaged else ()
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc),
bug=bug, facts=facts)
# THE ACTION LOG IS COALESCED PER EPISODE: consecutive engagements
# update one row's count. ⚠️ NOT COSMETIC: the log keeps 40 rows and a
# winding loop clamps every cycle, so a row each flushed SAFETY-18's
# "METER FROZEN?" row. Here as well as in write_setpoint() because on a
# default install the actuator-side clamp is unreachable.
# (DECISIONS/MAIN-orchestration-archive.md)
if engaged and not engaged_write:
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
row = (f"{tag}: "
f"{messages.count(self.clamp_wd.engagements, 'engagement')}"
f" - {reason}")
if self.events and tag in self.events[0]:
stamp = self.events[0].split(" ", 1)[0]
self.events[0] = f"{stamp} {row}"
else:
self.log_event(row, hint=messages.hint(
"power_too_high" if bug else "not_keeping_up"))
if not fired:
return
if not self.clamp_wd.bug:
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
# it can never replace a BUG SIGNAL notice still on screen.
_LOG.warning("The battery gives less than asked: %s. Held back %s "
"so far.", messages.reason_code(self.clamp_wd.reason),
messages.times(self.clamp_wd.engagements))
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
_LOG.debug("A short hold after a large load or solar step is "
"normal. If it lasts, check that the battery follows "
"its commands.")
await self.hass.call("persistent_notification", "create",
messages.loop_bound().data())
return
_LOG.error(
"Power request too high, a software fault: %s. Cut back %s so "
"far, control runs on.", messages.reason_code(self.clamp_wd.reason),
messages.times(self.clamp_wd.engagements))
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
_LOG.debug(
"SAFETY-03: silently clipping this is how a winding integrator "
"reached 14 768 W on this project before. The clamp makes every "
"dashboard read a legal number while the thing producing it goes "
"on climbing. This is an alarm, not a trip.")
# A fixed notification_id, for the reason check_meter_frozen() gives -
# and a DIFFERENT one from every other alarm this add-on raises,
# because a clamp is not a frozen meter and neither is a saturation
# freeze. (DECISIONS/MAIN-orchestration-archive.md)
await self.hass.call("persistent_notification", "create",
messages.output_clamp(self.clamp_wd.facts,
shadow=self.shadow).data())
async def check_peak_saturated(self) -> None:
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter. ⚠️ An
engagement is not an alarm: a peak floor above the rating is a house
load bigger than the inverter, not a defect (unlike SAFETY-03's winding
integrator). SAFETY-81: `g.saturated` must agree with
`peakguard.load_too_high()` for `ALARM_HOLD_S`. Edge-triggered: one
notification per episode, at most once per quarter.
(DECISIONS/MAIN-orchestration-archive.md)"""
g = self.peak_guard
batt, prev = self.site_batt_w, self._peak_batt_prev
if batt is None:
# Unknown, not "fine": the hold below neither grows nor restarts.
return
self._peak_batt_prev = batt
if prev is not None:
batt = min(float(batt), float(prev))
p = self._peak_proj
# OPT-15 x SAFETY-81: until the new quarter's first sample the old
# projection read "no seconds left" (not high), which restarted the
# hold; a rolled one (battery modes on) is not graded then, so a
# persistent overload is still notified once per quarter.
high = bool(
g is not None and g.saturated and p is not None
and p.block_start is not None and not self._peak_rolled
and peakguard.load_too_high(
p, batt_w=batt, ceiling_w=g.ceiling_w,
achievable_w=self.site_rating_w))
# SAFETY-81: and for ALARM_HOLD_S, on the projection's clock.
saturated = False
if high:
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
if self._peak_high_since is None:
self._peak_high_since = t
saturated = ((t - self._peak_high_since).total_seconds()
>= peakguard.ALARM_HOLD_S)
else:
self._peak_high_since = None
was, self._peak_saturated = self._peak_saturated, saturated
if not saturated or was:
return
_LOG.error(
"Switch off large loads, the battery alone cannot keep this "
"15-minute period under the peak limit: %s.",
messages.reason_code(g.reason))
_LOG.debug("Evidence: %s.", g.reason)
# SAFETY-81: notified once per quarter; a later episode is logged only.
if p.block_start == self._peak_alarm_block:
return
self._peak_alarm_block = p.block_start
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
hint=messages.hint("peak_saturated"))
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
# is not a frozen meter and is not an out-of-range setpoint.
await self.hass.call("persistent_notification", "create",
messages.peak_saturated(
g.ceiling_w, shadow=self.shadow).data())
async def check_peak_floor_limited(self) -> None:
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving. ⚠️
FAILING TO SHAVE MUST NOT BE SILENT (the capacity tariff bills the
month's worst quarter). Not check_peak_saturated(): that is shed-load,
this is nothing discharged; never both. ⚠️ SAFETY-25: the message says
the floor binds this guard only; the loop has no SoC floor and goes on
discharging. Edge-triggered. (DECISIONS/MAIN-orchestration-archive.md)"""
g = self.peak_guard
limited = bool(g is not None and g.floor_limited)
if limited and (g.floor_w or 0.0) > 0.0:
limited = self._reserve_short(g)
else:
self._reserve_batt_prev = self._reserve_high_since = None
# SAFETY-126: nothing can give, so it alarms at once, but only
# when the quarter really misses the aim (`_peak_landing()`).
limited = limited and not g.within_aim
was, self._peak_floor_limited = self._peak_floor_limited, limited
if not limited or was:
return
# No prefix: `reason` already opens with PEAK_GUARD_FLOOR_LIMITED, and
# a second copy is the kind of stutter that makes a 40-line action log
# read like boilerplate. (DECISIONS/MAIN-orchestration-archive.md)
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
_LOG.error(
"Switch off large loads, the peak guard may not draw the battery "
"lower: %s.", messages.reason_code(g.reason))
_LOG.debug("Evidence: %s.", g.reason)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. ⚠️ SAFETY-25's
# sentence (the reserve binds the peak guard only) is in
# messages.peak_reserve() on every branch, unreadable SoC included.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.hass.call("persistent_notification", "create",
messages.peak_reserve(
g.ceiling_w, soc_unread=g.soc is None,
floor_unknown=(g.soc is not None
and g.soc_floor is None),
packs=(g.held if len(self.inverters) > 1
else ()),
others=(g.floor_w or 0.0) > 0.0).data())
def _reserve_short(self, g) -> bool:
"""SAFETY-87: the batteries that can still give, at full power, miss
the limit (`load_too_high()` on `g.able_w`), for ALARM_HOLD_S, as
SAFETY-81 grades 'Load too high'."""
p, batt = self._peak_proj, self.site_batt_w
if batt is None:
return self._peak_floor_limited
prev, self._reserve_batt_prev = self._reserve_batt_prev, batt
if prev is not None:
batt = min(float(batt), float(prev))
if not (p is not None and p.block_start is not None
and peakguard.load_too_high(p, batt_w=batt,
ceiling_w=g.ceiling_w,
achievable_w=g.able_w)):
self._reserve_high_since = None
return False
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
if self._reserve_high_since is None:
self._reserve_high_since = t
return ((t - self._reserve_high_since).total_seconds()
>= peakguard.ALARM_HOLD_S)
async def check_circulating(self) -> None:
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. ⚠️
THE LATCH IS NOT THE ALARM: the notification is in memory, so
`_circ_announced` (per-PROCESS, not persisted) makes a restart re-raise
it, and REANNOUNCE_S re-asserts it after a Core-only restart. ⚠️ ONE
CODE PATH for a fresh trip and a restart into one.
(DECISIONS/MAIN-orchestration-archive.md)"""
if not self.circ_wd.tripped:
return
if self._circ_announced and not self._reannounce_due("goodwe_circulating_power"):
return
# Set BEFORE the await. `hass.call()` can raise or hang, and a retry
# every cycle for a broker that is down would be an alarm storm on the
# event bus on top of a site that is already stopped.
# (DECISIONS/MAIN-orchestration-archive.md)
self._circ_announced = True
self._announced_mono["goodwe_circulating_power"] = _mono()
names = self.units
pos, neg = opposing([inv.batt for inv in self.inverters],
min_w=self.circ_wd.cfg.min_w / 2.0)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# this add-on raises - check_meter_frozen() gives the reasoning.
await self.hass.call("persistent_notification", "create",
messages.circulating(
[names[i] for i in pos] if pos and neg else (),
[names[i] for i in neg] if pos and neg else (),
).data())
def _reannounce_due(self, notification_id: str) -> bool:
"""SAFETY-34: has this latched alarm gone REANNOUNCE_S without being re-asserted?"""
t = self._announced_mono.get(notification_id)
return t is None or _mono() - t >= REANNOUNCE_S
async def check_not_delivering(self) -> None:
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart. ⚠️
THE LATCH IS NOT THE ALARM: the notification is in memory, so
`_delivery_announced` (a per-PROCESS set of unit names, not persisted)
makes a restart re-raise it, and REANNOUNCE_S re-asserts it. ⚠️ ONE
CODE PATH for a fresh trip and a restart into one. ⚠️ ONE ID PER UNIT
(`unit_slugs()`): two wedged inverters give two cards.
(DECISIONS/MAIN-orchestration-archive.md)"""
names = self.units
slugs = unit_slugs(names)
for inv, slug in zip(self.inverters, slugs):
nid = f"goodwe_not_delivering_{slug}"
if not inv.delivery_wd.tripped:
continue
if inv.name in self._delivery_announced and not self._reannounce_due(nid):
continue
# Added BEFORE the await, for `check_circulating()`'s reason:
# `hass.call()` can raise or hang, and a retry every cycle for a
# broker that is down would be an alarm storm on the event bus.
# (DECISIONS/MAIN-orchestration-archive.md)
self._delivery_announced.add(inv.name)
self._announced_mono[nid] = _mono()
await self.hass.call("persistent_notification", "create",
messages.not_delivering(inv.name, slug).data())
async def check_price_gap(self) -> None:
"""PRICE-01. One notification when no price covers now, gone when one
does. (DECISIONS/MAIN-orchestration-archive.md)"""
if self.price_gap and not self._price_gap_announced:
self._price_gap_announced = True
await self.hass.call("persistent_notification", "create",
messages.no_prices(*self._gap_args()).data())
elif not self.price_gap and self._price_gap_announced:
self._price_gap_announced = False
await self.hass.call("persistent_notification", "dismiss",
messages.prices_back().data())
async def check_fixed_note(self) -> None:
"""PRICE-01 G1. First cycle only: one INFO line when `static_profile`
sits beside `price_service` (the plan's last fallback, not a fault, so
no notification). (DECISIONS/MAIN-orchestration-archive.md)"""
if self._fixed_note_said:
return
self._fixed_note_said = True
if fixed_tariff_beside_service(self.o):
_LOG.info(messages.FIXED_TARIFF_LOG)
try:
await self.hass.call("persistent_notification", "dismiss",
messages.fixed_tariff_card_gone().data())
except Exception: # noqa: BLE001 - housekeeping never stops a cycle
_LOG.debug("Could not take down the old fixed-tariff card.")
def _gap_args(self) -> tuple:
"""`(since, now)` in Brussels time for the gap's words. `since` is None
when no price was ever published."""
now = brussels(datetime.now(timezone.utc))
if self.price_gap_since is None:
return None, now
return brussels(self.price_gap_since), now
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
Notification only: no claim, no latch, no 0 W (freeze.py carries the
reasoning). getattr, not an attribute access: `unchanged_s` exists only
on `homewizard_local`, and `p1_source` is None until amain() builds the
transport. (DECISIONS/MAIN-orchestration-archive.md)"""
unchanged = getattr(self.p1_source, "unchanged_s", None)
# SITE, all three inputs: one meter, the SITE target the arbiter
# resolved, and the rail the SITE could stand at - which is the summed
# per-unit conjunction, so a small inverter B lowers the site rail rather
# than being graded against inverter A's.
fired = self.freeze_wd.evaluate(unchanged, self.target,
self.site_max_w,
datetime.now(timezone.utc),
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max"))
if not fired:
return
self.log_event("METER FROZEN? " + self.freeze_wd.reason,
hint=messages.hint("meter_frozen"))
_LOG.error(
"The meter shows the same value while the battery is asked for "
"full power, and control still follows it, so check the meter: "
"%s.", messages.reason_code(self.freeze_wd.reason))
_LOG.debug("Evidence: %s.", self.freeze_wd.reason)
_LOG.debug(
"SAFETY-18: nothing else stops this. The meter still answers, so "
"its age is fresh, the firmware watchdog is fed and the arbiter "
"only bounds the magnitude. Kept up for a quarter of an hour, it "
"sets the month's billed capacity peak.")
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
# rather than stacking. An operator who has to dismiss eleven identical
# notifications learns to dismiss the twelfth without reading it.
await self.hass.call("persistent_notification", "create",
messages.meter_frozen(unchanged).data())
File diff suppressed because it is too large Load Diff
+425
View File
@@ -0,0 +1,425 @@
"""Controller methods that read the inputs: meter, packs, PV, forecast.
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
import asyncio
import logging
import time
from datetime import datetime, timezone
from .arbiter import actuator_for
from . import solarfc
from . import freshness
from . import lateness
from . import messages
from . import pv as pv_read
from .price import brussels, price_option, provider_configured
from .price_chain import uses_chain
from .static_tariff import static_status
from .rows import tariff_plan_row
_LOG = logging.getLogger("goodwe")
# UX-11: a quiet meter logs at most one WARNING this often (on `ha_signed` a
# healthy meter can go 42-97 s between changes).
METER_QUIET_REPEAT_S = 600.0
_mono = time.monotonic # patched by the checks; asyncio keeps the real one
# OPT-04: the background tariff refresh. The tick NEVER awaits a fetch: it
# reads `self.tariff_plan`, which the refresh task assigns.
# DECISIONS/MAIN-orchestration.md.
TARIFF_REFRESH_S = 1800.0
# The age bound covers the refresh task dying: three missed refreshes.
# DECISIONS/MAIN-orchestration.md.
TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
# OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an
# evening restart keeps the night plan's figure.
FC_HOLD_KEY = "solar_fc_hold"
FORECAST_READ_S = 60.0
class InputsMixin:
def _readings_late(self, standing_w):
"""SAFETY-128: update the answer gate; seconds the late verdict has
stood, or None. DECISIONS/SAFETY-128-late-readings.md."""
a = self.answer
if self._now_mono is None:
return None
a.observe(self._now_mono, standing_w, self.site_batt_w,
grid_w=self.grid, exempt=bool(self.batt_refused) or any(
i.refused or (
i.batt is not None
and abs(i.standing_w()) >= lateness.ANSWER_W
and abs(i.batt - i.idle_w) < lateness.ANSWER_W)
for i in self.inverters),
idle_w=self._site_idle_w())
if a.late_seen != self._late_logged:
self._late_logged = a.late_seen
if a.late_seen:
self.log_event("readings late - holding",
hint=messages.hint("readings_late"))
_LOG.warning("Home Assistant readings arrive late, so control "
"holds the batteries: READINGS_LATE.")
_LOG.debug("READINGS_LATE: the readings show a power request "
"%.0f s after it is sent.", a.delay_s)
else:
self.log_event("readings on time", hint=messages.hint(
"readings_back"))
_LOG.info("Home Assistant readings are on time again.")
return a.late_s(self._now_mono)
@property
def inputs_ok(self) -> bool:
"""The site meter, and EVERY pack's own two readings: every pack, not
the live ones. A unit whose SoC did not answer cannot be graded for
the maintenance FSM or the pack floor, and 11.1 rule 1 for a missing
input is command zero and keep commanding."""
if self.grid is None or not self.inverters:
return False
return all(inv.soc is not None and inv.batt is not None
for inv in self.inverters)
def missing_reading(self) -> str:
"""UX-11: the first reading `inputs_ok` finds missing, in words."""
if self.grid is None or not self.inverters:
return "meter"
for inv in self.inverters:
of = "" if len(self.inverters) == 1 else f" from {inv.name}"
if inv.batt is None:
return f"battery power{of}"
if inv.soc is None:
return f"battery level{of}"
return "input"
# -- io ------------------------------------------------------------------
@property
def pv_roster(self) -> tuple:
"""Every configured PV production entity, or `()` (TEL-11/TEL-12). ONE
reading of the options, `pv.entities()`, the same call
`mqtt.optional_keys()` makes, so what is ANNOUNCED, READ and CHECKED
cannot disagree; it replaced a `pv_entity` property."""
return pv_read.entities(self.o)
async def _read_pv(self) -> None:
"""This cycle's SITE PV production, or an ABSENCE with a reason
(TEL-11/12). `pv.total()` makes "one of three absent" an absence of
the SITE figure. It assigns on EVERY call including failing ones (a
stale value must not survive a failure), and uses `hass.state()` not
`hass.number()` because the UNIT is half of the answer."""
# TEL-12: every entity in ONE gather. Read serially, three slow sensors
# would add three 10 s timeouts to a cycle that feeds the ESP32
# heartbeat; telemetry must have no foot in the control path.
roster = self.pv_roster
states = await asyncio.gather(
*(self.hass.state(entity) for entity in roster))
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
list(zip(roster, states)), pv_read.inverted(self.o))
# UX-54: kept for the Forecast tab only. It must never cost a cycle.
try:
self.solar_today.add(datetime.now(timezone.utc), self.pv_w)
except Exception: # noqa: BLE001
_LOG.debug("measured solar not kept", exc_info=True)
def _peak_fc_unit_log(self, fault) -> None:
"""SAFETY-118. One WARNING when the peak forecast's unit cannot be read
in watts, and again only if the unit changes; the unit is on DEBUG."""
if fault == self._peak_fc_fault:
return
self._peak_fc_fault = fault
if fault is None:
_LOG.info("The 15-minute demand forecast is used again.")
elif fault == "":
_LOG.warning("The 15-minute demand forecast has no unit, so it "
"is not used.")
else:
_LOG.warning("The 15-minute demand forecast is not in a power "
"unit, so it is not used.")
_LOG.debug("The 15-minute demand forecast reads in %r.", fault)
async def _read_forecast(self) -> None:
"""OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A
failed or stale read is no forecast, never a zero."""
# OPT-17: every member of both days in one gather.
ents = self.fc_entities + self.fc_tomorrow_entities
states = await asyncio.gather(*(self.hass.state(e) for e in ents))
n = len(self.fc_entities)
now = datetime.now(timezone.utc)
fc, why, gaps = solarfc.read_sum(
list(zip(self.fc_entities, states[:n])),
list(zip(self.fc_tomorrow_entities, states[n:])), now,
hold=self.fc_hold)
if self.fc_hold.changed:
self.fc_hold.changed = False
self.store.set(FC_HOLD_KEY, self.fc_hold.dump())
# A member is named only in a sum of several; one entity reads as
# before. Logged once per episode: on a change, never every read.
gaps = tuple(g for g in gaps if len(
self.fc_entities if g[0] == "today"
else self.fc_tomorrow_entities) > 1)
was_kept = bool(self.solar_fc is not None and self.solar_fc.kept)
if (fc is None) != (self.solar_fc is None) \
or why != self.solar_fc_why or gaps != self.solar_fc_gaps \
or bool(fc is not None and fc.kept) != was_kept:
self.log_event(
"solar forecast: " + (
"none (" + str(why) + ")" if fc is None else
f"read, kept from {brussels(fc.at):%H:%M}" if fc.kept
else "read")
+ "".join(f", {d} {e} not usable ({w})"
for d, _i, e, w in gaps),
hint=messages.hint(
"forecast_none" if fc is None else
"forecast_no_tomorrow" if gaps else "forecast_read"))
self.solar_fc, self.solar_fc_why = fc, why
self.solar_fc_gaps = gaps
async def read_inputs(self, now_mono: float | None = None) -> None:
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
`now_mono` is defaulted rather than read inside `_read_unit()` so
ONE instant covers every unit in a gather."""
o = self.o
now_mono = freshness.monotonic() if now_mono is None else float(now_mono)
self._now_mono = now_mono
if self.p1_enabled:
# P1 is the only authoritative measurement of what the utility sees
# (5.1); when stale this is None -> "inputs missing -> command 0
# W". NO fallback to an inverter-side figure: its AC power
# correlates 0.998 with battery power and 0.09 with the real meter
# (a controller would regulate against its own output).
self.grid = self.p1.net_w
self.settle.arrive(now_mono, self.p1.samples) # SAFETY-136
else:
self.grid = await self.hass.number(o.get("meter_entity", ""),
bool(o.get("meter_invert")))
# DEV-02: each inverter's own two readings from its own entities,
# CONCURRENTLY across units and sequentially within one (a slow entity
# must not delay the heartbeat path). The PV reads ride in the same
# gather and an empty PV roster appends nothing (no coroutine).
# DECISIONS/MAIN-orchestration.md.
_reads = [self._read_unit(inv, now_mono) for inv in self.inverters]
if self.pv_roster:
_reads.append(self._read_pv())
# OPT-11 P4: the solar forecast, in the same gather (a slow sensor must
# not delay control), at most once per FORECAST_READ_S.
if self.fc_entity is not None and (
self._fc_read_mono is None
or now_mono - self._fc_read_mono >= FORECAST_READ_S
or now_mono < self._fc_read_mono):
self._fc_read_mono = now_mono
_reads.append(self._read_forecast())
await asyncio.gather(*_reads)
if o.get("peak_forecast_entity"):
# SAFETY-118: read in its own unit like the PV path, one state
# read. No unit or a non-power unit: not used, as if the option
# were empty.
st = await self.hass.state(o["peak_forecast_entity"])
self.peak_fc, _why = pv_read.watts(st)
# A missing reading leaves the unit-fault state as it was.
if pv_read.has_reading(st):
self._peak_fc_unit_log(pv_read.unit_fault(st))
else:
self.peak_fc = None
self._peak_fc_fault = None
now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity")
if now_e and avg_e:
now_p = await self.hass.number(now_e)
avg_p = await self.hass.number(avg_e)
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
self._price_gated = True
self.tariff_words = "Prices from Home Assistant entities"
elif uses_chain(o):
# PRICE-01. A provider with fallbacks gives the maintenance gate
# its own prices: the current 15-minute price against today's
# average, as the Home Assistant pair did. No I/O here: the series
# is the one the background refresh stored.
# (DECISIONS/MAIN-orchestration-archive.md)
self._provider_gate(datetime.now(timezone.utc))
else:
# TARIFF-03: 9.2 makes a static day/night tariff a first-class
# product and its EMS handling TIME-WINDOW SHIFTING; the one
# shiftable load is the maintenance cycle's cheap-window grid
# top-up. Synchronous and I/O-free on purpose (the provider-backed
# plan is the background task's); validate.py refuses
# `static_profile` with the price entities, so the two cannot both
# be set.
_now = datetime.now(timezone.utc)
self.cheap, self.tariff_words = static_status(o, _now)
self._price_gated = self._static_gated(o, _now)
# TARIFF-11: the provider plan reaches the *Tariff* row here, the only
# place the two sources meet. `fresh_tariff_plan()` is an attribute
# read and an age comparison against TARIFF_MAX_AGE_S: still no fetch
# on this path.
_tick = datetime.now(timezone.utc)
_standing = self.fresh_tariff_plan(_tick)
self.tariff_words = tariff_plan_row(
_standing, self.tariff_words,
age_s=(None if _standing is None
else (_tick - self.tariff_at).total_seconds()),
horizon_h=float(price_option(o, "price_horizon_h")),
provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S)
# UX-11: the red meter row says "the add-on log shows why", so a quiet
# meter gets one WARNING per episode (at most one per
# METER_QUIET_REPEAT_S; the rest DEBUG). Log only: no notification,
# decides nothing.
if self.p1_enabled:
if (self.p1.stale and not self._meter_quiet_logged
and self.p1.published_age_s > self.p1.max_age_s):
self._meter_quiet_logged = True
now = _mono()
self._meter_quiet_loud = (
self._meter_quiet_warned is None
or now - self._meter_quiet_warned >= METER_QUIET_REPEAT_S)
if self._meter_quiet_loud:
self._meter_quiet_warned = now
_LOG.warning("No meter reading for over %s, so control "
"waits and then sets the battery to 0 W.",
messages.seconds(self.p1.max_age_s))
else:
_LOG.debug("No meter reading for over %s again.",
messages.seconds(self.p1.max_age_s))
elif not self.p1.stale and self._meter_quiet_logged:
self._meter_quiet_logged = False
if self._meter_quiet_loud:
_LOG.info("Meter readings are back.")
else:
_LOG.debug("Meter readings are back.")
self.answer.watch(now_mono, [(inv.name, inv.standing_w(), inv.batt)
for inv in self.inverters], # SAFETY-128
sum(inv.standing_w() for inv in self.inverters),
self.grid)
if self.inputs_ok:
if self.inputs_bad_since is not None:
self.log_event("inputs recovered",
hint=messages.hint("inputs_back"))
self.inputs_bad_since = None
elif self.inputs_bad_since is None:
self.inputs_bad_since = datetime.now(timezone.utc)
def _driver(self, inv):
"""`inv`'s driver, wired to this Controller's Home Assistant on first use
(SMA-2: bound here, so a unit added later and a swapped `self.hass` are
followed). DECISIONS/SMA-2-driver-seam.md."""
drv = inv.driver
if not drv.bound():
drv.bind_getter(lambda: self.hass)
return drv
async def _read_unit(self, inv, now_mono: float) -> None:
"""One inverter's own SoC and battery power, from ITS OWN entities.
SAFETY-37: the POWER reading goes through the freshness gate and the
SoC does not, deliberately: `batt` feeds SAFETY-30's per-unit sign
check and the delivery check, which a frozen register would fool.
The standing command is asked of the unit, not recomputed."""
# SMA-2: the driver reads SoC, then power, as this method always did.
# DECISIONS/SMA-2-driver-seam.md.
inv.soc, raw, _extra = await self._driver(inv).read()
was_stale = inv.batt_fresh.stale
was_releases = inv.batt_fresh.releases
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
# `inv.batt` is the GATED value: a stale register is a MISSING input,
# so the site holds for `stale_input_s` then commands 0 W and keeps
# commanding (11.1 rule 1).
inv.batt = inv.batt_fresh.reading()
# SAFETY-97: learn this unit's idle reading, only while control runs
# and nothing says the unit is refusing, stale or faulty.
if inv.idle.observe(
inv.batt, inv.standing_w(), now_mono,
bool(self.auto) and inv.batt is not None
and not inv.batt_fresh.stale and not inv.refused
and not self.batt_refused and not inv.sign_wd.tripped):
inv.store_set("idle_w", round(inv.idle.value, 1))
if inv.batt_fresh.stale and not was_stale:
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason,
hint=messages.hint("battery_stuck", unit=inv.name))
self._refusal_from_stale(inv, now_mono)
# SAFETY-56/57: the stale-reading line names two explanations once,
# in `%s`. The escape clause "clears itself when the register
# reports a different value" was false for the state this line
# announces (the 0 W it announces is what stops the value
# changing), so SAFETY-57 rewrote it and SAFETY-56 the diagnosis.
# DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md.
_LOG.error("%s: battery power reading stopped changing, set to "
"0 W until it changes, or %.0f s after the command "
"drops below %.0f W: %s.",
inv.name, inv.batt_fresh.bound.release_s,
inv.batt_fresh.bound.move_w,
messages.reason_code(inv.batt_fresh.reason))
_LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason)
_LOG.debug("%s: the limits are unchanged for %.0f s while asked "
"to move: a command over %.0f W that moved over %.0f W.",
inv.name,
inv.batt_fresh.bound.unchanged_s,
inv.batt_fresh.bound.move_w,
inv.batt_fresh.bound.move_w)
elif inv.batt_fresh.releases > was_releases:
# SAFETY-57: reported, never silent: a latch that lets go on a
# timer and says nothing cannot be audited, and this one withdraws
# a SITE STOP. `release_note()` says nothing has been learned about
# the register.
self.log_event(f"STALE WITHDRAWN {inv.name}",
hint=messages.hint("battery_back", unit=inv.name))
# Not "recovered": nothing is known about the reading yet, so the
# line gives the value it holds and the window it is rechecked over
# (SAFETY-57).
_LOG.warning("%s: the command stayed below %.0f W for %.0f s, so "
"battery power reading %+.0f W is used again and "
"rechecked over %.0f s.", inv.name,
inv.batt_fresh.bound.move_w,
inv.batt_fresh.bound.release_s, inv.batt_fresh.held,
inv.batt_fresh.bound.unchanged_s)
_LOG.debug("SAFETY-57: %s", inv.batt_fresh.release_note(inv.name))
# -- the cycle -----------------------------------------------------------
def actuator_of(self, source: str, inv=None) -> str:
"""Which actuator a claim from `source` is filed on this cycle. `inv`
is the roster entry the claim is ABOUT; left out it is the first
inverter (what every per-inverter claim meant at N=1). The
classification is not here: `arbiter.CLAIM_SCOPE` decides."""
return actuator_for(source, (inv or self.inverters[0]).name, self.units)
def live_units(self) -> tuple:
"""Which inverters may be commanded THIS TICK, for the allocator. The
allocator DROPS what this does not list and the survivors carry the
whole site target, so it is reported (`Allocation.dropped`).
Answered per tick, never cached (a master goes stale). It answers
ONE question: can this controller put a number on this unit's wire.
Deliberately not "its sensors are missing". At a roster of ONE,
False is a 0 W site."""
now = datetime.now(timezone.utc)
live = []
for inv in self.inverters:
ok, why = inv.is_live(now)
if ok:
live.append(inv.name)
else:
# Level-logged at DEBUG only: `Allocation.dropped` is the
# operator-facing surface (edge-logged in cycle()); a line
# every second would flush the 40-row action log.
_LOG.debug("%s is not commandable this tick: %s", inv.name, why)
return tuple(live)
def liveness_notes(self) -> list:
"""Why each unit is or is not commandable, for the status page.
SAFETY-37's stale-reading line rides here too: a different fault, a
different remedy (a dropped unit is carried by the survivors; a
stale power register is a missing input)."""
now = datetime.now(timezone.utc)
return [n for n in
[x for inv in self.inverters
for x in (inv.link_note(now), inv.stale_note(),
inv.refusal_note())]
if n]
+927
View File
@@ -0,0 +1,927 @@
"""Controller methods that build the status page, rows and MQTT output.
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
import logging
import time
from datetime import datetime, timezone
from . import arbitrage
from . import packmode
from .clamp import is_commandable
from . import dayview, homeview
from . import messages
from .freeze import rail_unreachable, rail_w
from .hass import OUTAGE
from .maintenance import IDLE
from .mqtt import (
announced_units, optional_keys, parse_command, unit_slugs, unit_state_key,
)
from . import peakguard
from . import pv as pv_read
from . import shadow
from .price import brussels
from .validate import FIRMWARE_INGEST_VETO_S
from .rows import arbitrage_row
_LOG = logging.getLogger("goodwe")
class StatusMixin:
async def mqtt_command(self, topic, payload, retained) -> None:
"""OPT-11 P2: one command from a battery's HA switch or mode list,
through handle_action(). A retained command is never applied (the store
already holds the last choice). Never raises: MQTT must not reach
control. (DECISIONS/MAIN-orchestration-archive.md)"""
try:
slug, action, value, why = parse_command(
topic, payload, retained, packmode.MODES)
if why is None:
names = [inv.name for inv in self.inverters]
unit = dict(zip(unit_slugs(names), names)).get(slug)
result = await self.handle_action(action, unit=unit,
value=value)
if not (isinstance(result, dict) and "error" in result):
self.mqtt.publish(self.mode_values())
return
why = result["error"]
else:
why = messages.pack_refused(why)
# Printable only: a topic is sender-chosen, so no log injection.
topic_s = "".join(c if c.isprintable() else "?"
for c in str(topic)[:128])
_LOG.debug("Home Assistant command %s %s refused: %s", topic_s,
repr(payload)[:48], why)
if (topic_s, why) in self._mqtt_refused \
or len(self._mqtt_refused) >= 32:
# Not logged again, but counted: 1, 10, 100... stay visible.
self._mqtt_refused_more += 1
more = self._mqtt_refused_more
if str(more).rstrip("0") == "1":
_LOG.warning("Home Assistant commands still ignored: %d "
"more so far.", more)
return
self._mqtt_refused.add((topic_s, why))
_LOG.warning("Home Assistant command on %s ignored: %s", topic_s,
why)
except Exception: # noqa: BLE001
_LOG.exception("Home Assistant command could not be handled")
@property
def why_words(self) -> str:
"""The page's "Why" line. While control waits for a missing reading
the age is counted when the page asks (UX-10), not frozen at the age
the last cycle saw."""
if self._why_waiting_since is not None:
return messages.why_waiting(max(0.0, (
datetime.now(timezone.utc)
- self._why_waiting_since).total_seconds()))
return self._why_words
@why_words.setter
def why_words(self, text: str) -> None:
self._why_words = text
self._why_waiting_since = None
def publish(self) -> None:
# ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE: `battery` stays the SUM and
# `soc` the EMPTIEST pack (their `unique_id`s are on live dashboards);
# per-inverter entities are TEL-07's.
# (DECISIONS/MAIN-orchestration-archive.md)
values = {
"setpoint": self.target,
"grid": self.grid,
"battery": self.site_batt_w,
"soc": self.pack_floor_soc,
"phase": self.maint.phase,
# ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing
# one: "failsafe" is additive to a sensor already on every
# dashboard, and a P_SAFETY stop that did not reach it would be a
# site at 0 W reporting "running".
# (DECISIONS/MAIN-orchestration-archive.md)
"status": ("failsafe" if any(i.sign_wd.tripped
for i in self.inverters)
or (self.site_sign_wd.tripped
and len(self.inverters) > 1)
or self.circ_wd.tripped
# SAFETY-10: "shadow" is its own value and not a flavour
# of "stopped". ⚠️ BELOW `failsafe`: a latch that is up
# is up, and shadow does not clear it.
# (DECISIONS/MAIN-orchestration-archive.md)
else "shadow" if self.shadow
else "running" if self.auto else "stopped"),
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
# `site_circulating_w`. `publish()` drops None values, so a missing
# per-unit reading leaves the last figure standing rather than
# publishing a zero that would read as health.
# (DECISIONS/MAIN-orchestration-archive.md)
"circulation": (None if self.site_circulating_w is None
else round(self.site_circulating_w)),
# TEL-10. The loop's own three, written from the fields `cycle()`
# recorded and not re-derived (the page and the publisher run on
# their own tasks, and `self.target` has moved on).
# (DECISIONS/MAIN-orchestration-archive.md)
"loop_i": round(self.loop_i_w, 1),
"loop_lead": round(self.loop_i_lead, 1),
"loop_reason": self.loop_reason,
}
# TEL-11. ⚠️ THE KEY IS PRESENT WITH `None` WHEN THERE IS NO READING
# AND ABSENT WHEN THERE IS NO SOURCE: two states
# (`mqtt.ABSENT_AS_UNKNOWN` makes the first an `unknown`, a gap in the
# recorder). TEL-12: still ONE number, the SITE total, None unless
# EVERY entity answered. ⚠️ THE GATE IS `optional_keys()`, THE SAME
# EXPRESSION THE ANNOUNCEMENT USES.
# (DECISIONS/MAIN-orchestration-archive.md)
if "pv" in optional_keys(self.o):
values["pv"] = (None if self.pv_w is None
else round(self.pv_w, 1))
# ⚠️ ONLY when P1 ingestion is running: the ESP32's stale-input
# watchdog subscribes to sensor.goodwe_p1_sample_age and forces layer-1
# failsafe at max_age_s, so with meter_source off a published age would
# climb unbounded and pin every existing install at 0 W within 30 s.
# Publishing nothing leaves the entity non-existent (firmware
# has_state()). (DECISIONS/MAIN-orchestration-archive.md)
if self.p1_enabled:
# Recomputed here, once a second, on purpose - see P1Ingest.
values["p1_age"] = round(self.p1.published_age_s, 1)
# TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE: ⚠️ no `if
# len(self.inverters) > 1` here, deliberately: `announced_units()`
# answers [] at N=1, so what is announced and what is published come
# from one expression. ⚠️ The site rows do not move: `battery` stays
# the SUM and `soc` the EMPTIEST pack (live dashboards use those ids).
# (DECISIONS/MAIN-orchestration-archive.md)
names = announced_units([inv.name for inv in self.inverters])
for inv, slug in zip(self.inverters, unit_slugs(names)):
values[unit_state_key(slug, "setpoint")] = inv.target
values[unit_state_key(slug, "battery")] = inv.batt
values[unit_state_key(slug, "soc")] = inv.soc
values[unit_state_key(slug, "sign_latch")] = (
"tripped" if inv.sign_wd.tripped else "clear")
# OPT-11 P2: each battery's mode and switch; nothing while modes are off.
values.update(self.mode_values())
self.mqtt.publish(values)
# -- UI ------------------------------------------------------------------
def checks(self, when: datetime | None = None) -> list:
"""The status rows. `when` is the reference time for anything billed by
the calendar - today's month when it is None, which is what the live
page means. ⚠️ IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17): a
suite that banks an August quarter went red at the September boundary.
(DECISIONS/MAIN-orchestration-archive.md)"""
o = self.o
out = []
# SAFETY-61. First, so a stalled loop is the headline: the heartbeat is
# sent from this loop, so past layer 1's veto the inverter is at 0 W.
if self.loop_pass_mono is None:
out.append({"ok": True, "warn": False, "id": "controller",
"text": messages.row_controller_starting()})
else:
_pass_age = time.monotonic() - self.loop_pass_mono
if _pass_age > FIRMWARE_INGEST_VETO_S:
out.append({"ok": False, "warn": False, "id": "controller",
"text": messages.row_controller_stalled(
_pass_age, FIRMWARE_INGEST_VETO_S)})
else:
out.append({"ok": True, "warn": False, "id": "controller",
"text": messages.row_controller_ran(_pass_age)})
# SAFETY-112. The headline unless control has stalled: while the
# Supervisor gives 502 (Home Assistant not yet RUNNING) no reading or
# write gets through, and the rows below that need it follow from it.
if OUTAGE.waiting():
out.insert(1 if out[0]["ok"] is False else 0,
{"ok": False, "warn": False, "id": "home_assistant",
"banner": messages.banner_ha_waiting(),
"text": messages.row_ha_waiting()})
if self.p1_enabled:
age = self.p1.published_age_s
if (self.p1.stale and self.p1.age_s() is None
and age <= self.p1.max_age_s):
# UX-10: no reading yet since the start, still inside the
# limit. Amber, not red: it is not a fault yet, but control
# waits for it. Red at 0 s contradicted the limit the row
# prints. (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": True, "id": "meter",
"text": messages.row_meter_waiting(
self.p1.max_age_s)})
elif self.p1.stale:
# The last error is on the add-on log's WARNING line (p1.py).
out.append({"ok": False, "warn": False, "id": "meter",
"text": messages.row_meter_stale(
age, self.p1.max_age_s)})
else:
# ⚠️ unchanged_s is REPORTED, never thresholded and never
# folded into the age - see HomeWizardLocalSource.unchanged_s
# for why (at the converged -10 W this controller aims for, a 1
# Wh register needs ~6 minutes to move, so any limit
# false-trips at the exact operating point we target).
# (DECISIONS/MAIN-orchestration-archive.md)
unchanged = getattr(self.p1_source, "unchanged_s", None)
out.append({"ok": True, "warn": False, "id": "meter",
"text": messages.row_meter(
self.p1.net_w, age, self.p1.parse_errors,
unchanged)})
# SAFETY-18. Reported in both states (a detector nobody can see is
# armed is not trusted when it fires). ok=False/warn=False so the
# banner goes red; it changes NOTHING about control (see
# check_meter_frozen()). (DECISIONS/MAIN-orchestration-archive.md)
if self.freeze_wd.active:
# The figures are in `freeze_wd.reason`, on the log and the
# event list (check_meter_frozen()).
out.append({"ok": False, "warn": False, "id": "meter_frozen",
"text": messages.row_meter_frozen()})
elif getattr(self.p1_source, "unchanged_s", None) is None:
# Not "armed". Every transport but homewizard_local is invisible
# to this check, and saying "armed" there would be a lie the
# status page tells on behalf of a guard that cannot run.
out.append({"ok": False, "warn": True, "id": "freeze_check",
"text": messages.row_freeze_off("source")})
elif rail_unreachable(self.site_max_w,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max")):
# SAFETY-20: the same row as the transport case above (the
# check cannot be evaluated here); only the missing term
# differs. (DECISIONS/MAIN-orchestration-archive.md)
_no_rating = (not self.site_max_w
or not is_commandable(self.site_max_w))
out.append({"ok": False, "warn": True, "id": "freeze_check",
"text": messages.row_freeze_off(
"rating" if _no_rating else "range")})
else:
# ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE
# USES, device range included.
# (DECISIONS/MAIN-orchestration-archive.md)
_rail = rail_w(self.site_max_w,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max"))
# And when the device set that number, SAY SO: the operator
# configured `max_w`. ⚠️ Either bound may be None on its own
# (`hass.limits()` reads min and max independently), so render
# what is there. (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": True, "warn": False, "id": "freeze_check",
"text": messages.row_freeze_on(
self.freeze_wd.cfg.hold_s, _rail,
own_limit=_rail < rail_w(self.site_max_w))})
# TARIFF-01. The month's billed peak, and a DIFFERENT row when the
# store could not answer: "no peak yet" is normal on the 1st,
# "unreadable" is a broken instrument.
# (DECISIONS/MAIN-orchestration-archive.md)
_pk = self.peak.peak(when)
if _pk.is_unreadable:
out.append({"ok": False, "warn": True, "id": "month_peak",
"text": messages.row_month_peak_unreadable()})
else:
# §9.1's "warning entity at 80 %", as a status row rather than
# a second entity - see the constructor on not doubling up.
_frac = self.peak.warning_frac(when) or 0.0
_ok = _frac < 0.8
out.append({"ok": _ok, "warn": not _ok, "id": "month_peak",
"text": (messages.row_month_peak_none()
if not _pk.is_observed else
messages.row_month_peak(
_pk.peak_w,
brussels(_pk.quarter_start),
_frac))})
out.append({"ok": True, "warn": False, "id": "periods_measured",
"text": messages.row_periods_measured(
self.peak.recorded)})
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
# are opposite states and a page that spelled them the same would
# show a green line over a guard that is switched off. `None` is a
# fourth state on purpose: before the first cycle nothing has been
# concluded. (DECISIONS/MAIN-orchestration-archive.md)
_pg = self.peak_guard
if _pg is None:
out.append({"ok": True, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_waiting()})
elif _pg.saturated and not _pg.within_aim:
out.append({"ok": False, "warn": False, "id": "peak_guard",
"runs": True,
"text": messages.row_peak_saturated(_pg.ceiling_w)})
elif _pg.floor_limited and (
not ((_pg.floor_w or 0.0) > 0.0 or _pg.within_aim)
or self._peak_floor_limited):
# SAFETY-24. NOT ok, like saturation (the money is being spent
# and a human is the only actuator left); a separate row
# because the remedy is opposite. ⚠️ SAFETY-25: the scope
# sentence (the floor binds the guard, not the loop) is part of
# the row, which carries its own short `banner`; shortening the
# ROW would weaken AC 3.
# (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": False, "id": "peak_guard",
"runs": True,
"banner": messages.banner_peak_reserve(),
"text": messages.row_peak_reserve(
_pg.ceiling_w, soc_unread=_pg.soc is None,
floor_unknown=(_pg.soc is not None
and _pg.soc_floor is None),
packs=(_pg.held if len(self.inverters) > 1
else ()),
others=(_pg.floor_w or 0.0) > 0.0)})
elif _pg.blind:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_blind(
peakguard.blind_kind(_pg.blind))})
elif self.peak_unmeasured_s > 0.0:
# SAFETY-90: the start of this quarter came before the first
# reading, and the guard counts it as high use.
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_unmeasured()})
elif _pg.engaged:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_on(
_pg.floor_w or 0.0, _pg.ceiling_w,
shadow=self.shadow)})
elif _pg.projected_w is not None:
# SAFETY-125: "under" only when it is; over the limit at this
# rate, a grid charge is cut back to the quarter's room.
out.append({"ok": True, "warn": False, "id": "peak_guard",
"text": messages.row_peak_guard_idle(
_pg.projected_w, _pg.ceiling_w,
month_peak=(_pg.ceiling_from
== peakguard.CEILING_MONTH_PEAK),
cut=self.room_charge and not self.shadow)})
else:
out.append({"ok": True, "warn": False, "id": "peak_guard",
"text": _pg.explain()})
else:
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the
# point: SAFETY-07 is NOT COVERED on the legacy single-entity path
# (no telegrams), and a row that rendered only where the guard runs
# would leave the unprotected install showing nothing.
# (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_off()})
# ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row
# on a site with two packs is a page that shows one pack and calls it
# the site. (DECISIONS/MAIN-orchestration-archive.md)
rows = []
for inv in self.inverters:
unit = None if len(self.inverters) == 1 else inv.name
rows.append((messages.LABEL_SOC, unit, inv.soc,
inv.driver.soc_source()))
rows.append((messages.LABEL_BATTERY, unit, inv.batt,
inv.driver.batt_source()))
if not self.p1_enabled:
# In P1 mode the check above replaces this one; leaving both in
# would report "no entity configured" for a meter_entity that is
# correctly unused, i.e. a permanent false NOT READY.
rows.insert(0, (messages.LABEL_GRID, None, self.grid,
o.get("meter_entity")))
# UX-08: the rows name the setting, never the entity id (STYLE.md).
for label, unit, value, entity in rows:
if not entity:
out.append({"ok": False, "warn": False, "id": "sensor",
"text": messages.row_sensor_unset(label, unit)})
elif value is None:
# ⚠️ SAFETY-56. THIS ROW ONCE ASSERTED A FALSE FACT ABOUT A
# NAMED ENTITY: `Freshness.reading()` returns None BY DESIGN
# while the gate withholds a numeric reading. ⚠️ FIXED BY
# SAYING LESS: the gate has its own surface (`stale_note()` in
# `liveness_notes()`).
# (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": False, "id": "sensor",
"text": messages.row_sensor_unusable(label, unit)})
else:
out.append({"ok": True, "warn": False, "id": "sensor",
"text": messages.row_sensor(label, value, unit)})
for inv in self.inverters:
unit = None if len(self.inverters) == 1 else inv.name
if not inv.driver.configured():
out.append({"ok": False, "warn": False, "id": "control",
"text": messages.row_control_unset(unit)})
elif inv.dev_max is None:
out.append({"ok": False, "warn": False, "id": "control",
"text": messages.row_control_missing(unit)})
else:
out.append({"ok": True, "warn": False, "id": "control",
"text": messages.row_control(inv.dev_min,
inv.dev_max, unit)})
# DEV-02. Whether this controller can still put a number on each
# inverter: a dropped link that shows nothing on the page reads as a
# healthy site. Red only for a unit no longer live; the rest amber. The
# same notes, in the same order, as `liveness_notes()`.
# (DECISIONS/MAIN-orchestration-archive.md)
_now = datetime.now(timezone.utc)
for inv in self.inverters:
for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]),
(inv.stale_note(), False),
(inv.refusal_note(), False)):
if note:
out.append({"ok": False, "warn": not red, "text": note})
if self.answer.late_seen: # SAFETY-128
out.append({"ok": False, "warn": True, "id": "late",
"text": messages.row_readings_late(
self.answer.shown_s, self._late_zero)})
# SAFETY-03. Red while an over-rating clamp is engaged (amber for the
# integrator bound, SAFETY-40), and the engagement COUNT stays shown
# after it clears. Each count is shown under ITS alarm's name, never
# the two summed (UX-07/UX-08).
# (DECISIONS/MAIN-orchestration-archive.md)
_cw = self.clamp_wd
if _cw.active and _cw.bug:
out.append({"ok": False, "warn": False, "id": "power_too_high",
"banner": messages.banner_power_too_high(),
"text": messages.row_power_too_high(
_cw.facts, _cw.over_rating, shadow=self.shadow)})
elif _cw.active:
# SAFETY-40: the integrator bound alone is amber, not red.
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
"text": messages.row_not_keeping_up(
_cw.bound_episodes)})
# No over-rating in THIS episode (`bug` would be set), so any count
# is from an earlier one: keep that software-fault evidence shown.
if _cw.over_rating:
out.append({"ok": False, "warn": True, "id": "power_too_high",
"text": messages.row_before(
messages.T_OUTPUT_CLAMP, _cw.over_rating)})
else:
if _cw.over_rating:
out.append({"ok": False, "warn": True, "id": "power_too_high",
"text": messages.row_before(
messages.T_OUTPUT_CLAMP, _cw.over_rating)})
if _cw.bound_episodes:
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
"text": messages.row_before(
messages.T_LOOP_BOUND, _cw.bound_episodes)})
for inv in self.inverters:
if inv.last_write_ok is False:
out.append({"ok": False, "warn": False, "id": "write",
"text": messages.row_write_refused(
None if len(self.inverters) == 1
else inv.name)})
# SAFETY-02. Reported in both states. ⚠️ ONE ROW PER INVERTER: a trip
# on B leaves A commanding. Since SAFETY-30 the row says what the unit
# was compared against (`sign_wd.against`, the string the trip sentence
# uses); at N=1 the wording is unchanged.
# (DECISIONS/MAIN-orchestration-archive.md)
for inv in self.inverters:
unit = None if len(self.inverters) == 1 else inv.name
# ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE: at N=1 the
# two sentences below are the ones that have always been on the
# page, word for word (AC 8); at N>=2 the noun is load-bearing, so
# the row does not send an operator to P1 for a fault visible only
# on one inverter's own power.
# (DECISIONS/MAIN-orchestration-archive.md)
if inv.sign_wd.tripped:
out.append({"ok": False, "warn": False, "id": "direction",
"text": messages.row_direction_trip(unit)})
else:
held = inv.sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, "id": "direction",
"text": messages.row_direction_check(
held, inv.sign_wd.cfg.hold_s, unit)})
# SAFETY-30: the SITE row. ⚠️ ONLY AT N>=2, and the absence at N=1 is
# the honest report: with one inverter the site comparison and the row
# above are the same sentence about the same actuator.
# (DECISIONS/MAIN-orchestration-archive.md)
if len(self.inverters) > 1:
if self.site_sign_wd.tripped:
out.append({"ok": False, "warn": False, "id": "site_direction",
"text": messages.row_site_direction_trip()})
else:
_sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, "id": "site_direction",
"text": messages.row_site_direction_check(
_sheld, self.site_sign_wd.cfg.hold_s)})
# SAFETY-31. ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and
# the row says so: there is one circulating figure for the site, and
# naming an inverter would invite clearing "the other one".
# (DECISIONS/MAIN-orchestration-archive.md)
_circ = self.site_circulating_w
if self.circ_wd.tripped:
out.append({"ok": False, "warn": False, "id": "circulating",
"text": messages.row_circulating_trip()})
elif len(self.inverters) <= 1:
# AC 5. At the shipped roster the quantity is identically zero for
# every value of every reading, so "armed" would be a claim to be
# watching something that cannot happen. Say what is true instead.
out.append({"ok": True, "warn": False, "id": "circulating",
"text": messages.row_one_inverter(
messages.CHECK_CIRCULATING)})
else:
held = self.circ_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, "id": "circulating",
"text": messages.row_circulating_check(
_circ, held, self.circ_wd.cfg.hold_s)})
# SAFETY-42. ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE
# ONE PLACE THE "reports, does not act" DECISION IS VISIBLE: every
# other latched row means the site is at 0 W, this one means it is
# STILL RUNNING and quietly short by one unit's share; `ok: False` with
# `warn: True` is this list's caution.
# (DECISIONS/MAIN-orchestration-archive.md)
if len(self.inverters) > 1:
for inv in self.inverters:
if inv.delivery_wd.tripped:
out.append({"ok": False, "warn": True, "id": "delivery",
"text": messages.row_not_delivering(inv.name)})
else:
_dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, "id": "delivery",
"text": messages.row_delivery_check(
inv.name, _dheld,
inv.delivery_wd.cfg.hold_s)})
else:
# AC 5: at one inverter this check has nothing to find, so it is
# not called "armed" (as `circulating power: n/a` above).
# (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": True, "warn": False, "id": "delivery",
"text": messages.row_one_inverter("Delivery check")})
if not o.get("estop_fitted", False):
out.append({"ok": False, "warn": True, "id": "estop",
"text": messages.row_no_estop()})
else:
out.append({"ok": True, "warn": False, "id": "estop",
"text": messages.row_estop_fitted()})
if not o.get("peak_forecast_entity"):
# ok=False + warn=True renders as a caution, not a pass. A check that
# is both is a check nobody reads.
out.append({"ok": False, "warn": True, "id": "peak_forecast",
"text": messages.row_no_peak_forecast()})
elif getattr(self, "_peak_fc_fault", None) is not None:
out.append({"ok": False, "warn": True, "id": "peak_forecast",
"text": messages.row_peak_forecast_unit(
self._peak_fc_fault)})
# OPT-17: a forecast summed from several entities names, by position,
# each member with no usable reading. A caution: control goes on.
gaps = getattr(self, "solar_fc_gaps", ())
for day, ents in (("today", getattr(self, "fc_entities", ())),
("tomorrow",
getattr(self, "fc_tomorrow_entities", ()))):
gone = [i for d, i, _e, _w in gaps if d == day]
if gone:
out.append({"ok": False, "warn": True, "id": "solar_forecast",
"text": messages.row_forecast_gap(
day, gone, len(ents))})
# TEL-11. ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY (pure
# instrumentation: no warning spam). ⚠️ WHEN IT IS SET, A FAILED READ
# IS `ok=False, warn=True`, a CAUTION never `bad`: telemetry must not
# put a battery site into NOT READY. UX-08 replaced 0.11.0's sentences
# on purpose. (DECISIONS/MAIN-orchestration-archive.md)
roster = self.pv_roster
if roster:
# UX-08: sources are numbered in the order of the settings list,
# never named by entity id (STYLE.md). The per-source figures and
# units are what `pv.total()` returns; they stay in `pv_parts`.
flip = pv_read.inverted(self.o)
n_src = len(roster)
if self.pv_w is None and n_src == 1:
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_none(self.pv_why)})
elif self.pv_w is None:
# ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN
# THOSE WORDS - and which sources are missing, by position.
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_partial(
[i + 1 for i, (_e, watt, _y)
in enumerate(self.pv_parts) if watt is None],
n_src)})
else:
out.append({"ok": True, "warn": False, "id": "pv",
"text": messages.row_pv(
self.pv_w, n_src,
n_src == 1 and roster[0] in flip)})
# ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
# `entities()` makes `pv_entities` REPLACE `pv_entity`, which is
# the rule an operator upgrading from 0.11.0 is most likely to trip
# over. (DECISIONS/MAIN-orchestration-archive.md)
if pv_read.entity_is_ignored(self.o):
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_single_ignored()})
# TEL-14: a sign that looks reversed, per entity rather than on the
# total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3.
for i, (ent, watt, _y) in enumerate(self.pv_parts):
if watt is not None and watt < pv_read.NEGATIVE_W:
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_negative(
i + 1, n_src, watt, ent in flip)})
_inv_list = [str(x or "").strip()
for x in (self.o.get("pv_invert_entities") or [])]
for ent in pv_read.stray_inverts(self.o):
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_stray(
_inv_list.index(ent) + 1)})
return out
def status_payload(self, lang: str = "en") -> dict:
checks = self.checks()
bad = [c for c in checks if not c["ok"] and not c["warn"]]
# SAFETY-87: a red row with `runs` (the peak alarms) is an alarm while
# control goes on, so it neither reads "Not ready" nor holds the page.
runs = [c for c in bad if c.get("runs")]
bad = [c for c in bad if not c.get("runs")]
running_alarm = waiting = False
# SAFETY-10, AND IT IS FIRST IN THE CASCADE: "The battery is NOT being
# controlled" may never be displaced by another condition. ⚠️ AND IT
# CARRIES A STANDING LATCH RATHER THAN HIDING ONE: `shadow.banner()`
# names the latch and goes red for it, so nothing the cascade would
# have said is lost. (DECISIONS/MAIN-orchestration-archive.md)
_shadow = shadow.banner(self.o, control_started=self.auto,
latched=self.shadow_latches())
if _shadow is not None:
level, banner = _shadow
elif self.circ_wd.tripped:
# ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision: both are
# latched P_SAFETY stops and the banner carries one sentence; this
# one goes first because it is the fault the operator has NO OTHER
# WAY of learning about (a sign disagreement is visible at the
# meter, circulating power nowhere).
# (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "bad", messages.banner_circulating()
elif self.site_sign_wd.tripped and len(self.inverters) > 1:
# SAFETY-30's site half, ABOVE the per-unit rows below and below
# circulating power above: the banner carries one sentence, so the
# one that says the widest true thing. A site sign trip parks EVERY
# inverter and the fault is in what the site was asking for; naming
# a unit would send an operator to swap the wrong box.
# (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "bad", messages.banner_site_direction()
elif any(i.sign_wd.tripped for i in self.inverters):
# Outranks "Not ready": nothing is missing, something went wrong.
# At N>=2 the unit was graded against its own power, so the banner
# says so (see checks()). (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "bad", messages.banner_direction(
len(self.inverters) > 1)
elif bad:
# ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE: a row may
# say more than fits the headline (SAFETY-25's floor row).
# (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "bad", messages.banner_not_ready(bad[0])
# SAFETY-112: nothing to do but wait, so Home says that instead
# of "Needs you now". Only when it IS the headline: a safety stop
# above outranks it and still needs a person.
waiting = bad[0].get("id") == "home_assistant"
elif not self.auto:
level, banner = "warn", messages.banner_stopped()
elif runs:
level, running_alarm = "bad", True
banner = runs[0].get("banner") or messages.headline_bi(
runs[0]["text"])
elif any(i.delivery_wd.tripped for i in self.inverters):
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE
# (the site is STILL RUNNING, one unit short). ⚠️ BUT IT MUST
# DISPLACE "Running - holding grid at zero": that sentence is a
# claim the site cannot make while a unit is absent. It sits BELOW
# "Stopped". (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "warn", messages.banner_not_delivering(
[i.name for i in self.inverters if i.delivery_wd.tripped])
elif self.maint.phase != IDLE:
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE: at N=1 the
# string is what it has always been, character for character.
# (DECISIONS/MAIN-orchestration-archive.md)
level, banner = "warn", messages.banner_maintenance(
self.maint.phase,
self.maint_unit.name if len(self.inverters) > 1 else "")
else:
level, banner = "ok", messages.banner_running()
def w(v):
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
# ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. `None` means
# "never run, due now" and has to win over any date, so it is tested
# for rather than sorted with. At N=1 this is that pack's own date.
# (DECISIONS/MAIN-orchestration-archive.md)
_now = datetime.now(timezone.utc)
_dues = [m.next_due(_now) for m in self.maints]
_waits = [u for u in (m.backoff_until(_now) for m in self.maints) if u]
next_due = None if any(d is None for d in _dues) else min(_dues)
rows = [
("Grid", w(self.grid)),
("Battery", w(self.site_batt_w)),
# SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is
# not derivable from ("Grid 0 W, Battery 0 W, Circulating 6000 W"
# is the whole card in three rows). Only shown at N>=2: at one
# inverter it is identically zero.
# (DECISIONS/MAIN-orchestration-archive.md)
*(() if len(self.inverters) <= 1
else ((("Circulating", w(self.site_circulating_w)),))),
# TEL-11, and ONLY when a source is configured - see `checks()`.
# `w(None)` renders "-", which is how this row says "no reading"
# rather than "0 W". (DECISIONS/MAIN-orchestration-archive.md)
*(() if not self.pv_roster else ((("Solar production",
w(self.pv_w)),))),
("Battery level", "—" if self.pack_floor_soc is None
else f"{self.pack_floor_soc:g} %"),
("Power request", w(self.target)),
# SAFETY-10, and it sits BETWEEN the two rows it reconciles.
# "Commanded 0 W" above and a "Why" below that explains a trade is
# the exact pair an operator would otherwise have to guess at; this
# row is what makes the two of them consistent, and it is rendered
# in EVERY state - including `off`, where it says the battery IS
# being controlled - because a surface whose ABSENCE is how you
# learn something is a surface you have to already know about.
("Shadow mode", shadow.words(
self.o, control_started=self.auto,
would_w=self.shadow_would_w,
# SAFETY-47. THE FLAG THE CYCLE RECORDED, NOT ONE DERIVED HERE
# - the page runs on its own task and `self.peak_guard` has
# moved on. See `Controller.__init__`.
sized_from_meter=self.shadow_sized_from_meter,
reason=self.why_words)),
("Why", self.why_words),
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
else f"{self.maint.phase} ({self.maint_unit.name})"),
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
# page - where `_maint_deferred` is empty on every tick - carries
# exactly the rows it carried before.
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
for n, why in self._maint_deferred)),)
if self._maint_deferred else ()),
# MAINT-13: only while a failed cycle waits, so a site with nothing
# failed carries exactly the rows it carried before.
*((("Maintenance paused", messages.row_maint_waits(
min(_waits).strftime("%Y-%m-%d"))),) if _waits else ()),
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
# PRICE-01: only while no price covers now.
*((("Prices", messages.row_no_prices(*self._gap_args())),)
if self.price_gap else ()),
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, the difference between a
# 48-hour plan and a 12-hour one on a weekday morning. Its own row:
# "Tariff" says what the plan IS, this says what it could still be
# filled from if the feed is late again.
# (DECISIONS/MAIN-orchestration-archive.md)
("Price history", self.price_history.words),
# OPT-04. The strategy's own sentence, not a re-derivation: every
# refusal it can make carries a reason a human can act on. ⚠️
# TARIFF-11: `self.arb is None` is NOT "trading is off" (cycle()
# returns at the input hold first); `arbitrage_row()` reads the
# option, and `cycle_at`/`arb_at` make "no decision this cycle" an
# observation. (DECISIONS/MAIN-orchestration-archive.md)
("Arbitrage", arbitrage_row(
on=arbitrage.enabled(self.o) or self.modes_on,
decision=self.arb,
decided_at=self.arb_at, cycle_at=self.cycle_at,
now=datetime.now(timezone.utc), why=self.why_words)),
]
held = level == "bad" and not running_alarm
# UX-46. The page's language is chosen once, here, for the Home
# sentences, checks, rows that carry one and the banner; a plain str
# (no Dutch twin) stays as it is.
# (DECISIONS/MAIN-orchestration-archive.md)
checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks]
rows = [(k, messages.pick(v, lang)) for k, v in rows]
banner = messages.pick(banner, lang)
out = {
"banner": banner, "level": level, "rows": rows, "checks": checks,
"hint": self.hint,
# The action log itself, newest first: not shown on the page (UX-08
# 2d) but readable in the `status` JSON, since several events are
# written nowhere else. (DECISIONS/MAIN-orchestration-archive.md)
"events": list(self.events),
}
# UX-13 P1: the Home screen's structured half. Read-only, and it may
# never cost the page its rows: a fault here is logged and the old
# fields are served alone (the page then shows the installer details).
try:
out.update(homeview.build(self, level=level, checks=checks,
lang=lang, held=held, waiting=waiting))
except Exception: # noqa: BLE001
# Once per process at ERROR: the page polls every 3 s.
if not getattr(self, "_home_failed", False):
self._home_failed = True
_LOG.exception("Home view could not be built, so the page "
"shows the plain status rows only")
else:
_LOG.debug("Home view failed again", exc_info=True)
out["home_error"] = True
return out
def forecast_payload(self, lang: str = "en") -> dict:
"""UX-54: the Forecast tab's data. Built on request, from what the
control cycle already holds; no Home Assistant call. Today's blocks are
Home's own, so `status_payload()` is built first and read."""
try:
return dayview.build(self, self.status_payload(lang=lang),
datetime.now(timezone.utc), lang)
except Exception: # noqa: BLE001
if not getattr(self, "_forecast_failed", False):
self._forecast_failed = True
_LOG.exception("The Forecast tab could not be built")
else:
_LOG.debug("The Forecast tab failed again", exc_info=True)
return {"ok": False}
async def handle_action(self, what: str, *, unit=None, value=None):
now = datetime.now(timezone.utc)
if what in ("pack_use", "pack_mode", "pack_sell"):
return self._pack_action(what, unit, value, now)
if what == "auto_toggle":
self.auto = not self.auto
self.store.set("auto", self.auto)
self.log_event("control started" if self.auto else "control stopped",
hint=messages.hint("control_started" if self.auto
else "control_stopped"))
if self.auto:
# ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose:
# it clears on a deliberate human start, not on the condition
# going away (the condition ALWAYS goes away, because the trip
# commands 0 W). ⚠️ IT CLEARS EVERY INVERTER'S LATCH, AND SAYS
# SO PER INVERTER, each under its OWN key.
# (DECISIONS/MAIN-orchestration-archive.md)
for inv in self.inverters:
if not inv.sign_wd.tripped:
continue
inv.sign_wd.reset()
inv.store_set("sign_tripped", False)
inv.store_set("sign_reason", "")
self.log_event(f"sign-disagreement failsafe cleared by "
f"operator on {inv.name}",
hint=messages.hint("stop_cleared",
unit=inv.name))
# ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM,
# UNCONDITIONALLY: this is what makes `flat True + namespaced
# False` MEAN something, and why `_grade_store_migration()` can
# run on every start. Left behind, a flat key is a fossil that
# re-arms the failsafe on every restart.
# (DECISIONS/MAIN-orchestration-archive.md)
self.store.set("sign_tripped", False)
self.store.set("sign_reason", "")
# SAFETY-30's SITE latch, cleared by the SAME press for the
# same reason (the trip commands 0 W, so the condition goes
# away on its own). ⚠️ UNCONDITIONAL, NOT GUARDED ON THE
# ROSTER: an install reconfigured back to one inverter would
# otherwise keep `site_sign_tripped: true` for ever.
# (DECISIONS/MAIN-orchestration-archive.md)
if self.site_sign_wd.tripped:
self.log_event("site sign-disagreement failsafe cleared "
"by operator",
hint=messages.hint("site_stop_cleared"))
self.site_sign_wd.reset()
self.store.set("site_sign_tripped", False)
self.store.set("site_sign_reason", "")
# SAFETY-31, cleared by the SAME press and for the same reason.
# ⚠️ AND `_circ_announced` GOES WITH IT: left True, the NEXT
# trip in this process would latch, stop the site and raise no
# notification. (DECISIONS/MAIN-orchestration-archive.md)
if self.circ_wd.tripped:
self.circ_wd.reset()
self.store.set("circ_tripped", False)
self.store.set("circ_reason", "")
self.log_event("circulating-power trip cleared by operator",
hint=messages.hint("site_stop_cleared"))
self._circ_announced = False
# SAFETY-42, cleared by the SAME press, for a reason that is
# NOT the one above: this latch commands nothing, so its
# evidence still stands and it could clear itself. It does not:
# a unit that stopped delivering and resumed is an INTERMITTENT
# fault. ⚠️ AND `_delivery_announced` GOES WITH IT, like
# `_circ_announced`. (DECISIONS/MAIN-orchestration-archive.md)
for inv in self.inverters:
if inv.delivery_wd.tripped:
self.log_event(f"not-delivering report on {inv.name} "
f"cleared by operator",
hint=messages.hint(
"not_delivering_cleared",
unit=inv.name))
inv.delivery_wd.reset()
inv.store_set("delivery_tripped", False)
inv.store_set("delivery_reason", "")
self._delivery_announced = set()
if not self.auto:
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see
# command_site_zero(). SMA-10: Stop gives hand-back units back.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.command_site_release()
return {"auto": self.auto}
if what in ("maint_start", "maint_abort") and unit is not None:
# MAINT-11: the operator named the battery. A request with no unit
# falls through to the branches below, unchanged.
return await self._maint_unit_action(what, unit, now)
if what == "maint_start":
# ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
# ONE LABEL: `_maint_button()` picks the pack that holds the
# maintenance slot, else the one longest without a cycle; at N=1
# both are `maints[0]`. This is the bare request; a named battery
# is `_maint_unit_action()` (MAINT-11).
# (DECISIONS/MAIN-orchestration-archive.md)
_m = self._maint_button(now)
for msg in _m.force_start(now):
self.log_event(msg, hint=messages.hint("maintenance",
phase=_m.phase))
return {"phase": self.maint.phase}
if what == "maint_abort":
# Aborts the pack that is actually cycling - `_maint_button()` again
# - and then zeroes the SITE, which is what it always did.
_m = self._maint_button(now)
for msg in _m.abort(now, "operator"):
self.log_event(msg, hint=messages.hint("maintenance",
phase=_m.phase))
await self.command_site_zero()
return {"phase": self.maint.phase}
return None
+243
View File
@@ -0,0 +1,243 @@
"""Controller methods that run as background tasks.
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
import asyncio
import contextlib
import logging
import time
from datetime import datetime, timedelta, timezone
from . import messages
from .inverter import UNIT_A
from .price import brussels, price_option
from .static_tariff import resolve_tariff
from .validate import refuse, validate_startup_config, validate_unit_ranges
from .ctl_inputs import TARIFF_MAX_AGE_S, TARIFF_REFRESH_S
_LOG = logging.getLogger("goodwe")
class TasksMixin:
def _grade_store_migration(self, store) -> None:
"""DEV-02. Did SAFETY-02's latch actually survive the key rename?
Graded, not performed: a latch that silently fails to move is a
DISARMED SAFETY GUARD on an install that believes it is protected. The
pre-DEV-02 flat value is compared with what the roster came up with; a
disagreement re-arms the latch by hand (the fail-safe direction is ON).
It runs on EVERY start, not only the migrating one: gating on `rep.ran`
swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip
in the window, upgrade; performed on this site 2026-09-02).
DECISIONS/MAIN-orchestration.md."""
rep = self.store_migration
if rep.ran and not rep.ok:
_LOG.error("The saved safety stops did not copy over cleanly, so "
"they are read again from the older saved state: %s.",
rep.explain())
data = getattr(store, "data", {})
legacy_tripped = bool(data.get("sign_tripped", False))
if not legacy_tripped or not self.inverters:
return
# The question is "is any namespaced latch set THAT A MACHINE READS"
# (SAFETY-44), asked of the STORE's keys, not the roster's objects:
# re-arming A off a flat key that B's trip mirrored would latch a
# healthy inverter on every restart. Three cases: on the roster, stop;
# off-roster named `UNIT_A` (the broken migration's fingerprint),
# repair onto `inverters[0]`; off-roster, other name: stop unless a
# stranded `UNIT_A` latch is present. This guard is the ONLY repair for
# sites upgraded before the fix. Residual: a first unit really named
# `inverter-a`, latched and removed, is re-armed on `inverters[0]`
# (conservative). DECISIONS/MAIN-orchestration.md.
on_roster = {inv.name for inv in self.inverters}
stranded, on_roster_latched, other_off_roster = [], False, False
for k, v in data.items():
if not (k.startswith("unit/") and k.endswith("/sign_tripped")):
continue
if not bool(v):
continue
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1:
# split through the writer's own spelling.
unit = k.split("/")[1]
if unit in on_roster:
on_roster_latched = True
elif unit == UNIT_A:
stranded.append(k)
else:
other_off_roster = True
if on_roster_latched or any(inv.sign_wd.tripped
for inv in self.inverters):
return
if other_off_roster and not stranded:
# A removed unit's own latch and no misdirected one: the flat key
# is that unit's mirror, so re-arming `inverters[0]` from it would
# latch a healthy pack.
return
if stranded:
_LOG.error(
"A saved safety stop belongs to %s, which is not in the "
"inverter list %s, so it is set again on the first inverter.",
", ".join(sorted(stranded)), sorted(on_roster))
first = self.inverters[0]
# The one state this method exists for.
reason = str(data.get("sign_reason", "") or "")
first.sign_wd.tripped = True
first.sign_wd.reason = reason or (
"SIGN_DISAGREEMENT: latched before this version; the reason was "
"not carried across the per-inverter store migration")
first.store_set("sign_reason", first.sign_wd.reason)
first.store_set("sign_tripped", True)
_LOG.error(
"%s is set to 0 W again because an older saved safety stop was "
"found, so find out why before you start control: %s.",
first.name, messages.reason_code(first.sign_wd.reason))
_LOG.debug("Evidence for %s: %s.", first.name, first.sign_wd.reason)
_LOG.debug(
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
"sign-disagreement failsafe is latched and no inverter came up "
"latched. Either the per-inverter migration lost it, or an older "
"add-on wrote it during a rollback. Both would be a disarmed "
"guard, so the latch is re-armed on %s.", first.name)
self.log_event("FAILSAFE re-armed from the pre-DEV-02 store keys - "
+ first.sign_wd.reason,
hint=messages.hint("stop_restored", unit=first.name))
# -- tasks ---------------------------------------------------------------
# -- OPT-04: the background tariff refresh -------------------------------
def fresh_tariff_plan(self, now):
"""The stored plan if it is still current, else None. NEVER a stale
one. THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for
`loop_is_following()`'s reason: a check has to be able to assert on the
shipped expression rather than on a transcription that drifts.
(DECISIONS/MAIN-orchestration-archive.md)"""
if self.tariff_plan is None or self.tariff_at is None:
return None
if (now - self.tariff_at).total_seconds() > TARIFF_MAX_AGE_S:
return None
return self.tariff_plan
async def run_tariff_refresh(self) -> None:
"""Resolve the tariff off the control path, forever. NEVER on the tick.
⚠️ IT CATCHES EVERYTHING AND KEEPS GOING: raising out of here would
freeze the plan, looking current for ever (`fresh_tariff_plan()`'s age
bound is the backstop). ⚠️ A FAILURE DOES NOT CLEAR THE PLAN
(TARIFF-12): a degraded plan replaces a standing observation only once
it has aged out; a §11.4 fallback never replaces a fresh provider plan.
⚠️ `tariff_at` DOES NOT MOVE ON A KEPT PLAN.
(DECISIONS/MAIN-orchestration-archive.md)"""
horizon = float(price_option(self.o, "price_horizon_h"))
while not self.stopping:
now = datetime.now(timezone.utc)
try:
plan = await resolve_tariff(
self.o, now, now + timedelta(hours=horizon), now=now,
history=self.price_history, watch=self.price_watch)
standing = self.fresh_tariff_plan(now)
# Three terms, and each of them is the only false one in some
# check: the feed answered (replace), nothing current stands
# (take the degradation - this is what makes the age bound
# govern), or what stands is itself a degradation (replace).
keep = (not plan.from_feed
and standing is not None and standing.from_feed)
if keep:
_LOG.warning(
"The price update was incomplete, so the plan from %s "
"is kept until it is %s old. The update held %s.",
messages.when(brussels(self.tariff_at)),
messages.duration(TARIFF_MAX_AGE_S), plan.words)
else:
self.tariff_plan, self.tariff_at = plan, now
_LOG.info("tariff refreshed: %s", plan.words)
except Exception as err: # noqa: BLE001
# ⚠️ NOT THE OUTAGE PATH - see the docstring: `load_prices()`
# returns on every failure a site can have, so what reaches
# here is unexpected, and the plan is kept on the same terms.
# (DECISIONS/MAIN-orchestration-archive.md)
_LOG.warning(
"The price update failed (%s), so the plan from %s is kept "
"until it is %s old, and trading stops then.",
err, messages.when(brussels(self.tariff_at)),
messages.duration(TARIFF_MAX_AGE_S))
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self.stop_event.wait(),
timeout=TARIFF_REFRESH_S)
async def run_control(self) -> None:
# ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4 (one
# read of one `setpoint_entity` left a second inverter's clamp, freeze
# rail and `achievable_w` on the first one's numbers). Concurrently: a
# slow ESPHome on one unit must not delay the start on the others.
# (DECISIONS/MAIN-orchestration-archive.md)
async def _limits(inv):
if inv.driver.configured():
inv.dev_min, inv.dev_max = await self._driver(inv).limits()
await asyncio.gather(*(_limits(inv) for inv in self.inverters))
entity = self.o.get("setpoint_entity", "")
# ⚠️ THIS USED TO WARN AND CARRY ON, the bug CFG-01 exists to remove: a
# max_w above the device's rating was silently clamped. amain() ran
# this gate already; it runs AGAIN because ESPHome is often not up at
# boot (limits None) and this is the first moment the real rating is
# known; a conflict refuses through the normal shutdown (0 W). ⚠️ THE
# `if entity:` THAT WRAPPED THIS BLOCK FAILED PERMISSIVE (CFG-04's
# hazard): a per-inverter config leaves the flat `setpoint_entity`
# blank, so the re-check was skipped; the per-unit rule now runs
# unconditionally. (DECISIONS/MAIN-orchestration-archive.md)
problems = []
if entity:
_first = self.inverters[0]
problems += validate_startup_config(self.o, _first.dev_min,
_first.dev_max)
problems += validate_unit_ranges(self.inverters, skip_entity=entity)
if problems:
refuse(problems)
self.refused = problems
self.log_event("REFUSED: " + problems[0],
hint=messages.hint("refused"))
# Signal and return WITHOUT entering the loop: amain() runs the
# wind-down (0 W, or a hand-back: SMA-10, see
# DECISIONS/SMA-10-two-idle-states.md) and exits with code 78.
self.stop_event.set()
return
last_grid = object()
heartbeat = float(self.o.get("heartbeat_s", 10))
# ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not
# a second scheduler: every cycle resets it, so on a healthy meter it
# never elapses and the per-cycle gain is unchanged. It exists because
# a meter that repeats a value would run NO cycles, and the `inputs
# missing -> 0 W` claim lives inside cycle(). NOT a freeze detector. ⚠️
# THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN: it sits under
# validate.FIRMWARE_INGEST_VETO_S (30 s) and `meter_max_age_s` and
# moves with the firmware's veto; the schema minimum of 5 s is the
# reference meter's interval and deserves a second look.
# (DECISIONS/MAIN-orchestration-archive.md)
floor = float(self.o.get("cycle_floor_s", 15))
last_cycle = None
while not self.stopping:
await self.read_inputs()
now_mono = asyncio.get_running_loop().time()
# A cycle is one meter update, exactly as on the reference install -
# or the floor elapsing, whichever comes first.
overdue = last_cycle is None or (now_mono - last_cycle) >= floor
if self.grid != last_grid or overdue:
last_grid = self.grid
last_cycle = now_mono
# ⚠️ The FULL cycle, not a degraded one: in P1 mode past
# `meter_max_age_s` (30 s) p1.net_w is None ("inputs missing").
# ⚠️ TRANSPORT-CONDITIONAL: with `meter_source: off`,
# read_inputs() has NO age gate, so a quiet entity reads as
# current; the floor does not make it fresh. The integrator
# advances per CYCLE, so bounding the gap bounds the
# accumulation. (DECISIONS/MAIN-orchestration-archive.md)
await self.cycle()
# ⚠️ The heartbeat is not an optimisation: the ESP32 zeroes the
# inverter after ~30 s of silence, and refreshing the SAME value
# proves we are alive. ⚠️ ONE DEADLINE PER INVERTER, refreshed
# concurrently, each bounded by WRITE_BUDGET_S (budget plus the
# largest `heartbeat_s` stays under layer 1's veto: graded); each
# unit refreshes its own `inv.target`, never the site target.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.send_heartbeats(asyncio.get_running_loop().time(),
heartbeat)
self.publish()
self.loop_pass_mono = time.monotonic()
await asyncio.sleep(1)
File diff suppressed because it is too large Load Diff
+613
View File
@@ -0,0 +1,613 @@
"""Controller methods that write to, refuse and hand back the inverters.
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
import asyncio
import logging
import time
from datetime import datetime, timezone
from . import control as _ctl
from .clamp import clamp_to_rating, is_commandable
from . import messages
from . import shadow
_LOG = logging.getLogger("goodwe")
# DEV-02: the per-write budget, DERIVED: `heartbeat_s` max 25 s + 4 <
# `validate.FIRMWARE_INGEST_VETO_S` (30 s), graded by test_inverter.py.
# DECISIONS/MAIN-orchestration.md.
WRITE_BUDGET_S = 4.0
# SMA-10: the bound for handing a unit back (`driver.release()`); units are
# released concurrently and one still running at the bound is abandoned.
# DECISIONS/SMA-10-two-idle-states.md.
RELEASE_BUDGET_S = WRITE_BUDGET_S
# SMA-10: the one fallback write after a failed release (default 0 W). Worst
# case RELEASE_BUDGET_S + FALLBACK_BUDGET_S = 6 s, inside the Supervisor's 10 s.
# DECISIONS/SMA-10-two-idle-states.md.
FALLBACK_BUDGET_S = 2.0
# SAFETY-137: how long a LAST write waits for a write already on its way to the
# same unit; half the release budget, so a GoodWe unit's worst case is
# HAND_BACK_WORST_S. DECISIONS/SAFETY-137-last-write-is-zero.md.
SETTLE_BUDGET_S = RELEASE_BUDGET_S / 2
# SAFETY-73: the evidence must also span this long, not only SETTLE_CYCLES
# cycles: a 1 s meter makes 12 cycles only 11 s, shorter than a healthy battery
# reading's lag, and both inverters were refused after ordinary load steps.
REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S
# SAFETY-94: evidence restarts when the reading has moved this far toward the
# ask. A refusing battery reads flat; live readings wander under 25 W at rest;
# a first-order lag gains at most 0.4 tau x 100 W across the band, so the
# evidence cannot span REFUSED_SPAN_S unless the lag is over 54 s.
REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2
async def _bounded(awaitable, budget_s: float):
"""SMA-10. `await awaitable`, abandoning it after `budget_s` without waiting
for it to wind down (`wait_for` would). Raises `asyncio.TimeoutError`."""
task = asyncio.ensure_future(awaitable)
done, _ = await asyncio.wait({task}, timeout=budget_s)
if not done:
task.cancel()
# a result or error that arrives later has nobody to read it
task.add_done_callback(lambda t: t.cancelled() or t.exception())
raise asyncio.TimeoutError
return task.result()
class WritesMixin:
# helpers; SAFETY-52: a battery that refuses is asked for 0 W
def _refusal_probe(self) -> bool:
"""True while a refused direction may try `PROBE_W` (every 15 min)."""
if (not self.batt_refused or self.batt_refused_at is None
or self._now_mono is None):
return False
held = self._now_mono - self.batt_refused_at
if held >= _ctl.PROBE_EVERY_S + _ctl.PROBE_S:
self.batt_refused_at = self._now_mono
return False
return held >= _ctl.PROBE_EVERY_S
def _refusal_start(self, sign: int, evidence: str) -> None:
self.batt_refused = sign
self.batt_refused_at = self._now_mono
self.log_event(f"BATTERY REFUSES {evidence}",
hint=messages.hint("battery_refuses"))
_LOG.info("The battery refuses to give power, so the add-on asks 0 W "
"and tries %.0f W every %.0f minutes: BATTERY_REFUSES.",
_ctl.PROBE_W, _ctl.PROBE_EVERY_S / 60)
_LOG.debug("Evidence: BATTERY_REFUSES: %s.", evidence)
def _refusal_end(self, why: str) -> None:
if not self.batt_refused:
return
self.batt_refused, self.batt_refused_at = 0, None
self.log_event(f"BATTERY REFUSES ENDED: {why}",
hint=messages.hint("battery_gives"))
_LOG.info("The battery refusal ends, control asks for power again: %s.",
why)
def _refusal_update(self, decision, asked_w, batt_w, probe,
rejudged: int = 0) -> None:
"""Follow `compute()`'s carried refused direction, and say so.
`rejudged`: the refusal held while another writer had the wire, so
`compute()` judged it afresh against that writer's ask (SAFETY-107)."""
now = decision.refused or 0
if rejudged:
if now == rejudged:
# Still refused against the ask on the wire: the same episode.
# The probe clock restarts: a probe is the loop's ask and is
# not on the wire while another writer is.
self.batt_refused_at = self._now_mono
return
gave = (rejudged * (batt_w - self._site_idle_w())
>= _ctl.PROBE_ANSWER_W)
self._refusal_end("the battery gives power" if gave
else "another writer")
if now and not self.batt_refused:
self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery "
f"gave {batt_w:+.0f} W for "
f"{decision.sat_count} cycles")
elif self.batt_refused and not now:
gave = (self.batt_refused * (batt_w - self._site_idle_w())
>= _ctl.PROBE_ANSWER_W)
self._refusal_end("the probe was answered" if gave and probe
else "the battery gives power" if gave
else "the grid now asks the other direction")
def _refusal_from_stale(self, inv, now_mono) -> None:
"""A stale-reading stop while the loop is frozen on ~0 W is a refusal.
Only at one inverter: with several, one empty battery must not stop
the site (OPT-09 moves its share)."""
t = self.tuning
standing = inv.standing_w()
if len(self.inverters) > 1:
# SAFETY-73: at N>1 the same trip refuses THIS unit only.
if ((self.auto or self.shadow) and not inv.refused
and inv.ref_count >= t.saturation_cycles
and inv.batt_fresh.held is not None
and abs(inv.batt_fresh.held - inv.idle_w)
< _ctl.REFUSED_BELOW_W
and abs(standing) > _ctl.PROBE_W):
self._now_mono = now_mono
self._unit_refusal_start(
inv, 1 if standing > 0 else -1,
f"stale reading {inv.batt_fresh.held:+.0f} W while asked "
f"{standing:+.0f} W")
return
if (len(self.inverters) == 1 and (self.auto or self.shadow)
and not self.batt_refused
and self.sat_count >= t.saturation_cycles
and inv.batt_fresh.held is not None
and abs(inv.batt_fresh.held - inv.idle_w)
< _ctl.REFUSED_BELOW_W
and abs(standing) > t.saturation_w):
self._now_mono = now_mono
self._refusal_start(1 if standing > 0 else -1,
f"stale reading {inv.batt_fresh.held:+.0f} W "
f"while asked {standing:+.0f} W")
# SAFETY-73: SAFETY-52's decisions per inverter at N>1: a unit giving <
# REFUSED_BELOW_W for SETTLE_CYCLES while asked > PROBE_W is closed in that
# direction by a P_SAFETY unit limit, so the allocator hands its share on;
# probed at PROBE_W every PROBE_EVERY_S, one unit at a time. The ask is
# PROBE_W, not `saturation_w`: a unit's share is often small and SAFETY-43
# would name the empty battery as not delivering.
# DECISIONS/SAFETY-73-refusal-per-inverter.md.
def _unit_refusal_start(self, inv, sign: int, evidence: str) -> None:
inv.refused, inv.refused_at = sign, self._now_mono
inv.probe_from, inv.ref_count = None, 0
self.log_event(f"BATTERY REFUSES {inv.name}: {evidence}",
hint=messages.hint("unit_refuses", unit=inv.name))
_LOG.info("%s: the battery refuses power, so it is asked 0 W and the "
"other inverters take over: BATTERY_REFUSES.", inv.name)
_LOG.debug("Evidence for %s: BATTERY_REFUSES: %s.", inv.name, evidence)
# Only a refusal to GIVE: a full battery that stops taking charge is
# above its cut-out level by definition.
if (sign > 0 and inv.soc is not None and inv.bms_soc_floor is not None
and float(inv.soc) > float(inv.bms_soc_floor)):
# Owner decision 3: a refusal above the configured cut-out level
# may mean that level is set wrong. One line, no safety stop.
_LOG.warning("%s refuses power above its battery cut-out level, "
"check its cut-out level: REFUSES_ABOVE_CUT_OUT.",
inv.name)
_LOG.debug("Evidence for %s: REFUSES_ABOVE_CUT_OUT: battery level "
"%.0f %%, cut-out level %.0f %%.", inv.name,
float(inv.soc), float(inv.bms_soc_floor))
def _unit_refusal_end(self, inv, why: str) -> None:
if not inv.refused:
return
inv.refused, inv.refused_at, inv.probe_from = 0, None, None
inv.ref_count = 0
self.log_event(f"BATTERY REFUSES ENDED {inv.name}: {why}",
hint=messages.hint("unit_gives", unit=inv.name))
_LOG.info("%s: the battery refusal ends, it is asked for power again: "
"%s.", inv.name, why)
# The site target may stand above what was placed (the refused share
# was unmet). Restart it from the watts on the wire, or the reopened
# unit gets the parked remainder at once (phase A: -1059 W).
placed = float(sum(i.standing_w() for i in self.inverters))
self.target = self.i_w = placed
self.sat_count = 0
def _unit_probe(self, inv) -> bool:
"""True while `inv` may try PROBE_W. One unit probes at a time."""
now = self._now_mono
if not inv.refused or inv.refused_at is None or now is None:
return False
if inv.probe_from is not None:
if now - inv.probe_from < _ctl.PROBE_S:
return True
inv.probe_from, inv.refused_at = None, now
return False
if now - inv.refused_at < _ctl.PROBE_EVERY_S or any(
i.probe_from is not None for i in self.inverters):
return False
inv.probe_from = now
return True
def _unit_refusals(self) -> None:
"""Engage and end each unit's refusal from its own reading."""
for inv in self.inverters:
b = inv.batt
if inv.refused:
# SAFETY-97: given and taken are counted from the idle reading.
if (b is not None and inv.refused * (b - inv.idle_w)
>= _ctl.PROBE_ANSWER_W):
self._unit_refusal_end(
inv, "the probe was answered"
if inv.probe_from is not None
else "the battery gives power")
continue
# The other direction: the battery takes PROBE_ANSWER_W or more
# the other way for SETTLE_CYCLES, counted on its own reading
# so one overshoot through zero does not hand its share back.
inv.ref_count = (inv.ref_count + 1 if b is not None and
-inv.refused * (b - inv.idle_w)
>= _ctl.PROBE_ANSWER_W else 0)
if inv.ref_count >= _ctl.SETTLE_CYCLES:
self._unit_refusal_end(
inv, "the battery takes power the other way")
continue
if inv.rejected_since is not None:
# SAFETY-96: its writes are refused, so its board may have gone
# to 0 W on its own; that is no refusal by the battery. The
# count is kept, as across a gap.
continue
sign = self._gave_nothing(inv)
if sign:
self._unit_refusal_start(
inv, sign,
f"asked {inv.standing_w():+.0f} W, the battery gave "
f"{b:+.0f} W for {_ctl.SETTLE_CYCLES} cycles")
def _gave_nothing(self, inv) -> int:
"""The refusal evidence, one cycle of it: the sign `inv` has given
under REFUSED_BELOW_W while asked over PROBE_W, one way, for
SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1;
SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the
band is centred on the unit's idle reading."""
b = inv.batt
if b is None:
return 0 # a gap keeps the count: the gate's trip seeds
standing = inv.standing_w()
sign = 1 if standing > 0 else -1
# One direction throughout: a command that swaps sign every cycle reads
# ~0 through the plant's lag, which is a battery following, not
# refusing (TEST-19).
if (abs(standing) > _ctl.PROBE_W
and abs(b - inv.idle_w) < _ctl.REFUSED_BELOW_W):
# SAFETY-94: a reading still moving towards the ask is a battery
# catching up through its lag: start again.
if (sign != inv.ref_sign or not inv.ref_count
or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W):
inv.ref_count, inv.ref_since, inv.ref_from = (
0, self._now_mono, b)
inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
else:
inv.ref_count = 0
inv.ref_sign = sign
if inv.ref_count >= _ctl.SETTLE_CYCLES and (
inv.ref_since is None or self._now_mono is None
or self._now_mono - inv.ref_since >= REFUSED_SPAN_S):
return sign
return 0
def _site_idle_w(self) -> float:
"""SAFETY-97: the site reading at idle, the sum of the units'."""
return float(sum(inv.idle_w for inv in self.inverters))
def _site_refused(self):
"""Owner decision 4: every unit refuses the same way, so the site law
holds 0 W (SAFETY-52's, in compute()). Otherwise None, the old law."""
signs = {inv.refused for inv in self.inverters}
return signs.pop() if len(signs) == 1 and 0 not in signs else None
@property
def shadow(self) -> bool:
"""SAFETY-10. Is the battery being left alone right now? A property,
not a field (as `self.units`): the cycle, the write and the status
page ask at three moments, and the write gate asks THIS, not a
parameter passed down."""
return shadow.engaged(self.o, control_started=self.auto)
def shadow_latches(self) -> tuple:
"""The failsafe latches that are UP, named, for the shadow banner: a
standing latch is standing when the controller stops being inert, so
an operator who turns shadow off is not handed a battery that will
not move with no reason shown."""
out = []
if self.circ_wd.tripped:
out.append("circulating power")
if self.site_sign_wd.tripped and len(self.inverters) > 1:
out.append("site sign watchdog")
for inv in self.inverters:
if inv.sign_wd.tripped:
out.append(f"{inv.name} sign watchdog")
return tuple(out)
def write_refusal(self, value: float, inv=None) -> str | None:
"""Why `value` may NOT be written to `inv` right now, or None if it
may. SAFETY-28: 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 tested
first, reading nothing: the failsafe writes run while the allocator has
REFUSED, so a gate asking permission to command zero would deadlock the
failsafe. It tests `value == 0.0`, not a flag the caller sets. DEV-02:
the unit is an ARGUMENT (the record holds one share per unit); the
default is for N=1 callers and REFUSES at N>=2.
DECISIONS/MAIN-orchestration.md."""
if value == 0.0:
return None
inv = self._only() if inv is None else inv
rec = self.allocation
if rec is None:
return ("no allocation record exists yet - nothing has authorised "
f"{value:+.0f} W on {inv.name}")
return rec.authorises(inv.name, value, self.alloc_tick)
async def write_setpoint(self, value: float, *, force: bool = False,
unit=None, last: bool = False) -> None:
"""Put `value` on ONE inverter, or with `unit=None` on all. `unit=None`
is for a number that is not a share (stop button, `shutdown()`,
`maint_abort()`: 0 W means the same to every inverter). Writes are
concurrent, each under its own budget (DEV-02). `last`:
DECISIONS/SAFETY-137-last-write-is-zero.md."""
if unit is None:
targets = [i for i in self.inverters]
if len(targets) > 1 and is_commandable(value) and float(value) != 0.0:
# Loud, and it commands zero rather than the value asked for:
# fail toward inaction, as every other refusal here.
self.log_event(
f"refusing to broadcast {float(value):+.0f} W to "
f"{len(targets)} inverters - that is the ARCH-01 fault; "
f"commanding 0 W", hint=messages.hint("all_blocked"))
_LOG.error("Refused to send %+.0f W to all %s at once, so "
"every inverter is set to 0 W. This is a software "
"fault.", float(value),
messages.count(len(targets), "inverter"))
_LOG.debug("ARCH-01: a site target handed to each unit is "
"supplied by each of them, so the gain at the meter "
"becomes N times the per-unit gain. Only 0 W may be "
"written site-wide, and shares come from the "
"allocator.")
value = 0.0
else:
targets = [unit]
if not targets:
return
await asyncio.gather(*(self._write_unit(inv, value, force=force,
last=last)
for inv in targets))
async def _await_inflight(self, inv, budget_s: float) -> None:
"""Wait, at most `budget_s`, for writes already on their way to `inv`; one
still running is left to its own WRITE_BUDGET_S.
DECISIONS/SAFETY-137-last-write-is-zero.md."""
pending = [t for t in inv.inflight if not t.done()]
if pending:
await asyncio.wait(pending, timeout=budget_s)
async def _release_last(self, inv) -> bool:
"""`release()` once any write in flight to `inv` has finished (at most
SETTLE_BUDGET_S): the release is the LAST thing the unit hears.
DECISIONS/SMA-10-two-idle-states.md."""
await self._await_inflight(inv, SETTLE_BUDGET_S)
return await self._driver(inv).release()
async def _hand_back(self, inv, *, force: bool = False) -> bool:
"""SMA-10. Give ONE unit back: exactly one `release()`, then nothing.
True when handed back. Never raises; a failure is retried on the next
idle write. ⚠️ `released` is set before the first await (one release per
unit, not two). ⚠️ It is not a write: no `note_write`, no `last_write`.
DECISIONS/SMA-10-two-idle-states.md."""
if inv.released:
return True
if not inv.driver.configured():
return False
# After a failure the next try waits one heartbeat; Stop and shutdown
# (`force`) always try. DECISIONS/SMA-10-two-idle-states.md.
now_m = time.monotonic()
if not force and now_m < inv.release_retry_at:
return False
inv.released = True
why = ""
try:
ok = bool(await _bounded(self._release_last(inv),
RELEASE_BUDGET_S))
except (asyncio.TimeoutError, TimeoutError):
ok, why = False, f"took over {RELEASE_BUDGET_S:.0f} s"
except Exception as err: # noqa: BLE001 - one unit never stops the rest
ok, why = False, f"{type(err).__name__}: {err}"
if ok:
inv.last_write = None
if inv.release_fails:
_LOG.info("Handing back %s worked again after %d failed "
"tries.", inv.name, inv.release_fails)
inv.release_fails = 0
inv.release_retry_at = 0.0
inv.release_zero_sent = False
self.log_event(f"{inv.name}: handed back",
hint=messages.hint("handed_back", unit=inv.name))
return True
# SMA-10: a failed hand-back is tried again, and sent 0 W once below.
inv.released = False
inv.release_fails += 1
inv.release_retry_at = now_m + float(self.o.get("heartbeat_s", 10))
_LOG.debug("Handing back %s failed (try %d): %s", inv.name,
inv.release_fails, why)
if inv.release_fails == 1: # one line per failure streak
_LOG.error("Handing back %s failed, so it is sent 0 W once and "
"handing back is tried again.", inv.name)
self.log_event(f"{inv.name}: hand-back failed",
hint=messages.hint("hand_back_failed", unit=inv.name))
if not inv.release_zero_sent:
# ⚠️ ONCE: a 0 W re-sent on every try would reset the inverter's own
# fallback timer for ever. DECISIONS/SMA-10-two-idle-states.md.
inv.release_zero_sent = True
try:
if await _bounded(self._driver(inv).release_fallback(),
FALLBACK_BUDGET_S):
inv.last_write = 0.0
except Exception as err: # noqa: BLE001
_LOG.debug("The fallback write to %s failed: %r", inv.name, err)
return False
async def _write_unit(self, inv, value: float, *, force: bool = False,
last: bool = False) -> None:
if not inv.driver.configured():
return
# SMA-10: a unit that hands back is released once and not written while
# the add-on is idle (shutdown, stopped, shadow). ⚠️ A safety stop is
# NOT idle: it falls through to the 0 W write. Control starting again
# re-arms the unit; `stopping` never does.
# DECISIONS/SMA-10-two-idle-states.md.
if inv.driver.hands_back:
if self.stopping or self.shadow or not self.auto:
await self._hand_back(inv)
return
if inv.released or inv.release_fails or inv.release_zero_sent:
# Control has the unit again: forget the hand-back and any
# failed streak.
inv.released = False
inv.last_write = None
inv.release_fails = 0
inv.release_retry_at = 0.0
inv.release_zero_sent = False
entity = inv.driver.actuator_id() # for the log line below
# SAFETY-03: the clamp is on the ONLY path to the wire (cycle,
# heartbeat, stop button, shutdown(); no optimizer, maintenance or
# manual path routes around it). A non-finite value is loss of input,
# not an out-of-range one (11.1): coerced to 0 W.
if not is_commandable(value):
self.log_event(f"non-finite setpoint {value!r} - commanding 0 W",
hint=messages.hint("bad_value"))
_LOG.error("refusing to write a non-finite setpoint %r - commanding "
"0 W and continuing to command. This is a bug upstream, "
"not a limit.", value)
value = 0.0
# SAFETY-28: the refusal is HERE, above the clamp and below the
# non-finite coercion (a nan is loss of input, not an unauthorised
# number), and asked about `inv`, not the site.
# DECISIONS/MAIN-orchestration.md.
refusal = self.write_refusal(value, inv)
if refusal is not None:
self.log_event(
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W",
hint=messages.hint("write_blocked", unit=inv.name))
_LOG.error("Refused to send %+.0f W to %s, so it is set to 0 W "
"instead, which is a software fault. The reason: %s.",
value, inv.name, refusal)
value = 0.0
# This unit's own three numbers: `inv.max_w` (supervised cap on THIS
# setpoint entity) and `inv.dev_min/dev_max` (what THIS device
# reported). Read off `self.o` they were one inverter's rating applied
# to every inverter.
result = clamp_to_rating(value, inv.max_w, inv.dev_min, inv.dev_max)
inv.clamp_last = result
if result.engaged:
# EVERY engagement is logged, never deduplicated: the action log
# (15) is a different surface from the deduplicated notification.
# The count says how hard the loop pushed.
self.log_event(f"{inv.name}: {result.describe()}",
hint=messages.hint("write_cut", unit=inv.name))
value = result.applied_w
# SAFETY-10: shadow mode runs the whole decision path and commands
# nothing, and the suppression is HERE, the last thing before the wire,
# in the one method every write reaches (so no call site routes round
# it). A substitution, not a return, like the two refusals above; below
# SAFETY-28's gate and SAFETY-03's clamp; not a second writer (no
# claim, no transport call).
if self.shadow and value != shadow.WIRE_W:
# UX-10: DEBUG, because it repeats every cycle in shadow mode; the
# INFO line when shadow comes on says it once.
_LOG.debug("Shadow mode: would send %+.0f W to %s, and holds "
"%+.0f W. Nothing was sent to the inverter.",
value, inv.name, shadow.WIRE_W)
value = shadow.WIRE_W
# SAFETY-137: `last` (the Stop and shutdown 0 W) waits, bounded, for a
# write already on its way; once `stopping`, every other write is dropped
# here with no await before the write is made.
# DECISIONS/SAFETY-137-last-write-is-zero.md.
if last:
await self._await_inflight(inv, SETTLE_BUDGET_S)
elif self.stopping:
_LOG.debug("Shutting down, so %+.0f W to %s is not sent.",
value, inv.name)
return
changed = (inv.last_write is None or value != inv.last_write)
if not (changed or force):
return
# BOUNDED, and the budget is AC 6: one hung transport would hold the
# coroutine, the whole loop and every OTHER inverter's heartbeat. An
# abandoned write is re-issued next tick (`last_write` moves only on an
# ACCEPTED write).
try:
_w = self._driver(inv).write(value)
# SMA-10 / SAFETY-137: kept so a release or last 0 W can wait for
# it. DECISIONS/SAFETY-137-last-write-is-zero.md.
_w = asyncio.ensure_future(_w)
inv.inflight.add(_w)
_w.add_done_callback(inv.inflight.discard)
ok = await asyncio.wait_for(_w, timeout=WRITE_BUDGET_S)
except (asyncio.TimeoutError, TimeoutError):
ok = False
_LOG.error("Sending %+.0f W to %s (%s) took over %.0f s and was "
"dropped, so it is sent again next cycle.",
value, inv.name, entity, WRITE_BUDGET_S)
self.log_event(f"{inv.name}: write timed out after "
f"{WRITE_BUDGET_S:.0f} s - re-issuing next tick",
hint=messages.hint("write_slow", unit=inv.name))
now = datetime.now(timezone.utc)
inv.note_write(bool(ok), now)
if ok:
inv.last_write = value
inv.last_write_at = now
async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None:
"""Refresh every inverter whose own deadline has come. AC 6 lives here.
⚠️ A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` so a check
can inject a hang: "a hung write to A cannot consume B's layer-1
budget"; inline, the mutation to a serial `for` loop passed the whole
suite. (DECISIONS/MAIN-orchestration-archive.md)"""
due = [inv for inv in self.inverters
if loop_now - inv.last_beat >= heartbeat_s]
if not due:
return
# ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT: after, a
# hanging write drifts into being re-sent every tick and a raising one
# never advances. (DECISIONS/MAIN-orchestration-archive.md)
for inv in due:
inv.last_beat = loop_now
# ⚠️ CONCURRENTLY: in series, every inverter behind a hung one waits
# out its whole budget, pushing a healthy sibling towards layer 1's ~30
# s veto. (DECISIONS/MAIN-orchestration-archive.md)
await asyncio.gather(*(
self.write_setpoint(inv.target, force=True, unit=inv)
for inv in due))
async def command_site_release(self) -> None:
"""SMA-10. The add-on is going away (Stop, shutdown, restart, task death,
config refusal): units that hand back are given back, the rest held at
0 W as `command_site_zero()` does. ⚠️ Safety stops and refusals do NOT
come here: they hold 0 W for EVERY unit. A roster with no hand-back unit
is `command_site_zero()` line for line.
DECISIONS/SMA-10-two-idle-states.md."""
if not any(inv.driver.hands_back for inv in self.inverters):
await self.command_site_zero(last=True)
return
self.target = 0.0
for inv in self.inverters:
inv.target = 0.0
await asyncio.gather(*(
self._hand_back(inv, force=True) if inv.driver.hands_back
else self._write_unit(inv, 0.0, force=True, last=True)
for inv in self.inverters))
async def command_site_zero(self, *, last: bool = False) -> None:
"""Every inverter to 0 W, and every inverter's TARGET with it. ⚠️ NOT
BOOKKEEPING (DEV-02): the heartbeat re-emits `inv.target`, so zeroing
only `self.target` lets the next heartbeat put each unit's last share
back on the wire. ⚠️ 0 W IS THE ONE VALUE THAT MAY BE WRITTEN SITE-WIDE
(`write_setpoint()` refuses others: ARCH-01).
(DECISIONS/MAIN-orchestration-archive.md)"""
self.target = 0.0
for inv in self.inverters:
inv.target = 0.0
await self.write_setpoint(0.0, force=True, last=last)
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -36,6 +36,9 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
makes the other batteries stop giving power, so a few watts of charge
flipping on and off would swing the house between them and the grid
(measured on the 2026-09-09 replay: 13 flips in 4 minutes at 3.5 kW);
SAFETY-135: a stop in a period's first YOUNG_S s holds only until then,
not to the period's end, and no charge starts then while the batteries'
give is still falling (`young`);
* selling needs selling on for that battery, the sell run, a price >= 0
now, and the battery above `arbitrage_soc_floor`; it stops there. The
peak guard never stops a sell (section 9.1: discharge is never capped);
@@ -69,6 +72,12 @@ PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
# would stop covering the house for less than they give.
START_W = 500.0
# SAFETY-135: seconds a 15-minute period is young, and how far back a
# battery's give counts as maybe not yet on the meter.
# DECISIONS/SAFETY-135-young-period.md
YOUNG_S = 60.0
GAVE_S = 15.0
@dataclass(frozen=True)
class Pack:
@@ -164,7 +173,7 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(),
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
blocked=False, done_charge=(), done_sell=(),
targets=None, draining=False) -> Tick:
targets=None, draining=False, young=False) -> Tick:
"""This tick's ranges, roles and site ask.
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
@@ -177,7 +186,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
it going); `blocked` that the guard or the headroom stopped a charge in
this 15-minute period. `targets` is the day plan (`charge_to()`).
`draining` says a battery drains for its monthly cycle, so none may
charge from the grid (why "cycle").
charge from the grid (why "cycle"). `young` says no charge may start
yet in this young period (SAFETY-135, why "young").
"""
plan_on = runs is not None and runs.on
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
@@ -198,6 +208,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
why = trade_why
elif headroom_w is None or not headroom_w > need:
why = "headroom"
elif young and not charging_now:
why = "young"
else:
charging = True
se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \
+83
View File
@@ -0,0 +1,83 @@
"""Module functions for the status rows and the Why line.
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
from .arbiter import ACTUATOR_SITE, SET
from . import control as _ctl
from . import messages
def why_raised(resolution, claims) -> bool:
"""F2 (OPT-11 P1 test): did the limit that bound the site target push it
ABOVE what the winner asked (the peak floor asking for more power)? Then
the Why line must not say "held back". False when nothing bound it."""
if not resolution.bound_by or resolution.contradiction:
return False
asked = next((c.value for c in sorted(
(c for c in claims if c.kind == SET
and c.actuator == ACTUATOR_SITE and c.source == resolution.winner),
key=lambda c: c.priority, reverse=True)), None)
return asked is not None and resolution.target_w > float(asked)
def why_key(winner: str, claims) -> str:
"""The key of `messages.WHY_WINNER` for a resolution's winner. "safety" and
"failsafe" stand for several causes, so their own claim reason is the
key ("stopped", "inputs missing", "sign disagreement", or "unsplit" when
this module refuses to split the power)."""
if winner == "loop":
# SAFETY-52: a loop holding a refused direction says so.
return next((c.reason for c in claims if c.source == "loop"
and c.reason in (_ctl.REFUSED, _ctl.PROBING)), winner)
if winner in ("safety", "failsafe"):
return next((c.reason for c in claims
if c.source == winner and c.kind == "set"),
"no claim was made" if not any(
c.kind == "set" for c in claims) else "unsplit")
return winner
def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
provider: bool, max_age_s: float) -> str:
"""The *Tariff* status row: the 30 s tick's sentence AND the provider plan
(TARIFF-11): two sources, two clocks, and the row says so. The plan is
stamped with its own age from `tariff_at` (not moved on a kept plan,
TARIFF-12), up to TARIFF_MAX_AGE_S (5400 s); `age_s` is the caller's
measurement. "No plan" is said aloud on a site that configured a provider.
DECISIONS/MAIN-orchestration.md."""
# UX-08: plain words, every figure kept. The tick's sentence is "now"; the
# plan carries its own age.
if plan is None:
if not provider:
return tick_words
return (f"{tick_words}. No price plan: none yet, or the last one is "
f"over {max_age_s:.0f} s old")
return (f"{tick_words}. Price plan from {age_s:.0f} s ago, for "
f"{horizon_h:.0f} hours ahead: {plan.words}")
def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
why: str) -> str:
"""The *Arbitrage* status row: three states (TARIFF-11): off (not armed),
armed with no decision this cycle (`why` is the loop's reason), or a
decision with its age.
`on` is read from the options, never inferred from `decision is None`:
`cycle()` returns at the input hold before `self.arb = None`, so the old
row named an option that was ON. The age is always printed, even 0.
DECISIONS/MAIN-orchestration.md."""
# UX-08: plain words; the setting is named by its label.
if not on:
return (f"Off, “{messages.LABELS['arbitrage_enabled']}” is not "
f"switched on")
if decided_at is None:
return (f"On, but no decision yet, because control has not got that "
f"far. Control says: {why or 'no reason recorded'}")
age = (now - decided_at).total_seconds()
stale = cycle_at is not None and decided_at < cycle_at
head = (f"On, no decision this cycle ({why or 'no reason recorded'}), "
f"the last one {age:.0f} s ago: " if stale else
f"On, decided {age:.0f} s ago: ")
if decision is None:
return (f"On, no decision this cycle ({why or 'no reason recorded'}) "
f"and none before")
return head + decision.explain()
+74
View File
@@ -0,0 +1,74 @@
"""SAFETY-136: on a slow meter, follow the meter gently until the last move shows.
A meter whose usual gap between readings is over 3.75 s (a 4-5 s meter), read
through Home Assistant, can be 10-15 s late. The loop then answers the same error again at each cycle
and overshoots. After a move of MOVE_W or more the loop therefore runs at
WAIT_SHARE of its gain until the meter has fallen SHOWN of the move, or for at
most WAIT_CYCLES cycles. Off for a meter with a usual gap of 3.75 s or less, so those sites run as
before. DECISIONS/SAFETY-136-slow-meter-settle.md.
"""
from collections import deque
from dataclasses import replace
from .lateness import M_FIT_S, M_ROWS
# A meter is slow when its readings come further apart than this (the usual
# gap, over the last 60 s and at least 5 readings): the fast meter fit's own
# limit (SAFETY-128), 3.75 s.
SLOW_GAP_S = M_FIT_S / M_ROWS
SLOW_ROWS = 5
# A move this big is waited for (above the house noise of about 80 W).
MOVE_W = 150.0
# The meter has shown a move once it fell by this share of it.
SHOWN = 0.5
# Meanwhile the loop runs at this share of its gain.
WAIT_SHARE = 0.5
# Wait at most this many cycles (10 s at a 5 s meter).
WAIT_CYCLES = 2
class Settle:
def __init__(self):
self.pend = None # (meter at the move, the move in W, cycles waited)
self._n = None # readings taken in so far
self._at = deque() # when the last ones arrived, over M_FIT_S
def arrive(self, now, n):
"""Each read: `n` is the count of meter readings taken in. A reading
counts when it arrives, not when its value changes, so a steady load on
a 1 s meter is not a slow meter."""
if n != self._n:
self._n = n
self._at.append(now)
while self._at and self._at[0] < now - M_FIT_S:
self._at.popleft()
def slow(self):
"""The usual gap between readings is over SLOW_GAP_S."""
ts = list(self._at)
if len(ts) < SLOW_ROWS:
return False
gaps = sorted(b - a for a, b in zip(ts, ts[1:]))
return gaps[len(gaps) // 2] > SLOW_GAP_S
def reset(self):
self.pend = None
def tuning(self, tuning, grid_w, slow):
"""The tuning for this cycle: `tuning`, or with the gain cut while the
last move has not shown on the meter (`grid_w`)."""
p = self.pend
if p is None:
return tuning
g0, move, waited = p
if not slow or (g0 - grid_w) / move >= SHOWN or waited >= WAIT_CYCLES:
self.pend = None
return tuning
self.pend = (g0, move, waited + 1)
return replace(tuning, gain=tuning.gain * WAIT_SHARE)
def moved(self, grid_w, prev_w, new_w, slow):
"""After a cycle: start waiting if the loop moved by MOVE_W or more."""
if slow and self.pend is None and abs(new_w - prev_w) >= MOVE_W:
self.pend = (float(grid_w), float(new_w - prev_w), 0)
+6 -7
View File
@@ -109,6 +109,8 @@ from app.validate import validate_startup_config
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
import main_files # noqa: E402
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
# point these fixtures build, so no period has ended and the whole series counts.
@@ -779,9 +781,7 @@ check("...and cycle() calls it rather than inlining the conjunction, so a "
"mutation back to a bare `engaged_now` is red rather than silent",
"arbitrage.guard_state(self.peak_guard)" in
ast.get_source_segment(
open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read(),
_fn(open(os.path.join(HERE, "app", "main.py"),
encoding="utf-8").read(), "cycle")))
main_files.source(), _fn(main_files.source(), "cycle")))
# --------------------------------------------------------------------------- #
@@ -907,8 +907,8 @@ check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export "
# above calls decide() itself - and a required keyword-only parameter that no
# caller fills is a TypeError only on the one path a suite does not run. Same
# pin, same reason, as `weights=` in test_packshare.py part 10.
_S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read()
_S45_MAIN_TREE = ast.parse(_S45_MAIN)
_S45_MAIN = main_files.source()
_S45_MAIN_TREE = main_files.tree(_S45_MAIN)
_s45_calls = [n for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, ast.Call)
and ast.unparse(n.func).endswith("arbitrage.decide")]
@@ -2215,8 +2215,7 @@ print()
print("8. the background refresh - the tick must never await a fetch")
_MAIN_SRC = derived(lambda: open(os.path.join(HERE, "app", "main.py"),
encoding="utf-8").read())
_MAIN_SRC = derived(main_files.source)
_cycle = derived(lambda: _fn(_MAIN_SRC, "cycle"))
_refresh = derived(lambda: _fn(_MAIN_SRC, "run_tariff_refresh"))
_read_inputs = derived(lambda: _fn(_MAIN_SRC, "read_inputs"))
+6 -5
View File
@@ -1969,9 +1969,11 @@ print("9. SAFETY-40: the integrator bound is not a BUG SIGNAL, and the numbers "
# The fixtures are chosen so the numbers the old text used DIFFER from the true
# ones (MUTATION.md habit 4); the PRECONDITION checks pin that.
import logging
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
import app.main as _main_mod
_real_compute = _main_mod.compute
_real_compute = main_files.live.compute
class _LogCap(logging.Handler):
@@ -1995,7 +1997,7 @@ def s40_run(grid, cycles, track, **over):
cap = _LogCap()
lg = logging.getLogger("goodwe")
lg.addHandler(cap)
_main_mod.compute = spy
main_files.live.compute = spy
async def go():
hass = _Hass(grid, track=track)
@@ -2024,7 +2026,7 @@ def s40_run(grid, cycles, track, **over):
try:
hass, c, red = asyncio.run(go())
finally:
_main_mod.compute = _real_compute
main_files.live.compute = _real_compute
lg.removeHandler(cap)
return ticks, hass, c, red, cap.records
@@ -2104,8 +2106,7 @@ check("...and its ERROR says it is a software fault and that control keeps "
any(x.levelno == logging.ERROR and "software fault" in x.getMessage()
and "control runs on" in x.getMessage() for x in log_o)
and "reached 14 768 W on this project" in
open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
"main.py"), encoding="utf-8").read())
main_files.source())
check("...under the over-rating title, quoting a tick's own asked and sent "
"numbers in that order, with a red status row",
clamp_notes(hass_o)
+7 -1
View File
@@ -149,9 +149,15 @@ print("2. the mirrors, DISCOVERED from the tree rather than listed here")
# `app/` for module-level assignments whose name ends in OPTION_DEFAULTS and
# grades whatever it finds, so the seventh is covered on the commit that
# introduces it. That is the property the six hand-written pins cannot have.
MODULES = sorted(p.name for p in APP.glob("*.py"))
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
# MAINT-21: app/main.py stands for itself and the files split from it.
MODULES = sorted(p.name for p in APP.glob("*.py")
if p.name == "main.py" or p.name not in main_files.files())
TREES = {name: ast.parse(io.open(APP / name, encoding="utf-8").read(), name)
for name in MODULES}
TREES["main.py"] = main_files.tree()
MIRROR = {} # schema key -> (module, table name, value)
TABLES = []
+6 -3
View File
@@ -1272,6 +1272,9 @@ check("the operator's START press is the only way out, and it clears the "
print()
print("8b. SAFETY-34: a Core-only restart - same add-on process, HA's memory wiped")
import os # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
import app.main as _main_mod # noqa: E402
@@ -1283,8 +1286,8 @@ async def _core_restart():
_c = asyncio.run(_core_restart())
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
_main_mod._mono = lambda: _clock[0]
_real_mono, _clock = main_files.live._mono, [main_files.live._mono()]
main_files.live._mono = lambda: _clock[0]
try:
_c.hass = _DeliveryHass(b_soc=50.0)
asyncio.run(_c.check_not_delivering())
@@ -1295,7 +1298,7 @@ try:
_late = [x for x in _c.hass.calls
if "not_delivering" in str(x[2].get("notification_id", ""))]
finally:
_main_mod._mono = _real_mono
main_files.live._mono = _real_mono
check("SAFETY-34: the latch tripped in this process",
_c.inverters[1].delivery_wd.tripped)
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise",
+18 -13
View File
@@ -75,6 +75,9 @@ from pathlib import Path
from named_absence import first, given, nth, present
import os # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
from app import main as app_main
from app.inverter import LINK_STALE_S, UNIT_A, Inverter, build_roster
from app.main import WRITE_BUDGET_S, Controller
@@ -88,8 +91,10 @@ from app.arbiter import P_LOOP, P_SAFETY, Claim, resolve
fails = []
HERE = Path(__file__).resolve().parent
MAIN_SRC = io.open(HERE / "app" / "main.py", encoding="utf-8").read()
MAIN_AST = ast.parse(MAIN_SRC)
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
MAIN_SRC = main_files.source()
MAIN_AST = main_files.tree(MAIN_SRC)
CTRL = [n for n in MAIN_AST.body
if isinstance(n, ast.ClassDef) and n.name == "Controller"][0]
@@ -1097,8 +1102,8 @@ check("...and it is below hass.py's own 10 s HTTP timeout, so the budget is "
# is healthy. The budget is shortened for the run - the shipped value is graded
# above - because a 4 s wait per case is 8 s of suite time to measure something
# that is already a ratio.
_REAL_BUDGET = app_main.WRITE_BUDGET_S
app_main.WRITE_BUDGET_S = 0.30
_REAL_BUDGET = main_files.live.WRITE_BUDGET_S
main_files.live.WRITE_BUDGET_S = 0.30
async def _hung_heartbeat():
@@ -1124,18 +1129,18 @@ async def _hung_heartbeat():
_c6, _h6, _elapsed, _t0 = run(_hung_heartbeat())
_b_at = [t - _t0 for e, _v, t in _h6.write_at if e == "number.sp_b"]
print(f" A hangs forever, B is healthy: the pair took {_elapsed:.2f} s "
f"(budget {app_main.WRITE_BUDGET_S:.2f} s); B's write reached the wire "
f"(budget {main_files.live.WRITE_BUDGET_S:.2f} s); B's write reached the wire "
f"{(_b_at[0] if _b_at else float('nan')):.3f} s in; wire saw "
f"{_h6.writes}")
check("⚠️ AC 6. B'S WRITE REACHED THE WIRE WHILE A WAS STILL HANGING. That is "
"the whole criterion: a hung write to A cannot consume B's layer-1 "
"budget, because B's write was never behind A's in a queue",
_h6.writes == [("number.sp_b", 700.0)]
and bool(_b_at) and _b_at[0] < app_main.WRITE_BUDGET_S / 2.0)
and bool(_b_at) and _b_at[0] < main_files.live.WRITE_BUDGET_S / 2.0)
check("...and the pair returned in about ONE budget rather than in two, which "
"is what says the writes were concurrent and not merely bounded. Two "
"bounded writes in series is still a sibling waiting for a corpse",
_elapsed < app_main.WRITE_BUDGET_S * 1.9)
_elapsed < main_files.live.WRITE_BUDGET_S * 1.9)
check("...and the hung inverter is recorded as having FAILED its write, so "
"`last_write` is not advanced and the next tick re-issues it by itself",
_c6.inverters[0].last_write is None
@@ -1176,7 +1181,7 @@ check("⚠️ AND THE DEFECT IS REPRODUCED BESIDE THE FIX. Written in series, B'
"every inverter behind the hung one, and with the unbounded write this "
"card replaces it was the HTTP stack's timeout - which is what put a "
"healthy sibling within reach of layer 1's veto",
_ser_b_at >= app_main.WRITE_BUDGET_S)
_ser_b_at >= main_files.live.WRITE_BUDGET_S)
# AND THE DEADLINE ITSELF IS PER UNIT - graded through the shipped method, for
# the reason the experiment above is: a `due` list computed in this file is a
@@ -1219,7 +1224,7 @@ check("...and the heartbeat re-emits each unit's OWN share rather than the "
# heartbeat is not the only place a hung inverter can hold the loop: every cycle
# writes every unit's share, and in series that is the same defect at the cycle
# rate rather than at the heartbeat rate.
app_main.WRITE_BUDGET_S = 0.30
main_files.live.WRITE_BUDGET_S = 0.30
async def _hung_cycle():
@@ -1248,17 +1253,17 @@ check("⚠️ AND A REAL CYCLE DELIVERS B'S SHARE WHILE A HANGS. This is the sam
"`cycle()` go out together, so a hung inverter costs the loop one budget "
"and costs its siblings nothing",
_h6c.writes and _h6c.writes[0][0] == "number.sp_b"
and bool(_cyc_b) and _cyc_b[0] < app_main.WRITE_BUDGET_S / 2.0
and _cyc_elapsed < app_main.WRITE_BUDGET_S * 1.9)
and bool(_cyc_b) and _cyc_b[0] < main_files.live.WRITE_BUDGET_S / 2.0
and _cyc_elapsed < main_files.live.WRITE_BUDGET_S * 1.9)
check("...and the share B was told is the allocator's, not the site target - "
"the hung sibling does not turn the cycle into a broadcast",
_h6c.writes[0][1] == float(_c6c.allocation.shares["inverter-b"])
and _h6c.writes[0][1] != _c6c.target)
app_main.WRITE_BUDGET_S = _REAL_BUDGET
main_files.live.WRITE_BUDGET_S = _REAL_BUDGET
check("...and the shipped budget is restored after the experiments, so nothing "
"below runs against a 0.3 s one",
app_main.WRITE_BUDGET_S == WRITE_BUDGET_S)
main_files.live.WRITE_BUDGET_S == WRITE_BUDGET_S)
# ⚠️ AND THE OPERATOR'S STOP HAS TO MOVE THE SAME TWO THINGS. This is a hazard
+2 -1
View File
@@ -22,6 +22,7 @@ from pathlib import Path
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools"))
import main_files # noqa: E402
import app.main as M # noqa: E402
from app import maintenance as MT # noqa: E402
from app.maintenance import ( # noqa: E402
@@ -367,7 +368,7 @@ def amain_zone(zone_body, env_tz):
M.load_options = lambda: {"setpoint_entity": "number.sp"}
M.HomeAssistant = lambda session, **kw: _Hass()
M.validate_startup_config = lambda *a, **k: []
main_files.live.validate_startup_config = lambda *a, **k: []
M.mode_announcement = lambda opts, store: ((), ())
M.Controller = _controller
M.aiohttp.ClientSession = lambda *a, **k: _Session(zone_body)
+5 -3
View File
@@ -520,9 +520,11 @@ check("a fixed tariff set up counts as a gate: outside its cheap hours a pack "
"that is not charging is closed, inside them it is open",
c._price_gated is True
and c._charge_gate_open(c.inverters[0]) is c.cheap)
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
import app.main as main_mod # noqa: E402
_real_gated, _calls = main_mod.static_gated, []
main_mod.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now)
_real_gated, _calls = main_files.live.static_gated, []
main_files.live.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now)
try:
c._gated_key = None
read(c)
@@ -530,7 +532,7 @@ try:
read(c)
c._static_gated(c.o, datetime.now(UTC) + timedelta(hours=1))
finally:
main_mod.static_gated = _real_gated
main_files.live.static_gated = _real_gated
check("...worked out once an hour, not on every tick (3 reads in one hour = 1 "
f"build; a new hour = 1 more): {len(_calls)} builds",
len(_calls) == 2 and c._price_gated is True)
+30 -9
View File
@@ -241,6 +241,27 @@ check("the output-clamp alarm quotes asked before sent",
# ============================================================================== #
print("\n2. no notification is worded outside app/messages.py")
APP = HERE / "app"
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
class _AppFile:
"""An app/*.py file. `main.py` stands for itself and the files split from
it (MAINT-21), so every scan below still sees the Controller whole."""
def __init__(self, path):
self.name, self.stem, self._path = path.name, path.stem, path
def read_text(self, encoding="utf-8"):
if self.name == "main.py":
return main_files.source()
return self._path.read_text(encoding=encoding)
def _app_py():
split = set(main_files.files()) - {"main.py"}
return [_AppFile(f) for f in sorted(APP.glob("*.py"))
if f.name not in split]
# UX-08 2d: a refusal is counted as it is SHOWN. A placeholder that names a
@@ -318,7 +339,7 @@ def notification_calls(tree):
bad_calls, n_calls = [], 0
for f in sorted(APP.glob("*.py")):
for f in _app_py():
for call in notification_calls(ast.parse(f.read_text(encoding="utf-8"))):
n_calls += 1
payload = call.args[2] if len(call.args) > 2 else None
@@ -368,13 +389,13 @@ def _module_constants(tree):
_MODCONST = {f.stem: _module_constants(ast.parse(f.read_text(encoding="utf-8")))
for f in sorted(APP.glob("*.py"))}
for f in _app_py()}
def collect():
"""(kind, where, text) for every user-facing literal of a ratcheted kind."""
out = []
for f in sorted(APP.glob("*.py")):
for f in _app_py():
if f.name == "messages.py":
continue
tree = ast.parse(f.read_text(encoding="utf-8"))
@@ -543,7 +564,7 @@ for key, text in messages.hint_samples():
p = row_problems(messages.GREEN, text)
check(f"hint {text!r}{' - ' + '; '.join(p) if p else ''}", not p)
_calls, _bare = 0, []
for f in sorted(APP.glob("*.py")):
for f in _app_py():
for node in ast.walk(ast.parse(f.read_text(encoding="utf-8"))):
if (isinstance(node, ast.Call)
and ast.unparse(node.func).endswith("log_event")):
@@ -1012,7 +1033,7 @@ def _static(spec):
the named file (placeholders count as one word, as in part 3)."""
fname, kind, name = spec
out = []
for f in ([APP / fname] if fname != "*" else sorted(APP.glob("*.py"))):
for f in ([_AppFile(APP / fname)] if fname != "*" else _app_py()):
tree = ast.parse(f.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if kind == "call" and isinstance(node, ast.Call) and node.args \
@@ -1201,7 +1222,7 @@ def scan_log_lines(files, text=None, data=None, long_ok=None, long_seen=None):
_FILES = [(f.name, ast.parse(f.read_text(encoding="utf-8")))
for f in sorted(APP.glob("*.py"))]
for f in _app_py()]
_long_seen = set()
_unknown, _stale, _bad = scan_log_lines(_FILES, long_seen=_long_seen)
for k, sample, p in _bad:
@@ -1415,7 +1436,7 @@ def _captured(logger_name, fn):
def _level_of(fname, words_in_line):
"""The level of the log call in `fname` whose literal holds the words."""
tree = ast.parse((APP / fname).read_text(encoding="utf-8"))
tree = ast.parse(_AppFile(APP / fname).read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.Call) and node.args \
and re.search(r"_LOG\.\w+$", ast.unparse(node.func)):
@@ -1561,7 +1582,7 @@ check(f"a socket Home Assistant closed is a plain ConnectionResetError, which "
# words. The keys are found in the code: each `Claim.set(source, ..., reason)`
# through main.why_key(), plus main.py's own refusal to split ("unsplit").
_SOURCES = {}
for _f in sorted(APP.glob("*.py")):
for _f in _app_py():
_tree = ast.parse(_f.read_text(encoding="utf-8"))
_consts = _module_constants(_tree)
for _n in ast.walk(_tree):
@@ -1781,7 +1802,7 @@ check(f"...and in the action log, which an installer reads at the default "
and any(e.endswith(messages.NO_REASON_SAVED) for e in _rs_ev))
# UX-11 item 4: the action log's clamp count and the shadow-off entry.
_main_src = (APP / "main.py").read_text(encoding="utf-8")
_main_src = _AppFile(APP / "main.py").read_text(encoding="utf-8")
check("the action log counts 'engagements' with a real plural and the "
"shadow-off entry is plain, not 'engagement(s)' or 'SHADOW MODE OFF -'",
"engagement(s)" not in _main_src and "SHADOW MODE OFF" not in _main_src
+7 -6
View File
@@ -32,6 +32,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
import opt11_identity as rig # noqa: E402
from named_absence import nth, present # noqa: E402
import main_files # noqa: E402
import app.main as app_main # noqa: E402
from app import homeview, messages, packmode # noqa: E402
from app.control import MODE_BOUND # noqa: E402
@@ -62,24 +63,24 @@ def store_with(**modes):
def spy_claims():
"""Record every claim list main.py's own `resolve` is handed."""
seen, real = [], app_main.resolve
seen, real = [], main_files.live.resolve
def _spy(claims, **kw):
seen.append(list(claims))
return real(claims, **kw)
app_main.resolve = _spy
return seen, lambda: setattr(app_main, "resolve", real)
main_files.live.resolve = _spy
return seen, lambda: setattr(main_files.live, "resolve", real)
def spy_bounds():
"""Record the `bound` main.py hands `compute()` on every cycle."""
seen, real = [], app_main.compute
seen, real = [], main_files.live.compute
def _spy(*a, **kw):
seen.append(kw.get("bound", "absent"))
return real(*a, **kw)
app_main.compute = _spy
return seen, lambda: setattr(app_main, "compute", real)
main_files.live.compute = _spy
return seen, lambda: setattr(main_files.live, "compute", real)
def flat(t):
+5 -4
View File
@@ -37,6 +37,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
from named_absence import nth, present # noqa: E402
import main_files # noqa: E402
import opt11p3_replay as rp # noqa: E402
from app import arbitrage, dayplan, homeview, modetrade, packmode # noqa
from app import priceruns, solarfc, validate # noqa: E402
@@ -777,7 +778,7 @@ check("battery modes off: the forecast sensor is never read", READS[0] == 0)
print("7. the Batteries screen and the Simple view")
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
hours=0.6)
rp.m.datetime = datetime
main_files.live.datetime = datetime
sb = homeview.solar_block(c, c.solar_fc.at)
check("the forecast line names the forecast, what is counted and when",
sb["line"].startswith("Solar still to come today:")
@@ -812,14 +813,14 @@ check("a lost forecast says so: no forecast, Leftover solar solar only",
c._charge_latch = latch
# the latch ends at its end time; this replay ran in September, so ask at a
# moment inside it
_real_dt = rp.m.datetime
rp.m.datetime = type("_DT", (datetime,), {"now": classmethod(
_real_dt = main_files.live.datetime
main_files.live.datetime = type("_DT", (datetime,), {"now": classmethod(
lambda cls, tz=None: latch[0] - timedelta(hours=1))})
check("OPT-34: with a charge under way (B charged in this run), the level "
"stays: plan_targets() gives it (the line: test_opt34.py)",
latch is not None and c.plan_targets() is not None
and B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B])
rp.m.datetime = _real_dt
main_files.live.datetime = _real_dt
check("the Why line wins over 'saves its power' only while holding",
homeview.battery_why(modes_on=True, mode="leftover_solar", off=False,
reading=True, phase="idle", shadow=False,
+4 -3
View File
@@ -46,6 +46,7 @@ logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import homeview, modetrade, solarfc # noqa: E402
from app.main import Controller # noqa: E402
@@ -404,14 +405,14 @@ def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
async def go():
c, hass = build(store)
real_dt, real_hv = m.datetime, homeview.datetime
real_dt, real_hv = main_files.live.datetime, homeview.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
main_files.live.datetime = homeview.datetime = _DT
task = None
try:
for t_pre in pre:
@@ -477,7 +478,7 @@ def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime = real_dt, real_hv
main_files.live.datetime, homeview.datetime = real_dt, real_hv
return c
return asyncio.run(go()), rows, pre_rows
+7 -6
View File
@@ -61,6 +61,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import homeview, solarfc # noqa: E402
from app.main import Controller # noqa: E402
@@ -348,14 +349,14 @@ def run_night(spec):
async def go():
cur = store
real_dt, real_hv = m.datetime, homeview.datetime
real_dt, real_hv = main_files.live.datetime, homeview.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
main_files.live.datetime = homeview.datetime = _DT
try:
# TEST-37: built under the scenario's clock, not the real one:
# Controller.__init__ checks a stored latch's end against now().
@@ -420,7 +421,7 @@ def run_night(spec):
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
out["latch"] = getattr(c, "_charge_latch", None) is not None
finally:
m.datetime, homeview.datetime = real_dt, real_hv
main_files.live.datetime, homeview.datetime = real_dt, real_hv
return out
return asyncio.run(go())
@@ -722,7 +723,7 @@ from types import SimpleNamespace # noqa: E402
from app import modetrade, priceruns # noqa: E402
CLOCK = [at(4, 1, 0)]
_dt0 = m.datetime
_dt0 = main_files.live.datetime
class _FixedDT(_dt0):
@@ -731,7 +732,7 @@ class _FixedDT(_dt0):
return CLOCK[0] if tz is None else CLOCK[0].astimezone(tz)
m.datetime = homeview.datetime = _FixedDT
main_files.live.datetime = homeview.datetime = _FixedDT
_tmp = tempfile.mkdtemp(prefix="o34-")
lc = Controller(dict(opts()), rp.Hass(("a", "b"), lambda t: 300.0,
{"a": 40.0, "b": 40.0}),
@@ -858,7 +859,7 @@ lc._charge_latch = (at(4, 6, 10), {A: 80.0}, at(4, 1, 0))
lt, _s = latch_step(None, resting, None, now=at(4, 6, 5), runs=r3)
check("F4: a run that starts AT the bell (06:00-09:00) does carry it on, "
"to its end + one slot", lt is not None and lt[0] == at(4, 9, 15))
m.datetime = homeview.datetime = _dt0
main_files.live.datetime = homeview.datetime = _dt0
print()
if fails:
+4 -3
View File
@@ -49,6 +49,7 @@ logging.disable(logging.WARNING)
from named_absence import nth, present # noqa: E402
import ux54_scenarios as S # noqa: E402
import opt11p3_replay as rp # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import dayplan, homeview, modetrade, packmode # noqa: E402
from app import priceruns, solarfc, uitext # noqa: E402
@@ -662,14 +663,14 @@ def run_night(spec):
for d in ("2026-10-31", "2026-11-01", "2026-11-02")})
async def go():
real_dt, real_hv = m.datetime, homeview.datetime
real_dt, real_hv = main_files.live.datetime, homeview.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
main_files.live.datetime = homeview.datetime = _DT
try:
for t_pre in EVENING:
clock[0] = t_pre
@@ -722,7 +723,7 @@ def run_night(spec):
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
out["latch"] = getattr(c, "_charge_latch", None) is not None
finally:
m.datetime, homeview.datetime = real_dt, real_hv
main_files.live.datetime, homeview.datetime = real_dt, real_hv
return out
return asyncio.run(go())
+4 -5
View File
@@ -34,6 +34,8 @@ import sys
from datetime import datetime, timedelta, timezone
from math import inf
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "tools"))
import main_files # noqa: E402
from app import arbitrage, packshare
from named_absence import first, nth, present
@@ -1132,8 +1134,7 @@ check("...and misfiling it ON THE SITE is caught and REFUSED, not consumed. A "
# main.py's own AST, the way test_peakguard.py part 12i pins the arguments of
# `arbitrage.decide()`, and for the same reason: a call site that stopped
# passing it is invisible to every fixture.
_main_src = (Path(__file__).resolve().parent / "app" / "main.py").read_text(
encoding="utf-8")
_main_src = main_files.source()
_resolve_calls = [n for n in ast.walk(ast.parse(_main_src))
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
and n.func.id == "resolve"]
@@ -1158,9 +1159,7 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE
# cycle's value, and logs. What it does NOT grade is the sentence it logs or
# that a real cycle reaches it; that is stated here rather than implied.
_cycle_src = first(
f for cls in ast.walk(ast.parse(
(Path(__file__).resolve().parent / "app" / "main.py")
.read_text(encoding="utf-8")))
f for cls in ast.walk(main_files.tree())
if isinstance(cls, ast.ClassDef) and cls.name == "Controller"
for f in cls.body
if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle")
+3 -3
View File
@@ -22,6 +22,7 @@ import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
import main_files # noqa: E402
import safety79_identity as rig # noqa: E402
from named_absence import nth, present # noqa: E402
@@ -207,9 +208,8 @@ check("...and the user-facing peak notice built from the decision's limit "
# ============================================================================ #
print("\n4. each call site in main.py")
with open(os.path.join(HERE, "app", "main.py"), encoding="utf-8") as f:
_src = f.read()
_tree = ast.parse(_src)
_src = main_files.source()
_tree = main_files.tree(_src)
def calls(name):
+13 -6
View File
@@ -135,6 +135,9 @@ from app.arbiter import (
)
from app.control import Tuning, compute, peak_at_risk
from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w
import os # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
from app import main as app_main
from app.inverter import Inverter, build_roster
from app.main import Controller
@@ -1295,7 +1298,7 @@ print(" the wiring's own mutations - a default install, maintenance charging")
# `peak_at_risk()` answers False forever. That is the whole point - it is the
# configuration on which the old suspension could not fire.
MAINT_CHARGING = {"phase": "charge"}
_mainsrc = (HERE / "app" / "main.py").read_text(encoding="utf-8")
_mainsrc = main_files.source()
def maint_cycle(tag, want_floor_w=LIVE_MAX_W * 0.3, **kw):
@@ -2709,7 +2712,7 @@ CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
# silent gap in the scan.
CLAIM_BUILDERS = {"peak_claim": "peak", "arb_claim": "arbitrage"}
_main_tree = ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8"))
_main_tree = main_files.tree()
_cycle_def = first((
f
for cls in ast.walk(_main_tree)
@@ -2762,7 +2765,7 @@ def cycle_claims(run):
call site and nowhere else.
"""
seen = []
real = app_main.resolve
real = main_files.live.resolve
def _spy(claims, **kw):
# ⚠️ `**kw` PASSES THE ROSTER THROUGH UNREAD, AND THAT IS THE WHOLE
@@ -2776,11 +2779,11 @@ def cycle_claims(run):
seen.append(list(claims))
return real(claims, **kw)
app_main.resolve = _spy
main_files.live.resolve = _spy
try:
out = run()
finally:
app_main.resolve = real
main_files.live.resolve = real
return out, (seen[-1] if seen else [])
@@ -4977,7 +4980,11 @@ check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide()
# written, at every roster length, in every configuration, and with no fixture
# required.
_APP_SRC = {p.stem: p.read_text(encoding="utf-8")
for p in sorted((HERE / "app").glob("*.py"))}
for p in sorted((HERE / "app").glob("*.py"))
if p.name == "main.py" or p.name not in main_files.files()}
# MAINT-21: main.py and the files split from it, the mixins read as Controller.
_APP_SRC["main"] = re.sub(r"^class \w+Mixin:", "class Controller:",
main_files.source(), flags=re.M)
_ROSTER_OPTION = "inverters" # the ONE seed. Everything else is closure.
_WRAPPERS = {"list", "tuple", "sorted", "enumerate", "zip", "reversed",
"filter", "iter", "set", "dict"}
+4 -2
View File
@@ -118,6 +118,8 @@ from app.static_tariff import resolve_tariff
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
import main_files # noqa: E402
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
# point these fixtures build, so no period has ended and the whole series counts.
@@ -2015,8 +2017,8 @@ check("...so calling it without one is a TypeError, not a quiet regression",
transport=transport_for(_body_today),
now=NOW)), TypeError))
_MAIN = open(os.path.join(_APP, "main.py"), encoding="utf-8").read()
_main_tree = ast.parse(_MAIN)
_MAIN = main_files.source()
_main_tree = main_files.tree(_MAIN)
_refresh = first((n for n in ast.walk(_main_tree)
if isinstance(n, ast.AsyncFunctionDef)
and n.name == "run_tariff_refresh"), "run_tariff_refresh in main.py")
+8 -6
View File
@@ -44,6 +44,8 @@ import tempfile
from datetime import date, datetime, timedelta, timezone
from named_absence import nth
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
from app import main as _main
from app import messages, price as P, price_chain as C
from app.price import (
@@ -686,8 +688,8 @@ def controller(clock, o, tmp=None):
return ctl
_REAL_DT, _REAL_GET = _main.datetime, P.https_get
_real_resolve = _main.resolve_tariff
_REAL_DT, _REAL_GET = main_files.live.datetime, P.https_get
_real_resolve = main_files.live.resolve_tariff
class Wired:
@@ -701,7 +703,7 @@ class Wired:
self.refreshes = 0
def __enter__(self):
_main.datetime = FakeDT
main_files.live.datetime = FakeDT
P.https_get = self.net
W = self
@@ -709,12 +711,12 @@ class Wired:
r = await _real_resolve(*a, **k)
W.refreshes += 1
return r
_main.resolve_tariff = counting
main_files.live.resolve_tariff = counting
return self
def __exit__(self, *exc):
_main.datetime, P.https_get = _REAL_DT, _REAL_GET
_main.resolve_tariff = _real_resolve
main_files.live.datetime, P.https_get = _REAL_DT, _REAL_GET
main_files.live.resolve_tariff = _real_resolve
async def refresh(self, now=None):
if now is not None:
+7 -1
View File
@@ -692,7 +692,13 @@ def touches(src: str) -> bool:
or "from .pv import" in src
_touch = sorted(f.name for f in APP if touches(f.read_text(encoding="utf-8")))
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
# MAINT-21: the files split from app/main.py are counted as main.py.
_SPLIT = set(main_files.files()) - {"main.py"}
_touch = sorted({"main.py" if f.name in _SPLIT else f.name for f in APP
if touches(f.read_text(encoding="utf-8"))})
print(f" {len(APP)} modules under app/; TEL-11 material is in: {_touch}")
check(f"PRECONDITION: the scan found the whole package, not a subset - "
f"{len(APP)} modules", len(APP) >= 25 and "control.py" in
+7 -4
View File
@@ -142,8 +142,11 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
**LIVE), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE["max_w"], LIVE["max_w"]
import os # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
import app.main as m
real, real_dt = m.compute, m.datetime
real, real_dt = main_files.live.compute, main_files.live.datetime
clock = [0.0]
class _DT(real_dt):
@@ -153,9 +156,9 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
main_files.live.datetime = _DT
if old:
m.compute = lambda *a, **k: real(*a, **dict(k, refused=None))
main_files.live.compute = lambda *a, **k: real(*a, **dict(k, refused=None))
rows, trips, was = [], 0, False
try:
t = 0.0
@@ -174,7 +177,7 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
hass.cmd, c.i_w, c.loop_reason, c.batt_refused))
t += TICK
finally:
m.compute, m.datetime = real, real_dt
main_files.live.compute, main_files.live.datetime = real, real_dt
return rows, trips, c
return asyncio.run(go())
+6 -3
View File
@@ -50,6 +50,9 @@ from app import control
from app.control import PROBE_EVERY_S, PROBE_S, PROBE_W
from app.main import Controller
from app.arbiter import P_LOOP, P_PEAK, P_SAFETY, Claim, resolve
import os # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
import app.main as m
fails = []
@@ -177,7 +180,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
c.auto = True
if old:
_off(c)
real_dt = m.datetime
real_dt = main_files.live.datetime
clock = [0.0]
class _DT(real_dt):
@@ -185,7 +188,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
main_files.live.datetime = _DT
rows = []
trips = {u: 0 for u in UNITS}
was = {u: False for u in UNITS}
@@ -211,7 +214,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
db=c.inverters[1].delivery_wd.tripped))
t += dt
finally:
m.datetime = real_dt
main_files.live.datetime = real_dt
return rows, trips, c
return asyncio.run(go())
+6
View File
@@ -73,6 +73,7 @@ def check(name, cond):
print("1. the harness's own preconditions")
# --------------------------------------------------------------------------- #
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
import rehearse_roster as RR # noqa: E402
check("the tool refuses rather than passing when it cannot run: its four exit "
@@ -210,6 +211,11 @@ def mutant_tree(edits) -> str:
dest / "tools" / "predeploy_check.py")
shutil.copyfile(HERE / "config.yaml", dest / "config.yaml")
for rel, old, new in edits:
if rel == "app/main.py":
# MAINT-21: app/main.py stands for the files split from it.
where = [n for n in main_files.files() if (dest / "app" / n)
.read_text(encoding="utf-8").count(old) == 1]
rel = f"app/{where[0]}" if len(where) == 1 else rel
path = dest / rel
text = path.read_text(encoding="utf-8")
hits = text.count(old)
+4 -3
View File
@@ -35,6 +35,7 @@ logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import app.main as app_main # noqa: E402
import main_files # noqa: E402
from app import control # noqa: E402
from app.arbiter import Claim, P_ARBITRAGE # noqa: E402
from app.main import Controller # noqa: E402
@@ -61,7 +62,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
(a writer above the loop), or None."""
log, rows, now = [], [], [0.0]
real_power, real_tick = rig._Hass.power, rig.TICK
real_log, real_resolve, real_c = (Controller.log_event, app_main.resolve,
real_log, real_resolve, real_c = (Controller.log_event, main_files.live.resolve,
rig.Controller)
def power(self, u):
@@ -108,7 +109,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
extra.update(s79.P1, peak_cap_w=cap)
db = os.path.join(tempfile.mkdtemp(prefix="s107-"), "tariff.db")
rig._Hass.power, rig.TICK = power, tick
Controller.log_event, app_main.resolve = log_event, resolve
Controller.log_event, main_files.live.resolve = log_event, resolve
rig.Controller = functools.partial(real_c, peak_db_path=db)
try:
_, c = rig.run(n, extra=extra, dur=dur, load=house,
@@ -117,7 +118,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
store=s79.store_charging(n) if maint else rig._Store())
finally:
rig._Hass.power, rig.TICK = real_power, real_tick
Controller.log_event, app_main.resolve = real_log, real_resolve
Controller.log_event, main_files.live.resolve = real_log, real_resolve
rig.Controller = real_c
return rows, log, c
+9 -5
View File
@@ -17,8 +17,11 @@ Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
import asyncio
import logging
from pathlib import Path
import os # noqa: E402
import sys # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
from app import main as _main
from app import messages, pv, validate
from app.main import Controller
@@ -217,7 +220,7 @@ check(f"one read of the forecast entity per read_inputs() ({h.reads})",
print("4. maintenance headroom and the charge-only clamp use watts")
seen = []
_real = _main.maintenance_charge_floor
_real = main_files.live.maintenance_charge_floor
def spy(charge_w, fc, cap):
@@ -226,7 +229,7 @@ def spy(charge_w, fc, cap):
return out
_main.maintenance_charge_floor = spy
main_files.live.maintenance_charge_floor = spy
try:
def cycle(fc, **kw):
c, h = make(fc, store={"phase": "charge"},
@@ -264,7 +267,7 @@ try:
check("no unit: forecast unused, clamp in place, as with the option empty",
clamp(st(6000)) is False)
finally:
_main.maintenance_charge_floor = _real
main_files.live.maintenance_charge_floor = _real
print("5. validator and strings")
def notes(unit, key="peak_forecast_entity"):
@@ -367,7 +370,8 @@ check("pv.unit_fault: None for a good unit and for a missing reading (has_readin
pv.unit_fault(st(1, "kW")) is None and pv.unit_fault(None) is None
and pv.unit_fault(st("unavailable")) is None
and pv.unit_fault(st(1)) == "" and pv.unit_fault(st(1, "kWh")) == "kWh")
src = (Path(__file__).parent / "app" / "main.py").read_text(encoding="utf-8")
# MAINT-21: main.py and the files split from it (the forecast read is in ctl_inputs).
src = main_files.source()
check("the size rule is gone from main.py", "abs(raw) < 50" not in src)
print()
+6 -5
View File
@@ -59,6 +59,7 @@ logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import safety84_identity as s84 # noqa: E402
import main_files # noqa: E402
import app.main as app_main # noqa: E402
from app import messages # noqa: E402
from app.main import Controller # noqa: E402
@@ -413,8 +414,8 @@ _rg = {t: never(t, hw, seed=1, delay=0) for t, hw in RIG.items()}
_rg_old = {t: never(t, hw, fix=False, seed=1, delay=0)
for t, hw in RIG.items()}
# SAFETY-119 as merged (bee7431) at 5 s: a settle of 12 cycles, budget 8.
_rg_119 = patched(app_main, "HANDBACK_CYCLES", app_main.SHORT_CYCLES)(
lambda: patched(app_main, "ROLLING_BOUND_CYCLES", 8)(
_rg_119 = patched(main_files.live, "HANDBACK_CYCLES", app_main.SHORT_CYCLES)(
lambda: patched(main_files.live, "ROLLING_BOUND_CYCLES", 8)(
lambda: never(5.0, 5100.0, seed=1, delay=0)))
for _t in _rg:
print(f" rig shape, {_t:.0f} s meter, house {RIG[_t]:.0f} W: per "
@@ -464,10 +465,10 @@ check("...every command the same as without the fix; without it the false "
# the rig's shape (1 s, house 5050 W, seed 1).
rig.TICK = 2.0
_q25 = (run(house(25), delay=2),
patched(app_main, "ROLLING_BOUND_CYCLES", 8)(
patched(main_files.live, "ROLLING_BOUND_CYCLES", 8)(
lambda: run(house(25), delay=2)))
rig.TICK = 1.0
_m14 = patched(app_main, "ROLLING_BOUND_CYCLES", 14)(
_m14 = patched(main_files.live, "ROLLING_BOUND_CYCLES", 14)(
lambda: never(1.0, 5050.0, seed=1, delay=0))
_m11 = never(1.0, 5050.0, seed=1, delay=0)
print(f" 2 s, quarter edge, seed 25: {notes(_q25[0][1])}, budget 8 "
@@ -480,7 +481,7 @@ check("...the budget's margins: 11 is silent at 2 s seed 25 and warns every "
and every_quarter(_m11[1]) and not every_quarter(_m14[1]))
check("the rule is the one the sweep chose. Changing it means re-running "
"the sweep in DECISIONS/SAFETY-121-bound-every-quarter.md",
app_main.HANDBACK_CYCLES == 4 and app_main.ROLLING_BOUND_CYCLES == 11
main_files.live.HANDBACK_CYCLES == 4 and main_files.live.ROLLING_BOUND_CYCLES == 11
and app_main.ROLLING_S == 900.0 and app_main.SHORT_SPAN_S == 22.0)
# A start, or a blind cycle, is not a hand-back: the loop at its rail after
+4 -3
View File
@@ -37,6 +37,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import main_files # noqa: E402
import app.main as M # noqa: E402
from app import dayplan, modetrade, packmode, priceruns, solarfc # noqa: E402
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
@@ -492,13 +493,13 @@ for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00",
"2027-03-28T00:00:05+00:00"):
now = datetime.fromisoformat(iso)
c, hass = controller(MODES["Leftover solar"], now)
real_dt = M.datetime
real_dt = main_files.live.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None, _n=now):
return _n if tz is None else _n.astimezone(tz)
M.datetime = _DT
main_files.live.datetime = _DT
try:
async def one():
await c.read_inputs(now_mono=0.0)
@@ -510,7 +511,7 @@ for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00",
except Slow:
ok = False
finally:
M.datetime = real_dt
main_files.live.datetime = real_dt
check(f"a whole control cycle at {iso[:16]}Z (three forecast sensors a "
"day) returns with a day plan", ok)
+4 -3
View File
@@ -48,6 +48,7 @@ logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import opt11p3_replay as rp # noqa: E402
import safety84_identity as s84 # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
import app.p1 as p1mod # noqa: E402
from named_absence import first, nth, present # noqa: E402
@@ -164,14 +165,14 @@ def drive(o, *, peak=None, late=0.0, minutes=31, every=0, start=None,
c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0
c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0
c.auto = True
real = m.datetime, homeview.datetime, p1mod.time
real = main_files.live.datetime, homeview.datetime, p1mod.time
clock, simt = [t0], [0.0]
class _DT(real[0]):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
main_files.live.datetime = homeview.datetime = _DT
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0])
real_load = _price.load_prices, _st.load_prices
_price.load_prices = _st.load_prices = _no_fetch
@@ -223,7 +224,7 @@ def drive(o, *, peak=None, late=0.0, minutes=31, every=0, start=None,
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime, p1mod.time = real
main_files.live.datetime, homeview.datetime, p1mod.time = real
_price.load_prices, _st.load_prices = real_load
return c
c = asyncio.run(go())
+4 -3
View File
@@ -43,6 +43,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
import app.p1 as p1mod # noqa: E402
from app import homeview, messages, peakguard, tariff # noqa: E402
@@ -179,14 +180,14 @@ def drive(o, *, modes=OWNER, socs=(10.0, 10.0), house=600.0, minutes=46,
c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0
c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0
c.auto = True
real = m.datetime, homeview.datetime, p1mod.time
real = main_files.live.datetime, homeview.datetime, p1mod.time
clock, simt = [t0], [0.0]
class _DT(real[0]):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
main_files.live.datetime = homeview.datetime = _DT
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0])
real_load = _price.load_prices, _st.load_prices
_price.load_prices = _st.load_prices = _no_fetch
@@ -237,7 +238,7 @@ def drive(o, *, modes=OWNER, socs=(10.0, 10.0), house=600.0, minutes=46,
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime, p1mod.time = real
main_files.live.datetime, homeview.datetime, p1mod.time = real
_price.load_prices, _st.load_prices = real_load
return c
out["c"] = asyncio.run(go())
+13 -1
View File
@@ -38,6 +38,7 @@ logging.disable(logging.WARNING)
import safety128_rig as R # noqa: E402
import app.main as app_main # noqa: E402
from app import lateness # noqa: E402
from app import settle # noqa: E402
fails = []
@@ -58,6 +59,17 @@ def no_gate(fn):
app_main.Controller._readings_late = real
def no_settle(fn):
"""`fn()` without SAFETY-136's gentler loop: the tree this card was written
against (with it the same run exports -645 W, not -1 325 W)."""
real = settle.Settle.moved
settle.Settle.moved = lambda self, *a, **k: None
try:
return fn()
finally:
settle.Settle.moved = real
def run_late(**kw):
"""Run the rig; (rig, seconds the verdict stood, first second it stood)."""
dur = kw.pop("dur")
@@ -91,7 +103,7 @@ for units, house in ((AB, 1800.0), (A, 1500.0)):
if len(units) == 2:
old = R.Rig(house=house, lag_s=30.0, lag_on=ON, lag_off=ON + 300.0,
units=units, p1_s=5.0)
no_gate(lambda: old.run(ON + 300.0))
no_settle(lambda: no_gate(lambda: old.run(ON + 300.0)))
check(f"{n}: without the gate the export goes below -1 kW",
grid_min(old.rows, ON, ON + 300.0) < -1000.0)
+202
View File
@@ -0,0 +1,202 @@
"""Runnable check for SAFETY-135: a young 15-minute period does not cost the
night charge the whole period. `python3 test_safety135.py`
The REAL Controller (tools/opt11p3_replay.py, read_inputs + cycle, battery
modes, the peak guard on a P1 meter) with TEST-23-like lag: 1 s cycles,
readings 1 s old, a 5 s old meter in part 2. "Off" is the same run with
`modetrade.YOUNG_S = 0`: the tree before this card.
DECISIONS/SAFETY-135-young-period.md.
1. A peak-bound night charge; a period starts with a 4 s kettle spike. The
guard cuts at once, and the charge resumes in that period (off: it stays
off to the period's end). The period stays at or under the aim.
2. A cheap run starts at 01:00 right after a give run, with a 5 s meter: no
start-then-cut within 60 s (off: one), and no period over the aim.
3. A real over-aim load: the guard and the charge stop on the same cycle as
off, and the charge stays off while the load is on.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import sys
from datetime import datetime, timedelta, timezone
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
from app import arbitrage, modetrade, packmode # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
L = timezone(timedelta(hours=1))
D0 = datetime(2026, 11, 3, tzinfo=L)
UTC0 = D0.astimezone(timezone.utc)
# Local quarters: dear 00:00-01:00 (a give run), cheap 01:00-07:00, 0.30 else.
PRICES = [0.40 if q < 4 else 0.10 if q < 28 else 0.30 for q in range(96)] * 2
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p)
for i, p in enumerate(PRICES)]
AIM = rp.PEAK_CAP - 50
CH = {"a": (True, packmode.CHEAP_HOURS, False),
"b": (True, packmode.CHEAP_HOURS, False)}
T1 = D0 + timedelta(hours=1) # 01:00 local, the cheap edge
def off(fn):
"""`fn()` with SAFETY-135 off: no period is young."""
real = getattr(modetrade, "YOUNG_S", None)
if real is None:
return fn()
modetrade.YOUNG_S = 0.0
try:
return fn()
finally:
modetrade.YOUNG_S = real
def run(house_w, start, secs, meter_s=1.0):
"""Rows per cycle and the Controller, for `house_w(seconds since 01:00)`."""
t0 = start.timestamp()
house = [(t0 + k, house_w(t0 + k - T1.timestamp()))
for k in range(int(secs) + 5)]
rows = []
def cb(c, h, now, pr):
g = c.peak_guard
rows.append(dict(now=now, s=now.timestamp() - T1.timestamp(),
grid=h.grid(),
charge=getattr(c.arb, "action", None)
== arbitrage.CHARGE,
engaged=g is not None and g.engaged))
_st, c = rp.run("p3_ch_sell", house, WPTS, start.astimezone(timezone.utc),
secs, check=cb, soc0={"a": 30.0, "b": 30.0}, choice=CH,
extra={"slew_w": 0, "cycle_cost_threshold_eur": 0.03},
lag=rp.Lag(meter_s=meter_s))
return rows, c
def banked(c, frm):
"""The add-on's own P1 period averages from `frm` (seconds after 01:00)."""
return [b.offtake_avg_w for b in c.p1.blocks
if b.start.timestamp() - T1.timestamp() >= frm]
def flaps(rows, frm, to):
"""Charge starts in [frm, to) s that stopped again within 60 s."""
out = []
for i in range(1, len(rows)):
r = rows[i]
if frm <= r["s"] < to and r["charge"] and not rows[i - 1]["charge"]:
if any(not x["charge"] for x in rows[i:]
if x["s"] - r["s"] <= 60):
out.append(r["s"])
return out
# ============================================================================ #
print("1. a 4 s spike at the start of a peak-bound night charge's period")
def spike(s):
return 2800.0 + (2500.0 if s >= 1800 and (s - 1800) % 900 < 4 else 0.0)
R1, C1 = run(spike, T1 + timedelta(minutes=20), 1560)
R1o, C1o = off(lambda: run(spike, T1 + timedelta(minutes=20), 1560))
def period(rows, frm):
return [r for r in rows if frm <= r["s"] < frm + 900]
for tag, rows, c in (("on", R1, C1), ("off", R1o, C1o)):
p = period(rows, 1800)
cut = next((r["s"] - 1800 for r in p if r["engaged"]), None)
on = sum(r["charge"] for r in p)
print(f" {tag}: guard cut at +{cut} s, charging {on} of {len(p)} s, "
f"period {banked(c, 1800)[:1]}")
_p = period(R1, 1800)
_cut = next((i for i, r in enumerate(_p) if r["engaged"]), None)
check("the spike trips the guard in the period's first seconds and the "
"charge stops there",
_cut is not None and _p[_cut]["s"] - 1800 <= 5
and not _p[_cut]["charge"])
_back = [r["s"] - 1800 for r in _p[(_cut or 0):] if r["charge"]]
check("the charge resumes in the same period, within 90 s of its start",
_back and _back[0] <= 90)
_po = period(R1o, 1800)
check("off, the same spike keeps the charge off to the period's end (the "
"defect this card fixes)",
any(r["engaged"] for r in _po[:5])
and not any(r["charge"] for r in _po[10:]))
check("every period with the charge is at or under the aim (lag allows 2 W)",
banked(C1, 0) and max(banked(C1, 0)) <= AIM + 2.0)
# ============================================================================ #
print("\n2. a cheap run at 01:00 right after a give run, 5 s meter")
for house in (2400.0, 3000.0):
def flat(s, w=house):
return w
R2, C2 = run(flat, T1 - timedelta(minutes=5), 1260, meter_s=5.0)
R2o, C2o = off(lambda: run(flat, T1 - timedelta(minutes=5), 1260,
meter_s=5.0))
f2, f2o = flaps(R2, 0, 900), flaps(R2o, 0, 900)
p2, p2o = banked(C2, 0)[:1], banked(C2o, 0)[:1]
on2 = sum(r["charge"] for r in period(R2, 0))
on2o = sum(r["charge"] for r in period(R2o, 0))
print(f" house {house:.0f} W: on flaps {f2} charging {on2} s period "
f"{p2}; off flaps {f2o} charging {on2o} s period {p2o}")
check(f"house {house:.0f} W: no charge start is cut within 60 s in the "
f"01:00 period", not f2)
check(f"house {house:.0f} W: the 01:00 period is at or under the aim "
f"(lag allows 2 W)", p2 and p2[0] <= AIM + 2.0)
check(f"house {house:.0f} W: the charge still runs in the 01:00 period",
on2 >= 800)
if house == 3000.0:
check("off, the 3 000 W house starts on the young period and is cut "
"within 60 s (the defect this card fixes)", bool(f2o))
# ============================================================================ #
print("\n3. a real over-aim load still cuts on the same cycle")
def load(s):
return 2800.0 + (4000.0 if 1820 <= s < 2120 else 0.0)
R3, C3 = run(load, T1 + timedelta(minutes=20), 1500)
R3o, C3o = off(lambda: run(load, T1 + timedelta(minutes=20), 1500))
def first(rows, key, val, frm):
return next((r["s"] for r in rows if r["s"] >= frm and r[key] == val),
None)
_g, _go = first(R3, "engaged", True, 1800), first(R3o, "engaged", True, 1800)
_s, _so = first(R3, "charge", False, 1800), first(R3o, "charge", False, 1800)
print(f" guard cut at {_g} s (off {_go} s), charge stop at {_s} s "
f"(off {_so} s)")
check("the guard cuts on the same cycle as off, and the charge stops on it",
_g is not None and _g == _go and _s == _so == _g)
check("the charge stays off while the load is on",
not any(r["charge"] for r in R3 if _g <= r["s"] < 2120))
check("every period is at or under the aim (lag allows 2 W)",
banked(C3, 0) and max(banked(C3, 0)) <= AIM + 2.0)
print()
if fails:
print(f"FAILED: {len(fails)}")
sys.exit(1)
print("all SAFETY-135 checks passed")
+263
View File
@@ -0,0 +1,263 @@
"""Runnable check for SAFETY-136: a calmer loop on a slow meter.
`python3 test_safety136.py`
With a 5 s meter and Home Assistant readings 10-15 s late the loop answers the
same error again at each cycle and overshoots: with an oven, a kettle and hob,
or a step the export was 3-10 times that of a meter with no lag. After a move
of 150 W or more `app/settle.py` now runs the loop at half its gain (at most
2 cycles) until the meter shows half of it. It is off for a meter under 4 s.
DECISIONS/SAFETY-136-slow-meter-settle.md.
The REAL Controller on TEST-23's plant (tools/safety128_rig.py). "Off" is the
same run with `Settle.moved` doing nothing: the tree before this card.
1. `Settle` alone.
2. 5 s meter, readings 10 s late: oven, kettle and hob, a step down and a step
up export clearly less than with it off.
3. 5 s meter, no lag: the same houses are no worse than a small margin.
4. 1 s and 2 s meters: the commands are those of the run with it off, and the
loop never waits.
5. The slow meter is told by its observed interval, at 4 s and 5 s.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import sys
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 safety128_rig as R # noqa: E402
from app.control import Tuning # noqa: E402
from named_absence import nth, present # noqa: E402
from app import settle # noqa: E402
from app.lateness import MeterFit # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def off(fn):
"""`fn()` with the settle wait off: the tree before this card."""
real = settle.Settle.moved
settle.Settle.moved = lambda self, *a, **k: None
try:
return fn()
finally:
settle.Settle.moved = real
def oven(t):
return 1800.0 + (2000.0 if (t >= 300.0 and (t - 300.0) % 105 < 45) else 0.0)
def kettle(t):
w = 600.0
if t % 400 < 120:
w += 2200
if t >= 900 and t % 90 < 30:
w += 1500
return w
def step(t):
return 1800.0 if t < 320.0 else 300.0
def stepup(t):
return 300.0 if t < 320.0 else 2500.0
# name: (house, noise, run length, lag end)
HOUSES = {"oven": (oven, 80.0, 900.0, 900.0),
"kettle": (kettle, 80.0, 1500.0, 1500.0),
"step": (step, 50.0, 700.0, 600.0),
"stepup": (stepup, 50.0, 700.0, 600.0)}
ON = 300.0
def wh(rows, t0, t1, sign):
"""Export (sign -1) or import (sign +1) energy in Wh over [t0, t1)."""
return sum((-min(r[1], 0.0) if sign < 0 else max(r[1], 0.0))
for r in rows if t0 <= r[0] < t1) / 3600.0
def run(name, lag, p1_s=5.0, units=("a", "b"), dur=None):
house, noise, length, lag_off = HOUSES[name]
r = R.Rig(house=house, noise=noise, p1_s=p1_s, units=units, lag_s=lag,
lag_on=ON, lag_off=lag_off)
waits = []
r.run(dur or length + 20.0,
lambda rig, c, t: waits.append(1) if c.settle.pend else None)
return r, len(waits)
# --------------------------------------------------------------------------- #
print("1. Settle alone")
T = Tuning()
s = settle.Settle()
check("no move: the tuning is untouched", s.tuning(T, 500.0, True) is T)
s.moved(500.0, 0.0, 100.0, True)
check("a move under 150 W is not waited for", s.pend is None)
s.moved(500.0, 0.0, 600.0, False)
check("a meter that is not slow is not waited for", s.pend is None)
s.moved(500.0, 0.0, 600.0, True)
check("a move of 600 W is waited for: half the gain",
s.tuning(T, 500.0, True).gain == T.gain * 0.5)
check("the meter fell 200 W of 600 W: still half the gain",
s.tuning(T, 300.0, True).gain == T.gain * 0.5)
check("two cycles waited: the third has the whole gain",
s.tuning(T, 300.0, True) is T and s.pend is None)
s.moved(500.0, 0.0, 600.0, True)
check("the meter went the other way by half the move: the wait stands",
s.tuning(T, 800.0, True).gain == T.gain * 0.5)
s.reset()
s.moved(500.0, 0.0, -600.0, True)
check("a move down, the meter rose half the move: the whole gain",
s.tuning(T, 800.0, True) is T)
s.reset()
s.moved(500.0, 0.0, 600.0, True)
check("the meter fell half the move: the whole gain at once",
s.tuning(T, 200.0, True) is T and s.pend is None)
s.moved(500.0, 0.0, -600.0, True)
check("a move down is waited for too",
s.tuning(T, 500.0, True).gain == T.gain * 0.5)
s.reset()
check("reset ends the wait", s.pend is None)
s.moved(500.0, 0.0, 600.0, True)
check("a meter that turns out not to be slow ends the wait",
s.tuning(T, 500.0, False) is T and s.pend is None)
check("the numbers are pinned",
(settle.MOVE_W, settle.SHOWN, settle.WAIT_SHARE, settle.WAIT_CYCLES)
== (150.0, 0.5, 0.5, 2))
# --------------------------------------------------------------------------- #
print("\n2. 5 s meter, readings 10 s late")
tot_on = tot_off = 0.0
for name, (_h, _n, length, lag_off) in HOUSES.items():
a, waited = run(name, 10.0)
b, _w = off(lambda: run(name, 10.0))
ea, eb = wh(a.rows, ON, lag_off, -1), wh(b.rows, ON, lag_off, -1)
tot_on, tot_off = tot_on + ea, tot_off + eb
print(f" {name}: export {ea:.1f} Wh, off {eb:.1f} Wh, waited {waited} s")
check(f"{name}: waits at some point", waited > 0)
check(f"{name}: exports at most 75 % of the run with it off",
ea <= 0.75 * eb)
print(f" sum: {tot_on:.1f} Wh, off {tot_off:.1f} Wh")
check("the four together export at most 65 % of the run with it off",
tot_on <= 0.65 * tot_off)
# --------------------------------------------------------------------------- #
print("\n2b. Another writer overrides the loop: the wait ends")
halved = []
real_tuning = settle.Settle.tuning
def watch_tuning(self, tuning, grid_w, slow):
r = real_tuning(self, tuning, grid_w, slow)
halved.append(r is not tuning)
return r
settle.Settle.tuning = watch_tuning
try:
mark = []
def bump(rig, c, t):
# right after a cycle that started a wait, another writer moves the
# command, so the next cycle sees `target != loop_w`
if not mark and c.settle.pend and t > 330.0:
mark.append(len(halved))
c.target = c.target + 300.0
r = R.Rig(house=oven, noise=80.0, p1_s=5.0, lag_s=10.0, lag_on=ON,
lag_off=900.0)
r.run(500.0, bump)
finally:
settle.Settle.tuning = real_tuning
nxt = nth(halved, mark[0] if mark else 10 ** 6)
check("the cycle after an override has the whole gain",
bool(mark) and present(nxt) and nxt is False)
# --------------------------------------------------------------------------- #
print("\n3. 5 s meter, no lag")
for name in ("oven", "kettle", "step", "stepup"):
_h, _n, length, lag_off = HOUSES[name]
a, _w = run(name, 0.0)
b, _w = off(lambda: run(name, 0.0))
ea, eb = wh(a.rows, ON, lag_off, -1), wh(b.rows, ON, lag_off, -1)
ia, ib = wh(a.rows, ON, lag_off, 1), wh(b.rows, ON, lag_off, 1)
print(f" {name}: export {ea:.1f} Wh (off {eb:.1f}), import {ia:.1f} Wh "
f"(off {ib:.1f})")
check(f"{name}: export at most 6 Wh over the run with it off",
ea <= eb + 6.0)
check(f"{name}: import at most 1 Wh over the run with it off",
ia <= ib + 1.0)
# --------------------------------------------------------------------------- #
print("\n4. 1 s and 2 s meters")
for p1 in (1.0, 2.0):
for lag in (0.0, 10.0):
for units in (("a", "b"), ("a",)):
a, waited = run("oven", lag, p1_s=p1, units=units, dur=700.0)
b, _w = off(lambda: run("oven", lag, p1_s=p1, units=units,
dur=700.0))
same = ([(r[0], r[1], r[2]) for r in a.rows]
== [(r[0], r[1], r[2]) for r in b.rows])
check(f"{p1:.0f} s meter, {lag:.0f} s late, N={len(units)}: "
f"same commands, never waits", same and waited == 0)
# --------------------------------------------------------------------------- #
print("\n5. The slow meter is told by its observed interval")
s = settle.Settle()
for k in range(30):
s.arrive(float(k), k)
check("a reading each second is not a slow meter", not s.slow())
s = settle.Settle()
for k in range(30):
s.arrive(5.0 * k, k)
check("a reading every 5 s is a slow meter", s.slow())
s = settle.Settle()
for k in range(4):
s.arrive(5.0 * k, k)
check("fewer than 5 readings in a minute tell nothing", not s.slow())
s = settle.Settle()
for k in range(30):
s.arrive(5.0 * k, 0 if k else 1)
check("the same count again is no new reading", not s.slow())
for p1, expect in ((1.0, False), (2.0, False), (3.0, False), (4.0, True),
(5.0, True), (10.0, True)):
r = R.Rig(house=1800.0, p1_s=p1)
seen = []
r.run(120.0, lambda rig, c, t: seen.append(c.settle.slow()))
last = nth(seen, -1)
check(f"{p1:.0f} s meter: slow is {expect}",
present(last) and last is expect)
# A 1 s meter on a house that changes every 5 s repeats its value 4 times in 5.
# Where every reading arrives (the rig, a polled meter) it is not slow; where
# Home Assistant sends a reading only when the value changes (ha_signed) the
# same meter is slow (T3 of the review; DECISIONS).
s, m, h = settle.Settle(), MeterFit(), settle.Settle()
for k in range(60):
s.arrive(float(k), k)
h.arrive(float(k), k // 5)
m.add(float(k), 0.0, 1800.0 + 100.0 * (k // 5))
check("every reading arrives, value changes every 5 s: not slow (the value "
"fit says slow)", not s.slow() and m.slow())
check("the same meter through Home Assistant on change (a reading per "
"change): slow", h.slow())
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")
+8 -7
View File
@@ -38,6 +38,7 @@ logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import safety84_identity as s84 # noqa: E402
import main_files # noqa: E402
import app.main as app_main # noqa: E402
from app import clamp, messages # noqa: E402
from app.main import Controller # noqa: E402
@@ -202,7 +203,7 @@ check("⭐ N=2, A in its HOLD and B, which the loop drives, gives nothing: the "
check("...it starts no earlier than the saturation cycles allow (3 x 5 s)",
all(e.split(" ", 1)[0] >= "11:10:10" for c in (_c1, _c2)
for e in c.events if "OUTPUT_CLAMP" in e))
_quiet = patched(app_main, "delivery_short", lambda *a: False)(
_quiet = patched(main_files.live, "delivery_short", lambda *a: False)(
lambda: run(1, "drain", 1000.0, 20000, dur=1200.0, cut=gone(600, 1200)))
check("non-vacuous: without the delivery rule the N=1 drain that gives "
"nothing is silent (the loop's clamp never reaches its rail here)",
@@ -214,7 +215,7 @@ check("a dip of two cycles is not an alarm", warned(_dip[1]) == (0, 0, 0))
print("\n3. control coming back from maintenance does not jump")
END = {"maintenance_hold_min": 10}
_bounds = []
_real_compute = app_main.compute
_real_compute = main_files.live.compute
def _spy_compute(*a, **kw):
@@ -222,11 +223,11 @@ def _spy_compute(*a, **kw):
return _real_compute(*a, **kw)
app_main.compute = _spy_compute
main_files.live.compute = _spy_compute
try:
_h2 = run(2, "hold", 4500.0, 20000, dur=900.0, extra=END)
finally:
app_main.compute = _real_compute
main_files.live.compute = _real_compute
_h2_old = no_bound(lambda: run(2, "hold", 4500.0, 20000, dur=900.0,
extra=END))
_h1 = run(1, "hold", 4500.0, 20000, dur=900.0, extra=END)
@@ -380,8 +381,8 @@ def lag_runs():
_lag = lagging(lag_runs)
_lag_old = patched(app_main, "SHORT_CYCLES", 1)(
lambda: patched(app_main, "SHORT_SPAN_S", 0.0)(
_lag_old = patched(main_files.live, "SHORT_CYCLES", 1)(
lambda: patched(main_files.live, "SHORT_SPAN_S", 0.0)(
lambda: lagging(lag_runs)))
_lag_dead = lagging(lambda: run(1, "drain", 1000.0, 20000, dur=900.0,
cut=gone(600, 900)))
@@ -432,7 +433,7 @@ def both_ways(asked, given, margin):
_tp = taper(charge_runs)
_tp_old = patched(app_main, "delivery_short", both_ways)(
_tp_old = patched(main_files.live, "delivery_short", both_ways)(
lambda: taper(charge_runs))
print(f" a charge that tapers: {[warned(c) for _r, c in _tp]}, grading "
f"charges too {[warned(c) for _r, c in _tp_old]}")
+6 -2
View File
@@ -38,6 +38,7 @@ import types
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
import main_files # noqa: E402
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
@@ -703,8 +704,11 @@ check("⭐ the reserve alarm is graded on the real limit, not the aim: a "
_runs_src = {}
for _f in ("main.py", "homeview.py"):
with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh:
_src = _fh.read()
if _f == "main.py":
_src = main_files.source()
else:
with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh:
_src = _fh.read()
for _part in _src.split('out.append({')[1:]:
if '"runs": True' in _part.split("})", 1)[0]:
_runs_src.setdefault(_f, []).append(
+4 -3
View File
@@ -127,16 +127,17 @@ def without_fix(fn):
"""release/1.0 8c438ab: held only for the peak, a drain stays pinned, the
quarter's edge is not rolled over."""
real = (Controller._held_back, Controller._drain_rise)
import main_files # noqa: E402
import app.main as m
real_nq = m.next_quarter
real_nq = main_files.live.next_quarter
Controller._held_back = lambda self: self._peak_reserve()
Controller._drain_rise = lambda self, claims, results: None
m.next_quarter = lambda p, max_hold_s: p
main_files.live.next_quarter = lambda p, max_hold_s: p
try:
return fn()
finally:
Controller._held_back, Controller._drain_rise = real
m.next_quarter = real_nq
main_files.live.next_quarter = real_nq
def cmd(r):
+9 -8
View File
@@ -37,6 +37,7 @@ logging.disable(logging.CRITICAL)
import safety94_rig as rig # noqa: E402
import app.inverter as app_inverter # noqa: E402
import main_files # noqa: E402
import app.main as app_main # noqa: E402
from app import control # noqa: E402
from app.freshness import BOUNDS, Freshness # noqa: E402
@@ -59,19 +60,19 @@ class Before94(Freshness):
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_f, real_c = app_inverter.Freshness, main_files.live.compute
real_p = main_files.live.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")
main_files.live.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None))
main_files.live.REFUSED_PROGRESS_W = float("inf")
try:
return fn()
finally:
app_inverter.Freshness, app_main.compute = real_f, real_c
app_main.REFUSED_PROGRESS_W = real_p
app_inverter.Freshness, main_files.live.compute = real_f, real_c
main_files.live.REFUSED_PROGRESS_W = real_p
app_main.Controller._readings_late = real_l
@@ -315,7 +316,7 @@ check("compute(): a frozen loop at SETTLE_CYCLES reading -20 W engages with "
and control.compute(**args, gave_nothing=False).refused == 0)
# The evidence restarts on progress towards the ask, not on jitter.
print(f" REFUSED_PROGRESS_W {app_main.REFUSED_PROGRESS_W:.0f} W, span "
print(f" REFUSED_PROGRESS_W {main_files.live.REFUSED_PROGRESS_W:.0f} W, span "
f"{app_main.REFUSED_SPAN_S:.0f} s")
@@ -356,7 +357,7 @@ check("a command that swaps direction every cycle is never evidence, and a "
not any(flap) and 1 in away and away.index(1) == 22)
check("REFUSED_PROGRESS_W is under the band's width and over the live "
"readings' rest wander (25 W)",
25.0 < app_main.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W)
25.0 < main_files.live.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W)
print()
if fails:
+4 -3
View File
@@ -36,6 +36,7 @@ logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import main_files # noqa: E402
from app import main as app_main, packmode # noqa: E402
from app.arbiter import Claim, P_MAINTENANCE, P_SAFETY # noqa: E402
from app.main import Controller # noqa: E402
@@ -149,13 +150,13 @@ def run(n, house, *, store, extra=None, soc=None, reject=None, dead=None,
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, clock = app_main.datetime, [0.0]
real_dt, clock = main_files.live.datetime, [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return rig.BASE + timedelta(seconds=clock[0])
app_main.datetime = _DT
main_files.live.datetime = _DT
rows = []
try:
t = 0.0
@@ -182,7 +183,7 @@ def run(n, house, *, store, extra=None, soc=None, reject=None, dead=None,
else g.floor_w))
t += 2.0
finally:
app_main.datetime = real_dt
main_files.live.datetime = real_dt
return rows, c
return asyncio.run(go())
@@ -32,6 +32,7 @@ from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
fails = []
@@ -336,7 +337,7 @@ def run(coro):
return asyncio.run(coro)
_real_time = M.time
_real_time = main_files.live.time
class _FakeTime:
@@ -891,9 +892,9 @@ check("the units that raised, failed or hung are NOT marked handed back, so "
for i in (1, 2, 4)))
fb_raise.mode = "ok"
FT.t = _real_time.monotonic() + 11.0 # one heartbeat later
M.time = FT
main_files.live.time = FT
run(c.send_heartbeats(60_000.0, 10.0))
M.time = _real_time
main_files.live.time = _real_time
check("...and the next idle write (a heartbeat later) retries the one that "
"raised (now answers): released, and the healthy unit is not released a "
"second time",
@@ -955,7 +956,7 @@ for mode in ("false", "raise", "hang"):
# the cadence and the log, over N failed retries on a clock we move
c, h, f = failing_site("false")
M.time = FT
main_files.live.time = FT
FT.t = 5000.0
_cap.clear()
run(c.handle_action("auto_toggle")) # Stop: try 1 (forced)
@@ -997,7 +998,7 @@ _cap.clear()
run(c.handle_action("auto_toggle")) # Stop, fails again
check("a NEW failure streak is logged again, once",
len([1 for lv, m in _cap if lv >= logging.ERROR]) == 1)
M.time = _real_time
main_files.live.time = _real_time
# shutdown with a failed release: the one 0 W still goes out inside the bound
c, h, f = failing_site("hang")
@@ -1098,7 +1099,7 @@ check("the two new Last-event hints exist and are plain",
.endswith("trying again"))
# --- 3j. the call sites -------------------------------------------------------
src = (HERE / "app" / "main.py").read_text(encoding="utf-8")
src = main_files.source()
ss = src.splitlines()
@@ -1115,7 +1116,8 @@ check("main.py: the Stop button and shutdown() call command_site_release(); "
"the two maintenance-abort paths keep command_site_zero() (a third call "
"is the all-GoodWe branch inside command_site_release itself)",
len(rel) == 2 and len(zer) == 3
and "async def command_site_release" in "\n".join(ss[zer[1] - 30:zer[1]]))
and any("async def command_site_release" in "\n".join(ss[i - 30:i])
for i in zer))
# =============================================================================
print()
+181
View File
@@ -0,0 +1,181 @@
"""MAINT-21 tripwire: a test must patch a Controller global where it is read.
The Controller's code lives in app/main.py and in the files split from it
(app/ctl_*.py, app/rows.py). A function reads a module global from its own
file, so `app.main.NAME = fake` changes only the code that is still in
main.py. This file fails when a test or tool still does that:
1. it patches an `app.main` attribute that main.py's own functions no
longer read (a stale patch, silently inert), or
2. it patches an `app.main` attribute that another file also reads (the
patch reaches only part of the code).
The way to patch is `tools/main_files.py`: `main_files.live.NAME = fake` sets
NAME in every file that reads it and raises when none does.
Part 3 runs the scan on spoilt sources and the patch helper on the real tree.
Read MUTATION.md for the habit this follows. `python3 test_split_patches.py`
"""
import ast
import glob
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
import main_files # noqa: E402
fails = []
ALIASES = {"m", "M", "main", "app_main", "_main", "_main_mod", "main_mod"}
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def main_aliases(tree):
"""Names bound to the module app.main in one file."""
al = set()
for n in ast.walk(tree):
if isinstance(n, ast.Import):
al.update(a.asname for a in n.names
if a.name == "app.main" and a.asname)
elif isinstance(n, ast.ImportFrom) and n.module == "app":
al.update(a.asname or a.name for a in n.names if a.name == "main")
for n in ast.walk(tree):
if (isinstance(n, ast.Assign) and isinstance(n.value, ast.Name)
and n.value.id in al):
al.update(t.id for t in n.targets if isinstance(t, ast.Name))
# `for mod in (app_main, homeview): mod.datetime = fake`
if (isinstance(n, ast.For) and isinstance(n.target, ast.Name)
and isinstance(n.iter, (ast.Tuple, ast.List))
and any(isinstance(e, ast.Name) and e.id in al
for e in n.iter.elts)):
al.add(n.target.id)
return al
def flat(targets):
"""The assignment targets, tuples and lists taken apart."""
for t in targets:
if isinstance(t, (ast.Tuple, ast.List)):
yield from flat(t.elts)
else:
yield t
def patches(src):
"""[(line, name)]: every attribute of app.main a file assigns, or hands to
setattr / a patching helper as `(app.main alias, "name", value)`."""
tree = ast.parse(src)
al = main_aliases(tree)
out = []
for n in ast.walk(tree):
targets = []
if isinstance(n, ast.Assign):
targets = n.targets
elif isinstance(n, (ast.AugAssign, ast.AnnAssign)):
targets = [n.target]
for t in flat(targets):
# `app_main.NAME = x`, or `rig.m.NAME = x` where rig did
# `import app.main as m`
if isinstance(t, ast.Attribute) and (
(isinstance(t.value, ast.Name) and t.value.id in al)
or (isinstance(t.value, ast.Attribute)
and t.value.attr in ALIASES)):
out.append((n.lineno, t.attr))
if (isinstance(n, ast.Call) and len(n.args) >= 3
and isinstance(n.args[0], ast.Name) and n.args[0].id in al
and isinstance(n.args[1], ast.Constant)
and isinstance(n.args[1].value, str)
and ast.unparse(n.func) != "getattr"):
out.append((n.lineno, n.args[1].value))
return out
def problems(src, where="<source>", readers=main_files.reading):
"""Messages for each patch in `src` that the rule above rejects."""
out = []
for line, name in patches(src):
who = readers(name)
if "main" not in who:
out.append(f"{where}:{line} patches main.{name}, which main.py "
f"does not read" + (f" (read by {who})" if who else ""))
elif who != ["main"]:
out.append(f"{where}:{line} patches main.{name}, which "
f"{[w for w in who if w != 'main']} also read: use "
f"main_files.live.{name}")
return out
# --------------------------------------------------------------------------- #
print("1. no test and no tool patches a main global the code reads elsewhere")
sites = bad = 0
for path in sorted(glob.glob(os.path.join(HERE, "test_*.py"))
+ glob.glob(os.path.join(HERE, "tools", "*.py"))):
if os.path.basename(path) == "test_split_patches.py":
continue
with open(path, encoding="utf-8") as fh:
src = fh.read()
found = problems(src, os.path.relpath(path, HERE))
sites += len(patches(src))
for msg in found:
print(" " + msg)
bad += len(found)
check(f"{sites} patches of an app.main attribute found, {bad} stale or partial",
sites > 0 and bad == 0)
# --------------------------------------------------------------------------- #
print("\n2. the scan is not blind: spoilt sources are caught")
HEAD = "import app.main as m\n"
check("main.<a name nothing reads> is stale",
len(problems(HEAD + "m.no_such_global_anywhere = 1\n")) == 1)
check("main.datetime is partial (the clock is read in more than one file)"
if len(main_files.reading("datetime")) > 1 else
"main.datetime (one reader here, so nothing to catch yet)",
len(problems(HEAD + "m.datetime = 1\n")) ==
(1 if len(main_files.reading("datetime")) > 1 else 0))
check("the same through setattr, a helper and an alias",
len(problems(HEAD + "k = m\nsetattr(k, 'no_such_global_anywhere', 1)\n"
"patched(m, 'no_such_global_anywhere', 1)(f)\n")) == 2)
check("a patch through main_files.live is not flagged",
problems(HEAD + "import main_files\n"
"main_files.live.datetime = 1\n") == [])
check("a name only main reads may still be patched on main",
problems(HEAD + "m.load_options = 1\n") == [])
# --------------------------------------------------------------------------- #
print("\n3. main_files.live sets a global in every file that reads it")
import importlib # noqa: E402
live = main_files.live
for name in ("datetime", "compute", "_mono"):
readers = main_files.reading(name)
mods = [importlib.import_module("app." + r) for r in readers]
before = [getattr(m, name) for m in mods]
sentinel = object()
setattr(live, name, sentinel)
try:
hit = all(getattr(m, name) is sentinel for m in mods)
read_back = getattr(live, name) is sentinel
finally:
for m, old in zip(mods, before):
setattr(m, name, old)
check(f"{name}: set in all {len(mods)} reading file(s) {readers}, and "
f"read back", bool(mods) and hit and read_back)
try:
live.no_such_global_anywhere = 1
raised = False
except AttributeError:
raised = True
check("a name no file reads raises instead of patching nothing", raised)
print()
if fails:
print(f"FAILED: {len(fails)}")
sys.exit(1)
print("all ok")
+3 -1
View File
@@ -81,6 +81,8 @@ from app.static_tariff import (
from app.validate import validate_startup_config
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
UTC = timezone.utc
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
@@ -893,7 +895,7 @@ check("the new module holds no option-defaults dict of its own, so this ticket "
check("...and it reads the option through price.price_option(), the framework's "
"single read path, rather than through opts.get(key, <a number>)",
"price_option(opts" in _SRC and 'opts.get("static_profile"' not in _SRC)
_MAIN = (HERE / "app" / "main.py").read_text(encoding="utf-8")
_MAIN = main_files.source()
check("...and main.py grew no `o.get(\"static_profile\", <a default>)` fallback "
"either - it calls static_status(o, now), and the option is read once, "
"inside the framework",
+4 -3
View File
@@ -40,6 +40,7 @@ logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import homeview, modetrade, packmode # noqa: E402
from app import price as _price, static_tariff as _st # noqa: E402
@@ -196,7 +197,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, real_hv = m.datetime, homeview.datetime
real_dt, real_hv = main_files.live.datetime, homeview.datetime
clock = [start]
class _DT(real_dt):
@@ -204,7 +205,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
main_files.live.datetime = homeview.datetime = _DT
_real_load = _price.load_prices, _st.load_prices
_price.load_prices = _st.load_prices = _no_fetch
started = task_started(o)
@@ -246,7 +247,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime = real_dt, real_hv
main_files.live.datetime, homeview.datetime = real_dt, real_hv
_price.load_prices, _st.load_prices = _real_load
return c, started
+4 -3
View File
@@ -47,6 +47,7 @@ logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import arbitrage, homeview, modetrade, solarfc # noqa: E402
from app.main import Controller # noqa: E402
@@ -175,7 +176,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
# forecast IS lost, so it runs with a hold that never covers
# (test_opt27.py tests the cover).
c.fc_hold = _NoHold()
real_dt, real_hv = m.datetime, homeview.datetime
real_dt, real_hv = main_files.live.datetime, homeview.datetime
clock = [t0]
class _DT(real_dt):
@@ -183,7 +184,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
main_files.live.datetime = homeview.datetime = _DT
pts = None if points is None else points
task = None
if pts is None:
@@ -248,7 +249,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime = real_dt, real_hv
main_files.live.datetime, homeview.datetime = real_dt, real_hv
return c
return asyncio.run(go()), rows
+3 -1
View File
@@ -36,6 +36,7 @@ logging.disable(logging.WARNING)
import ux54_scenarios as S # noqa: E402
import app.main as M # noqa: E402
import main_files # noqa: E402
from app import arbitrage, dayplan, dayview, homeview, solarfc # noqa: E402
from app import tariff # noqa: E402
from app.daycurve import SolarToday # noqa: E402
@@ -90,7 +91,8 @@ class FakeDT(datetime):
return NOW[0].astimezone(tz) if tz else NOW[0].replace(tzinfo=None)
for _mod in (M, homeview, dayview): # what reads the wall clock
main_files.live.datetime = FakeDT # the Controller's clock
for _mod in (homeview, dayview): # the other readers of the clock
_mod.datetime = FakeDT
TZ_MODULES = (tariff, dayplan, homeview, solarfc)
+5
View File
@@ -1028,6 +1028,11 @@ check("...and the drain SET is not clamped either, so the twin key the card "
_srcs = {name: open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
"app", name), encoding="utf-8").read()
for name in ("main.py", "validate.py", "maintenance.py")}
# MAINT-21: main.py stands for itself and the files split from it.
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
"tools"))
import main_files # noqa: E402
_srcs["main.py"] = main_files.source()
_literals = {name: re.findall(
r'"(maintenance_[a-z_]+)"\s*,\s*(-?\d+(?:\.\d+)?)\s*[,)]', src)
for name, src in _srcs.items()}
+6 -3
View File
@@ -1161,9 +1161,12 @@ check("...and it does not raise the same card on every cycle - a per-PROCESS "
# SAFETY-34: a Core-ONLY restart. The add-on process lives on (measured on
# TEST-23), HA's memory is wiped, so the SAME process must re-assert the card.
import os # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
import app.main as _main_mod # noqa: E402
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
_main_mod._mono = lambda: _clock[0]
_real_mono, _clock = main_files.live._mono, [main_files.live._mono()]
main_files.live._mono = lambda: _clock[0]
try:
_h3 = _CircHass(a_w=0.0, b_w=0.0)
_b.hass = _h3
@@ -1173,7 +1176,7 @@ try:
asyncio.run(_b.check_circulating())
_late = [c for c in _h3.calls if c[0] == "persistent_notification"]
finally:
_main_mod._mono = _real_mono
main_files.live._mono = _real_mono
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise - no "
"storm on the event bus", len(_early) == 0)
check("SAFETY-34: a Core-only restart wipes the card and the add-on process "
+98
View File
@@ -0,0 +1,98 @@
"""MAINT-21: the Controller lives in app/main.py and the files split from it.
A test that reads `app/main.py` for a call, a comment or a def reads
`source()` instead; a test that replaces a global that `main` used to read
calls `patch_global()`, which sets it in each file whose code reads it. A name
no file reads is an error: a stale patch cannot pass unnoticed.
"""
import ast
import glob
import importlib
import os
import symtable
APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "app")
def files():
"""main.py, rows.py and ctl_*.py, in that order, as bare file names."""
split = ["rows.py"] + sorted(os.path.basename(p)
for p in glob.glob(os.path.join(APP, "ctl_*.py")))
return ["main.py"] + [f for f in split
if os.path.exists(os.path.join(APP, f))]
def text(name):
with open(os.path.join(APP, name), encoding="utf-8") as fh:
return fh.read()
def source():
"""All the files' text, one after the other."""
return "\n".join(text(f) for f in files())
def tree(src=None):
"""`ast.parse(source())` with every `*Mixin` class folded into
`Controller`, so `Controller` has all its methods. Positions refer to
`source()`."""
src = source() if src is None else src
mod = ast.parse(src)
ctl = next(n for n in mod.body
if isinstance(n, ast.ClassDef) and n.name == "Controller")
keep = []
for n in mod.body:
if isinstance(n, ast.ClassDef) and n.name.endswith("Mixin"):
ctl.body.extend(n.body)
else:
keep.append(n)
mod.body = keep
return mod
def _function_reads(table, acc):
for s in table.get_symbols():
if (table.get_type() == "function" and s.is_global()
and s.is_referenced()):
acc.add(s.get_name())
for child in table.get_children():
_function_reads(child, acc)
def reading(name):
"""The bare module names (`main`, `ctl_cycle` ...) whose functions read
`name` as a global."""
out = []
for f in files():
acc = set()
_function_reads(symtable.symtable(text(f), f, "exec"), acc)
if name in acc:
out.append(f[:-3])
return out
class _Live:
"""The globals the Controller's code reads, wherever they are read.
`live.NAME = value` sets NAME in every file whose functions read it;
`live.NAME` reads it. A name no file reads is an AttributeError, so a
patch that changes nothing cannot pass unnoticed.
"""
def __getattr__(self, name):
mods = reading(name)
if not mods:
raise AttributeError(f"no file under app/ reads the global "
f"{name!r}")
return getattr(importlib.import_module("app." + mods[0]), name)
def __setattr__(self, name, value):
mods = reading(name)
if not mods:
raise AttributeError(f"no file under app/ reads the global "
f"{name!r}: a patch of it changes nothing")
for stem in mods:
setattr(importlib.import_module("app." + stem), name, value)
live = _Live()
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""MAINT-21: prove that splitting app/main.py moved code and changed none.
python3 goodwe_controller/tools/maint21_same_ast.py [BASE_REF] [--docstrings]
Every module-level def, class and assignment of app/main.py at BASE_REF
(default release/1.0), and every method of its `Controller`, is looked up by
qualified name in the working tree: app/main.py, app/rows.py and the mixin
classes of app/ctl_*.py (a mixin's methods count as `Controller.<name>`).
Each must be found exactly once with an identical `ast.dump()`, docstrings
removed unless --docstrings. Allowed extras: the `Controller` class header,
`_LOG = logging.getLogger("goodwe")` in a module that logs, and import lines.
Prints one line per file and `OK` or the differences. The diff against
BASE_REF may touch only goodwe_controller/app, DECISIONS, test_*.py and
tools. Run from the repository root on a clean checkout (untracked files are
read, but the diff check only sees tracked ones). Exit 1 on any difference.
"""
import ast
import collections
import glob
import os
import re
import subprocess
import sys
APP = "goodwe_controller/app"
MIXIN = re.compile(r"^[A-Z][A-Za-z]*Mixin$")
def git(*a):
return subprocess.run(["git", *a], check=True, capture_output=True,
text=True).stdout
def bare(n, docstrings):
if not docstrings:
for x in ast.walk(n):
if isinstance(x, (ast.FunctionDef, ast.AsyncFunctionDef,
ast.ClassDef)) and x.body:
e = x.body[0]
if (isinstance(e, ast.Expr) and isinstance(e.value, ast.Constant)
and isinstance(e.value.value, str)):
x.body = x.body[1:] or [ast.Pass()]
return ast.dump(n)
def key_of(n):
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
return n.name
if isinstance(n, ast.Assign):
return "=" + ",".join(ast.unparse(t) for t in n.targets)
if isinstance(n, ast.AnnAssign):
return "=" + ast.unparse(n.target)
return "?" + ast.unparse(n)[:60]
def units(src, docstrings, mixins):
"""{qualified name: [dump, ...]} for one file's text."""
out = collections.defaultdict(list)
tree = ast.parse(src)
for n in tree.body:
if isinstance(n, (ast.Import, ast.ImportFrom)):
continue
if (isinstance(n, ast.Expr) and isinstance(n.value, ast.Constant)
and isinstance(n.value.value, str)):
continue
if isinstance(n, ast.ClassDef) and (
n.name == "Controller" or (mixins and MIXIN.match(n.name))):
for m in n.body:
out["Controller." + key_of(m)].append(bare(m, docstrings))
continue
out[key_of(n)].append(bare(n, docstrings))
return out
def main(argv):
docstrings = "--docstrings" in argv
argv = [a for a in argv if not a.startswith("--")]
base = argv[0] if argv else "release/1.0"
bad = 0
print(f"base {base} = {git('rev-parse', base).strip()}")
for f in git("diff", "--name-only", base).split():
if not re.match(r"^goodwe_controller/(app/[^/]+|DECISIONS/[^/]+|"
r"test_[^/]+\.py|tools/[^/]+)$", f):
print(f"FAIL: diff touches {f}")
bad += 1
old = units(git("show", f"{base}:{APP}/main.py"), docstrings, False)
files = ["main.py", "rows.py"] + sorted(
os.path.basename(p) for p in glob.glob(f"{APP}/ctl_*.py"))
where = collections.defaultdict(list) # key -> [(file, dump)]
for fn in files:
p = f"{APP}/{fn}"
if not os.path.exists(p):
continue
for k, dumps in units(open(p, encoding="utf-8").read(), docstrings,
True).items():
for d in dumps:
where[k].append((fn, d))
found = collections.Counter()
problems = collections.defaultdict(list)
for k, dumps in old.items():
if k == "Controller":
continue
got = where.get(k, [])
if k == "=_LOG": # one per module that logs, all the same
got = got[:1] * len(dumps) if all(
d == dumps[0] for _, d in got) else []
if len(got) != len(dumps):
problems["?"].append(f"{k}: {len(dumps)} in base, "
f"{len(got)} in head")
continue
for (fn, d), want in zip(got, dumps):
if d != want:
problems[fn].append(f"{k}: AST differs")
else:
found[fn] += 1
for k, got in where.items():
if k not in old and k != "=_LOG":
for fn, _ in got:
problems[fn].append(f"{k}: not in base")
for fn in files:
if not os.path.exists(f"{APP}/{fn}"):
continue
if problems.get(fn):
print(f"DIFFERS {fn}")
for line in problems[fn]:
print(" ", line)
bad += len(problems[fn])
else:
print(f"OK {fn} {found[fn]} units identical")
for line in problems.get("?", []):
print("DIFFERS ", line)
bad += 1
total = sum(found.values())
print(f"base units {sum(len(v) for k, v in old.items() if k != 'Controller')}"
f", identical in head {total}")
print("OK: code moved, none changed" if not bad else f"FAIL: {bad} problem(s)")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+4 -3
View File
@@ -26,6 +26,7 @@ from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import main_files # noqa: E402
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
@@ -157,7 +158,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt = m.datetime
real_dt = main_files.live.datetime
clock = [0.0]
class _DT(real_dt):
@@ -165,7 +166,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
main_files.live.datetime = _DT
rows = []
try:
t = 0.0
@@ -186,7 +187,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
on_cycle(c, t)
t += TICK
finally:
m.datetime = real_dt
main_files.live.datetime = real_dt
return rows, c
return asyncio.run(go())
+4 -3
View File
@@ -41,6 +41,7 @@ from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, HERE)
import main_files # noqa: E402
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
@@ -348,7 +349,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
if patch is not None:
patch(c)
c.auto = True
real_dt = m.datetime
real_dt = main_files.live.datetime
clock = [start]
class _DT(real_dt):
@@ -356,7 +357,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = _DT
main_files.live.datetime = _DT
plan_hour = None
try:
t = 0.0
@@ -416,7 +417,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
check(c, hass, now, pr)
t += tick
finally:
m.datetime = real_dt
main_files.live.datetime = real_dt
return c
c = asyncio.run(go())
return stats, c
+4 -3
View File
@@ -22,6 +22,7 @@ sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
import opt11_identity as rig # noqa: E402
import main_files # noqa: E402
from app import main as app_main, packmode # noqa: E402
from app.main import Controller # noqa: E402
from app.store import unit_key # noqa: E402
@@ -127,13 +128,13 @@ def run(n, house, *, before=off, dur=T + 90.0, tick=1.0, reg_s=10.0,
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, clock = app_main.datetime, [0.0]
real_dt, clock = main_files.live.datetime, [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return rig.BASE + timedelta(seconds=clock[0])
app_main.datetime = _DT
main_files.live.datetime = _DT
rows = []
try:
t = 0.0
@@ -161,7 +162,7 @@ def run(n, house, *, before=off, dur=T + 90.0, tick=1.0, reg_s=10.0,
reason=c.loop_reason))
t += tick
finally:
app_main.datetime = real_dt
main_files.live.datetime = real_dt
return rows, c
return asyncio.run(go())
+4 -3
View File
@@ -33,6 +33,7 @@ from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import main_files # noqa: E402
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
@@ -222,7 +223,7 @@ class Rig:
i.dev_min, i.dev_max = -MAXW[i.name[-1]], MAXW[i.name[-1]]
c.auto = True
self.c = c
real_dt = m.datetime
real_dt = main_files.live.datetime
clock = [self.start]
class _DT(real_dt):
@@ -230,7 +231,7 @@ class Rig:
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = _DT
main_files.live.datetime = _DT
floor = float(self.o.get("cycle_floor_s", 15))
last_grid, last_cycle, last_n = object(), None, None
try:
@@ -260,7 +261,7 @@ class Rig:
sample(self, c, t)
t += 1.0
finally:
m.datetime = real_dt
main_files.live.datetime = real_dt
return c
return asyncio.run(go())
+4 -3
View File
@@ -33,6 +33,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
import opt11p3_identity as p3 # noqa: E402
import safety79_identity as s79 # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import packmode # noqa: E402
from app.store import unit_key # noqa: E402
@@ -76,7 +77,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None):
if modes:
extra.update(p3.ARB, battery_modes_enabled=True)
seen, extra_rows = [], []
real_resolve = m.resolve
real_resolve = main_files.live.resolve
def spy(claims, **kw):
seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi,
@@ -96,7 +97,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None):
db = os.path.join(tempfile.mkdtemp(prefix="s78-"), "tariff.db")
real = rig.Controller
rig.Controller = functools.partial(real, peak_db_path=db)
m.resolve = spy
main_files.live.resolve = spy
try:
rows, c = rig.run(n, extra=extra, dur=dur, load=load, soc=s79.soc,
cut=lambda u, t: False,
@@ -105,7 +106,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None):
store=store_cheap(n) if modes else None)
finally:
rig.Controller = real
m.resolve = real_resolve
main_files.live.resolve = real_resolve
return [r + (x,) for r, x in zip(rows, extra_rows)], c
+4 -3
View File
@@ -29,6 +29,7 @@ from datetime import timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import packmode # noqa: E402
from app.main import Controller # noqa: E402
@@ -140,13 +141,13 @@ def run(load, *, margin=50, dur=2700.0, rating=2000.0, cap=3500.0,
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, clock = m.datetime, [0.0]
real_dt, clock = main_files.live.datetime, [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return rig.BASE + timedelta(seconds=clock[0])
m.datetime = _DT
main_files.live.datetime = _DT
rows, late = [], random.Random(8100)
errors = []
@@ -173,7 +174,7 @@ def run(load, *, margin=50, dur=2700.0, rating=2000.0, cap=3500.0,
rows.append((t, c.target, round(plant.hist[-1][1], 6)))
t += TICK
finally:
m.datetime = real_dt
main_files.live.datetime = real_dt
logging.getLogger("goodwe").removeHandler(h)
return plant, c, rows, errors
plant, c, rows, errors = asyncio.run(go())
+4 -3
View File
@@ -23,6 +23,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import tariff # noqa: E402
@@ -71,7 +72,7 @@ def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None,
"""Rows (opt11_identity's fields plus every claim, the guard and the
phases) and the controller. `month_peak` banks that peak this month."""
seen, extra_rows = [], []
real_resolve = m.resolve
real_resolve = main_files.live.resolve
def spy(claims, **kw):
seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi,
@@ -93,7 +94,7 @@ def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None,
"2026-09-02T10:00:00+00:00")).connection.commit()
real = rig.Controller
rig.Controller = functools.partial(real, peak_db_path=db)
m.resolve = spy
main_files.live.resolve = spy
try:
rows, c = rig.run(n, extra=dict(s79.P1, **OPT, **(extra or {})),
dur=dur, load=load, soc=soc or s79.soc,
@@ -102,7 +103,7 @@ def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None,
store=store or s79.store_charging(n))
finally:
rig.Controller = real
m.resolve = real_resolve
main_files.live.resolve = real_resolve
return [r + (x,) for r, x in zip(rows, extra_rows)], c
+4 -3
View File
@@ -25,6 +25,7 @@ from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import main_files # noqa: E402
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
@@ -169,7 +170,7 @@ def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None,
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt = m.datetime
real_dt = main_files.live.datetime
clock = [0.0]
class _DT(real_dt):
@@ -177,7 +178,7 @@ def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None,
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
main_files.live.datetime = _DT
out = {"stale": [], "refused": [], "rows": [], "c": c}
was = {i.name: False for i in c.inverters}
was_ref = {i.name: 0 for i in c.inverters}
@@ -213,7 +214,7 @@ def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None,
dict(plant.reg), [mm.phase for mm in c.maints]))
t += 1.0
finally:
m.datetime = real_dt
main_files.live.datetime = real_dt
return out
return asyncio.run(go())