Compare commits

...
55 Commits
Author SHA1 Message Date
glenn schrooyenandClaude c1809b4ca9 UX-46: test_ux46 points at MUTATION.md and reads rows without raising
checks / unit (pull_request) Successful in 44m51s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-22 03:41:18 +02:00
glenn schrooyenandClaude 7a392a5367 UX-46: Dutch status rows, banners, notes and wizard lines on the NL pages
Rows and banners are built once with their Dutch beside them (messages.Bi)
and status_payload() picks one by the page's language. Settings notes and the
wizard's checks carry a Dutch twin (Note.nl) that /config/validate picks by
the posted language. The peak forecast label drops "(W)" in EN and NL.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-22 03:21:41 +02:00
admin e077ae7f97 Merge pull request 'SAFETY-121: a recurring overload warns every quarter' (#204) from SAFETY-121 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-21 12:41:12 +02:00
glenn schrooyenandClaude Opus 5.5 24f31a9c3a SAFETY-121 review: DECISIONS limits state the jitter spread and the slow-cycle case
checks / unit (pull_request) Successful in 44m29s
infra-pin / fresh (pull_request) Successful in 1s
The jittery-meter numbers depend on the draw (review: 4, 4, 5 of 11 at the
quarter edge; up to 4 quiet quarters at 20 s; first warning up to 61 min
later). Above about 40 s per cycle, a short bound can still use up the
budget when the wire changes hands every few cycles. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:55:24 +02:00
glenn schrooyenandClaude 8bf25696cb SAFETY-121: a recurring overload warns every quarter
checks / unit (pull_request) Successful in 44m51s
infra-pin / fresh (pull_request) Successful in 1s
TEST-23 F-1: on a 5 s meter a steady overload warned once in 70 min. The
loop was at its bound 4-5 cycles a quarter, all inside SAFETY-119's settle
of 12 cycles (60 s) and under its budget of 8 per 900 s. The simulation
fed the meter a cycle behind the battery reading, so the guard and the
charge chased each other and gave ~20 bound cycles a quarter.

The settle is now 4 cycles and 22 s (HANDBACK_CYCLES, SHORT_SPAN_S), and
the rolling budget one number, 11 per 900 s, at every cycle length. The
cycle length is no longer tracked. Commands unchanged; the re-pinned
digests of test_safety84/87 differ from bee7431 in OUTPUT_CLAMP rows only.
test_safety119 runs the rig's shape (meter in the same cycle) at 1-15 s
and checks a stop keeps the budget (review F2, X3).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 10:09:14 +02:00
admin bee7431793 Merge pull request 'SAFETY-119: no "not keeping up" when the loop takes the wire back' (#203) from SAFETY-119 into release/1.0
infra-pin / fresh (push) Successful in 1s
Merge pull request 'SAFETY-119: no "not keeping up" when the loop takes the wire back' (#203) from SAFETY-119 into release/1.0
2026-10-21 03:32:38 +02:00
glenn schrooyenandClaude Opus 5.5 96375ecbec SAFETY-119 review: DECISIONS states the steady-meter limit
checks / unit (pull_request) Successful in 43m33s
infra-pin / fresh (pull_request) Successful in 2s
The quarter-edge numbers were measured with one cycle per reading at a
steady interval. With the default 15 s cycle floor, a meter that is
sometimes slower adds cycles on an old reading and the false alarm can
come back (reviewer's runs, 11 seeds). Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:47:27 +02:00
glenn schrooyenandClaude 6fd0fa05f1 SAFETY-119 round 3c: the hidden-bound budget follows the cycle length
checks / unit (pull_request) Successful in 43m29s
infra-pin / fresh (pull_request) Successful in 1s
No single rolling budget met both cases in the sweep (8 false-alarmed on
2 s seed 25 and warned in 1 to 3 of 7 quarters at 15 s). The budget is
now 10 cycles up to a 3.5 s cycle, 8 up to 11 s, 6 up to 16 s, 4 beyond,
per rolling 15 minutes; 8 while the cycle length is unknown. A stop or a
gap over two minutes is not a cycle length. The unsourced "owner
accepted N=8" paragraph is removed from DECISIONS. test_safety119 part 4
now runs 2 s seed 25 and shows a fixed budget of 8 warns there; the
never case runs at 2 s and checks every other quarter at 15 s.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 00:57:43 +02:00
glenn schrooyenandClaude e27e62ff72 SAFETY-119 round 3b: no-clock budget, fixed-seed tests, accepted limits
checks / unit (pull_request) Successful in 43m1s
infra-pin / fresh (pull_request) Successful in 1s
The rolling budget now holds without a monotonic clock (it never refills).
Quarter-edge tests use seeds 1-3 at 2, 5, 10 and 15 s (no hand-picked seeds).
Re-pin comments point at part 6. DECISIONS lists the limits the owner
accepted with N=8 on 2026-10-20.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-20 20:46:14 +02:00
glenn schrooyenandClaude 86b94bedb5 SAFETY-119 round 3: DECISIONS table ranges over all runs
checks / unit (pull_request) Successful in 42m30s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-20 17:28:22 +02:00
glenn schrooyenandClaude 103db2fcda SAFETY-119 round 3: rolling 15-minute budget for the hidden integrator bound
checks / unit (pull_request) Successful in 42m33s
infra-pin / fresh (pull_request) Successful in 2s
At most 8 hidden bound cycles in any 900 s across all hand-backs, replacing
the per-run budget. Chosen from a sweep (N 3 to 10, 1 to 15 s meters, both
cases); DECISIONS has the table. Tests: never case at 5, 10, 15 s, quarter
edge at 2, 10, 15 s, the rolling budget at its boundaries.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-20 17:27:43 +02:00
glenn schrooyenandClaude 85542e443b SAFETY-119 round 2: test_safety119 reads through named_absence
checks / unit (pull_request) Successful in 42m22s
infra-pin / fresh (pull_request) Successful in 1s
test_named_absence flagged two unguarded reads (next() without a default,
a list indexed in a check name). Use first(), nth() and present().

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-20 01:38:24 +02:00
glenn schrooyenandClaude e76c70ecb0 SAFETY-119 round 2: one settle window per run of hand-backs
checks / unit (pull_request) Failing after 42m32s
infra-pin / fresh (pull_request) Successful in 2s
A hand-back used to restart the window, so a guard and a charge taking
turns hid the loop's integrator bound for good (0 warnings in 2 h; 28
before the fix). A run of hand-backs (the wire away less than a settle
between them) now hides the bound for at most 11 bound cycles and 22 s in
all, then it counts. The quarter-edge kick stays hidden. No command or text
changes. Tests for the two reviewer mutants that only unit checks caught
(a restart counted as a hand-back; span only), DECISIONS gives the delay at
2 s, 5 s and 60 s. test_safety87.py:543 shortened.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-20 00:52:24 +02:00
glenn schrooyenandClaude Opus 5.5 b9ff6acea4 SAFETY-119 review: DECISIONS, at least once a quarter
checks / unit (pull_request) Successful in 42m1s
infra-pin / fresh (pull_request) Successful in 1s
A bigger house warns more often (every 150 s at 7.4 kW), so 'about' was
wrong. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-19 20:29:00 +02:00
glenn schrooyenandClaude Opus 5.5 98026f9a46 SAFETY-119 review: DECISIONS states the worst delay and the no-warn case
checks / unit (pull_request) Successful in 42m3s
infra-pin / fresh (pull_request) Successful in 1s
The delay is 11 cycles, so up to 11 min at a 60 s cycle. A loop that loses
the wire again within 11 cycles is never counted; review measured one such
case (N=2, cheap-window charge, no forecast, house just over the rating).
The over-rating rule applies on cycles the loop decided. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-19 20:17:08 +02:00
glenn schrooyenandClaude b0c2e5977d SAFETY-119: re-pin test_safety84/87 digests (log only); prove it
checks / unit (pull_request) Successful in 41m47s
infra-pin / fresh (pull_request) Successful in 1s
The re-pinned digests differ only in OUTPUT_CLAMP log rows; every command
and claim is the same as without the fix (test_safety119.py part 5).
DECISIONS: the delay at a 5 s meter and the open swap case.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 15:30:18 +02:00
glenn schrooyenandClaude c4e15ec4cd SAFETY-119: no "not keeping up" when the loop takes the wire back
When the peak guard or a maintenance charge hands the wire back, the loop's
integrator is re-seeded from that owner's command and kicked by a meter that
does not show it yet. It crossed its rail for a cycle or two and raised
goodwe_integrator_bound while the battery gave what it was told.

The loop's integrator bound is not counted until the plant's settle
(SHORT_CYCLES, SHORT_SPAN_S) has passed since the hand-back. An over-rating
always counts; a bound that lasts warns after the settle. No command
changes and no text changes. test_safety119.py; DECISIONS entry.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 14:14:08 +02:00
admin 75f0804f9d Merge pull request 'UX-45: Home price chart shows the 15-minute prices the add-on uses' (#202) from UX-45 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-19 12:15:56 +02:00
glenn schrooyenandClaude eb8375157d UX-45: count priced hours in the wizard, fill chart holes by the add-on's cover rule, mark only for price-mode batteries
checks / unit (pull_request) Successful in 40m20s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 10:01:58 +02:00
glenn schrooyenandClaude 17c6048417 UX-45: Home price chart draws one bar per price step the add-on uses
checks / unit (pull_request) Successful in 40m15s
infra-pin / fresh (pull_request) Successful in 1s
With 15-minute prices the chart now has one bar per quarter (92/96/100),
not hour averages; hourly and static tariffs still draw one bar per hour.
Cheap and dear marks come from the kept priceruns result (battery modes
on) or the arbitrage verdict's two points (off), not a thirds split.
A missing quarter is an empty bar. Removes plan.average_tip (EN/NL).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 00:35:29 +02:00
admin 243a756e7b Merge pull request 'DOC-8: say EMS port, not meter bus' (#201) from DOC-8 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-17 04:09:33 +02:00
glenn schrooyenandClaude Opus 5.5 a4a7ff695e DOC-8 review: say the field guide PDF is rebuilt, still v0.1.0
checks / unit (pull_request) Successful in 40m29s
infra-pin / fresh (pull_request) Successful in 1s
README and FIELD-GUIDE.md said the PDF was not rebuilt; it was, on
2026-10-17. Its content is still v0.1.0 and outdated. Rewrap the
DOCS.md intro line. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:27:35 +02:00
glenn schrooyenandClaude b8b357b552 DOC-8: update test_mqtt digest pins for the new manufacturer string
infra-pin / fresh (pull_request) Successful in 2s
checks / unit (pull_request) Successful in 40m23s
Both digests hash the discovery payloads, which carry the manufacturer.
Values regenerated; nothing else changes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 02:16:34 +02:00
glenn schrooyenandClaude 4584502adb DOC-8: HA manufacturer says EMS port; rebuild field guide PDF
checks / unit (pull_request) Failing after 41m1s
infra-pin / fresh (pull_request) Successful in 1s
Device id (identifiers) is unchanged; only the display text moves.
test_mqtt baseline updated for that one string.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 01:53:33 +02:00
glenn schrooyenandClaude fd08090360 DOC-8: say EMS port, not meter bus
checks / unit (pull_request) Successful in 40m22s
infra-pin / fresh (pull_request) Successful in 1s
The add-on is wired to the inverter's RS485 port labelled EMS. Reword
README, DOCS, config description, field guide source and HTML. The HA
manufacturer string is left unchanged: test_mqtt pins it byte for byte.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 01:09:42 +02:00
admin c67d0cdb67 Merge pull request 'Release 0.18.2' (#199) from release-0.18.2 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 40m25s
infra-pin / fresh (pull_request) Successful in 1s
2026-10-16 22:31:09 +02:00
glenn schrooyenandClaude Opus 5.5 ea77c63552 CHANGELOG 0.18.2: say how a no-unit forecast shows; who needs a look
checks / unit (pull_request) Successful in 40m19s
infra-pin / fresh (pull_request) Successful in 1s
Review of PR #199. "Warns once" left out the Home row and the Settings
note, which stay while the fault lasts. "No option change" now points to
the one case that needs a look.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:48:05 +02:00
glenn schrooyenandClaude 75f4359aac Release 0.18.2
checks / unit (pull_request) Successful in 40m23s
infra-pin / fresh (pull_request) Successful in 2s
Version bump to 0.18.2 and a CHANGELOG section for SAFETY-118 (#198).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 20:54:29 +02:00
admin 09a54859f2 Merge pull request 'SAFETY-118: read the peak forecast in its own unit' (#198) from SAFETY-118 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-16 19:53:54 +02:00
glenn schrooyenandClaude 7d52dc1c0f SAFETY-118: a missing peak forecast reading keeps the unit-fault state
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 40m19s
A missing reading (unavailable, unknown, failed read, non-numeric) no longer
clears the fault: no false 'used again' INFO, no repeated WARNING, the status
row stays. Adds pv.has_reading(). DECISIONS wording fixed; tests added.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 18:19:44 +02:00
glenn schrooyenandClaude f0b4e68e49 SAFETY-118: read the peak forecast in its own unit
checks / unit (pull_request) Successful in 40m20s
infra-pin / fresh (pull_request) Successful in 1s
peak_forecast_entity is read through pv.watts() by its unit_of_measurement;
the abs(raw) < 50 size rule is gone. No unit or a non-power unit means the
forecast is not used, with one warning per cause. The validator no longer
claims a kW sensor is taken as watts for this entity.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 16:00:34 +02:00
admin 780c1f67bb Merge pull request 'Release 0.18.1' (#196) from release-0.18.1 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 40m21s
infra-pin / fresh (pull_request) Successful in 1s
Release 0.18.1
2026-10-16 13:46:24 +02:00
glenn schrooyenandClaude Opus 5.5 0ee3c3468b Release 0.18.1 review: CHANGELOG text fixes
checks / unit (pull_request) Successful in 40m18s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-118 line: the add-on already turns a kW forecast into W
(main.py read_inputs, abs(raw) < 50), so only the settings-page warning is
wrong. Rounding note and rows: the new settings page; UX-44 also changed
one DOCS line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:03:37 +02:00
glenn schrooyenandClaude dec8e7b69f Release 0.18.1
checks / unit (pull_request) Successful in 40m39s
infra-pin / fresh (pull_request) Successful in 2s
Version 0.18.0 -> 0.18.1 (patch, no option changes) and a closed 0.18.1 CHANGELOG section.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 12:08:58 +02:00
admin 8e02a0928b Merge pull request 'UX-44: contract label says every 15 minutes, not hourly' (#195) from UX-44 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-16 06:59:19 +02:00
glenn schrooyenandClaude 1bd2ba1397 UX-44: contract label says every 15 minutes, not hourly
checks / unit (pull_request) Successful in 40m37s
infra-pin / fresh (pull_request) Successful in 2s
The settings page contract choice read "Dynamic, hourly" / "Dynamisch,
per uur". Belgian day-ahead prices are per 15 minutes since 2025-10-01.
Now "Dynamic, every 15 minutes" / "Dynamisch, per kwartier". One DOCS
line that called dynamic pricing "hourly pricing" now says dynamic.
test_ui_text.py part 7 fails if either label says hourly again.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 05:26:32 +02:00
admin b703aaa6d5 UX-43: tariff prices take five decimals (#194)
infra-pin / fresh (push) Successful in 2s
2026-10-16 04:30:09 +02:00
UX-43 review 92a3d3400a UX-43 review: plain wording for the DOCS line on five-decimal prices
checks / unit (pull_request) Successful in 40m23s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:47:28 +02:00
glenn schrooyenandClaude 8c954acb36 UX-43: keep each field description to one sentence (STYLE)
checks / unit (pull_request) Successful in 40m31s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-16 02:29:31 +02:00
glenn schrooyenandClaude 8f72ab00d7 UX-43: tariff price fields take five decimals
ui.fixed_price, ui.day_price, ui.night_price and ui.periods step 0.00001
EUR/kWh (the period editor's price input took a hard-coded 0.01). A short
c-EUR/kWh hint is added to the four field descriptions, EN and NL.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-16 01:40:40 +02:00
admin a0e091cca3 Release 0.18.0 (#192)
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 40m22s
infra-pin / fresh (pull_request) Successful in 1s
2026-10-15 23:17:10 +02:00
glenn schrooyenandClaude Opus 5.5 44cd1fde2a Release 0.18.0: review fixes to the CHANGELOG section
checks / unit (pull_request) Successful in 40m17s
infra-pin / fresh (pull_request) Successful in 2s
OPT-15 and OPT-20 rows: the retry is two inverters only, and OPT-20 no
longer claims no other battery moves. SAFETY-111 added to known issues.
MAINT-19 marked as from code reading. Wording fixes in the SAFETY-112 and
MAINT-11 rows, UX-38 and the price service note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:35:47 +02:00
Claude e7c83d3a57 Release 0.18.0: version bump and CHANGELOG section
checks / unit (pull_request) Successful in 40m26s
infra-pin / fresh (pull_request) Successful in 1s
config.yaml version 0.17.0 -> 0.18.0 and a closed ## 0.18.0 CHANGELOG
section for OPT-17, SAFETY-112, OPT-15, MAINT-11, PRICE-01 and OPT-20.
No code change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 21:37:17 +02:00
adminandClaude Opus 5.5 004f859ec7 Merge PR #191: OPT-20 a refusal probe asks only PROBE_W, not the whole trade
infra-pin / fresh (push) Successful in 1s
Reviewed at ccdb9dc: full suite 78/78, CI run 1289 green, identity equal to bf9019e. Review: PR #191 comment 2185.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:56:17 +02:00
glenn schrooyenandClaude ccdb9dc17a OPT-20: test_opt20 points at MUTATION.md (test_bytecode part 5)
checks / unit (pull_request) Successful in 40m25s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 13:12:22 +02:00
glenn schrooyenandClaude 06339a2446 OPT-20: a refusal probe asks only PROBE_W, not the whole trade
checks / unit (pull_request) Failing after 40m47s
infra-pin / fresh (pull_request) Successful in 1s
The probe's trade was sized at the probing battery's rating. Its SAFETY-73
pin held 200 W of that, and the allocator put the rest on a battery with no
mode range (one in a maintenance phase): TEST-23 OPT-15 g7, -2300 W on A in
its maintenance charge for 34 s, grid 3100 W.

modetrade.decide() now asks PROBE_W for the probing battery plus what the
other chargers would have had without the probe; size() adds the probe on top
of their share, or files PROBE_W exactly, or nothing. _mode_trade() carries
the pin only on the ticks it is filed, so the window's first and last cycle
do not move another battery's share either. Modes off and modes on with no
probe are unchanged (bf9019e digests). Tests: test_opt20.py.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 13:08:59 +02:00
admin bf9019e70d Merge PR #190: PRICE-01 price_service provider, fallback chain and gate
infra-pin / fresh (push) Successful in 1s
2026-10-14 22:47:43 +02:00
adminandClaude Opus 5.5 6862dea52a PRICE-01 review 3: tests for the round 3 note and dismiss; doc wording
checks / unit (pull_request) Successful in 39m4s
infra-pin / fresh (pull_request) Successful in 2s
- test_price_secret.py: the fixed-tariff note on the real /config/validate
  route, EN and NL, and none for a spaces-only profile (web.py lang wiring).
- test_price_service.py: the start line is INFO, not WARNING; a dismiss that
  raises does not stop the cycle.
- test_maint13/test_setup_only/test_watchdog: ignore only the dismiss of the
  old card, so a create with that id is still counted.
- DECISIONS rule 7 quotes the settings note as it is; messages.py comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 22:07:09 +02:00
glenn schrooyenandClaude c68fd275cc PRICE-01 round 3: replace the fixed-tariff notification with a log line and a settings note
checks / unit (pull_request) Successful in 39m6s
infra-pin / fresh (pull_request) Successful in 1s
G1: remove the false 'Fixed tariff not used' notification. One INFO line at
start and an info note beside the setting (EN/NL) when price_service has a
non-blank static_profile; the old notification id is dismissed at start.
DOCS, migration step 5 and DECISIONS rule 7 now say what the code does.
S05: whitespace-only static_profile counts as not set.
G2: maintenance_enabled read through MaintConfig.from_options().

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 20:27:15 +02:00
glenn schrooyenandClaude Opus 5.5 2f261f8ba4 PRICE-01 review: spaced-token check passes the length rule; F3 default pinned
checks / unit (pull_request) Successful in 39m51s
infra-pin / fresh (pull_request) Successful in 1s
The spaced-token check used a 5-character token, which the new 16-character
rule refuses on its own, so a validator that stopped checking spaces passed
(reviewer mutation R06). It now uses a long token and checks the reason.

Also pin F3 with no maintenance_enabled key: the default (off) holds and no
no-price words show (reviewer mutation S04). Tests only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 19:58:50 +02:00
glenn schrooyenandClaude 0801776645 PRICE-01 round 2: the token on no page, fixed-tariff note, token length, F3 words, test gaps
checks / unit (pull_request) Successful in 39m0s
infra-pin / fresh (pull_request) Successful in 2s
F1: every password option is shown as ******** (set) or empty in /config/model
(options, running, ui) and in a stale refusal; the classic page draws it as a
password field; a save or validate that sends ******** or nothing keeps the
stored value. F2: a static_profile beside price_service is said once at start
(notification and log), not refused. F3 (owner, 2026-10-14): the no-price row,
Home line and notification only while maintenance_enabled is on. F4: tests for
the reviewer's R01 and R10. F5: price_service_token must be 16+ characters.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 18:56:51 +02:00
glenn schrooyenandClaude 4eb88f7fda PRICE-01: migration note, and the tests drive the real control cycle
checks / unit (pull_request) Successful in 39m36s
infra-pin / fresh (pull_request) Successful in 1s
- DOCS: the migration note said markup and taxes do not matter; with the
  provider they are added to the wholesale price. Step 4 and 5 now say what
  they set and what static_profile does while no source has a price.
- The gap tests run the notification through cycle(), where the check is
  called, so a check that is never called goes red.
- test header: the real-transport checks are section 11.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 17:03:05 +02:00
glenn schrooyenandClaude 87ad958842 PRICE-01: Dutch dates, no redirects with the token, more checks
checks / unit (pull_request) Successful in 39m2s
infra-pin / fresh (pull_request) Successful in 1s
- The notification, status row and Home line name an earlier day in the
  language of the text ("13 okt om 21:00"); the Dutch title is "Prijzen
  ontbreken", so the body no longer repeats it.
- The token request does not follow redirects: the host is fixed in code.
- A step back up to the saved prices is one INFO line.
- Tests: whole status payload free of the token, a redirect, the 5-decimal
  edge of the old average, modes plan equal to generic_dayahead's.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 16:54:11 +02:00
glenn schrooyenandClaude 9ac3a9d2bc PRICE-01: price_service provider, fallback chain, and the gate read from it
checks / unit (pull_request) Successful in 39m31s
infra-pin / fresh (pull_request) Successful in 1s
A new price source, `price_provider: price_service`: the owner's price server,
one fixed HTTPS host, a bearer token (new password option
`price_service_token`), wholesale energy only so markup and taxes are added as
for generic_dayahead.

The chain is server, then energy-charts, then the prices the history store
kept. A step counts only if it has a price for now. Each step down and back up
is one log line. With every source down a site plans from what it saved.

No price for now: one status row, one Home plan line and one notification, in
English and Dutch, cleared when a price returns. With this provider the
maintenance charge gate reads the provider's prices and keeps the Home
Assistant rule: the current 15-minute price is at or below today's average.

The token is hidden on the settings page, refused when missing, and taken out
of any error text. Other providers, `off` and the Home Assistant pair take the
road they always took (scripted-day probe against 0ce7d35: one line differs,
the refusal that lists the known providers). See
DECISIONS/PRICE-01-own-price-server.md.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-14 16:22:24 +02:00
admin 0ce7d35f80 Merge pull request 'MAINT-11: start or stop the maintenance cycle per battery' (#189) from MAINT-11 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-14 01:18:42 +02:00
59 changed files with 7213 additions and 375 deletions
+3 -3
View File
@@ -4,9 +4,9 @@ The guide technicians carry is the PDF:
**`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided.
⚠️ **Outdated: it is v0.1.0.** Its `slew_w` row is corrected in `build_guide.py`
and `field-guide.html`, but the PDF has not been rebuilt (see below). Where the
guide disagrees with `goodwe_controller/DOCS.md`, DOCS.md wins.
⚠️ **Outdated: the content is still v0.1.0.** The PDF was rebuilt on 2026-10-17
for the EMS-port wording and the `slew_w` row only. Where the guide disagrees with
`goodwe_controller/DOCS.md`, DOCS.md wins.
It covers, in the order the work is done: pre-visit checks, hardware, firmware,
add-on install and configuration, how to read the Web UI, the six commissioning
+1 -1
View File
@@ -156,7 +156,7 @@ that looks hung gets deleted.
**A GoodWe "-20" / G2 inverter cannot be driven by this.**
Platform comes from characters 6–8 of the serial number. Only
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era meter bus — are
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era generation — are
supported. SBP G2 (`SPB`/`SPN`) and ES G2 (`ESN`/`ESC`) are platform 745, a
different protocol. The reference unit is `BPS`.
+6 -6
View File
@@ -1,8 +1,8 @@
# GoodWe RS485 Controller — Home Assistant add-on repository
Replaces a GoodWe battery inverter's vendor controller with a local one: holds
net grid exchange at zero by emulating the inverter's smart meter over RS485,
and runs the monthly battery maintenance cycle the vendor box was doing.
net grid exchange at zero by sending the inverter a grid reading over its RS485
EMS port, and runs the monthly battery maintenance cycle the vendor box was doing.
```
repository.yaml add-on repository metadata
@@ -30,7 +30,7 @@ goodwe_controller/ the add-on
returns, run against its own release image
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
estop/rs485_log.py optional RS485 e-stop / bus witness
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; outdated)
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; rebuilt 2026-10-17, content still v0.1.0 and outdated)
docs/build_guide.py its single source - rebuilds the HTML and PDF
FIELD-GUIDE.md how to edit and rebuild the guide
docs/RELEASE.md how a change reaches the battery - `master` is
@@ -118,17 +118,17 @@ the platform tag:
| tag | platform | verdict |
|---|---|---|
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era meter bus | **supported** |
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era | **supported** |
| `SPB SPN` | 745 — SBP **G2** (`GW…-SBP-20`) | no |
| `ESN ESC` | 745 — ES **G2** (`GW…-ES-20`) | no |
| `ETU EHU BTU …` | 205/745/753 — ET/EH/BT hybrids | no |
| `SDT DST MSU NSU …` | PV-only inverters — no battery to control | no |
A "-20" or "G2" suffix means a different platform with a different meter
A "-20" or "G2" suffix means a different platform with a different
protocol. The tags come from the `goodwe` library's `PLATFORM_105_MODELS`, which
is also what Home Assistant's own integration uses to pick a protocol.
- GoodWe **ES / BP family** inverters (AA55 / RS485 meter-bus generation)
- GoodWe **ES / BP family** inverters (AA55 generation)
- The protocol is **reverse-engineered**. There is no vendor contract, and a
firmware change on GoodWe's side could break every installation at once. Say
so when you sell it.
Binary file not shown.
+6 -6
View File
@@ -154,7 +154,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table style="margin-bottom:2mm">
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
<td><b>SUPPORTED</b></td></tr>
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
@@ -162,7 +162,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
</table>
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
A “-20” or “G2” suffix in the model name means a different platform with a different meter
A “-20” or “G2” suffix in the model name means a different platform with a different
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
</div>
@@ -208,13 +208,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<!-- ============ PAGE 3 ============ -->
<div class="page" id="p3">
<h2>Steps 1–3 — Hardware</h2>
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
is low-voltage but sits inside an energised installation.</div>
<div class="step">
<h3><span class="num">1</span> Survey and record</h3>
<ol class="do">
<li><b>Photograph</b> the meter-port wiring before touching it.</li>
<li><b>Photograph</b> the EMS-port wiring before touching it.</li>
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
</ol>
@@ -226,7 +226,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<ol class="do">
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
inverter display).</li>
<li>Disconnect the vendor controller from the meter port.</li>
<li>Disconnect the vendor controller from the inverter's EMS port.</li>
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
you a rollback story.</li>
</ol>
@@ -237,7 +237,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="step">
<h3><span class="num">3</span> Fit and power the T-CAN485</h3>
<ol class="do">
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li>
<li>Wire <b>A→A, B→B</b> to the EMS port, plus its own supply.</li>
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li>
<li>Power the T-CAN485 <b>last</b>.</li>
</ol>
+6 -6
View File
@@ -119,7 +119,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table style="margin-bottom:2mm">
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
<td><b>SUPPORTED</b></td></tr>
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
@@ -127,7 +127,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
</table>
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
A “-20” or “G2” suffix in the model name means a different platform with a different meter
A “-20” or “G2” suffix in the model name means a different platform with a different
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
</div>
@@ -173,13 +173,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<!-- ============ PAGE 3 ============ -->
<div class="page" id="p3">
<h2>Steps 1–3 — Hardware</h2>
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
is low-voltage but sits inside an energised installation.</div>
<div class="step">
<h3><span class="num">1</span> Survey and record</h3>
<ol class="do">
<li><b>Photograph</b> the meter-port wiring before touching it.</li>
<li><b>Photograph</b> the EMS-port wiring before touching it.</li>
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
</ol>
@@ -191,7 +191,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<ol class="do">
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
inverter display).</li>
<li>Disconnect the vendor controller from the meter port.</li>
<li>Disconnect the vendor controller from the inverter's EMS port.</li>
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
you a rollback story.</li>
</ol>
@@ -202,7 +202,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="step">
<h3><span class="num">3</span> Fit and power the T-CAN485</h3>
<ol class="do">
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li>
<li>Wire <b>A→A, B→B</b> to the EMS port, plus its own supply.</li>
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li>
<li>Power the T-CAN485 <b>last</b>.</li>
</ol>
+204
View File
@@ -1,5 +1,209 @@
# Changelog
## 0.18.2
**A patch bump: one fix to how the 15-minute demand forecast sensor is read, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options).
**Existing installs need no option change:** options that start 0.18.1 also
start 0.18.2. Only a forecast sensor with no power unit needs a look (below).
The change below applies with one inverter or two.
**A forecast sensor with no power unit.** If your 15-minute demand forecast
sensor has no unit, or a unit that is not power (for example kWh), the add-on
now ignores that forecast. The log warns once, and the Home and Settings pages
say so while it lasts. Pick a sensor that reads in W or kW.
Contents of this release, in merge order since 0.18.1 (`780c1f6`):
| | |
|---|---|
| `09a5485` | The 15-minute demand forecast sensor is now read in its own unit, W or kW, and the old rule that took any value below 50 as kW is gone. With no unit, or a unit that is not power, the forecast is not used, and the log and the pages say so; the settings page no longer says a kW sensor is taken as watts ([[SAFETY-118]], #198) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
- The price chart on the Home page shows one bar per hour, the average of four 15-minute prices, while the add-on decides on the 15-minute prices ([[UX-45]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.18.1
**A patch bump: two small fixes to the settings page, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options).
**Existing installs need no option change:** options that start 0.18.0 also
start 0.18.1. The changes below apply with one inverter or two.
**Price rounding on 0.18.0.** On 0.18.0, the new settings page saved the fixed,
day and night prices rounded to 2 decimals (0.34512 became 0.35); prices in time
windows kept their decimals. If you typed more than 2 decimals in those
fields on 0.18.0, enter them again.
Contents of this release, in merge order since 0.18.0 (`a0e091c`):
| | |
|---|---|
| `b703aaa` | The four tariff price fields on the new settings page (fixed, day, night and time windows) take 5 decimals, for example 0.34512 €/kWh, and are saved as typed. A short hint says to divide the c€/kWh on your bill by 100 ([[UX-43]], #194) |
| `8e02a09` | The dynamic contract choice on the new settings page now says "Dynamic, every 15 minutes" ("Dynamisch, per kwartier") instead of "hourly". The choice works as before, and one line in the docs no longer calls dynamic prices hourly ([[UX-44]], #195) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
- If the 15-minute demand forecast sensor reads in kW, the settings page warns that its number is taken as watts and asks for a sensor in W. The add-on does turn kW into W, so a kW sensor works ([[SAFETY-118]]).
- The price chart on the Home page shows one bar per hour, the average of four 15-minute prices, while the add-on decides on the 15-minute prices ([[UX-45]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.18.0
**A minor bump: new behaviour, three new options and one new value for an
existing option.** No option was removed or renamed, and no default of an
existing option changed (83 options -> 86). **Existing installs need no option
change:** options that start 0.17.0 also start 0.18.0, and the new options are
off or empty until you use them. The changes below apply with one inverter or
two, unless a line says "Two inverters".
New options, and what they do if you leave them alone:
| option | default | left alone |
|---|---|---|
| `pv_forecast_entities`, `pv_forecast_tomorrow_entities` | `[]` | the single forecast sensors work as before ([[OPT-17]]) |
| `price_service_token` | empty | not used; it is read only with `price_provider: price_service` ([[PRICE-01]]) |
`price_provider` also accepts a new value, `price_service`. Its default is
unchanged, so the price source stays as it is.
**Price service.** The new price source `price_service` reads prices from the
add-on's price server, with the token you were given. If it does not answer,
prices come from energy-charts.info, then from the prices the add-on saved.
Sites on the Home Assistant price sensors or a fixed tariff need no change. To
move a site over, see `DOCS.md`, "Moving a site from the Home Assistant price
sensors to `price_service`".
Contents of this release, in merge order since 0.17.0 (`e36565f`):
| | |
|---|---|
| `3050fa5` | The solar forecast can come from several sensors, added up, for example one per inverter. If one of them has no reading, that day's forecast is not used, and the status says which one. The single sensor still works ([[OPT-17]]) |
| `e3e4ba6` | While Home Assistant is still starting, the page now says "Waiting for Home Assistant" and the log says the add-on waits, instead of looking like a fault. Control itself is unchanged: it starts when Home Assistant answers ([[SAFETY-112]]) |
| `79dd077` | Battery modes on only: a charge in a cheap run no longer stops at the start of a new 15-minute period, and with two inverters a battery that refused to charge is tried again every 15 min. In one test, the export at the end of a charge was 48 W where the code before this fix gave 746 W ([[OPT-15]]) |
| `0ce7d35` | Two inverters: each battery card has Start cycle and Stop cycle buttons, so you can start or stop the cycle on the battery you choose. Only one battery can cycle at a time, and a second start is refused with a short message ([[MAINT-11]]) |
| `bf9019e` | New price source `price_service`. If it fails, prices come from energy-charts.info, then from saved prices. With no price at all and the monthly cycle on, a notice shows and the cycle waits ([[PRICE-01]]) |
| `004f859` | Two inverters, battery modes on: the 15-minute retry of a battery that refused to charge now asks that battery for 200 W, and no longer puts the rest of the charge on another battery. In the test, without this fix, a battery in its maintenance charge took about 2,300 W for about 34 s at each retry ([[OPT-20]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.17.0
**A minor bump: changed behaviour, and no option changes.** No option was
@@ -132,6 +132,23 @@ P1 is its section 6 P1, and the pull request lists what was measured.
own, and a rise past that is the peak floor's, which ends the hold.
A maintenance drain, hold or charge that starts during the hold ends it
on that tick (replayed at edge + 3 s).
- OPT-20 (TEST-23 OPT-15 g7, `test_opt20.py`): a probe asks only PROBE_W.
The trade was sized at the probing battery's rating (2500 W), B's pin held
200 W of it, and the allocator put the other 2300 W on A, in its
maintenance charge phase (no mode range there, so nothing kept it out):
grid 3100 W for 34 s. Now the ask is PROBE_W for the probing battery plus
what the other chargers would have had without the probe (none: 200 W
alone), and it carries the pin only on the ticks the pin is filed (the
window's first and last cycle moved another battery's share by 200 W).
- Chosen: the probe sits on top of a headroom-sized charge, so beside a
normal charger its command is the same and the grid rises 200 W (about
7 W on the quarter) rather than that charger giving 200 W up. A probe
that the headroom or the bound cannot hold at 200 W is not filed.
- Unchanged, same on bf9019e: with a charge ask the other batteries cannot
give (one sign), so an idle-looking A that gives the house stops for the
window and the grid rises by what it gave. The trade line now says 200 W
for a probe alone; its words are UX-38's. The base P4 F4 path (a normal
trade on a battery in maintenance charge) is not touched.
## P4: the solar forecast and the day plan (2026-09-29)
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
@@ -0,0 +1,117 @@
# PRICE-01 - the owner's price server as the price source, with fallbacks
**Status: for review.** Tests: `test_price_service.py`. One option added,
`price_service_token` (password). Follows [[TARIFF-10]], whose section 4 this
card answers for one provider. Design: the owner's price service note of
2026-10-05. Owner go 2026-10-05 and 2026-10-14.
## What it is
`price_provider: price_service` reads `GET https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE`
with `Authorization: Bearer <token>`. The answer is the energy-charts layout
plus `covers`, so the existing columnar parser reads it. The price is wholesale
energy only; the framework adds markup and taxes, as for `generic_dayahead`.
## Rules
1. **The host is a constant in the plugin**, declared as its only egress host,
HTTPS only. The operator's `price_feed_url` is not read by this provider.
*Cost:* every client talks to the one host, and moving the server is a new
add-on version. *What replaces the per-site setting:* the egress allow-list,
TLS, and a token per client that the server can revoke.
2. **The chain is server, then energy-charts, then the saved prices**
(`app/price_chain.py`). A step counts only if it has a price for *now*: a
server that answers with old prices is skipped like one that is down. A step
down and a step up are each one log line, written when the step changes. The
operator's `price_feed_url` is cleared for the energy-charts step, which is
asked for its own feed.
3. **The saved prices are `/data/price_history.json`**, the file `PriceHistory`
already keeps (the last three Brussels days of what was published). They are
the all-in prices as stored; a change of markup shows from the next fetch
that works. A read from the store is not filed again.
4. **No price for now** means no published price covers the present moment. Not
"none for the coming hours": before about 13:00 tomorrow's prices are not
published on any healthy day, and carried hours are already marked estimated.
It sets one status row (*Prices*), one Home plan line (English and Dutch),
and one notification (*No prices*, English and Dutch), taken down when a
price returns. A start-up before the first refresh finished is not a gap.
The notification is not re-asserted after a Home Assistant core restart
(SAFETY-34 does that for latched safety alarms); the status row stays.
*Open for the owner:* if "coming hours" should mean a look-ahead of N hours,
that is a change to `gate_for()` and `PriceWatch`, not to the chain.
5. **The maintenance charge gate** (owner's decision, 2026-10-14): the charge may
run when the *current 15-minute price is at or below today's average*, today
being the Brussels day, computed from the provider's prices. This is the rule
the Home Assistant pair gave (`price_service.yaml`), so a site that moves from
the pair to the provider does not change when the maintenance charge runs.
It is not `TariffPlan.cheap_now()` (strictly below the mean of the firm
hours of the whole plan). This replaces, for this provider only,
TARIFF-10 section 4: its three reasons are answered as follows. (1) Two
writers: the pair beside a provider stays refused; `static_profile` beside
`price_service` is accepted and no longer writes `cheap`. (2) An expired or
absent plan shuts the gate (the charge waits), as an unavailable HA sensor
did. (3) The basis is the one above, not `cheap_now()`.
6. **The token is a secret.** `password?` in the schema; hidden on the settings
page (`uilayout.HIDDEN`), which can keep `price_service` but not choose it;
never put in a message by the plugin; taken out of any reason text by
`price.scrub()`; the real transport reports only a status or an exception's
name. validate.py refuses `price_service` with no token, a token with a
space or line break, and one under 16 characters (so `scrub()`, which skips
values under 4 characters, always has something to take out). Round 2: the
token is in no page and no JSON answer of the web app. Every `password`
option of the schema is shown as `********` (set) or empty, in
`/config/model` (`options`, `running`, `ui`) and in a stale refusal; the
classic page draws it as a password field; a save or a validate that sends
`********` or nothing keeps the stored value, any other value replaces it.
A token can therefore be changed but not cleared from the page.
7. **A fixed tariff beside `price_service` is allowed and quietly noted.** It is
still the plan's last fallback (`resolve_tariff` -> `_degrade`); only the
cheap window and the gate ignore it, because they read the provider's
prices. Keeping it set is the recommended setup, and clearing it triggers the
"static_profile is empty" warning, so no notification (round 2's *Fixed
tariff not used* was false and fired on every sound start; round 3 removed
it). Instead one INFO line at start (English, as every log line) and an info
note beside the setting (EN and NL, "With the price service, the fixed
tariff is only used if no prices are available."), both
only with a non-blank `static_profile` (whitespace counts as blank). At
start the add-on dismisses the old notification id, in case a pre-release
build raised it.
8. **The no-price words show only with the maintenance charge on** (owner,
2026-10-14): the status row, the Home line and the notification say
'maintenance waits', so with `maintenance_enabled` off none is shown.
## Where the gate equals the Home Assistant pair, and where it does not
Equal: `test_price_service.py` section 8 feeds the same day both ways, to the
rules in `price_service.yaml` (price at the quarter, average of today's
quarters rounded to 0.00001 EUR/kWh, `<=`), over 6 days including both
clock-change days, at three instants in every period, and through a real
`Controller` for a whole day. Every answer is the same.
Not equal, by design or by arithmetic:
- The comparison is made on all-in prices. With a flat taxes figure and a
markup and taxes of at most five decimals the offset cancels and the answer
is the same. With `price_taxes_profile` (an hour-varying levy) the all-in
prices differ by hour, so the answer can differ from the wholesale comparison
the HA sensors made. A site on that profile should check before moving.
- A quarter within 0.00001 EUR/kWh of the average can differ if markup or taxes
have more than five decimals.
- The plan is retired when no refresh has succeeded for 90 minutes
(`TARIFF_MAX_AGE_S`) and the gate shuts then. How long the HA pair kept an
answer after its REST sensor stopped updating was not measured here.
- The average is over the firm prices the provider holds for today. After a
restart in the middle of a day with only the saved part of today, it is the
average of that part.
## Not covered
- **No real answer of the server was seen.** No token was available. The
layout is the design's; `test_price_service.py` uses a mock and, for the
parser, the real energy-charts capture reshaped with the design's extra keys.
The first capture from the live server (rig test) should replace the mock.
- The 429 limit (about 60 calls an hour) is not approached: one fetch per
30 minutes, and at most two requests when the server fails.
- Battery modes read the plan as for `generic_dayahead` and were not changed.
- The `price_service` REST and template sensors in Home Assistant are not
touched; they stop being read when the pair is cleared.
@@ -0,0 +1,18 @@
# SAFETY-118 - the peak forecast is read in its own unit
**Status: for review.** Tests: `test_safety118.py`.
`peak_forecast_entity` was read as a bare number, and anything under 50 was
multiplied by 1000 ("accept kW or W"). A W sensor reading 17 to 45 W, which a
real site did 430 times in one morning, would be taken as 17 to 45 kW. The
validator also called a kW sensor wrong, which was false for this entity.
Decision: the size rule is gone. The entity is read through `pv.watts()`, the
same unit table and reader as the solar entities, so W, kW, MW and the rest
convert by the sensor's own `unit_of_measurement`. No unit, or a unit that is
not power (kWh), means the forecast is not used, exactly as if the option were
empty: the loop cap and every non-forecast peak step still apply. One WARNING
per cause, and a status row and a settings note say the same.
No size rule can be right in both units: 30 W and 30 kW are both ordinary.
The read is still one `GET /api/states/<id>` per cycle, as `hass.number()` made.
@@ -0,0 +1,99 @@
# SAFETY-119 - no "not keeping up" when the loop takes the wire back
**Status: merged (bee7431); the settle and the budget changed in SAFETY-121**
(DECISIONS/SAFETY-121-bound-every-quarter.md: 4 cycles and 22 s, one budget
of 11). The rolling-budget sections below describe bee7431. Tests:
`test_safety119.py`.
**Cause.** When another owner (the peak guard, a maintenance charge) has set
the wire and the loop wins again, `cycle()` re-seeds the integrator from that
owner's command, and `compute()` adds `gain * error` from a meter that does
not show that command yet. On the TEST-23 rig (SAFETY-118 I-2) the guard
stepped from -2000 W to +429 W with 5 s left. A cycle later the loop's ask
crossed the +2500 W rail and beat the guard's floor, so SAFETY-85 rule 1
counted it as the loop's own bound and raised `goodwe_integrator_bound`. The
battery gave what it was told, and the cap held.
**Decision.** After a hand-back, the loop's integrator bound is not counted
until the plant's settle has passed (`SHORT_CYCLES` cycles and
`SHORT_SPAN_S`, the window SAFETY-85 grades a battery on). After that it
counts as before. An over-rating (`clamped`, a bug) on a cycle the loop
decided always counts. No command changes, and no text was added or changed.
**A rolling budget (round 3).** The settle above is per hand-back, and
losing the wire restarts it. Across all hand-backs the bound is hidden on at
most a budget of cycles in any 15 minutes (`ROLLING_BUDGET`, `ROLLING_S`); a
cycle over the budget counts. Round 1 restarted the settle at every
hand-back, so a guard and a charge taking turns hid the bound for good (0
warnings in 2 h where 28 were raised before). Round 2 gave each run of
hand-backs its own budget; at 10 s and 15 s meters each quarter then started
a fresh one and the loop's bound was never counted. A quarter-edge kick is a
few bound cycles in one quarter, a real bound that stays is many: a budget
per 15 minutes tells them apart.
**The budget follows the cycle length.** 10 cycles up to a 3.5 s cycle, 8 up
to 11 s, 6 up to 16 s, 4 beyond. The cycle length is the time between two
decided cycles (a stop, or a gap over two minutes, is not one); until it is
known, and without a monotonic clock, the budget is 8. Without a monotonic
clock the budget never refills.
**Why not one number.** Measured over 2 h (`goodwe-addon-checks:1`), the
quarter-edge case (house 800 W, cap 2500 W, no forecast, 30 seeds) and the
case below. No single budget held both at 1 to 15 s: 3 to 6 let the false
alarm back at 2 s; 7 on 10 of 30 seeds at 2 s; 8 on 1 of 30 (seed 25) at
2 s, and warned in only 1 to 3 of 7 quarters at 15 s; 9 and 10 miss quarters
at 10 s. A slow cycle needs a small budget to warn, a fast one a large budget
to stay quiet. Per band, the largest budget that still warns: up to 3.5 s,
10 (9 and 10 had no false alarm at 1 and 2 s; above 10 was not swept); up to
11 s, 8 (9 misses quarters at 10 s); up to 16 s, 6 (7 leaves two quiet
quarters in a row at 15 s, 4 false-alarms at 12 s on 7 of 30 seeds); beyond,
4 (3 false-alarms at 20 and 30 s on 14 of 30, 5 leaves five quiet quarters at
20 s).
**What holds.** Quarter-edge case, 30 seeds each: 0 notifications at 1, 2,
3, 4, 5, 10, 12, 15, 20 and 30 s. Repeated hand-backs (2 x 2500 W,
cheap-window charge, no usable forecast, cap 5000 W, house 5050 and 5100 W,
constant and with noise, 8 runs per meter):
| meter | warnings in 2 h | quarters warned (of 7 after the first) | without the fix |
|---|---|---|---|
| 1 s | 56 to 63 | 7 | 56 to 63 |
| 2 s | 49 to 56 | 7 | 54 to 56 |
| 5 s | 19 to 23 | 7 | 28 |
| 10 s | 7 to 10 | 7 | 14 |
| 15 s | 4 to 10 | 4 to 7, never two quiet in a row | 14 |
| 30 s | 3 to 7 | 3 to 5, up to two quiet in a row | 7 to 11 |
At 30 s no budget does better: 4 leaves two quiet quarters, 3 brings back
the false alarm. Single hand-back, the loop at its rail, first warning after
the hand-back compared with no fix: 10 s at 1 s, 20 s at 2 s, 40 s at 5 s
(default), 80 s at 10 s, 90 s at 15 s, 80 s at 20 s, 120 s at 30 s, 240 s at
60 s. At 60 s the rig is a different regime (the house is not over the cap
in the case above), so nothing more is claimed there. The cap held at 1 to
10 s in the quarter-edge case and at 1 to 5 s in the case above; at slower
meters the meter's lag puts quarters over it, the same with and without the
fix (commands are identical).
**Limits.** These runs have one cycle per meter reading, at a steady
interval. A 20 or 30 s cycle needs `cycle_floor_s` raised; at the default
15 s, a slower meter adds cycles on an old reading. Measured that way (11
seeds): a 15 s meter that varies by up to 30 % false-alarmed on 5 seeds, a
30 s meter on 10 (0 to 4 in 2 h, 8 to 15 without the fix); a 20 s meter on
none. The case above still warned every quarter there.
**Kept.** The loop in charge at its rail warns on the same cycle as before.
The start and a restart after a stop are not hand-backs. The quarter-edge
kick of `test_safety119.py` part 1 stays hidden (see above).
**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`,
`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ; commands and
claims are the same (`test_safety119.py` part 6).
**Not hidden.** A battery that gives less than another owner told it still
alarms through SAFETY-85 rule 2. "Always counts" for an over-rating holds on
cycles the loop decided; on others SAFETY-85 rule 1 already dropped it.
**Not changed.** The kick itself is control behaviour: for a few seconds at
a quarter's edge the loop asks up to its rail. In `test_safety119.py` part 1
that is more discharge than the guard asked at a quarter's end, and a few
seconds of up to 500 W more charge at the next quarter's start.
@@ -0,0 +1,95 @@
# SAFETY-121 - a recurring overload warns every quarter
**Status: for review.** Tests: `test_safety119.py` (parts 2, 4, 5, 6).
Follows DECISIONS/SAFETY-119-bound-after-hand-back.md.
**The defect (TEST-23, SAFETY-119 rig test F-1).** 5 s meter, 2 x 2500 W,
cap 5000 W, maintenance charge, no forecast, house 5100 W. bee7431 warned
once in 70 min (04:31:01), then not at 04:45, 05:00 or 05:15. Base 75f0804
warned at every quarter start. The loop held the wire once a quarter, at its
start, and was at its bound on 4 or 5 cycles. SAFETY-119's settle (12
cycles, 60 s at 5 s) covered all of them, and 5 cycles a quarter fit in its
budget of 8 per 900 s.
**Why the simulation said "every quarter".** `test_safety119.py` fed the
meter one cycle behind the battery reading (`delay` counts cycles: 5 s at a
5 s meter). Just after the loop leaves its rail, the guard's charge
headroom (house = meter + battery) is then off by up to 5000 W, the charge
pulls 2000 W, the guard steps in again, and the two chase each other: 8 to
10 hand-backs and about 20 bound cycles a quarter at 5 s (rig: 1 and 4-5).
With the meter read in the same cycle (`delay` 0, the rig's shape) the sim
gives the rig's numbers: 3 to 5 bound cycles a quarter at 5 s, all inside
SAFETY-119's settle, and bee7431 warns in 0 to 2 of 7 quarters. The test
now runs both shapes.
**Decision.** After a hand-back the loop's bound is hidden until 4 cycles
(`HANDBACK_CYCLES`) and 22 s (`SHORT_SPAN_S`) have passed; across all
hand-backs on at most 11 cycles (`ROLLING_BOUND_CYCLES`) in any 900 s. The
budget no longer follows the cycle length, and the cycle length is not
tracked. A stop ends the settle and keeps the budget. An over-rating always
counts. Commands are unchanged.
**Why it holds for any number of bound cycles a quarter.** The settle is
fixed: at most 3 cycles or 22 s after a hand-back, whichever is longer. A
bound that lasts past it counts on its first cycle after it. So one
hand-back a quarter, with the loop at its rail past the settle, warns in
that quarter, whether it is 1 bound cycle or 100. A bound that never lasts
past a settle counts once more than 11 are hidden within 900 s. Hidden
are only shapes with at most 11 bound cycles in 900 s, each within 3 cycles
or 22 s of a hand-back. That is the quarter-edge kick: in every steady-meter
run below it is on the 1st to 3rd cycle after its hand-back (up to 30 s at
a 30 s meter). In the rig's shape at 5 s the bound comes 30 to 55 s after
the hand-back.
**Sweep** (`goodwe-addon-checks:1`, 2 h runs, scratch/SAFETY-121/dev/).
Quarter edge = test_safety119 part 1 (house 800 W, cap 2500 W, no
forecast). Overload = part 4 (2 x 2500 W, cap 5000 W, house 5050 or
5100 W, seeds 0-3). "Delay 1" is the old sim, "delay 0" the rig's shape.
| meter | quarter edge, runs with a warning (new / bee7431 / no fix) | overload delay 1, quarters warned of 7 (new / bee7431 / no fix) | overload delay 0, 5100 W (new / bee7431 / no fix) |
|---|---|---|---|
| 1 s | 0/70 / 0/70 / 68/70 | 7 / 7 / 7 | 7 / 7 / 7 |
| 2 s | 0/70 / 0/70 / 39/70 | 7 / 7 / 7 | 7 / 7 / 7 |
| 3 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 7 / 7 (5050 W: 7 / 1-7 / 7) |
| 5 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 0-2 / 7 |
| 10 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 0 / 7 |
| 15 s | 0/70 / 0/70 / 40/70 | 7 / 4-7 / 7 | no fix warns 0-1 (5300 W: 7 / 0 / 7) |
| 20 s | 0/60 / 0/60 / 30/60 | 7 / 5-7 / 7 | no fix warns 0 (5300 W: 7 / 0 / 7) |
| 30 s | 0/60 / 0/60 / 30/60 | 7 / 3-5 / 7 | no fix warns 0 (5300 W: 6-7 / 0 / 6-7) |
Quarter-edge seeds: 1-30 with the meter in the same cycle, and 1-40 (1-30
at 20 and 30 s) with the test's 1-2 cycle delay. 5300 W: seeds 1-5.
Commands identical to no fix and to bee7431 in all 1013 runs (these and
the limits below). Choosing the numbers: the runs above were recorded
without the fix and replayed through candidate rules; with 3, 4 or 5
cycles, budgets 9 to 13 met both cases at 1-30 s. 8 warns at the quarter
edge (2 s, seed 25) and 14 misses quarters in the rig's shape (1 s,
5050 W), both checked in part 4. 11 is the middle, and 4 the middle of 3
to 5; with a jittery meter (below) 3 gave more false alarms, 5 more quiet
quarters (replay).
**First warning after one hand-back** (the loop at its rail; later than
no fix by): 11 s at 1 s, 22 s at 2 s, 24 s at 3 s, 25 s at 5 s (default),
30 s at 10 s, 45 s at 15 s, 60 s at 20 s, 90 s at 30 s, 180 s at 60 s.
bee7431: 10, 20, 30, 40, 80, 90, 80, 120, 240 s. In the overload runs at
1 to 30 s, the first warning was at most 90 s later than without the fix.
**Limits (measured, not fixed).**
- A meter that jitters by 30 % with `cycle_floor_s` 15 (11 seeds): with
the test's 1-2 cycle meter delay the quarter edge warns on 2 of 11 seeds
at 15 s, 2 at 20 s and 5 at 30 s (bee7431: 4, 0, 10; no fix: 11 each);
with the meter in the same cycle on none (no fix: 10, 9, 11). The overload at 5100 W leaves up
to 4 quiet quarters in a row at 15 and 20 s (bee7431: no warning at all
on some seeds); at 5200 W at most 1. Its first warning came up to 61 min
later than without the fix. The numbers depend on the jitter draw: the
review's draw gave 4, 4 and 5 of 11 at the quarter edge (bee7431: 4, 1,
9) and up to 2 and 4 quiet quarters at 15 and 20 s.
- With cycles over about 40 s, a bound that is never past a settle uses up
11 in 900 s only if the wire changes hands every few cycles; otherwise
it is never counted (part 5 prints it at 60 s).
- Hidden as designed: a real bound with the kick's shape (at most 11 cycles
in 900 s, each within 3 cycles or 22 s of a hand-back).
**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`,
`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ from
bee7431 and from no fix; commands, claims, guard and phases are the same.
+123 -15
View File
@@ -1,8 +1,9 @@
# GoodWe RS485 Controller
Drives a GoodWe ES/BP battery inverter over its RS485 meter bus: holds net grid
exchange at zero, and runs a monthly battery maintenance cycle so the BMS can
balance cells and recalibrate its coulomb counter.
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port: sends it a grid
reading to hold net grid exchange at zero, and runs a monthly battery
maintenance cycle so the BMS can balance cells and recalibrate its coulomb
counter.
Installers: the printed field guide (`docs/GoodWe-RS485-Field-Guide.pdf`) is
v0.1.0 and outdated; where it disagrees with this page, this page wins.
@@ -33,8 +34,8 @@ v0.1.0 and outdated; where it disagrees with this page, this page wins.
You need:
- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation)
- The vendor's meter-emulating controller **disconnected** from the bus
- A GoodWe **ES / BP family** inverter (AA55 generation)
- The vendor's controller **disconnected** from the inverter's EMS port
- A control board: a T-CAN485 (ESP32) flashed with
`firmware/goodwe-master.yaml`
- A grid-power sensor already working in Home Assistant, updating every ~5–10 s
@@ -210,7 +211,7 @@ The label and description each setting shows on the Configuration tab, in tab or
| Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. |
| Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. |
| Limits | Margin below the peak limit (W) | `peak_margin_w` | The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it. |
| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit. |
| Limits | 15-minute demand forecast entity | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit. |
| Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. |
| Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. |
| Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. |
@@ -222,6 +223,7 @@ The label and description each setting shows on the Configuration tab, in tab or
| Tariff | Your own injection formula | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. |
| Tariff | Price CSV file | `price_csv_path` | For manual_csv only. Where in /share the add-on reads your price file. |
| Tariff | Price feed URL override | `price_feed_url` | Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day. |
| Tariff | Price service token | `price_service_token` | For the price service only. The token you were given for it. Leave it empty with any other price source. |
| Tariff | Price look-ahead (hours) | `price_horizon_h` | How many hours of prices to ask the source for. |
| Tariff | Price request timeout (s) | `price_timeout_s` | How long one request to the price source may take before it is given up. |
| Tariff | Current price entity (older) | `price_now_entity` | The older way to get a price, used only to charge in a cheap window during maintenance. Do not combine it with a dynamic price source. |
@@ -290,6 +292,7 @@ When the add-on refuses to start, each reason names the setting by its label and
- **Fixed or day/night tariff** (`static_profile`): the periods must cover every day type exactly once with no gap and no overlap, and a gap is refused rather than filled. A window 22:00-07:00 on mon-fri does not cover Saturday morning, so a weekend row is required. See [Static day/night and fully-fixed tariffs](#static-daynight-and-fully-fixed-tariffs-static_profile).
- **Your own tariff and injection formula** (`price_formula`, `price_export_formula`): type the card's numbers in the card's units. The `card` block is required on the tariff formula: the add-on recomputes the card's example price and refuses to start if it does not come out. Injection is VAT-exempt in Belgium, so `vat_pct` there is normally 0. See [Your own tariff formula](#your-own-tariff-formula-custom_formula) and [`price_export_formula`, field by field](#price_export_formula-field-by-field).
- **Price feed URL override** (`price_feed_url`): only a host the chosen source declares is accepted. Nothing is added to the URL, not even the date range, so an override often returns one day and the rest of the plan is carried over and excluded from trading. The log warns on every refresh.
- **Price service token** (`price_service_token`): a password field. Every page of the add-on, and every answer its web app gives, shows only `********` when a token is set and nothing when not; the token itself is never sent to a page. A save that leaves the field empty or as `********` keeps the stored token, and a new value replaces it. It must be at least 16 characters. It is needed with `price_provider: price_service`, and the add-on refuses to start without it. It never appears in the log, the status page, a refusal or a notification. See [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to).
- **Current and average price entities** (`price_now_entity`, `price_avg_entity`): kept because removing them would take the maintenance cycle's only price signal away from a live site.
- **Trade on the price spread** (`arbitrage_enabled`): off as shipped, on purpose, because it moves energy against the meter by design. It needs a price source and a P1 meter, and the add-on refuses combinations that cannot work.
- **Battery cycle cost** (`cycle_cost_threshold_eur`): wear plus conversion losses. It is not derived from a measurement: 0.10 is a product decision still open with the owner (OPT-08). At this value a fixed day/night site does not trade, since a realistic Belgian day/night spread is about 0.08 EUR/kWh.
@@ -1695,7 +1698,7 @@ DECISIONS/MAINT-13-full-pack-is-complete.md.
| option | meaning |
|---|---|
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Read in its own unit (W, kW or any power unit). No unit, or a unit that is not power, means it is not used, as if empty (SAFETY-118). Empty = no cap |
| `peak_cap_w` | The site's capacity-tariff target |
| `peak_margin_w` (50) | Watts the peak guard aims under `peak_cap_w` (SAFETY-79). Reports and the bill use `peak_cap_w` itself |
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
@@ -2198,20 +2201,24 @@ does, and only after you have read why.
> `static_profile` below needs no network at all, so its cheap window is
> computed on every cycle and reaches the maintenance cycle's grid top-up
> whether or not arbitrage is armed. ⚠️ **And it is still the only source of
> that window.** A provider-backed plan now exists on every configured site,
> but *Cheap window* and the maintenance cycle's grid top-up read
> `static_profile` and nothing else — a deliberate limit, recorded with what it
> would take to change in `DECISIONS/TARIFF-10-fetching-is-not-trading.md`.
> that window, with one exception.** A provider-backed plan exists on every
> configured site, but *Cheap window* and the maintenance cycle's grid top-up
> read `static_profile` and nothing else — a deliberate limit, recorded with
> what it would take to change in
> `DECISIONS/TARIFF-10-fetching-is-not-trading.md`. The exception is
> `price_service` (PRICE-01): with it, the window comes from the provider's own
> prices, as described under [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to).
| option | meaning |
|---|---|
| `price_provider` | `off` (default) or one of the three plugins below |
| `price_provider` | `off` (default) or one of the plugins below |
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
| `price_taxes_eur_kwh` | Distribution, levies and VAT, EUR/kWh. Same rule. Defaults to **0.11736** — a real Flemish figure, and **yours is probably different**: see *What `price_taxes_eur_kwh` is made of* below |
| `export_price_eur_kwh` | The **flat injection** price, EUR/kWh. May be negative — see below |
| `price_export_formula` | The **injection formula**, if your contract has one. Same shape as `price_formula`, its own coefficients, its own `vat_pct` — see below |
| `price_csv_path` | Where `manual_csv` reads its file |
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
| `price_service_token` | The token for `price_service`. A password field. Empty with any other source |
| `price_horizon_h` | How far ahead to ask for, hours |
| `price_timeout_s` | Per-request timeout |
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
@@ -2224,6 +2231,7 @@ arbitrage decision wrong by that amount, in a direction nothing reports.
| plugin | product | the published figure contains | the add-on adds |
|---|---|---|---|
| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes |
| `price_service` | day-ahead | energy only (wholesale, from your own price server) | markup **and** taxes |
| `custom_formula` | day-ahead | energy **and** your contract's markup — your card's `Tariefformule` line | taxes |
| `manual_csv` | day-ahead | everything, all-in | nothing |
@@ -2253,6 +2261,86 @@ from. Any alias would have to invent the numbers your battery is priced against.
Eneco's dynamic product is *Zon & Wind Dynamisch*, and it is indexed to
Belpex/EPEX **day-ahead**, not to imbalance.)
#### `price_service` — your own price server, and what it falls back to
*For installers: PRICE-01. The decision is in
`DECISIONS/PRICE-01-own-price-server.md`.*
`price_provider: price_service` reads the day-ahead prices for Belgium from the
owner's price server, `https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE`.
The host is fixed in the add-on and only HTTPS is used. The token goes in
`price_service_token` and is sent as `Authorization: Bearer <token>`. The server
answers the energy-charts layout, so the same parser reads it. The price is the
wholesale energy price and nothing else, so the add-on adds your markup and
taxes exactly as for `generic_dayahead`.
**Where the prices come from.** Each step is used only if it has a price for
now. A server that answers with last night's prices is treated as down.
| Step | Source | When it is used | What the log says (once per change) |
|---|---|---|---|
| 1 | The price server | It answers, with a price for now | `The price service answers again.` on the way back. |
| 2 | energy-charts.info | Step 1 fails: down, timeout, refused token (401), error (5xx), a damaged answer, or no price for now | `The price service does not answer, so prices come from energy-charts.` Or `The price service refused the token, so prices come from energy-charts.` |
| 3 | The prices saved in `/data/price_history.json` | Steps 1 and 2 fail | `No price source answers, so the add-on uses the prices it saved.` |
| none | Nothing | No saved price covers now either | `No price source answers, and no saved price covers now.` |
The saved prices are the last answers that worked, kept on disk, so a site
whose server and energy-charts are both down for a day goes on planning from
what it has. It keeps the days the history holds (today, tomorrow once it was
fetched, and a day back). They are the all-in prices as stored: a change of
markup or taxes shows from the next fetch that works. Going back up the chain
is logged once as well: `Prices come from energy-charts again.` and `The price
service answers again.` The refresh runs every 30 minutes.
**When there is no price for now (step none).** One status line, `Prices: No
prices since 00:00, maintenance waits.` (on the add-on page; the Home screen says
the same in English and Dutch), and one notification, *No prices*. Both go when a
source has a price for now again. They show only while **Run the monthly battery cycle**
(`maintenance_enabled`) is on; with it off, neither is shown. "Since" is the moment the last price ran out.
Before the first refresh has finished there is no line, so a start-up is not a
gap. Control is not touched: the battery follows the meter as ever, battery
modes and trading read what prices there are, and only the maintenance charge
waits.
**The maintenance charge and the cheap window.** With this provider the
maintenance charge gate reads the provider's prices and no Home Assistant
entity. It keeps the rule the Home Assistant pair gave: the charge may run
when the **current 15-minute price is at or below today's average**, today
being the Brussels day. Moving a site from the pair to `price_service` does not
change when the charge runs. Where the two can differ is listed in the decision
record: the Home Assistant average was rounded to 0.00001 EUR/kWh, and so is
this one before it is compared. With no price for now the gate is shut, as it
was when an entity was unavailable. *Cheap window* on the status page shows this
answer. This is not the *cheap hours* rule of battery modes and not
`cheap_now()`.
**Moving a site from the Home Assistant price sensors to `price_service`.**
Do this on the Configuration tab, in one save, and restart once:
1. Set **Dynamic price source** (`price_provider`) to `price_service`.
2. Paste the token into **Price service token** (`price_service_token`).
3. **Clear** **Current price entity** (`price_now_entity`) and **Average price
entity** (`price_avg_entity`). The add-on refuses to start with a price
source and these two together.
4. Check **Supplier markup** and **Distribution, levies and VAT**. With the
Home Assistant pair they decided nothing about the maintenance charge. With
the provider they are added to the wholesale price, so they set the prices
shown on the page and, if battery modes or trading are on, the prices those
decide on. The maintenance charge gate does not depend on them.
5. Keep **Fixed or day/night tariff** (`static_profile`) if you have one; it
is fine to leave it set. It is the plan's last fallback: it is used,
marked as estimated and without trading, only when no source has a price.
*Cheap window* and the maintenance charge never read it: the provider's
prices decide them. The add-on logs one INFO line at start ("Fixed tariff
is only used if no prices are available.") and shows the same note beside
the setting. Nothing needs clearing, and clearing it only removes the
fallback (the settings page then warns that the site has no tariff while the
price feed is down).
6. Remove the `price_service` REST and template sensors from Home Assistant
when you are satisfied. They are no longer read.
To go back: put the two entities back, set the source to `off`, and restart.
#### So what should a Belgian dynamic-tariff customer do today?
**Honestly: `generic_dayahead` gets you close, and you should know exactly how
@@ -2809,6 +2897,9 @@ uses the result as a cheap-window hint for the maintenance charge. They are
— configure two of them and the add-on refuses to start, naming the conflict,
because the add-on reads exactly one and the loser is silent. Use one.
To move a site off them onto the owner's price server, see [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to):
it lists which options to set and which to clear.
### The three buckets — which price components can move a decision
*For installers: card TARIFF-17.*
@@ -3023,15 +3114,16 @@ second set of 23- and 25-hour-day bugs.
### Static day/night and fully-fixed tariffs (`static_profile`)
Most Belgian households are **not** on a dynamic contract, and an EMS that only
works under hourly pricing is an EMS for a minority. `static_profile` is how a
works under dynamic pricing is an EMS for a minority. `static_profile` is how a
two-period day/night tariff or a single fixed price becomes a first-class
configuration rather than "turn the price feature off and hope".
On the add-on page, choose **Other time windows** under Settings, Tariff and
prices, or in Setup step 4. Add one row per time window, with its start, end,
days and price. The page names any overlap, gap or empty price before you can
save. You only need the JSON below for days the page cannot choose, such as
Monday and Wednesday only.
save. Prices take five decimals: divide the c€/kWh on your bill by 100, so
34.512 c€/kWh is 0.34512 €/kWh. You only need the JSON below for days the page
cannot choose, such as Monday and Wednesday only.
It is one option, holding a JSON object, on one line:
@@ -3282,8 +3374,22 @@ days before it tries again, so it does not empty the battery every night. To
try sooner, start a cycle on the add-on page. If it keeps happening, check that
the battery follows its commands and that the cheap hours leave time to charge.
### No prices
No price source has a price for the present moment: not the price server,
not energy-charts, and nothing saved. The monthly battery cycle waits, because
it will not charge from the grid without a price to judge by. Control of the
battery goes on as before. Check that the add-on can reach the internet and that
the price service token is right. The notification goes by itself when a price
is back. It is shown only while the monthly battery cycle is switched on.
### For installers: notifications
A fixed tariff beside the price service raises no notification: it is the
plan's last fallback, not a fault. An earlier pre-release build raised a card
with the id `goodwe_fixed_tariff_unused`; the add-on takes that id down at every
start, which does nothing when it is absent.
This part is for installers. The texts live in `app/messages.py` and follow
`STYLE.md`. Every notification keeps a fixed `notification_id`, so a repeat
replaces the card. The ERROR or WARNING line of the add-on log names the
@@ -3302,6 +3408,7 @@ default `info` hides it.
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` | SAFETY-42 |
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
| Battery cycle did not finish | warning | `goodwe_maint_failed_<name>` | MAINT-13 |
| No prices | warning | `goodwe_no_prices` | PRICE-01 |
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
`/config/app/<slug>/documentation`; 2026.5 removed the old
@@ -3499,6 +3606,7 @@ for a new install*.
| Direction check, Site direction check | Whether each inverter's power goes the way the add-on asks. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
| Check for inverters working against each other, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
| Delivery check, *name* is not delivering | Only with several inverters. See *An inverter is not delivering*. |
| Prices | Only while no price source has a price for now: *No prices since 00:00, maintenance waits.* See *No prices* under Notifications. |
| Solar production | Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”. |
### For installers: status page
+5
View File
@@ -221,6 +221,11 @@ function makeInput(field, value, onchange){
input.step = field.type === 'integer' ? '1' : 'any';
if(field.min !== undefined) input.min = field.min;
if(field.max !== undefined) input.max = field.max;
} else if(field.type === 'password'){
input.type = 'password';
input.autocomplete = 'new-password';
input.autocapitalize = 'none';
input.spellcheck = false;
} else {
input.type = 'text';
input.autocapitalize = 'none';
+48
View File
@@ -206,6 +206,8 @@ def _one_field(node, translations, lang, path=()):
"multiple": bool(node.get("multiple")),
"unit": unit_of(key),
}
if node.get("format") == "password":
field["type"] = "password"
if node.get("lengthMin") is not None:
field["min"] = node["lengthMin"]
if node.get("lengthMax") is not None:
@@ -306,6 +308,52 @@ def problems_by_field(problems, keys):
return by_key, unanchored
# --------------------------------------------------------------------------- #
# SECRETS (PRICE-01 F1). A `password` option never leaves this process in a page
# or a JSON answer. The pages get SECRET_SHOWN when one is set and "" when not.
# A save that sends SECRET_SHOWN or nothing keeps the stored value; any other
# value replaces it. The names come from the Supervisor's own schema, so a
# password option added later is covered without an edit here.
# --------------------------------------------------------------------------- #
SECRET_SHOWN = "********"
def secret_keys(schema, extra=()):
"""Names of the options whose schema type is `password`, plus `extra`."""
# The Supervisor may spell it `type: password` or `type: string` with
# `format: password`; both count.
return frozenset(str(n.get("name")) for n in schema or ()
if isinstance(n, dict) and (
n.get("type") == "password"
or n.get("format") == "password")
) | frozenset(extra)
def hide_secrets(value, keys):
"""A copy of `value` with every secret key's value replaced by
SECRET_SHOWN (set) or "" (empty), at any depth."""
if isinstance(value, dict):
return {k: ((SECRET_SHOWN if v else "") if k in keys
else hide_secrets(v, keys))
for k, v in value.items()}
if isinstance(value, list):
return [hide_secrets(v, keys) for v in value]
return value
def keep_secrets(proposed, stored, keys):
"""`proposed` with each secret the page did not change put back from
`stored`: absent, empty or SECRET_SHOWN means 'leave it'."""
out = dict(proposed)
for key in keys:
if out.get(key) in (None, "", SECRET_SHOWN):
if isinstance(stored, dict) and key in stored:
out[key] = stored[key]
elif key in out and out[key] == SECRET_SHOWN:
out[key] = ""
return out
def changed_keys(left, right):
"""Sorted keys whose values differ between two option blobs.
+115 -38
View File
@@ -22,16 +22,13 @@ from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE, next_slot_start
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
from .price import Absence, Uncertainty, brussels
from .price import Absence, Uncertainty, brussels, _cover_table
from .static_tariff import static_plan
from .tariff import BILLING_TZ
from .uimodel import REGION_PRESETS, options_to_ui
# A battery at or under this many watts is resting, not charging or giving.
IDLE_W = 50.0
# Display only: the lowest third of today's price range is "cheap", the top
# third "expensive". A price range under this is flat, and nothing is marked.
FLAT_EUR_KWH = 0.01
# Severity words the page shows; the same four as STYLE.md section 3.
ALARM, WARNING, INFO = messages.ALARM, messages.WARNING, messages.INFO
@@ -142,55 +139,103 @@ class DayPrices:
def __init__(self):
self.day = None
self.points = {}
self.kinds = {}
def see(self, points, day0, day1):
if self.day != day0:
self.day, self.points = day0, {}
self.day, self.points, self.kinds = day0, {}, {}
for p in points:
if day0 <= p.start < day1:
self.points[p.start] = p
return sorted(self.points.values(), key=lambda p: p.start)
def hour_bars(points, day0, day1):
"""One bar per local hour: the mean of its prices, or None when unknown.
`kind` is cheap / dear / mid by thirds of today's range (display only).
The page calls these "low price" and "high price", not cheap hours: the
add-on trades in the one cheapest and one dearest hour, not in a third."""
n = int(round(_hours(day1, day0)))
def _bar_step(pts):
"""The series' own spacing: its smallest gap, never over an hour."""
gaps = [y.start - x.start for x, y in zip(pts, pts[1:])
if y.start > x.start]
return min(gaps + [timedelta(hours=1)])
def _bar_cover(pts):
"""`[(start, end, point)]`: what each point is the price of on the chart.
The add-on's own rule, not a copy: `price._cover_table()` (TARIFF-14), so
a point covers until the next one starts and never over an hour, and the
last one for the span of the one before it."""
return _cover_table(pts)
def price_bars(points, day0, day1, windows=(), now=None, kept=None):
"""One bar per price step of the day: a quarter for a 15-minute series
(96; 92 and 100 on the clock-change days), an hour for an hourly one or a
static tariff. A bar is the price of the point that covers it, never an
average, and a step with no price is a bar with `eur_kwh` None.
`kind` is "cheap" or "dear" where `windows` (`[(kind, start, end)]`, the
runs the add-on itself uses) cover the bar's start, else "mid". A bar that
is over keeps the kind it had while it was still ahead (`kept`, a dict
that lives a day). Display only."""
pts = sorted(points, key=lambda p: p.start)
step = _bar_step(pts)
cover = _bar_cover(pts)
n = int((day1 - day0) / step)
bars = []
for k in range(n):
a, b = day0 + timedelta(hours=k), day0 + timedelta(hours=k + 1)
inside = [p for p in points if a <= p.start < b]
price = (sum(p.eur_per_kwh for p in inside) / len(inside)
if inside else None)
a = day0 + k * step
z = a + step
hit = next((p for s, e, p in cover if s <= a < e), None)
kind = None
if hit is not None:
if kept is not None and now is not None and z <= now:
kind = kept.get(a, "mid")
else:
kind = next((w[0] for w in windows if w[1] <= a < w[2]),
"mid")
if kept is not None:
kept[a] = kind
bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a),
"end_label": hhmm(b), "points": len(inside),
"eur_kwh": None if price is None else round(price, 4),
"estimate": bool(inside) and any(p.provisional
for p in inside),
"kind": None})
known = [b["eur_kwh"] for b in bars if b["eur_kwh"] is not None]
if known and max(known) - min(known) >= FLAT_EUR_KWH:
lo, hi = min(known), max(known)
third = (hi - lo) / 3.0
for b in bars:
p = b["eur_kwh"]
if p is None:
continue
b["kind"] = ("cheap" if p <= lo + third else
"dear" if p >= hi - third else "mid")
elif known:
for b in bars:
if b["eur_kwh"] is not None:
b["kind"] = "mid"
"end_label": hhmm(z),
"eur_kwh": None if hit is None
else round(hit.eur_per_kwh, 4),
"estimate": hit is not None and bool(hit.provisional),
"kind": kind})
return bars
def _price_mode_in_use(c):
"""True when at least one battery is on and uses a price mode. The runs
belong to those batteries; with all on Solar first or off, nothing acts
on a price."""
for inv in c.inverters:
pm = c.pack_mode(inv)
if pm.use and pm.mode in PRICE_MODES:
return True
return False
def price_windows(c, plan, verdict):
"""`[(kind, start, end)]`: where the add-on itself buys ("cheap") and
gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan`
(`c.mode_runs`): the cheap run when charging in it pays, and every give
run. With them off it is the arbitrage verdict's two points, the ones
`trade_runs()` draws. Nothing is classified here."""
if getattr(c, "modes_on", False):
rp = getattr(c, "mode_runs", None)
if rp is None or not rp.on or not _price_mode_in_use(c):
return []
out = [("cheap", rp.cheap.start, rp.cheap.end)] \
if rp.charge_useful() else []
return out + [("dear", g.start, g.end) for g in rp.give]
if verdict is None:
return []
return [("cheap", verdict.cheapest.start,
_point_end(plan, verdict.cheapest)),
("dear", verdict.dearest.start,
_point_end(plan, verdict.dearest))]
def day_range(points):
"""(lowest, highest) of today's published points, as {eur_kwh, label},
or (None, None). The feed's own points, like the trade legs, not the
chart's hour averages."""
or (None, None). The feed's own points, like the trade legs."""
firm = [p for p in points if not p.provisional]
if not firm:
return None, None
@@ -551,13 +596,15 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
plan = static
except Exception: # noqa: BLE001
pass
seen = _day_prices(c).see(_points(plan), day0, day1)
bars = hour_bars(seen, day0, day1)
days = _day_prices(c)
seen = days.see(_points(plan), day0, day1)
limit_w = c.peak.cap_w # the limit the controller parsed at start
runs, lines = [], []
# The control cycle trades on the fresh plan only, so this does too.
verdict, trade_why = trade_verdict(c, fresh)
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict),
now, days.kinds)
modes_on = bool(getattr(c, "modes_on", False))
if running:
off = {i for i, inv in enumerate(c.inverters) if _off(c, inv)}
@@ -582,6 +629,8 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
cyc, waits = maintenance_runs(c, day0, day1, now, shadow)
runs += cyc
lines += waits
if getattr(c, "price_gap", False):
lines.append(line_no_prices(c.price_gap_since, now))
runs += peak_run(c, day0, day1, now, shadow, limit_w)
if c.p1_enabled:
# The guard's ceiling: the cap, or this month's peak once above it.
@@ -1268,6 +1317,17 @@ def line_cycle_waits(name) -> str:
return _t("cycle_waits", name=name)
def line_no_prices(since, now) -> str:
"""PRICE-01: no price covers now. `since` is an instant or None."""
if since is None:
return _t("no_prices_yet")
local, today = brussels(since), brussels(now)
if local.date() == today.date():
return _t("no_prices_since", at=f"{local:%H:%M}")
return _t("no_prices_since_day", day=local.day, month=month_name(local),
at=f"{local:%H:%M}")
def line_peak_limit(limit_w, shadow) -> str:
return _w(shadow, "step_in", kw=_kw(limit_w))
@@ -1387,6 +1447,16 @@ def _samples() -> list:
("plan", run_house_why()), ("plan", run_house_why(True)),
("plan", run_house_amount()),
("plan", line_cycle_waits("Battery B")),
("plan", line_no_prices(None, datetime(2026, 10, 14, 9, 0,
tzinfo=timezone.utc))),
("plan", line_no_prices(datetime(2026, 10, 14, 0, 0,
tzinfo=timezone.utc),
datetime(2026, 10, 14, 9, 0,
tzinfo=timezone.utc))),
("plan", line_no_prices(datetime(2026, 10, 13, 22, 0,
tzinfo=timezone.utc),
datetime(2026, 10, 14, 9, 0,
tzinfo=timezone.utc))),
("plan", line_prices(0.16, {"eur_kwh": 0.14, "label": "13:00"},
{"eur_kwh": 0.36, "label": "19:00"})),
("plan", line_prices(None, None, None)),
@@ -1408,3 +1478,10 @@ def _samples() -> list:
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
"no_level")]
return out
def priced_hours(bars):
"""How many clock hours of the day have a price: distinct hours among the
bars that have one, so 96 quarters count 24, not 96. The setup wizard's
"N of 24 hours". A 23-hour day gives 23, a 25-hour day 24."""
return len({b["label"][:2] for b in bars if b.get("eur_kwh") is not None})
+218 -11
View File
@@ -3,7 +3,7 @@
WHAT THIS THING DOES, in one paragraph: it reads the house's net grid power from
Home Assistant, decides how hard the battery should charge or discharge to hold
that at zero, and writes that figure to an ESP32 which puts it on the inverter's
RS485 meter bus. Once a month it runs a full low->full battery cycle so the BMS
RS485 EMS port. Once a month it runs a full low->full battery cycle so the BMS
can balance cells and recalibrate its coulomb counter.
⚠️ THE ONE THING TO UNDERSTAND BEFORE CHANGING ANY OF THIS: the inverter holds
@@ -90,6 +90,8 @@ from .p1 import P1Ingest, build_source, is_enabled
from .price import Absence, Uncertainty, brussels, price_option, provider_configured
from .price import required_spread as price_required_spread
from .price import uncertainty_of as price_uncertainty_of
from .price_chain import (PriceWatch, fixed_tariff_beside_service, gate_for,
uses_chain)
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_gated, static_status
from .tariff import PeakStore, PeakTracker, next_quarter
@@ -297,6 +299,14 @@ REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2
# was told over the same span as above (the plant's measured settle time), so
# a healthy battery still settling on a new command 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 until
# HANDBACK_CYCLES cycles and SHORT_SPAN_S have passed; across all hand-backs
# on at most ROLLING_BOUND_CYCLES cycles in any ROLLING_S seconds. Measured in
# 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
def _refused_interval(sign: int) -> tuple:
@@ -1200,8 +1210,22 @@ class Controller:
# `grid` is P1, one meter, one connection, and belongs here.
self.grid = None
self.peak_fc = None
# SAFETY-118: the unit fault last logged for the peak forecast (None =
# no fault), so the warning comes once and again only if it changes.
self._peak_fc_fault = None
self.cheap = False
self._price_gated = False # MAINT-13: is a price gate set up
# PRICE-01. Which step of the price chain the prices come from (logged
# once per change), the last series that was published (so a gap can say
# since when), and the gap itself: True while a refresh has finished and
# no published price covers now. The notification is raised once per
# gap and taken down when it ends.
self.price_watch = PriceWatch()
self._last_published = None
self.price_gap = False
self.price_gap_since = None
self._price_gap_announced = False
self._fixed_note_said = False
self._gated_key = None # MAINT-13: (options, hour) of the cache
# TARIFF-03. One line of prose for the status page saying WHICH tariff
# window we are in and why it is or is not cheap - so "Cheap window:
@@ -1256,6 +1280,13 @@ class Controller:
self._loop_decision = None
self._asked_hist: dict = {}
self._short_run = 0
# SAFETY-119: did another owner set the wire last cycle; the window
# after a hand-back (monotonic s, the loop's cycles in a row); the
# monotonic s of the bound cycles hidden in the last ROLLING_S (a
# stop does not clear them).
self._wire_lost = False
self._handback = None
self._hidden = ()
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
@@ -2428,6 +2459,19 @@ class Controller:
tick = self.mode_tick
if tick is None:
return False
if tick.action == modetrade.CHARGE:
# OPT-20: the ask carries PROBE_W for exactly the probes whose pin
# is filed this tick. `decide()` ran before the pin was (or the
# window closed since), so the two can differ by one cycle; left
# alone, the allocator takes the pin's watts out of another
# battery's share, or hands them to one it can reach.
pins = _ctl.PROBE_W * sum(
1 for c in claims if c.source == "refused"
and c.kind == "set" and c.value < 0)
if pins != tick.probe_w:
tick = self.mode_tick = dataclasses.replace(
tick, ask_w=tick.ask_w + tick.probe_w - pins,
probe_w=pins)
size = modetrade.size(tick, self.loop_bound(claims)
if tick.action else None, self.arb_headroom_w)
runs = self.mode_runs
@@ -3332,6 +3376,22 @@ class Controller:
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
list(zip(roster, states)), pv_read.inverted(self.o))
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."""
@@ -3422,11 +3482,17 @@ class Controller:
_reads.append(self._read_forecast())
await asyncio.gather(*_reads)
if o.get("peak_forecast_entity"):
raw = await self.hass.number(o["peak_forecast_entity"])
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
self.peak_fc = None if raw is None else (raw * 1000 if abs(raw) < 50 else raw)
# SAFETY-118: read in its own unit, like the PV path. One state
# read, as `hass.number()` made. No unit or a unit that is not
# power: 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:
@@ -3435,6 +3501,12 @@ class Controller:
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. Still no I/O here - the
# series is the one the background refresh stored.
self._provider_gate(datetime.now(timezone.utc))
else:
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
@@ -3998,6 +4070,38 @@ class Controller:
inv.refusal_note())]
if n]
def _provider_gate(self, now: datetime) -> None:
"""PRICE-01: `cheap`, the gate and the gap from the standing plan.
The gate exists whenever the provider does: no price for now is a
closed gate, so the charge waits and does not run unpriced. The gap
flag stays False until the first refresh has finished, so a start-up
is not a gap.
"""
plan = self.fresh_tariff_plan(now)
published = None if plan is None else plan.published
if published is not None:
self._last_published = published
gate = gate_for(published, now)
self.cheap, self._price_gated = gate.cheap, True
self.tariff_words = gate.words
# Owner, 2026-10-14: the words say 'maintenance waits', so they show
# only while the maintenance charge is on.
self.price_gap = ((not gate.covered) and self.tariff_at is not None
and MaintConfig.from_options(self.o).enabled)
last = self._last_published
if gate.covered or last is None:
self.price_gap_since = None
elif last.covers(now):
# The last series still covers now, but no refresh has made it
# current for TARIFF_MAX_AGE_S: the refresh task has stopped. The
# gap began when the plan was retired.
self.price_gap_since = (
None if self.tariff_at is None
else self.tariff_at + timedelta(seconds=TARIFF_MAX_AGE_S))
else:
self.price_gap_since = last.last_end_before(now)
def _static_gated(self, o: dict, now: datetime) -> bool:
"""`static_gated()`, worked out once an hour: it builds the same
month-long plan as `static_status()` (about 20 ms with a profile), and
@@ -5536,6 +5640,12 @@ class Controller:
# is not, and test_freeze.py part 5 fails on exactly that.
await self.check_meter_frozen()
# --- PRICE-01: no price for now, said once and taken down once --------
# Not a safety check, so it is one of the cheap ones: an attribute test
# and, on a change only, one Home Assistant call.
await self.check_price_gap()
await self.check_fixed_note()
# --- SAFETY-31's alarm, and its boundary is the LATCH, not a number ---
# Unconstrained where the two above are not: it grades
# `self.circ_wd.tripped`, which was advanced near the top of this method
@@ -5718,6 +5828,7 @@ class Controller:
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]
@@ -5743,7 +5854,9 @@ class Controller:
and delivery_short(asked[inv.name], inv.batt,
self.tuning.saturation_w)]
self._short_run = self._short_run + 1 if short else 0
if owns and self.loop_clamped[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
@@ -5752,6 +5865,41 @@ class Controller:
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 after another owner handed the wire back: its integrator is
re-seeded from that owner's command and kicked by a meter that does
not show it yet. SAFETY-121: the settle is HANDBACK_CYCLES cycles and
SHORT_SPAN_S after the hand-back, so a bound that outlasts it counts
at any cycle length; losing the wire restarts it. Across all
hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES cycles
in any ROLLING_S, so a bound that never outlasts a settle is counted
too. 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.
@@ -6152,6 +6300,50 @@ class Controller:
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.
`price_gap` is worked out on the tick from the stored plan. A gap
raises the notification once: the flag is set BEFORE the await, for
`check_circulating()`'s reason (a Home Assistant call can raise or
hang, and a retry every cycle would be a storm). When the gap ends the
same notification is taken down. Control is not touched either way: the
maintenance charge waits because its gate is shut (`_provider_gate`),
and nothing here decides that.
"""
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. At the first cycle only: one INFO line when a
`static_profile` is set beside `price_service` (it is the plan's last
fallback, not a fault, so no notification), and a dismiss of the card an
earlier build raised, which is harmless when absent."""
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.
@@ -6286,7 +6478,7 @@ class Controller:
try:
plan = await resolve_tariff(
self.o, now, now + timedelta(hours=horizon), now=now,
history=self.price_history)
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
@@ -7126,6 +7318,10 @@ class Controller:
# 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", ())
@@ -7268,7 +7464,8 @@ class Controller:
level, banner = "warn", messages.banner_stopped()
elif runs:
level, running_alarm = "bad", True
banner = runs[0].get("banner") or messages.headline(runs[0]["text"])
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.
@@ -7291,11 +7488,11 @@ class Controller:
# character; at two, "Maintenance: drain" without a name is a
# banner an operator cannot act on, because the remedy is at one
# device and the site has two.
level, banner = "warn", f"Maintenance: {self.maint.phase}"
if len(self.inverters) > 1:
banner += f" ({self.maint_unit.name})"
level, banner = "warn", messages.banner_maintenance(
self.maint.phase,
self.maint_unit.name if len(self.inverters) > 1 else "")
else:
level, banner = "ok", "Running — holding grid at zero"
level, banner = "ok", messages.banner_running()
def w(v):
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
@@ -7363,6 +7560,9 @@ class Controller:
("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, which is the difference
# between a 48-hour plan and a 12-hour one on a weekday morning.
# It is its own row rather than a clause of "Tariff" because it
@@ -7396,6 +7596,13 @@ class Controller:
now=datetime.now(timezone.utc), why=self.why_words)),
]
held = level == "bad" and not running_alarm
# UX-46. The rows were built in English with their Dutch beside them
# (`messages.Bi`); the page's language is chosen once, here, for the
# Home sentences, the checks list and the rows that carry one. The
# banner above stays English: it is the plain fallback.
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,
File diff suppressed because it is too large Load Diff
+26 -4
View File
@@ -47,12 +47,17 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
is its SAFETY-73 probe inside a cheap run (OPT-15): it is a charger then,
pinned at PROBE_W by its own claim, so the others rest (one sign) and the
card's "tries again every 15 minutes" holds in the run too;
* OPT-20: a probe asks PROBE_W for the probing battery and nothing more.
The rest of the ask is what the other chargers would have had without
the probe (none: PROBE_W alone), so a probe never places charge on a
battery in a maintenance phase or on one that rests;
* no grid charge while another battery drains for its monthly cycle
(`draining`, SAFETY-78): it would charge against the drain.
"""
from dataclasses import dataclass
from .control import PROBE_W
from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR,
PLAN_CHARGE_MODES, SOLAR_FIRST)
@@ -88,6 +93,7 @@ class Tick:
action: str | None # "charge" | "sell" | None
ask_w: float # signed site ask before sizing (0 if none)
why: str | None # why no trade when a run is on, else None
probe_w: float = 0.0 # OPT-20: the part of `ask_w` that is a probe
def _room_kwh(p: Pack, pct: float) -> float:
@@ -232,8 +238,12 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
ranges[p.name] = (lo, hi)
if charging:
ask = -sum(abs(p.rating_w) for p in ch)
return Tick(ranges, roles, CHARGE, ask, None)
# OPT-20: a probing refuser is asked PROBE_W (its own pin holds it
# there); only the batteries that charge without a probe are asked
# for their rating.
probe_w = PROBE_W * sum(1 for p in ch if p.refused < 0)
ask = -sum(abs(p.rating_w) for p in ch if p.refused >= 0) - probe_w
return Tick(ranges, roles, CHARGE, ask, None, probe_w)
if selling:
ask = sum(abs(p.rating_w) for p in se) + max(0.0, float(house_w))
return Tick(ranges, roles, SELL, ask, None)
@@ -250,8 +260,20 @@ def size(tick: Tick, bound, headroom_w) -> float:
return 0.0
lo, hi = bound
if tick.action == CHARGE:
cap = min(-float(tick.ask_w), -float(lo),
float(headroom_w) if headroom_w is not None else 0.0)
room = float(headroom_w) if headroom_w is not None else 0.0
pw = float(tick.probe_w)
if pw > 0.0:
# OPT-20: the others' share is sized as without the probe; the
# probe's PROBE_W is added on top (the grid rises by PROBE_W at
# most), and alone it is sized by the bound and the headroom.
rest = max(0.0, min(-float(tick.ask_w) - pw, -float(lo) - pw,
room))
if rest > 0.0:
return -(rest + pw)
# PROBE_W exactly or no trade: less would void the pin and hand
# the remainder to whichever battery the allocator can reach.
return -pw if min(-float(lo), room) >= pw else 0.0
cap = min(-float(tick.ask_w), -float(lo), room)
return -max(0.0, cap)
return max(0.0, min(float(tick.ask_w), float(hi)))
+1 -1
View File
@@ -29,7 +29,7 @@ _LOG = logging.getLogger("goodwe.mqtt")
DEVICE = {
"identifiers": ["goodwe_rs485_controller"],
"name": "GoodWe",
"manufacturer": "GoodWe (via RS485 meter emulation)",
"manufacturer": "GoodWe (via RS485 EMS port)",
"model": "ES/BP series",
}
+175 -7
View File
@@ -750,6 +750,18 @@ class _Series(PriceRead):
except Absence:
return False
def last_end_before(self, instant: datetime):
"""When the last price that has run out by `instant` stopped covering,
or None if no price has (PRICE-01: "no prices since 00:00").
Each point's end is the one `_cover_table()` worked out, so a hole in
the series is reported from where the hole starts and not from the end
of the day.
"""
u = instant.astimezone(timezone.utc)
ends = [end for _start, end, _p in self._cover if end <= u]
return max(ends) if ends else None
def window(self, since: datetime, until: datetime) -> tuple:
"""Points whose START falls in [since, until). May legitimately be empty.
@@ -906,6 +918,10 @@ PRICE_OPTION_DEFAULTS = {
"export_price_eur_kwh": 0.04, # §14 tariffs.export_price_eur_kwh (AC4)
"price_csv_path": "/share/goodwe_prices.csv",
"price_feed_url": "",
# PRICE-01. The bearer token of the owner's price server. A secret: it is
# in SECRET_OPTIONS below, so no text built from an error reaches a log,
# the status page or a notification with it in.
"price_service_token": "",
"price_horizon_h": 48,
"price_timeout_s": 10,
# TARIFF-03. §14 `tariffs.static_profile: null`. A JSON object describing a
@@ -992,6 +1008,26 @@ FRAMEWORK_KEYS = frozenset({
})
# PRICE-01. Options whose VALUE must never be printed. `scrub()` removes them
# from any text the price code builds out of an error.
SECRET_OPTIONS = frozenset({"price_service_token"})
def scrub(text, opts) -> str:
"""`text` with every secret option's value replaced by `***`.
The price code builds reasons out of library errors, and a library may put
a request header into its error text. Taking the value out here is the
last line of defence; the plugin also never puts it in a message itself.
"""
out = str(text)
for key in SECRET_OPTIONS:
secret = str((opts or {}).get(key) or "").strip()
if len(secret) >= 4:
out = out.replace(secret, "***")
return out
class PluginOptions:
"""The options dict, minus the keys only the framework may read.
@@ -1216,6 +1252,13 @@ class PriceProvider(ABC):
id = "?"
# PRICE-01. The ids to try, in order, when this source cannot answer, and
# whether the last answer is kept for a day without any source. Empty and
# False for every plugin that existed before, which is what keeps them
# exactly as they were: `price_chain.py` is not used for them at all.
FALLBACKS: tuple = ()
KEEPS_LAST_GOOD: bool = False
@classmethod
@abstractmethod
def capabilities(cls) -> ProviderCapabilities:
@@ -1533,19 +1576,58 @@ def assemble(caps: ProviderCapabilities, raw, markup: float,
# Transport - one place, HTTPS only, host checked against the declaration
# --------------------------------------------------------------------------- #
async def https_get(url: str, timeout_s: float) -> bytes:
async def https_get(url: str, timeout_s: float, headers=None) -> bytes:
"""The default transport. §16: outbound is allow-listed and TLS'd.
Imported lazily so `manual_csv` - the offline fallback - never needs aiohttp
to be importable at all. AC3 says it works fully offline; that includes a
machine where the HTTP stack is missing.
PRICE-01. `headers` is None for every plugin but `price_service`, and with
None this is the code it has always been, byte for byte. With headers it
answers in the SAFE words of `status_words()` and never lets a library
error through: an error from a request that carried a token is the one
place a token could be printed by somebody else's code.
"""
import aiohttp
t = aiohttp.ClientTimeout(total=float(timeout_s))
async with aiohttp.ClientSession(timeout=t) as s:
async with s.get(url) as r:
r.raise_for_status()
return await r.read()
if headers is None:
async with aiohttp.ClientSession(timeout=t) as s:
async with s.get(url) as r:
r.raise_for_status()
return await r.read()
try:
async with aiohttp.ClientSession(timeout=t) as s:
# No redirects: the host is fixed in code, and a redirect would
# take the request (and the token) somewhere the allow-list never
# saw. A 3xx is reported like any other status.
async with s.get(url, headers=headers,
allow_redirects=False) as r:
if r.status != 200:
raise SourceUnavailable(status_words(r.status))
return await r.read()
except SourceUnavailable:
raise
except Exception as e: # noqa: BLE001
raise SourceUnavailable(
f"the price service did not answer ({type(e).__name__})")
# PRICE-01. The one phrase a refused token is reported with. The fallback chain
# reads it to tell "the owner must act" from "the server is down", so it is a
# constant both sides import.
AUTH_REFUSED = "the price service refused the token"
def status_words(status: int) -> str:
"""What an HTTP status from the price service is called. No header text."""
if status in (401, 403):
return f"{AUTH_REFUSED} ({status})"
if status == 429:
return "the price service says too many requests (429)"
if status >= 500:
return f"the price service has an error ({status})"
return f"the price service answered {status}"
def check_egress(url: str, caps: ProviderCapabilities) -> str:
@@ -1870,6 +1952,91 @@ class GenericDayAhead(PriceProvider):
provisional_default=False)
@register
class PriceService(PriceProvider):
"""The owner's own price server (PRICE-01), COLUMNAR, in EUR/MWh.
SOURCES: energy
The framework ADDS: markup, taxes
The server answers the energy-charts layout (`unix_seconds`, `price`,
`unit`) plus `covers`, the complete Brussels days it holds. The price is
the wholesale energy price and nothing else, so markup and taxes are added
by the framework exactly as for `generic_dayahead`.
The host is fixed here, HTTPS only, and a bearer token (the option
`price_service_token`) goes in the `Authorization` header. The token is not
put into any message this class builds.
`FALLBACKS` names the next source down. `app/price_chain.py` walks it and
then the stored prices; this class only fetches.
"""
id = "price_service"
HOST = "prices.kammenstraatha.duckdns.org"
URL = "https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE"
FALLBACKS = ("generic_dayahead",)
KEEPS_LAST_GOOD = True
@classmethod
def capabilities(cls):
return ProviderCapabilities(
product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY}),
egress_hosts=(cls.HOST,))
async def fetch(self, opts, since, until, transport):
token = str(opts.get("price_service_token") or "").strip()
if not token:
raise SourceUnavailable("no token is set for the price service")
if any(c.isspace() or not c.isprintable() for c in token):
raise SourceUnavailable(
"the price service token has a space or a line break in it")
check_egress(self.URL, self.capabilities())
try:
body = await transport(
self.URL, float(opts.get("price_timeout_s")),
headers={"Authorization": "Bearer " + token})
except SourceUnavailable:
raise
except Exception as e: # noqa: BLE001
raise SourceUnavailable(
f"the price service did not answer ({type(e).__name__})")
points = _points_from_columnar_json(body, expect_unit="EUR / MWh",
scale=0.001,
provisional_default=False)
covers = _covered_days(body)
if covers is None:
return points
whole = [p for p in points
if brussels(p.start).date().isoformat() in covers]
if not whole:
raise SourceUnavailable(
"the price service sent no complete day")
return whole
def _covered_days(body):
"""The `covers` list of a price service answer as a set, or None if absent.
A day the server does not list as complete is not used: an hour missing
inside a day is a hole, and a hole is not a free hour.
"""
try:
text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \
else str(body)
covers = json.loads(text).get("covers")
except (ValueError, UnicodeDecodeError, AttributeError):
return None
if covers is None:
return None
if not isinstance(covers, list) or \
not all(isinstance(d, str) for d in covers):
raise SourceUnavailable("the price service sent a bad list of days")
return set(covers)
@register
class ManualCsv(PriceProvider):
"""A file on disk. AC3: the documented fallback, fully offline.
@@ -2169,9 +2336,10 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
except FrameworkOption:
raise # a plugin bug, not a site condition
except SourceUnavailable as e:
return PriceRead.no_data(f"{pid}: {e}")
return PriceRead.no_data(scrub(f"{pid}: {e}", opts))
except Exception as e: # noqa: BLE001 - see the docstring
return PriceRead.no_data(f"{pid}: {type(e).__name__}: {e}")
return PriceRead.no_data(
scrub(f"{pid}: {type(e).__name__}: {e}", opts))
_emit_startup_preview(plugin, pid, points)
return PriceRead.series(points, caps.product, pid,
+228
View File
@@ -0,0 +1,228 @@
"""PRICE-01 - where the prices come from when the first source does not answer.
A provider that names `FALLBACKS` (app/price.py) is read through this module
instead of straight from `load_prices()`. The order is fixed:
1. the provider itself (the owner's price server)
2. each id in its `FALLBACKS` (energy-charts.info)
3. the prices the history store kept from the last fetch that worked
A step counts only if its answer holds a price for NOW. A server that answers
200 with yesterday's prices is as useless to a battery as one that is down, so
it is skipped, with the same log line.
Every other provider never comes here (`uses_chain()` is False for it), so
`generic_dayahead`, `manual_csv`, `custom_formula` and `off` take the road they
always took.
THE GATE. `gate_for()` is the maintenance charge rule for a site on such a
provider: the current 15-minute price is at or below today's average. It is the
rule the Home Assistant pair (`price_now_entity`, `price_avg_entity`) gave, read
from the provider's own prices, so moving a site from the pair to the provider
does not change WHEN the charge runs. DECISIONS/PRICE-01-own-price-server.md
says how equal, and where it is not.
"""
import logging
from dataclasses import dataclass
from datetime import datetime
from .price import (AUTH_REFUSED, PROVIDERS, PriceRead, brussels,
price_option, provider_id, scrub)
_LOG = logging.getLogger("goodwe.price.chain")
# The four places a plan can come from, best first. A higher number is further
# down the chain.
STEP_OWN, STEP_PUBLIC, STEP_STORED, STEP_NONE = 0, 1, 2, 3
# What a step is called in a status line.
SOURCE_WORDS = {
"price_service": "the price service",
"generic_dayahead": "energy-charts",
"price_history": "saved prices",
}
def uses_chain(opts: dict) -> bool:
"""Does the selected provider ask for the fallback chain?"""
cls = PROVIDERS.get(provider_id(opts))
return bool(cls is not None and (cls.FALLBACKS or cls.KEEPS_LAST_GOOD))
def fixed_tariff_beside_service(opts: dict) -> bool:
"""PRICE-01: a fixed tariff is set beside `price_service`. Not a fault: it
stays the plan's last fallback (`resolve_tariff`), and only the cheap window
and the gate ignore it. A blank or whitespace-only value is not set."""
return (provider_id(opts) == "price_service"
and bool(str(price_option(opts, "static_profile")).strip()))
@dataclass(frozen=True)
class Resolved:
"""What the chain found. `published` is the series BEFORE the yesterday
fill and the window, which is what the day's average has to be read from:
the plan itself starts at the current hour."""
read: PriceRead
step: int
reasons: tuple = ()
@property
def published(self):
return self.read.match(series=lambda r: r, no_data=lambda _r: None)
class PriceWatch:
"""Which step the prices come from, and one log line per change of it.
The line is written when the step CHANGES and not before. Nothing repeats
while a source stays down, and nothing is written for a refresh that
changes nothing. The reasons (a status code, a timeout) go on a DEBUG line
beside it, with the token taken out.
"""
def __init__(self):
self.step = None
self.auth_refused = False
def note(self, step: int, reasons=(), opts=None) -> None:
old, self.step = self.step, step
self.auth_refused = any(AUTH_REFUSED in r for r in reasons)
if old == step:
return
if reasons:
_LOG.debug("Price sources said: %s.",
scrub("; ".join(reasons), opts))
if step == STEP_OWN:
if old is None:
_LOG.info("Prices come from the price service.")
else:
_LOG.info("The price service answers again.")
elif step == STEP_PUBLIC:
if old is not None and old > step:
_LOG.info("Prices come from energy-charts again.")
elif self.auth_refused:
_LOG.warning("The price service refused the token, so prices "
"come from energy-charts.")
else:
_LOG.warning("The price service does not answer, so prices "
"come from energy-charts.")
elif step == STEP_STORED:
if old is not None and old > step:
_LOG.info("The add-on uses the prices it saved again.")
else:
_LOG.warning("No price source answers, so the add-on uses "
"the prices it saved.")
else:
_LOG.warning("No price source answers, and no saved price covers "
"now.")
async def resolve(opts: dict, since: datetime, until: datetime, transport,
history, stamp: datetime, watch: PriceWatch,
load) -> Resolved:
"""Walk the chain and return the first step that has a price for `stamp`.
`load` is the caller's `load_prices`, passed in so that `static_tariff.py`
stays the one module that fetches prices. Never raises
for a source condition: a read that gets nowhere is a `no_data` whose
reason names every step, so the §11.4 row applies exactly as for a single
provider that failed.
"""
pid = provider_id(opts)
cls = PROVIDERS[pid]
reasons = []
for step, sid in enumerate((pid,) + tuple(cls.FALLBACKS)):
# A fallback is asked for ITS OWN feed. The operator's feed override is
# for the first provider and would send energy-charts' request to a
# URL it was never meant for.
o = opts if step == 0 else dict(opts, price_provider=sid,
price_feed_url="")
read = await load(o, since, until, transport)
if read.match(series=lambda r: r.covers(stamp), no_data=lambda _r: False):
# One fallback is shipped. Any further one would also be "public".
where = STEP_OWN if step == 0 else STEP_PUBLIC
watch.note(where, reasons, opts)
return Resolved(read, where, tuple(reasons))
reasons.append(read.match(
series=lambda r: f"{sid}: it has no price for now",
no_data=lambda r: r.reason))
if cls.KEEPS_LAST_GOOD:
kept = history.stored(stamp)
if kept.match(series=lambda r: r.covers(stamp),
no_data=lambda _r: False):
watch.note(STEP_STORED, reasons, opts)
return Resolved(kept, STEP_STORED, tuple(reasons))
reasons.append(kept.match(
series=lambda r: "price_history: it has no price for now",
no_data=lambda r: r.reason))
watch.note(STEP_NONE, reasons, opts)
# The reason is short on purpose: it is printed on the status page and in
# a WARNING every refresh. What each step said is on the DEBUG line.
return Resolved(PriceRead.no_data(
"no price source has a price for now"), STEP_NONE,
tuple(scrub(r, opts) for r in reasons))
# --------------------------------------------------------------------------- #
# The maintenance charge gate, read from the provider's prices
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Gate:
"""What the 30 s tick takes from the standing plan.
`covered` is whether a published price covers now. When it is False,
`cheap` is False and the charge waits: no price is not a cheap price.
`since` is when the cover ended, for the status line, or None when nothing
was ever published.
"""
cheap: bool
covered: bool
since: datetime | None
words: str
NO_PLAN = Gate(False, False, None, "No prices yet")
def _round5(value: float) -> float:
# The Home Assistant template rounded the average to 5 decimals, and a
# price to 5 as well. Both sides are compared as it did, so a quarter that
# sits within 0.00001 EUR/kWh of the average gets the same answer.
return round(float(value), 5)
def gate_for(published, now: datetime) -> Gate:
"""The charge gate for `now`, from the series the chain published.
THE RULE (owner's decision, PRICE-01): the current 15-minute price is at or
below the average of today's prices, today being the Brussels day. This is
what `now_p <= avg_p` was on the Home Assistant pair. It is NOT
`TariffPlan.cheap_now()`, which is strictly below the mean of the firm
hours of the whole plan, and it is not meant to be.
`published` is a series or None. With no price for now there is no gate
answer: the charge waits.
"""
if published is None:
return NO_PLAN
if not published.covers(now):
return Gate(False, False, published.last_end_before(now),
"No price for now")
today = brussels(now).date()
todays = [p.eur_per_kwh for p in published.points
if not p.provisional and brussels(p.start).date() == today]
here = published.at(now)
if here.provisional or not todays:
return Gate(False, False, published.last_end_before(now),
"No price for now")
avg = sum(todays) / len(todays)
cheap = _round5(here.eur_per_kwh) <= _round5(avg)
source = SOURCE_WORDS.get(published.provider, published.provider)
return Gate(cheap, True, None,
f"Prices from {source}, {here.eur_per_kwh:.4f} EUR/kWh now, "
f"{'cheap' if cheap else 'not cheap'} against today's average "
f"of {avg:.4f}")
+43 -3
View File
@@ -581,7 +581,7 @@ class PriceHistory:
# -- the one public entry point -----------------------------------------
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
stamp: datetime) -> PriceRead:
stamp: datetime, remember: bool = True) -> PriceRead:
"""Remember what this read published, then §8.3-fill the missing hours.
THE ORDER IS DELIBERATE AND IS THE POINT OF THE METHOD. The read is
@@ -599,7 +599,11 @@ class PriceHistory:
source can price is left out (§11.4 for that hour), and `no_data` comes
back when that is every hour.
"""
self._remember(read, stamp)
if remember:
# PRICE-01: False only for a read that CAME FROM this store
# (`stored()`), which is already filed; filing it again would
# rewrite the same file for nothing, and nothing else may pass it.
self._remember(read, stamp)
return fill_from_yesterday(read, yesterday=self.yesterday(stamp),
since=since, until=until, stamp=stamp)
@@ -798,6 +802,37 @@ class PriceHistory:
str(rec.get("provider") or "price_history"),
stamp)
def stored(self, stamp: datetime) -> PriceRead:
"""PRICE-01: every price this store holds, as one series, or no_data.
This is the LAST step of the price server's fallback (price_chain.py):
the answer of the last fetch that worked, kept on disk, so a site can
plan for as long as the days it holds last. It is a read of what was
PUBLISHED and filed, in the order they happened, and it is never the
carried fill (rule 1 holds: `carry(..., remember=False)` is how this
read is filled, and it is not filed again).
The prices are the all-in ones stored when they were fetched. They are
not worked out again, so a change of markup or taxes shows from the
next fetch that works, not in these.
"""
if stamp.tzinfo is None or stamp.utcoffset() is None:
raise ValueError(f"stored({stamp!r}): a naive datetime is not an "
f"instant")
points = []
for day in sorted(self._days):
rec = self._days[day]
got, _bad = _parse_points(rec.get("points") if isinstance(
rec, dict) else None)
points.extend(got)
if not points:
why = self.last_error or "it holds no prices"
return PriceRead.no_data(
f"the price history at {self.path} gives nothing: {why}")
points.sort(key=lambda p: p.start.astimezone(timezone.utc))
return PriceRead.series(points, PriceProduct.DAY_AHEAD,
"price_history", stamp)
# -- observability -------------------------------------------------------
@property
def words(self) -> str:
@@ -873,9 +908,14 @@ class NoHistory:
last_error = "no history is kept by this caller"
words = "not kept"
def carry(self, read: PriceRead, *, since, until, stamp) -> PriceRead:
def carry(self, read: PriceRead, *, since, until, stamp,
remember: bool = True) -> PriceRead:
return read
def stored(self, stamp: datetime) -> PriceRead:
return PriceRead.no_data("this caller keeps no price history, so "
"there are no stored prices")
def yesterday(self, stamp: datetime) -> PriceRead:
return PriceRead.no_data(
"this caller keeps no price history, so there are no prices from "
+31 -6
View File
@@ -93,12 +93,12 @@ def watts(state: dict | None) -> tuple:
unknown" are different claims and the page has to be able to make the first.
⚠️ THE UNIT IS READ OFF THE ENTITY AND NOT ASSUMED, AND THERE IS NO
MAGNITUDE HEURISTIC. `read_inputs()` guesses kW-or-W for
`peak_forecast_entity` with `abs(raw) < 50`, which is safe there because
nobody's quarter-hour demand forecast is 50 W. It is NOT safe here: 30 W of
real production at dusk is an ordinary reading, and that heuristic would
publish it as 30 kW - a fabricated number, in the channel whose entire
purpose is to be believed a week later. A sensor whose unit this cannot read
MAGNITUDE HEURISTIC. 30 W of real production at dusk is an ordinary reading,
and a "small number means kW" rule would publish it as 30 kW - a fabricated
number, in the channel whose entire purpose is to be believed a week later.
SAFETY-118 removed the last such rule, on `peak_forecast_entity`, for the
same reason (DECISIONS/SAFETY-118-peak-forecast-unit.md): the peak forecast
now reads through this function too. A sensor whose unit this cannot read
is REFUSED rather than guessed at.
"""
if state is None:
@@ -126,6 +126,31 @@ def watts(state: dict | None) -> tuple:
return value * WATTS_PER[unit], unit
def has_reading(state: dict | None) -> bool:
"""SAFETY-118. True when `state` holds a number, so its unit can be judged.
A missing, unavailable, unknown or non-numeric state is a missing reading."""
if state is None:
return False
raw = str(state.get("state", "")).strip()
if raw.lower() in BAD_STATES:
return False
try:
float(raw)
except ValueError:
return False
return True
def unit_fault(state: dict | None):
"""SAFETY-118. `None` unless `state` holds a number whose unit cannot be
read in watts, then that unit (`""` for none). `None` also means a missing
reading: use `has_reading()` to tell it from a good unit."""
if not has_reading(state):
return None
unit = str((state.get("attributes") or {}).get(UNIT_KEY) or "").strip()
return None if unit in WATTS_PER else unit
# --- TEL-12: the ROSTER, and the SITE figure over it --------------------------
def entities(opts: dict) -> tuple:
+4 -2
View File
@@ -54,10 +54,12 @@ class SetupOnly:
def status_payload(self, lang: str = "en") -> dict:
return {"setup_only": True, "mode": "setup_only",
"banner": messages.banner_setup_only(), "level": "warn",
"banner": messages.pick(messages.banner_setup_only(),
lang), "level": "warn",
"rows": [["Control", "not started, in setup"]],
"checks": [{"ok": False, "warn": True,
"text": messages.row_setup_only()}],
"text": messages.pick(messages.row_setup_only(),
lang)}],
"hint": self.hint, "events": list(self.events)}
async def handle_action(self, what):
+2
View File
@@ -78,6 +78,8 @@ h2{font-size:15px;margin:0 0 10px;font-weight:650}
.bars i.cheap{background:var(--cheap)}
/* not by colour alone: expensive hours are striped, estimates are faded */
.bars i.dear{background:repeating-linear-gradient(135deg,var(--dear) 0 4px,color-mix(in srgb,var(--dear) 60%,var(--card)) 4px 7px)}
.bars.fine{gap:1px}
.bars.fine i{border-radius:1px 1px 0 0}
.bars i.est{opacity:.55}
.bars i.none{background:none;border-bottom:2px dotted var(--faint);height:2px!important}
.lane{position:relative;height:18px;margin-top:4px;background:var(--bg);border-radius:4px}
+2 -1
View File
@@ -177,11 +177,12 @@
const known = p.prices.filter((b) => b.eur_kwh != null);
const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh)));
bars.setAttribute("aria-label", t("plan.name"));
bars.classList.toggle("fine", p.prices.length > 48);
bars.replaceChildren(...p.prices.map((b) => {
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]);
const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (b.estimate ? ", " + t("plan.estimate_tip") : "");
const est = (b.estimate ? ", " + t("plan.estimate_tip") : "");
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
+1 -1
View File
@@ -311,7 +311,7 @@
const days = h("select", { class: "sel", "aria-label": say(P.days) });
for (const [v, label] of f.days || []) days.append(h("option", { value: v, selected: v === row.days }, label));
days.addEventListener("change", () => set("days", days.value));
const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: "0.01",
const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: String(f.step || 0.01),
value: row.price == null ? "" : row.price, "aria-label": say(P.price) });
price.addEventListener("input", () => set("price", price.value === "" || isNaN(+price.value) ? null : +price.value));
const cell = (label, ...kids) => h("label", { class: "pf" }, h("span", { class: "tiny muted" }, label), ...kids);
+35 -4
View File
@@ -204,12 +204,13 @@ import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from . import messages
from . import messages, price_chain
from .price import (
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
Uncertainty, _Outcome, brussels, load_prices, price_option,
provider_configured, required_spread, uncertainty_of, widen_factor,
)
from .price_chain import uses_chain
_LOG = logging.getLogger("goodwe.tariff.static")
@@ -793,6 +794,13 @@ class TariffPlan:
self.read = read
self.profile = profile
self.origin = origin
# PRICE-01. The series the price chain found, BEFORE the yesterday fill
# cut it to the hours ahead: the maintenance gate needs the whole of
# today to know its average. None for every plan that did not come
# through the chain, which is every plan of every other provider.
self.published = None
self.step = None
self.reasons = ()
# -- derived facts -------------------------------------------------------
@property
@@ -1274,7 +1282,8 @@ def static_status(opts: dict, now: datetime):
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
transport=None, now=None, *, history) -> TariffPlan:
transport=None, now=None, *, history,
watch=None) -> TariffPlan:
"""The full §9.2 + §11.4 resolution. MAY PERFORM I/O - see `static_plan`.
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
@@ -1330,13 +1339,30 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
if not provider_configured(opts):
return static_plan(opts, since, until, provisional=False, now=stamp)
if uses_chain(opts):
# PRICE-01. The provider names fallbacks, so its prices come from the
# first step of the chain that has one for now. Nothing else does.
found = await price_chain.resolve(
opts, since, until, transport, history, stamp,
watch if watch is not None else price_chain.PriceWatch(),
load=load_prices)
plan = found.read.match(
series=lambda r: _provider_plan(
opts, r, since, until, stamp, history,
remember=found.step != price_chain.STEP_STORED),
no_data=lambda r: _degrade(opts, r, since, until, stamp))
plan.published, plan.step = found.published, found.step
plan.reasons = found.reasons
return plan
read = await load_prices(opts, since, until, transport)
return read.match(
series=lambda r: _provider_plan(opts, r, since, until, stamp, history),
no_data=lambda r: _degrade(opts, r, since, until, stamp))
def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
def _provider_plan(opts, read, since, until, stamp, history, *,
remember=True) -> TariffPlan:
"""§8.3: record what the feed published, carry yesterday into the gaps.
The fill can itself return `no_data` - a feed whose points all fall outside
@@ -1344,7 +1370,12 @@ def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
§11.4 row arrived at one step later, so it degrades exactly as a failed
fetch does rather than being special-cased here.
"""
filled = history.carry(read, since=since, until=until, stamp=stamp)
if remember:
filled = history.carry(read, since=since, until=until, stamp=stamp)
else:
# PRICE-01: a read that came out of the store is not filed again.
filled = history.carry(read, since=since, until=until, stamp=stamp,
remember=False)
return filled.match(
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
no_data=lambda r: _degrade(opts, r, since, until, stamp))
+10 -8
View File
@@ -23,19 +23,19 @@
"contract": {"name": "Your contract", "description": "How your supplier prices each kWh you take from the grid."},
"region": {"name": "Region", "description": "Fills in grid costs, levies and VAT for the year shown, but not yet for Wallonia or Brussels, so check your bill."},
"fallback": {"name": "Price when the feed is down", "description": "Used when the price source does not answer."},
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together."},
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill."},
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."},
"periods": {"name": "Time windows and prices", "description": "One price per kWh for each time window, all in, as on your bill."}
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together: divide c€/kWh from the bill by 100, so 34.512 c€/kWh is 0.34512."},
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill: divide c€/kWh by 100, so 34.512 c€/kWh is 0.34512."},
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill: divide c€/kWh by 100, so 34.512 c€/kWh is 0.34512."},
"periods": {"name": "Time windows and prices", "description": "One price per kWh for each time window, all in, as on your bill: divide c€/kWh by 100, so 34.512 c€/kWh is 0.34512."}
},
"choices": {
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"},
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, every 15 minutes", "custom": "Other time windows"},
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"},
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
"meter_phases": {"1": "1 phase", "3": "3 phases"},
"shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"},
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file"},
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file", "price_service": "Your own price service"},
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"},
"period_days": {"mon-sun": "Every day", "mon-fri": "Monday to Friday", "sat,sun": "Saturday and Sunday", "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday"}
},
@@ -66,8 +66,7 @@
"estimate_tip": "estimate from yesterday",
"none_tip": "No price known",
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
"list": "The plan in words",
"average_tip": "hour average"
"list": "The plan in words"
},
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
@@ -410,6 +409,9 @@
"give_would": "Tomorrow: would give power at {at}, {eur} per kWh."
},
"cycle_waits": "{name} waits its turn for the monthly cycle.",
"no_prices_yet": "No prices yet, maintenance waits.",
"no_prices_since": "No prices since {at}, maintenance waits.",
"no_prices_since_day": "No prices since {day} {month} at {at}, maintenance waits.",
"step_in": "The batteries step in above {kw}.",
"step_in_would": "The batteries would step in above {kw}.",
"period": "This 15-minute period is heading for {kw}.",
+10 -8
View File
@@ -23,19 +23,19 @@
"contract": {"name": "Uw contract", "description": "Hoe uw leverancier elke kWh prijst die u van het net afneemt."},
"region": {"name": "Regio", "description": "Vult nettarieven, heffingen en btw in voor het getoonde jaar, nog niet voor Wallonië of Brussel, dus kijk uw factuur na."},
"fallback": {"name": "Prijs als de feed wegvalt", "description": "Gebruikt als de prijsbron niet antwoordt."},
"fixed_price": {"name": "Prijs per kWh, alles inbegrepen", "description": "Energie, kosten van de leverancier, nettarieven, heffingen en btw samen."},
"day_price": {"name": "Dagprijs, weekdagen 07:00 tot 22:00", "description": "Alles inbegrepen, zoals op uw factuur."},
"night_price": {"name": "Nacht- en weekendprijs", "description": "Alles inbegrepen, zoals op uw factuur."},
"periods": {"name": "Tijdvakken en prijzen", "description": "Eén prijs per kWh voor elk tijdvak, alles inbegrepen, zoals op uw factuur."}
"fixed_price": {"name": "Prijs per kWh, alles inbegrepen", "description": "Energie, kosten van de leverancier, nettarieven, heffingen en btw samen: deel c€/kWh van de factuur door 100, dus 34,512 c€/kWh is 0,34512."},
"day_price": {"name": "Dagprijs, weekdagen 07:00 tot 22:00", "description": "Alles inbegrepen, zoals op uw factuur: deel c€/kWh door 100, dus 34,512 c€/kWh is 0,34512."},
"night_price": {"name": "Nacht- en weekendprijs", "description": "Alles inbegrepen, zoals op uw factuur: deel c€/kWh door 100, dus 34,512 c€/kWh is 0,34512."},
"periods": {"name": "Tijdvakken en prijzen", "description": "Eén prijs per kWh voor elk tijdvak, alles inbegrepen, zoals op uw factuur: deel c€/kWh door 100, dus 34,512 c€/kWh is 0,34512."}
},
"choices": {
"contract": {"none": "Geen prijzen", "fixed": "Vaste prijs", "daynight": "Dag en nacht", "dynamic": "Dynamisch, per uur", "custom": "Andere tijdvakken"},
"contract": {"none": "Geen prijzen", "fixed": "Vaste prijs", "daynight": "Dag en nacht", "dynamic": "Dynamisch, per kwartier", "custom": "Andere tijdvakken"},
"fallback": {"none": "Geen", "fixed": "Eén prijs", "daynight": "Dag en nacht", "custom": "Andere tijdvakken"},
"region": {"flanders": "Vlaanderen, Fluvius Antwerpen", "own": "Ik vul zelf een bedrag in"},
"meter_source": {"off": "Eén entiteit voor netvermogen", "ha_dsmr": "Entiteiten voor afname en injectie", "mqtt_p1": "Meter via MQTT", "ha_signed": "Eén entiteit voor nettovermogen", "homewizard_local": "HomeWizard-meter op het netwerk"},
"meter_phases": {"1": "1 fase", "3": "3 fasen"},
"shadow_mode": {"auto": "Automatisch", "on": "Altijd aan", "off": "Uit"},
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand"},
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand", "price_service": "Uw eigen prijsdienst"},
"log_level": {"trace": "Alles, voor support", "debug": "Uitgebreid, om een probleem te vinden", "info": "Normaal", "warning": "Waarschuwingen en fouten", "error": "Alleen fouten"},
"period_days": {"mon-sun": "Elke dag", "mon-fri": "Maandag tot vrijdag", "sat,sun": "Zaterdag en zondag", "mon": "maandag", "tue": "dinsdag", "wed": "woensdag", "thu": "donderdag", "fri": "vrijdag", "sat": "zaterdag", "sun": "zondag"}
},
@@ -63,8 +63,7 @@
"estimate_tip": "schatting op basis van gisteren",
"none_tip": "Geen prijs bekend",
"no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."},
"list": "Het plan in woorden",
"average_tip": "gemiddelde van het uur"
"list": "Het plan in woorden"
},
"peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"},
"attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"},
@@ -345,6 +344,9 @@
},
"tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."},
"cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
"no_prices_yet": "Nog geen prijzen, onderhoud wacht.",
"no_prices_since": "Geen prijzen sinds {at}, onderhoud wacht.",
"no_prices_since_day": "Geen prijzen sinds {day} {month} om {at}, onderhoud wacht.",
"step_in": "De batterijen springen bij boven {kw}.",
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
"period": "Dit kwartier gaat richting {kw}.",
+8 -6
View File
@@ -164,13 +164,13 @@ GROUPS = (
wizard=4),
Field("price_export_formula", TEXT),
Field("ui.fallback", SEGMENTED, show=_DYNAMIC, wizard=4),
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.01,
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.00001,
show=(("ui.fallback", "==", "fixed"),), wizard=4),
Field("ui.day_price", NUMBER, "€/kWh", step=0.01,
Field("ui.day_price", NUMBER, "€/kWh", step=0.00001,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
Field("ui.night_price", NUMBER, "€/kWh", step=0.01,
Field("ui.night_price", NUMBER, "€/kWh", step=0.00001,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
Field("ui.periods", PERIOD_LIST, "€/kWh", step=0.01,
Field("ui.periods", PERIOD_LIST, "€/kWh", step=0.00001,
show=(("ui.fallback", "==", "custom"),), wizard=4),
)),
Part("selling", (
@@ -194,7 +194,7 @@ GROUPS = (
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
simple=True, wizard=5, note="month_peak"),
Field("peak_margin_w", SLIDER, "W", step=10), # SAFETY-79
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
Field("peak_forecast_entity", ENTITY, kind="power", wizard=5),
Field("peak_soc_floor", LOCKED, "%"),
)),
Part("start", (
@@ -297,7 +297,9 @@ DERIVED = {
"price_taxes_profile": "ui.region", # a preset writes it, "own" keeps it
}
# Option keys the page never shows and never writes: passed through as stored.
HIDDEN: dict = {}
# PRICE-01: the price service token is a secret. It is set in the Configuration
# tab (a password field there) and this page never draws it.
HIDDEN: dict = {"price_service_token": "a secret, set on the Configuration tab"}
def fields():
+12 -7
View File
@@ -12,14 +12,17 @@ import copy
import os
import re
from . import messages
from . import uilayout as L
from . import uitext
from . import uimodel as U
from .configui import help_of, label_of, problems_by_field, split_group
from .validate import power_request_entities
from .validate import note_text, power_request_entities
# A choice the page cannot make, only keep. None since P5's period editor.
ONLY_IF_HELD = ()
# A choice the page cannot make, only keep. `price_service` needs a token, and
# the token is a secret this page never draws (uilayout.HIDDEN), so it is set on
# the Configuration tab and the page only keeps it (PRICE-01).
ONLY_IF_HELD = ("price_service",)
_TIERS = ("bad", "warn", "info")
@@ -245,7 +248,7 @@ def derive_messages(errors, problems, notes, model, keys, lang="en"):
kept = [p for p in problems if p in kept]
notes = [n for n in notes if not set(n.keys or ()) & failed]
return ([derive_message(err, lang) for err in errors]
+ anchors(kept, notes, model, keys))
+ anchors(kept, notes, model, keys, lang))
# --------------------------------------------------------------------------- #
@@ -290,9 +293,10 @@ def targets(key: str, text: str, model: dict) -> list:
return [(None, None)]
def anchors(problems, notes, model, keys) -> list:
def anchors(problems, notes, model, keys, lang="en") -> list:
"""[{field, card, tier, text}] for the refusals and notes. `field` is
None for a sentence that names no setting on the page."""
None for a sentence that names no setting on the page. A note with a Dutch
twin in `messages.NOTE_NL` is shown in Dutch when `lang` is "nl"."""
out, seen = [], set()
def add(field, card, tier, text):
@@ -310,10 +314,11 @@ def anchors(problems, notes, model, keys) -> list:
add(None, None, "bad", text)
for note in notes:
tier = note.tier if note.tier in _TIERS else "info"
text = note_text(note, lang)
for key in note.keys or (None,):
for field, card in (targets(key, note.text, model) if key
else [(None, None)]):
add(field, card, tier, note.text)
add(field, card, tier, text)
# A sentence placed on one inverter's field is not also put on the list.
roster = _roster()
placed = {m["text"] for m in out
+165 -39
View File
@@ -386,12 +386,23 @@ class Note(NamedTuple):
# also "ok" and "bad" (setup_checks)
keys: tuple
text: str
nl: str = "" # UX-46: the same sentence in Dutch
def _advise(notes, tier: str, keys, text: str) -> None:
"""Add a Note when the caller collects them (the settings page)."""
def _advise(notes, tier: str, keys, text: str, nl: str = "") -> None:
"""Add a Note when the caller collects them (the settings page). `nl` is
the Dutch twin of `text`; the page picks one (`note_text()`)."""
if notes is not None:
notes.append(Note(tier, tuple(keys), text))
notes.append(Note(tier, tuple(keys), text, nl))
def note_text(note, lang: str = "en") -> str:
"""The note's sentence in the page's language, English when it has no
Dutch twin."""
if lang == "nl":
return getattr(note, "nl", "") or messages.NOTE_NL.get(
note.text, note.text)
return note.text
# ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware
@@ -628,6 +639,11 @@ def S(key: str, where: str = "") -> str:
return messages.setting(key, where.strip().strip("()"))
def SN(key: str, where: str = "") -> str:
"""`S()` with the Dutch label, for the Dutch twin of a note."""
return messages.setting(key, where.strip().strip("()"), "nl")
def _labels(keys, joiner: str = "and") -> str:
"""Several settings, each with its key: the config panel files a refusal
under every key it names (`configui.problems_by_field()`)."""
@@ -1216,7 +1232,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
integ_w, max_w)
_advise(notes, "warn", ("integrator_max_w",),
f"{S('integrator_max_w')} is above {S('max_w')}, which only "
"adds overshoot, so set it to 0.")
"adds overshoot, so set it to 0.",
nl=f"{SN('integrator_max_w')} is hoger dan {SN('max_w')}, wat "
"alleen doorschieten geeft, dus zet het op 0.")
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
# drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is
@@ -1244,7 +1262,10 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
_advise(notes, "warn", ("maintenance_discharge_w",),
f"{S('maintenance_discharge_w')} is above "
f"{S('max_w', maint_where)}, so the battery empties at "
f"{messages.watts(maint_cap)} and the cycle takes longer.")
f"{messages.watts(maint_cap)} and the cycle takes longer.",
nl=f"{SN('maintenance_discharge_w')} is hoger dan "
f"{SN('max_w', maint_where)}, dus de batterij loopt leeg met "
f"{messages.watts(maint_cap)} en de cyclus duurt langer.")
_LOG.debug(
"Not refused: the drain phase sets the setpoint, the arbiter "
"clamps it to %.0f W and names the clamp, and the drain phase has "
@@ -1473,7 +1494,9 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
TAXES_PROFILE_OPTION, negative)
_advise(notes, "warn", (TAXES_PROFILE_OPTION,),
f"{S(TAXES_PROFILE_OPTION)} has negative figures, which "
"lower the price, and that is usually a sign error.")
"lower the price, and that is usually a sign error.",
nl=f"{SN(TAXES_PROFILE_OPTION)} heeft negatieve bedragen, "
"die de prijs verlagen, en dat is meestal een tekenfout.")
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
# STARTUP RATHER THAN AT 03:00.
@@ -1532,7 +1555,10 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
_advise(notes, "warn", ("price_formula", "price_provider"),
f"{S('price_formula')} is not used, because "
f"{S('price_provider')} is not 'custom_formula', so clear "
"it or choose that source.")
"it or choose that source.",
nl=f"{SN('price_formula')} wordt niet gebruikt, omdat "
f"{SN('price_provider')} niet 'custom_formula' is, dus wis "
"ze of kies die bron.")
if spec is not None and pid == "custom_formula":
taxes = _number(opts, "price_taxes_eur_kwh",
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
@@ -1547,7 +1573,8 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
# cannot start.
_LOG.warning("%s", aged)
_advise(notes, "warn", ("price_formula",),
_card_note("price_formula", spec))
_card_note("price_formula", spec),
nl=_card_note("price_formula", spec, "nl"))
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
#
@@ -1630,7 +1657,10 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
xspec.offset_eur_kwh)
_advise(notes, "info", (EXPORT_FORMULA_OPTION,),
f"{S(EXPORT_FORMULA_OPTION)} has no card block, so "
"only the shape of its numbers is checked.")
"only the shape of its numbers is checked.",
nl=f"{SN(EXPORT_FORMULA_OPTION)} heeft geen kaartblok, "
"dus alleen de vorm van de getallen wordt "
"gecontroleerd.")
_LOG.debug(
"Accepted because some suppliers (Eneco) print the "
"injection formula with no price beside it. If your card "
@@ -1643,7 +1673,8 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
if xaged:
_LOG.warning("%s", xaged)
_advise(notes, "warn", (EXPORT_FORMULA_OPTION,),
_card_note(EXPORT_FORMULA_OPTION, xspec))
_card_note(EXPORT_FORMULA_OPTION, xspec),
nl=_card_note(EXPORT_FORMULA_OPTION, xspec, "nl"))
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
# site with no price data falls back to the static profile; a dynamic site
@@ -1657,7 +1688,16 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
"fallback.", pid)
_advise(notes, "warn", ("static_profile",),
f"{S('static_profile')} is empty, so the site has no tariff "
"at all while the price feed is down.")
"at all while the price feed is down.",
nl=f"{SN('static_profile')} is leeg, dus de installatie heeft "
"helemaal geen tarief zolang de prijsfeed uitvalt.")
# PRICE-01: not a fault, so an info note and no log line here (the add-on
# logs it once at start). With `price_service` the fixed tariff is only the
# plan's last fallback; the cheap window and the gate do not read it.
if pid == "price_service" and str(opts.get("static_profile", "") or "").strip():
_advise(notes, "info", ("static_profile",), messages.FIXED_TARIFF_NOTE,
nl=messages.FIXED_TARIFF_NOTE_NL)
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
# rather than logged, because a strategy that is armed in the UI and cannot
@@ -1706,20 +1746,45 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
f"{S('price_csv_path')} is empty. Set the path to the CSV file, or "
"choose 'off'.")
# PRICE-01. The price service answers only to its token. Without one every
# fetch is refused and the add-on lives on its fallbacks, which the
# operator would not see: a source they chose, overridden by a layer they
# cannot see. A token the server cannot read as a header is the same case.
if pid == "price_service":
token = str(opts.get("price_service_token", "") or "").strip()
if not token:
problems.append(
f"{S('price_provider')} is 'price_service', but "
f"{S('price_service_token')} is empty. Set the token, or "
"choose another source.")
elif any(c.isspace() or not c.isprintable() for c in token):
problems.append(
f"{S('price_service_token')} has a space or a line break in "
"it. Paste the token again, with nothing around it.")
elif len(token) < PRICE_TOKEN_MIN:
problems.append(
f"{S('price_service_token')} is too short. Paste the whole "
"token.")
return problems
def _card_note(option: str, spec) -> str:
def _card_note(option: str, spec, lang: str = "en") -> str:
if lang == "nl":
return (f"{SN(option)} gebruikt een tariefkaart van "
f"{spec.card_month}, dus neem de nieuwe cijfers over als uw "
"contract veranderde.")
return (f"{S(option)} uses a tariff card dated {spec.card_month}, so copy "
"the new figures if your contract changed.")
# UX-13: entity options whose number the add-on takes as watts, with no unit
# conversion. pv.py converts each solar entity's own unit, so those are not here.
# conversion. pv.py converts each solar entity's own unit, so those are not here,
# and SAFETY-118 moved the peak forecast there too.
WATT_ENTITY_KEYS = (
"meter_entity", "p1_net_entity", "p1_import_entity", "p1_export_entity",
"p1_phase_net_entities", "p1_phase_import_entities",
"p1_phase_export_entities", "batt_entity", "peak_forecast_entity")
"p1_phase_export_entities", "batt_entity")
# The meter entities each `meter_source` reads through Home Assistant.
@@ -1762,6 +1827,11 @@ def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
return out
# PRICE-01 F5. A shorter value is not a token; it also could not be scrubbed
# from a log (price.scrub skips values under 4 characters).
PRICE_TOKEN_MIN = 16
def config_notes(opts: dict, *, entities: dict | None = None,
month_peak_w=None) -> list[Note]:
"""UX-13: warnings that need live data, for the settings page. Never a
@@ -1778,7 +1848,10 @@ def config_notes(opts: dict, *, entities: dict | None = None,
if month_peak_w is not None and cap is not None and cap < month_peak_w:
_advise(notes, "warn", ("peak_cap_w",),
f"This month's peak is already {messages.watts(month_peak_w)}, "
f"so a lower {S('peak_cap_w')} saves nothing until next month.")
f"so a lower {S('peak_cap_w')} saves nothing until next month.",
nl=f"De piek van deze maand is al "
f"{messages.watts(month_peak_w)}, dus een lagere "
f"{SN('peak_cap_w')} bespaart niets tot volgende maand.")
return notes
@@ -1791,27 +1864,43 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
if row is None:
_advise(notes, "bad" if key in bad else "warn", (key,),
f"Home Assistant has no entity {entity}, which "
f"{S(key, where)} names.")
f"{S(key, where)} names.",
nl=f"Home Assistant heeft geen entiteit {entity}, terwijl "
f"{SN(key, where)} die noemt.")
continue
unit = str(row.get("unit") or "")
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
_advise(notes, "warn", (key,),
f"{entity} reads in {unit}, but {S(key, where)} is "
"taken as watts, so pick a sensor in W.")
"taken as watts, so pick a sensor in W.",
nl=f"{entity} meldt in {unit}, maar {SN(key, where)} wordt "
"als watt gelezen, dus kies een sensor in W.")
if key == "peak_forecast_entity" and unit not in pv.WATTS_PER:
_advise(notes, "warn", (key,),
messages.peak_forecast_unit(unit) + " Pick a sensor in "
"W or kW.",
nl=messages.peak_forecast_unit(unit, "nl")
+ " Kies een sensor in W of kW.")
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
_advise(notes, "warn", (key,),
f"{entity} does not read in kWh, so pick a solar "
"forecast sensor in kWh.")
"forecast sensor in kWh.",
nl=f"{entity} meldt niet in kWh, dus kies een sensor voor "
"de zonneprognose in kWh.")
today = solarfc.entities(opts)
tomorrow = solarfc.entities(opts, tomorrow=True)
if (today or tomorrow) and \
opts.get("battery_modes_enabled") is not True:
_advise(notes, "info", ("pv_forecast_entity",),
"The solar forecast is used only with battery modes on.")
"The solar forecast is used only with battery modes on.",
nl="De zonneprognose wordt alleen gebruikt met batterijmodi "
"aan.")
if tomorrow and not today:
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
"Tomorrow's forecast is used only with today's, so set "
f"{S('pv_forecast_entity')} too.")
f"{S('pv_forecast_entity')} too.",
nl="De prognose voor morgen wordt alleen met die van vandaag "
f"gebruikt, dus stel ook {SN('pv_forecast_entity')} in.")
# --------------------------------------------------------------------------- #
@@ -1890,7 +1979,9 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
if entities is None:
# Red: only the list can tell a missing power request entity (SAFETY-77).
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
"answering, so press Check again once it is back.")
"answering, so press Check again once it is back.",
nl="Home Assistant antwoordt niet, dus druk op Opnieuw "
"nakijken zodra het terug is.")
else:
# A missing meter entity is red too (UX-15): with the kettle test
# skipped, nothing else on the way to the save would catch it.
@@ -1912,13 +2003,16 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
solar.append(None)
_advise(notes, "warn", ("pv_invert_entities",),
f"{entity} reads below zero now, so turn on its "
"“Reads negative” switch in the Solar step.")
"“Reads negative” switch in the Solar step.",
nl=f"{entity} meet nu onder nul, dus zet de schakelaar "
"“Meet negatief” aan bij de stap Zonnepanelen.")
else:
w = -w if entity in flipped else w
solar.append(w)
_advise(notes, "ok", ("pv_entity",),
f"{entity} reads {_signed(w)} of solar production "
"now.")
"now.",
nl=f"{entity} meet nu {_signed(w)} zonneproductie.")
# A battery sensor that reads the wrong way while giving power makes the
# house use less than nothing. Only with every term known: a missing
# solar reading could be the difference.
@@ -1928,7 +2022,10 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
_advise(notes, "warn", ("batt_invert",),
f"The readings add up to {_signed(sum(known))} of house "
"use, so check each "
"battery's “Battery power sign is reversed” switch in step 2.")
"battery's “Battery power sign is reversed” switch in step 2.",
nl=f"De metingen tellen op tot {_signed(sum(known))} "
"huisverbruik, dus controleer bij elke batterij de schakelaar "
"“Teken batterijvermogen omkeren” in stap 2.")
_price_check(notes, opts, running, prices_known)
return notes
@@ -1952,7 +2049,9 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
return float(meter_now_w)
_advise(notes, "info", ("meter_source",), "The add-on reads "
"this meter itself, so Home shows its reading after "
"the restart.")
"the restart.",
nl="De add-on leest deze meter zelf, dus Start toont de "
"meting na de herstart.")
return None
if entities is None:
return None
@@ -1965,7 +2064,9 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
if w is None:
_advise(notes, "warn", ("meter_source",),
f"{entity} reads {text} now, so check it in Home "
"Assistant before you start.")
"Assistant before you start.",
nl=f"{entity} meldt nu {text}, dus controleer ze in Home "
"Assistant voor u start.")
return None
total += sign * w
if source == "off" and opts.get("meter_invert"):
@@ -1976,12 +2077,14 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
def _meter_line(notes, w) -> None:
if abs(w) < 1.0:
text = "The meter reads 0 W now."
text, nl = "The meter reads 0 W now.", "De meter meldt nu 0 W."
elif w > 0:
text = f"The meter reads {messages.watts(w)} taken from the grid now."
nl = f"De meter meldt nu {messages.watts(w)} afname van het net."
else:
text = f"The meter reads {messages.watts(w)} fed into the grid now."
_advise(notes, "ok", ("meter_source",), text)
nl = f"De meter meldt nu {messages.watts(w)} injectie op het net."
_advise(notes, "ok", ("meter_source",), text, nl=nl)
def _card_checks(notes, who, entry, entities):
@@ -1989,40 +2092,55 @@ def _card_checks(notes, who, entry, entities):
positive while giving power, or None when it is not known."""
if entities is None:
return None
wie = who.replace(" for ", " voor ", 1)
setpoint = str(entry.get("setpoint_entity") or "").strip()
if setpoint and setpoint in entities:
_advise(notes, "ok", ("setpoint_entity",),
f"Home Assistant has {setpoint}, the power request "
f"entity{who}.")
f"entity{who}.",
nl=f"Home Assistant heeft {setpoint}, de entiteit voor de "
f"vermogensvraag{wie}.")
soc = str(entry.get("soc_entity") or "").strip()
if not soc:
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
f"level entity{who}, so pick one in step 2 or the add-on "
"holds 0 W.")
"holds 0 W.",
nl=f"Er is geen entiteit voor het laadniveau{wie}, dus kies er "
"een in stap 2 of de add-on houdt 0 W aan.")
elif soc in entities:
level, _unit = _reading(entities, soc)
if level is None or not 0.0 <= level <= 100.0:
_advise(notes, "warn", ("soc_entity",), f"The battery "
f"level{who} reads nothing now, so check {soc}.")
f"level{who} reads nothing now, so check {soc}.",
nl=f"Het laadniveau{wie} meldt niets, dus controleer "
f"{soc}.")
else:
_advise(notes, "ok", ("soc_entity",),
f"The battery level{who} reads {level:.0f} %.")
f"The battery level{who} reads {level:.0f} %.",
nl=f"Het laadniveau{wie} meldt {level:.0f} %.")
batt = str(entry.get("batt_entity") or "").strip()
if batt and batt in entities:
w, _unit = _reading(entities, batt)
if w is None:
_advise(notes, "warn", ("batt_entity",), f"The battery "
f"power{who} reads nothing now, so check {batt}.")
f"power{who} reads nothing now, so check {batt}.",
nl=f"Het batterijvermogen{wie} meldt niets, dus controleer "
f"{batt}.")
return None
w = -w if entry.get("batt_invert") else w
if abs(w) < IDLE_W:
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
"idle now, so check its direction on Home later.")
"idle now, so check its direction on Home later.",
nl=f"De batterij{wie} staat nu stil, dus controleer later "
"op Start de richting.")
else:
doing = "giving power" if w > 0 else "charging"
doet = "levert" if w > 0 else "laadt"
_advise(notes, "info", ("batt_invert",),
f"The battery{who} reads {messages.watts(w)} {doing} "
"now, so check the inverter shows the same.")
"now, so check the inverter shows the same.",
nl=f"De batterij{wie} {doet} nu {messages.watts(w)}, dus "
"controleer of de omvormer hetzelfde toont.")
return w
return None
@@ -2034,20 +2152,28 @@ def _price_check(notes, opts, running, prices_known) -> None:
if profile:
_advise(notes, "ok", ("static_profile",),
"Your prices come from your contract, so they are "
"always known.")
"always known.",
nl="Uw prijzen komen uit uw contract, dus ze zijn altijd "
"bekend.")
else:
_advise(notes, "info", ("price_provider",),
"No prices are set, so the batteries do not charge "
"on price.")
"on price.",
nl="Er zijn geen prijzen ingesteld, dus de batterijen "
"laden niet op prijs.")
return
same = (isinstance(running, dict)
and str(running.get("price_provider") or "") == provider)
if same and prices_known:
_advise(notes, "ok", ("price_provider",),
f"Prices for today are in, for {prices_known} of 24 hours.")
f"Prices for today are in, for {prices_known} of 24 hours.",
nl=f"De prijzen van vandaag zijn binnen, voor {prices_known} "
"van 24 uur.")
else:
_advise(notes, "info", ("price_provider",),
"Prices are fetched after the restart, and Home shows them.")
"Prices are fetched after the restart, and Home shows them.",
nl="De prijzen worden na de herstart opgehaald, en Start toont "
"ze.")
def _margin_problems(opts: dict) -> list[str]:
+43 -16
View File
@@ -48,12 +48,13 @@ from datetime import datetime, timezone
from aiohttp import web
from . import messages, selfconfig, uipage, uitext
from . import messages, price, selfconfig, uipage, uitext
from .haentities import EntityCache
from .homeview import region_of
from .validate import config_notes, setup_checks
from .homeview import priced_hours, region_of
from .validate import config_notes, note_text, setup_checks
from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
problems_by_field, secret_keys)
from .uimodel import DeriveError, options_to_ui
_LOG = logging.getLogger("goodwe.web")
@@ -104,6 +105,9 @@ def page() -> str:
DEVICE_NOTE = (
"%s did not report its power range, so two checks were skipped, here and "
"at start. If they fail later, the add-on will not start then.")
DEVICE_NOTE_NL = (
"%s meldde geen vermogensbereik, dus twee controles zijn overgeslagen, "
"hier en bij het starten. Falen ze later, dan start de add-on dan niet.")
def build_app(controller, selfcfg=None) -> web.Application:
@@ -209,6 +213,11 @@ def build_app(controller, selfcfg=None) -> web.Application:
raise web.HTTPBadRequest(text="expected an options object")
return body, options, None
def _secrets(info):
# PRICE-01 F1: every `password` option of the schema, and the price
# token by name in case a schema is missing.
return secret_keys((info or {}).get("schema"), price.SECRET_OPTIONS)
async def config_model(request):
"""Everything the page needs to draw itself, from ONE Supervisor call.
@@ -227,13 +236,14 @@ def build_app(controller, selfcfg=None) -> web.Application:
_LOG.error("cannot read the add-on's own options: %s", err)
return web.json_response({"error": str(err)}, status=502)
options = info.get("options") or {}
secrets = _secrets(info)
lang = str(request.query.get("lang", "en") or "en")[:8]
model = field_model(info.get("schema") or [],
info.get("translations") or {}, lang)
model.update({
"options": options,
"options": hide_secrets(options, secrets),
"fingerprint": hand_out(options),
"running": controller.o,
"running": hide_secrets(controller.o, secrets),
"running_differs": changed_keys(controller.o, options),
"lang": lang,
# UX-13 P1: the levies preset and its year, for the P2 Settings
@@ -242,7 +252,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
# UX-13 P2: the Installer view draws from these two.
"layout": uipage.layout(info.get("schema") or [],
info.get("translations") or {}, lang),
"ui": options_to_ui(options),
"ui": hide_secrets(options_to_ui(options), secrets),
# UX-13 P4: the setup wizard, and why it opens by itself.
"setup": uipage.setup_spec(),
"setup_needed": uipage.setup_needed(
@@ -281,6 +291,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
model, options = None, _body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
try:
info_now = await selfcfg.info()
options = keep_secrets(options, info_now.get("options"),
_secrets(info_now))
except Exception: # noqa: BLE001 - a check never fails for this
pass
notes = []
problems, unread = await selfconfig.problems_and_ranges(
options, controller.hass, notes=notes)
@@ -288,21 +304,27 @@ def build_app(controller, selfcfg=None) -> web.Application:
# UX-13: warnings and info beside the refusals. Never block a save.
# The entity checks use the picker's cached list and never wait on HA.
notes += config_notes(options, entities=entities.peek())
# UX-46: one language choice for everything below that has words.
lang = uitext.lang_of(_body.get("lang"))
payload = {"problems": problems, "by_key": by_key,
"unanchored": unanchored,
"notes": [n._asdict() for n in notes]}
if unread:
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
payload["device_note"] = (DEVICE_NOTE_NL if lang == "nl"
else DEVICE_NOTE) % ", ".join(
sorted(set(unread)))
if model is not None:
payload["messages"] = (
uipage.derive_messages(errors, problems, notes, model,
_keys_of(options),
uitext.lang_of(_body.get("lang")))
_keys_of(options), lang)
if errors
else uipage.anchors(problems, notes, model, _keys_of(options)))
else uipage.anchors(problems, notes, model, _keys_of(options),
lang))
payload["steps"] = uipage.step_problems(payload["messages"], model)
if _body.get("setup"):
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
payload["setup"] = [
dict(n._asdict(), text=note_text(n, lang))
for n in await _setup_checks(options)]
return web.json_response(payload)
async def _setup_checks(options):
@@ -314,7 +336,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
try:
prices = (controller.status_payload().get("plan") or {}).get(
"prices") or []
known = sum(1 for p in prices if p.get("eur_kwh") is not None)
known = priced_hours(prices)
except Exception: # noqa: BLE001 - a display figure, never a failure
known = None
return setup_checks(options, entities=rows, running=controller.o,
@@ -339,9 +361,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
stored_now = None
if isinstance(posted, dict) and isinstance(posted.get("model"), dict):
secrets = frozenset(price.SECRET_OPTIONS)
if isinstance(posted, dict):
try:
stored_now = (await selfcfg.info()).get("options") or {}
info_now = await selfcfg.info()
stored_now = info_now.get("options") or {}
secrets = _secrets(info_now)
except selfconfig.SupervisorUnavailable as err:
_LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
@@ -357,6 +382,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
"unanchored": [], "messages": messages_,
"steps": uipage.step_problems(messages_, model)},
status=422)
# PRICE-01 F1: a secret the page did not change is the stored one.
options = keep_secrets(options, stored_now, secrets)
expect = str(body.get("fingerprint") or "")
try:
result = await selfconfig.save(selfcfg, options, expect,
@@ -374,7 +401,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
"moved": refused.moved,
"changed_fields": (uipage.fields_of(moved, model)
if model is not None else []),
"stored": stored,
"stored": hide_secrets(stored, secrets),
# The page needs the CURRENT token to be able to save
# again after the operator has read the difference. That
# is not a force door: it takes a second, deliberate press
+9 -4
View File
@@ -1,9 +1,9 @@
name: GoodWe RS485 Controller
version: "0.17.0"
version: "0.18.2"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
meter, holding net grid exchange at zero and running a monthly battery
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
grid reading, holding net grid exchange at zero and running a monthly battery
maintenance cycle.
url: https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
arch:
@@ -556,6 +556,10 @@ options:
# naming that consequence on every fetch that uses an override; clearing this
# option is what restores the full horizon.
price_feed_url: ""
# PRICE-01. The bearer token for the owner's price server
# (`price_provider: price_service`). A password field. Empty with any other
# source. Never printed: not in the log, the status page or a notification.
price_service_token: ""
price_horizon_h: 48
price_timeout_s: 10
@@ -1085,7 +1089,7 @@ schema:
# this line offers exactly PROVIDERS + RETIRED_PROVIDERS + 'off' - derived, not
# typed - and register() refuses to re-register a retired id, so the two
# cannot both claim one.
price_provider: list(off|generic_dayahead|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
price_provider: list(off|generic_dayahead|price_service|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
price_markup_eur_kwh: float(0,1)
price_taxes_eur_kwh: float(0,1)
@@ -1119,6 +1123,7 @@ schema:
price_formula: str?
price_csv_path: str?
price_feed_url: str?
price_service_token: password?
price_horizon_h: int(1,72)
price_timeout_s: int(2,60)
price_now_entity: str?
+2
View File
@@ -81,6 +81,7 @@ from datetime import datetime, timedelta, timezone
from app import arbitrage as _arb
from named_absence import first
from app import main as _main
from app.price_chain import PriceWatch
from app import peakguard as _pg
from app.arbiter import (
P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim,
@@ -2311,6 +2312,7 @@ class _T12Controller:
self.tariff_plan = None
self.tariff_at = None
self.price_history = NoHistory()
self.price_watch = PriceWatch() # PRICE-01: the refresh passes it on
self.stop_event = asyncio.Event()
self.stop_event.set() # so the inter-refresh wait returns
self._ticks = 0
+275 -6
View File
@@ -33,7 +33,9 @@ from app import homeview, web
from app.main import Controller
from app.peakguard import PeakDecision
from app.price import PricePoint, PriceProduct, PriceRead
from app.static_tariff import TariffPlan
from app.static_tariff import TariffPlan, static_plan
from app import packmode
from app.store import unit_key
from app.uimodel import REGION_PRESETS
from named_absence import field, nth, present
@@ -152,6 +154,14 @@ def quiet_guard(projected_w=1240.0):
ceiling_from="cap", projected_w=projected_w)
def set_modes(c, *modes):
"""File each battery's mode in the store (None leaves the default, which
follows the house)."""
for inv, m in zip(c.inverters, modes):
if m is not None:
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = m
def status(c, now=NOW):
s = c.status_payload()
# The status route builds at wall-clock time; rebuild the structured half
@@ -317,7 +327,7 @@ check("a plan with no published prices yet says so, not 'the price gap is "
_t == [] and homeview.line_no_trade("not_firm") in _l
and homeview.line_no_trade("not_worth") not in _l)
check("the price bars are today's 24 hours, from the real series, with the "
"cheap and expensive thirds marked",
"cheap and dear hour marked",
[b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK
and s_arb["plan"]["prices"][13]["kind"] == "cheap"
and s_arb["plan"]["prices"][19]["kind"] == "dear"
@@ -518,10 +528,269 @@ check("quarter-hour prices: the charge block, its price and the price line's "
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
and "the list" not in json.dumps(s_q["plan"]))
check("...and the hour bar says it is an hour average",
s_q["plan"]["prices"][13]["points"] == 4
and '"plan.average_tip"' in io.open(os.path.join(
HERE, "app", "static", "app.js"), encoding="utf-8").read())
# UX-45: the chart draws the steps the add-on decides on.
_pq = s_q["plan"]["prices"]
check("UX-45: 96 quarter prices -> 96 bars, each one quarter, labelled "
"from its own start, with its own price and no average",
len(_pq) == 96 and _pq[0]["label"] == "00:00"
and _pq[0]["end_label"] == "00:15" and _pq[57]["label"] == "14:15"
and _pq[57]["end_label"] == "14:30"
and [b["eur_kwh"] for b in _pq] == [round(p.eur_per_kwh, 4)
for p in _q[:96]]
and all("points" not in b for b in _pq)
and s_q["plan"]["hours"] == 24)
check("...the quarters of one hour differ (the chart shows the steps)",
len({b["eur_kwh"] for b in _pq[52:56]}) > 1)
check("...and the page has no 'hour average' tip any more",
"average_tip" not in io.open(os.path.join(
HERE, "app", "static", "app.js"), encoding="utf-8").read()
and "average_tip" not in io.open(os.path.join(
HERE, "app", "ui_text", "en.json"), encoding="utf-8").read()
and "average_tip" not in io.open(os.path.join(
HERE, "app", "ui_text", "nl.json"), encoding="utf-8").read())
_cheap_q = [b["start"] for b in _pq if b["kind"] == "cheap"]
_dear_q = [b["start"] for b in _pq if b["kind"] == "dear"]
_hi = max(_q[:96], key=lambda p: p.eur_per_kwh)
check("...without battery modes the marks are the two quarters the "
"arbitrage verdict trades in, and no others",
_cheap_q == [_lo.start.isoformat()] and _dear_q == [_hi.start.isoformat()]
and sum(1 for b in _pq if b["kind"] == "mid") == 94)
# Battery modes on: the marks ARE the kept priceruns result.
from app import priceruns
_rp = priceruns.plan(tuple(_q), NOW, required_eur_kwh=0.05, charge_kwh=2.0,
charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0)
c_m = make(ARB)
c_m.tariff_plan, c_m.tariff_at = c_q.tariff_plan, NOW
c_m.peak_guard = quiet_guard()
c_m.modes_on, c_m.mode_runs = True, _rp
set_modes(c_m, packmode.SOLAR_FIRST, packmode.CHEAP_HOURS)
_pm = status(c_m)["plan"]["prices"]
_today = [(a, z) for a, z, _p in priceruns.firm_slots(_q, NOW)
if a < DAY0 + timedelta(hours=24)]
_want_c = {a.isoformat() for a, z in _today
if _rp.cheap.start <= a < _rp.cheap.end}
_want_d = {a.isoformat() for g in _rp.give for a, z in _today
if g.start <= a < g.end}
check("UX-45: with battery modes on, the cheap and dear quarters are the "
"priceruns cheap run and give runs for the same input",
_rp.on and _rp.charge_useful() and _want_c and _want_d
and {b["start"] for b in _pm if b["kind"] == "cheap"} == _want_c
and {b["start"] for b in _pm if b["kind"] == "dear"} == _want_d
and len(_pm) == 96)
c_m2 = make(ARB)
c_m2.tariff_plan, c_m2.tariff_at = c_q.tariff_plan, NOW
c_m2.peak_guard = quiet_guard()
c_m2.modes_on = True
set_modes(c_m2, packmode.CHEAP_HOURS, None)
c_m2.mode_runs = priceruns.plan(tuple(_q), NOW, required_eur_kwh=5.0,
charge_kwh=2.0, charge_kw=4.0, sell_kwh=0.0,
sell_kw=0.0)
check("...a run plan that says no trade marks nothing",
not c_m2.mode_runs.on
and {b["kind"] for b in status(c_m2)["plan"]["prices"]} == {"mid"})
# A quarter that is over keeps the mark it had while it was ahead.
# (`status()` also builds at wall-clock time, which starts the day's memory
# over, so the two builds below go straight to `homeview.build`.)
_later = NOW + timedelta(hours=4)
_s0 = c_q.status_payload()
homeview.build(c_q, level=_s0["level"], checks=_s0["checks"], now=NOW)
c_q.tariff_at = _later
_pl = homeview.build(c_q, level=_s0["level"], checks=_s0["checks"],
now=_later)["plan"]["prices"]
check("...and a quarter already over keeps the mark it had while ahead",
[b["kind"] for b in _pl if b["start"] == _lo.start.isoformat()]
== ["cheap"]
and [b["kind"] for b in _pl if b["label"] == "19:00"] == ["dear"])
c_q.tariff_at = NOW
# UX-45 round 2 (F2): a hole is covered by the add-on's own rule,
# `price._cover_table()`: the point before it runs on, never over an hour.
def _hole_bars(missing):
pts = [PricePoint(DAY0 + timedelta(minutes=15 * i), .10 + .001 * i, False)
for i in range(96)]
gone = {DAY0 + timedelta(hours=10, minutes=15 * (1 + j))
for j in range(missing)}
pts = [p for p in pts if p.start not in gone]
return homeview.price_bars(pts, DAY0, DAY0 + timedelta(hours=24))
_gap = {}
for _n in (1, 2, 3, 4, 5):
_bb = _hole_bars(_n)
_gap[_n] = ([b["eur_kwh"] is None for b in _bb[41:41 + _n]],
len(_bb))
_b1 = _hole_bars(1)
check("UX-45: 1, 2 or 3 missing quarters show the price before them (the "
"add-on's own cover rule), and nothing shifts",
all(len(_hole_bars(n)) == 96 for n in (1, 2, 3))
and _gap[1][0] == [False] and _gap[2][0] == [False, False]
and _gap[3][0] == [False, False, False]
and [b["eur_kwh"] for b in _hole_bars(3)[40:44]]
== [_hole_bars(3)[40]["eur_kwh"]] * 4
and _b1[42]["label"] == "10:30" and _b1[43]["label"] == "10:45"
and _b1[43]["eur_kwh"] == round(.10 + .001 * 43, 4))
check("...4 missing quarters leave 1 bar empty, 5 leave 2: a hole over an "
"hour is not filled past the hour",
_gap[4][0] == [False, False, False, True]
and _gap[5][0] == [False, False, False, True, True]
and sum(b["eur_kwh"] is None for b in _hole_bars(4)) == 1
and sum(b["eur_kwh"] is None for b in _hole_bars(5)) == 2)
# A cheap run that is not worth charging in is not marked; an estimate is.
import types
_pts = [PricePoint(DAY0 + timedelta(minutes=15 * i),
.30 if i < 4 else .10, False) for i in range(8)]
_rp2 = priceruns.plan(tuple(_pts), DAY0, required_eur_kwh=0.05,
charge_kwh=0.5, charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0)
c_w2 = make(ARB)
c_w2.modes_on, c_w2.mode_runs = True, _rp2
set_modes(c_w2, packmode.LEFTOVER_SOLAR, None)
_w2 = homeview.price_windows(c_w2, None, None)
check("UX-45: a cheap run that comes after every dear slot is not worth a "
"charge, so it is not marked (the dear run is)",
_rp2.on and _rp2.cheap is not None and not _rp2.charge_useful()
and [w[0] for w in _w2] == ["dear"])
_est = [PricePoint(DAY0 + timedelta(minutes=15 * i), .2, False)
for i in range(80)] + [PricePoint(DAY0 + timedelta(hours=20), .25,
True)]
_be = homeview.price_bars(_est, DAY0, DAY0 + timedelta(hours=24))
check("UX-45: a carried (estimate) price after quarter prices is the last "
"point, so it covers one quarter (the add-on's tail rule), as an "
"estimate bar; the rest stays empty",
[b["estimate"] for b in _be[78:83]] == [False, False, True, False, False]
and _be[80]["eur_kwh"] == .25 and _be[81]["eur_kwh"] is None)
_est2 = _est[:80] + [PricePoint(DAY0 + timedelta(hours=20 + k), .25, True)
for k in range(3)]
_be2 = homeview.price_bars(_est2, DAY0, DAY0 + timedelta(hours=24))
check("...carried hourly points one after another cover an hour each",
[b["eur_kwh"] for b in _be2[80:92]] == [.25] * 12
and _be2[92]["eur_kwh"] is None)
# Hourly prices -> 24 bars (above); a day/night tariff -> 24 bars.
_ph1 = s_arb["plan"]["prices"]
check("UX-45: hourly prices -> 24 hour bars with the hour's own price",
len(_ph1) == 24 and _ph1[13]["end_label"] == "14:00"
and "points" not in _ph1[13])
c_dn = make(dict(ARB, static_profile=(
'{"periods": [{"name":"day","from":"07:00","to":"22:00","days":"all",'
'"eur_per_kwh":0.34},{"name":"night","from":"22:00","to":"07:00",'
'"days":"all","eur_per_kwh":0.26}]}')))
c_dn.peak_guard = quiet_guard()
_pd = status(c_dn)["plan"]["prices"]
check("UX-45: a day/night tariff -> 24 bars, 0.26 at night and 0.34 by day",
len(_pd) == 24
and [b["eur_kwh"] for b in _pd]
== [.26] * 7 + [.34] * 15 + [.26] * 2
and all(b["kind"] == "mid" for b in _pd))
# Clock-change days: 92 and 100 quarters.
_d0 = datetime(2026, 3, 28, 23, 0, tzinfo=timezone.utc)
_d1 = datetime(2026, 3, 29, 22, 0, tzinfo=timezone.utc)
_sp = [PricePoint(_d0 + timedelta(minutes=15 * i), .1, False)
for i in range(92)]
check("UX-45: a 23-hour day has 92 quarter bars, a 25-hour day 100",
len(homeview.price_bars(_sp, _d0, _d1)) == 92
and len(homeview.price_bars(
_sp, _d0, _d0 + timedelta(hours=25))) == 100)
_f0 = datetime(2026, 10, 24, 22, 0, tzinfo=timezone.utc) # 25-hour day
_fb = [PricePoint(_f0 + timedelta(minutes=15 * i), .1, False)
for i in range(100)]
# F5: the day/night chart with the stored static plan, as in production
# (the cycle stores `static_plan()` when the provider is off).
_PROF = ('{"periods": [{"name":"day","from":"07:00","to":"22:00",'
'"days":"all","eur_per_kwh":0.34},{"name":"night","from":"22:00",'
'"to":"07:00","days":"all","eur_per_kwh":0.26}]}')
def _dn(arb):
c = make(dict(ARB if arb else TWO, static_profile=_PROF))
c.peak_guard = quiet_guard()
c.tariff_plan = static_plan(c.o, NOW, NOW + timedelta(hours=36), now=NOW)
c.tariff_at = NOW
return c, status(c)["plan"]["prices"]
_c_off, _p_off = _dn(False)
_c_on, _p_on = _dn(True)
_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW))
check("UX-45: day/night tariff with the stored plan and price trading off: "
"24 bars, no cheap or dear mark",
len(_p_off) == 24 and {b["kind"] for b in _p_off} == {"mid"})
check("...and with price trading on, the marks are the verdict's cheapest "
"and dearest hour (one each)",
_v_on is not None and len(_p_on) == 24
and [datetime.fromisoformat(b["start"]) for b in _p_on
if b["kind"] == "cheap"] == [_v_on.cheapest.start]
and [datetime.fromisoformat(b["start"]) for b in _p_on
if b["kind"] == "dear"] == [_v_on.dearest.start]
and sum(b["kind"] == "mid" for b in _p_on) == 22)
# F3 / owner decision 2a: with battery modes on, marks only when a battery
# uses a price mode.
def _modes(*modes, use=(True, True)):
c = make(ARB)
c.tariff_plan, c.tariff_at = c_q.tariff_plan, NOW
c.peak_guard = quiet_guard()
c.modes_on, c.mode_runs = True, _rp
set_modes(c, *modes)
for inv, u in zip(c.inverters, use):
c.store.data[unit_key(inv.name, packmode.USE_KEY)] = u
return {b["kind"] for b in status(c)["plan"]["prices"]}
_F, _L, _C = (packmode.SOLAR_FIRST, packmode.LEFTOVER_SOLAR,
packmode.CHEAP_HOURS)
check("UX-45: battery modes on, every battery follows the house: no marks",
_modes(_F, _F) == {"mid"} and _modes(None, None) == {"mid"})
check("...one battery on Cheap hours or Leftover solar: the marks show",
{"cheap", "dear"} <= _modes(_F, _C) and {"cheap", "dear"} <= _modes(_L, _F))
check("...a price mode on a battery that is switched off does not count",
_modes(_F, _C, use=(True, False)) == {"mid"}
and {"cheap", "dear"} <= _modes(_F, _C, use=(False, True)))
# F4: the day's memory of past marks starts over on a new day.
_s4 = c_q.status_payload()
homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=NOW)
_k0 = dict(c_q.home_prices.kinds)
_tom = NOW + timedelta(days=1)
c_q.tariff_at = _tom
homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=_tom)
c_q.tariff_at = NOW
check("UX-45: past marks start over on a new day (nothing of yesterday is "
"kept)",
len(_k0) > 0 and c_q.home_prices.day != DAY0
and c_q.home_prices.kinds
and all(k >= DAY0 + timedelta(days=1) for k in c_q.home_prices.kinds)
and len(c_q.home_prices.kinds) <= 96)
# F1: the setup wizard counts hours, not bars.
from app.validate import setup_checks
_o = dict(ARB, price_provider="entsoe_be")
_say = lambda n: [x for x in map(repr, setup_checks(
_o, entities={}, running=_o, prices_known=n)) if "Prices for today" in x]
check("UX-45: 96 quarter prices count as 24 hours in the setup wizard, "
"not 96",
homeview.priced_hours(_pq) == 24
and homeview.priced_hours(s_arb["plan"]["prices"]) == 24
and "for 24 of 24 hours" in "".join(_say(homeview.priced_hours(_pq)))
and "96 of 24" not in "".join(_say(homeview.priced_hours(_pq))))
check("...a half-priced day counts its hours; a hole of one quarter loses "
"no hour",
homeview.priced_hours(_pq[:48]) == 12
and homeview.priced_hours(_hole_bars(5)) == 24
and homeview.priced_hours([dict(b, eur_kwh=None) for b in _pq]) == 0)
check("...a 23-hour day gives 23 and a 25-hour day 24 (clock hours)",
homeview.priced_hours(homeview.price_bars(_sp, _d0, _d1)) == 23
and homeview.priced_hours(homeview.price_bars(
_fb, _f0, _f0 + timedelta(hours=25))) == 24)
check("...and the wizard takes that count",
"known = priced_hours(prices)" in io.open(os.path.join(
HERE, "app", "web.py"), encoding="utf-8").read())
# --------------------------------------------------------------------------- #
print("\n4. a fault in the Home view never costs the page its rows")
+5 -1
View File
@@ -415,7 +415,11 @@ def put_in_charge(c, which, hours_ago, open_min):
def notes(h):
return [x for x in h.calls if x[0] == "persistent_notification"]
# Not the dismiss of PRICE-01's old fixed-tariff card, which every start sends.
# Only that dismiss: a `create` with the old id would still be counted.
return [x for x in h.calls if x[0] == "persistent_notification"
and not (x[1] == "dismiss"
and x[2].get("notification_id") == "goodwe_fixed_tariff_unused")]
for cheap in (True, False):
+3 -3
View File
@@ -104,7 +104,7 @@ def check(name, cond):
# release/1.0 rather than against release/1.0.
# ---------------------------------------------------------------------------
_DEV = ('{"identifiers": ["goodwe_rs485_controller"], "name": "GoodWe", '
'"manufacturer": "GoodWe (via RS485 meter emulation)", '
'"manufacturer": "GoodWe (via RS485 EMS port)", '
'"model": "ES/BP series"}')
BASELINE = [
@@ -442,7 +442,7 @@ for c_ in unit_cfgs:
print(f" regression pin (sha256 of the eight payloads): {_pin}")
check("the per-unit payloads are the ones this PR was reviewed with "
"(REGRESSION PIN, not a correctness claim - see the comment)",
_pin == "eb5022b0455158d7")
_pin == "b4274714d8557c37")
# ---------------------------------------------------------------------------
print("\n4. zero overlap between A's entities and B's")
@@ -976,7 +976,7 @@ def announce_p2(units=(), omit=(), include=(), modes=(), retract=(),
# `git show ed739d4:goodwe_controller/app/mqtt.py` and this loop.
ROSTERS = [[], ["inverter-a"], ["inverter-a", "inverter-b"], ["a", "b", "c"],
["inverter-b", "Inverter B", "x", "y"], ["café"]]
ED739D4_OFF = "501a7d93782a260b"
ED739D4_OFF = "057be1d8ad7bb582" # DOC-8: ed739d4 digest with only the manufacturer string changed
_h, _n, _subs_off = hashlib.sha256(), 0, []
for _r in ROSTERS:
for _om in [(), ("p1_age",), ("p1_age", "grid")]:
+267
View File
@@ -0,0 +1,267 @@
"""Runnable check for OPT-20. `python3 test_opt20.py`
A refusal probe (OPT-15 F3, SAFETY-73) asks the probing battery PROBE_W and
nothing else. On TEST-23 (OPT-15 g7) the probe made the trade ask 2500 W: B
was pinned at 200 W and the allocator put the other 2300 W on A, which sat in
its maintenance charge phase at 0 W, with the grid at 3100 W for 34 s.
1. modetrade: the ask carries PROBE_W for the probing battery, the other
chargers' ratings as without the probe, and `size()` adds the probe on top
of their share (or asks PROBE_W exactly, or nothing).
2. main: the ask carries PROBE_W for exactly the pins filed this tick, so the
first and the last cycle of a window do not move another battery's share.
3. The real Controller in the lagged replay, with B refusing and probed while
A is (a) in its maintenance charge phase (also with the charge shaped to
0 W), (b) idle, (c) a normal charger in the same run, and the OPT-15 rig's
g7 sequence: A's command is the same through the window as just before it,
B gets PROBE_W, and the commanded charge rises by PROBE_W at most.
4. Battery modes on with no probe: byte-identical to release/1.0 bf9019e.
Out of scope, and unchanged: a normal (non-probing) trade placed on a battery
in maintenance charge (OPT-11 P4 F4) and the action-log wording (UX-38).
Mutations: MUTATION.md says how to run them (purged copies); the 12 of
OPT-20 are listed in the pull request.
"""
import dataclasses
import logging
import os
import sys
from types import SimpleNamespace
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 opt20_scen as sc # noqa: E402
from app import arbitrage, modetrade, packmode # noqa: E402
from app import control as ctl # noqa: E402
from app import main # noqa: E402
from app.arbiter import Claim, P_SAFETY # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
PW = ctl.PROBE_W
A, B = sc.A, sc.B
# ============================================================================ #
print("1. modetrade: the ask and the size")
_runs = SimpleNamespace(on=True, cheap=SimpleNamespace(holds=lambda n: True),
charge_useful=lambda: True, give_at=lambda n: None,
sell=None)
def _dec(packs, **kw):
args = dict(charge_ok=True, sell_ok=True, trade_why=None, price_now=0.1,
headroom_w=3000.0, floor_pct=20.0, ceiling_pct=95.0,
house_w=800.0)
args.update(kw)
return modetrade.decide(_runs, sc.UTC0, packs, **args)
_pb = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0,
refused=-1, probing=True)
_pa = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 50.0, 10.0)
_pm = dataclasses.replace(_pa, mode=packmode.SOLAR_FIRST, idle=False)
_pi = dataclasses.replace(_pa, mode=packmode.SOLAR_FIRST)
_t_alone = _dec([_pb, _pm])
check("a probe beside a battery in maintenance asks PROBE_W and no more",
_t_alone.action == modetrade.CHARGE and _t_alone.ask_w == -PW
and _t_alone.probe_w == PW)
check("...beside an idle battery that cannot charge from the grid too",
_dec([_pb, _pi]).ask_w == -PW and _dec([_pb, _pi]).probe_w == PW)
_t_two = _dec([_pb, _pa])
check("...beside a normal charger the ask is that charger's rating plus "
"PROBE_W", _t_two.ask_w == -2500.0 - PW and _t_two.probe_w == PW)
_t_np = _dec([dataclasses.replace(_pb, probing=False), _pa])
check("no probe: the ask is the chargers' ratings, probe_w is 0 (as before)",
_t_np.ask_w == -2500.0 and _t_np.probe_w == 0.0
and _dec([_pa, _pm]).ask_w == -2500.0
and modetrade.Tick({}, {}, None, 0.0, None).probe_w == 0.0)
def sz(ask, probe_w, lo, room, action=modetrade.CHARGE):
t = modetrade.Tick({}, {}, action, ask, None, probe_w)
return modetrade.size(t, (lo, 2500.0), room)
check("size, a probe alone: PROBE_W, whatever the headroom above it",
sz(-PW, PW, -(PW), 3000.0) == -PW and sz(-PW, PW, -2700.0, 9000.0) == -PW)
check("...exactly PROBE_W or nothing: a headroom or a bound under it files "
"no trade (a smaller ask voids the pin and the allocator would place "
"it elsewhere)",
sz(-PW, PW, -2700.0, PW - 1) == 0.0 and sz(-PW, PW, -(PW - 1), 3000.0)
== 0.0 and sz(-PW, PW, -2700.0, 0.0) == 0.0
and sz(-PW, PW, -2700.0, PW) == -PW)
check("size with another charger: its share is sized as without the probe, "
"the probe's PROBE_W is added on top",
sz(-2700.0, PW, -2700.0, 3000.0) == -2700.0
and sz(-2700.0, PW, -2700.0, 1000.0) == -1200.0
and sz(-2700.0, PW, -1700.0, 3000.0) == -1700.0
and sz(-2700.0, PW, -2700.0, 0.0) == 0.0)
check("size with no probe is what it was (ask, bound, headroom)",
sz(-2500.0, 0.0, -2700.0, 3000.0) == -2500.0
and sz(-2500.0, 0.0, -2700.0, 1000.0) == -1000.0
and sz(-2500.0, 0.0, -1800.0, 3000.0) == -1800.0
and sz(-2500.0, 0.0, -2700.0, None) == 0.0
and modetrade.size(modetrade.Tick({}, {}, None, 0.0, None),
(-5.0, 5.0), 3000.0) == 0.0)
# ============================================================================ #
print("\n2. main: the ask carries the pins filed this tick")
def trade(tick, pins, bound=(-2700.0, 2500.0), room=3000.0):
"""The trade `_mode_trade()` files: (claims, size or None)."""
claims = [Claim.set("refused", P_SAFETY, -PW * 1.0, ctl.PROBING,
actuator="x") for _ in range(pins)]
me = SimpleNamespace(
mode_tick=tick, loop_bound=lambda cl: bound, arb_headroom_w=room,
mode_runs=SimpleNamespace(
cheap=SimpleNamespace(start=sc.UTC0, end=sc.UTC0,
mean_eur_kwh=0.1),
sell=None, level_eur_kwh=0.1),
arb=None, _arb_action=None, log_event=lambda *a, **k: None)
filed = main.Controller._mode_trade(me, claims, sc.UTC0)
t = [c for c in claims if c.source == arbitrage.SOURCE]
return filed, (t[0].value if t else None), me.mode_tick
_t0 = modetrade.Tick({}, {A: modetrade.CHARGE}, modetrade.CHARGE, -2500.0,
None, 0.0)
check("the pin is filed but decide() ran before it (the window's first "
"cycle): the charger keeps its whole ask and the pin is added",
trade(_t0, 1)[1] == -2700.0 and trade(_t0, 1)[2].probe_w == PW)
check("the window closed after decide() (its last cycle): the probe's PROBE_W "
"leaves the ask, so no battery is handed it",
trade(_t_two, 0)[1] == -2500.0
and trade(_t_alone, 0) == (False, None, trade(_t_alone, 0)[2])
and trade(_t_alone, 0)[2].action is None)
check("pin and ask agree: nothing changes",
trade(_t_two, 1)[1] == -2700.0 and trade(_t_alone, 1)[1] == -PW
and trade(_t0, 0)[1] == -2500.0)
# ============================================================================ #
print("\n3. the real Controller: B refuses and is probed")
def charge(r):
return -sum(min(0.0, v) for v in r["shares"].values())
def window_checks(rows, label):
"""Per probe window: A as just before, B at PROBE_W (or 0 when nothing is
traded), the charge up by at most PROBE_W."""
win = sc.windows(rows)
ok_a = ok_b = ok_sum = ok_grid = True
for i, j in win:
pre = rows[i - 1]
lo, hi = max(0, i - 3), min(len(rows) - 1, j + 3)
ext = rows[lo:hi + 1]
base_sum = sum(min(0.0, v) for v in pre["shares"].values())
ok_a &= all(abs(r["shares"].get(A, 0.0) - pre["shares"].get(A, 0.0))
< 1e-6 for r in ext)
bs = [r["shares"].get(B, 0.0) for r in ext]
if any(r["action"] for r in rows[i:j + 1]):
ok_b &= set(bs) <= {0.0, -PW} and bs.count(-PW) >= 20
else: # a drain comes first: the window trades nothing
ok_b &= set(bs) == {0.0}
ok_sum &= all(charge(r) <= charge(pre) + PW + 1e-6 for r in ext)
ok_grid &= max(r["grid"] for r in ext) <= pre["grid"] + PW + 50.0
n_ch = sum(1 for r in rows if r["probing"] and r["action"])
print(f" {label}: {len(win)} window(s), {n_ch} probing cycles with a "
f"trade, highest grid "
f"{max(r['grid'] for r in rows):.0f} W")
return win, ok_a, ok_b, ok_sum, ok_grid
SCEN = {
"(a) A in its maintenance charge phase": (
sc.day(sc.SF_CH, a_maint=sc.MAINT_CHARGE), "same"),
"(a') ...with the charge shaped to 0 W": (
sc.day(sc.SF_CH, a_maint=sc.MAINT_CHARGE, shaped=True), "same"),
"(b) A idle at 0 W (Solar first, the sun covers the house)": (
sc.day(sc.SF_CH, house_w=-10.0), "same"),
"(c) A a normal charger in the same run": (sc.day(sc.CH_CH), "same"),
"(g7) the OPT-15 rig: drain, then maintenance charge at 0 W": (
sc.g7(), "same"),
# Not the fix's: A gives the house, and a charge ask means no other
# battery gives (OPT-11 owner answer 3), so A rests, as on bf9019e.
"(b2) A gives 800 W to the house": (sc.day(sc.SF_CH), "rests"),
}
for _name, (_rows, _kind) in SCEN.items():
win, ok_a, ok_b, ok_sum, ok_grid = window_checks(_rows, _name)
check(f"{_name}: a probe happens (non-vacuous)", len(win) >= 1)
if _kind == "same":
check(f"{_name}: A's command is the same through each window, first "
"and last cycle included", ok_a)
check(f"{_name}: the grid rises by PROBE_W at most (plus 50 W of "
"noise)", ok_grid)
else:
check(f"{_name}: A stops giving for the window (the one-sign rule, "
"as on bf9019e), charges nothing and gives back after it",
all(r["shares"].get(A, 0.0) == 0.0 for i, j in win
for r in _rows[i + 1:j]) and all(
_rows[j + 4]["shares"].get(A, 0.0) > 0.0 for i, j in win))
check(f"{_name}: B gets PROBE_W and nothing else", ok_b)
check(f"{_name}: the commanded charge rises by PROBE_W at most", ok_sum)
# The g7 sequence in detail: the probe after the drain.
_g7 = SCEN["(g7) the OPT-15 rig: drain, then maintenance charge at 0 W"][0]
_w7 = sc.windows(_g7)
_after = [w for w in _w7 if _g7[w[0]]["now"] > sc.at(18.3, sc.GUTC0)]
_peak7 = max(r["grid"] for r in _g7 if r["now"] > sc.at(18.3, sc.GUTC0))
print(f" g7: probes after the drain at "
f"{[_g7[w[0]]['now'].astimezone(sc.L).strftime('%H:%M:%S') for w in _after]}"
f", grid after the drain at most {_peak7:.0f} W (TEST-23: 3100 W)")
check("g7: the probe after A's drain asks B for 200 W with A at 0 W "
"(TEST-23: -2300 W on A, the grid at 3100 W)",
_after and all(
abs(r["shares"].get(A, 0.0)) < 1e-6 for i, j in _after
for r in _g7[i:j + 4]) and _peak7 <= 800.0 + PW + 50.0)
check("g7: during the drain no probe trades (a drain comes first)",
all(r["action"] is None for r in _g7
if sc.at(18.0, sc.GUTC0) <= r["now"] < sc.at(18.28, sc.GUTC0)))
_acts = [r["reason"] for i, j in _after for r in _g7[i:j + 1]
if r["action"]]
check("g7: the action log's trade line names the probe's watts, 200 W",
_acts and all(f"{PW:.0f} W on {B}" in a for a in _acts))
check("no opposite-direction split on any of these cycles",
not [r for _r, _ in SCEN.values() for r in _r
if any(v > 0 for k, v in r["shares"].items() if r["action"])
and any(v < 0 for v in r["shares"].values())])
# ============================================================================ #
print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e")
# Printed by tools/opt20_scen.py on release/1.0 bf9019e (2026-10-15),
# goodwe-addon-checks:1.
AT_BASE = {
"sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9",
"ch_ch": "15fc25dd34186e56d34b752d37c9e8613295ba737938c92454acca08c83080fb",
"ch_ch_maint":
"e46e3c666dd63ab33b56e83c0a560c07cec7774a9a70560f55c62a6ae6f55920",
}
_got = sc.no_probe_digests()
for _k in AT_BASE:
print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} "
f"{_got[_k][:16]}")
check("with no refusal every share, grid, trade sentence, role and plant "
"power of a scripted cheap run equals bf9019e's (A Solar first, A "
"charging, A in maintenance charge)", _got == AT_BASE)
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
+20 -4
View File
@@ -534,11 +534,15 @@ FIXTURES = {
[v / 1000 for v in EPEX_EUR_MWH]),
"custom_formula": (columnar_feed(EPEX_EUR_MWH), FORMULA_EUR_KWH),
"manual_csv": (None, [0.1834, -0.0210, 0.3115]),
# PRICE-01: the owner's price server answers the same columnar layout.
"price_service": (columnar_feed(EPEX_EUR_MWH),
[v / 1000 for v in EPEX_EUR_MWH]),
}
check(f"the plugins that ship after TARIFF-07 and TARIFF-15 are registered "
f"{sorted(PROVIDERS)}",
set(PROVIDERS) == {"generic_dayahead", "custom_formula", "manual_csv"})
set(PROVIDERS) == {"generic_dayahead", "custom_formula", "manual_csv",
"price_service"})
check(f"...and the three withdrawn ids resolve to NOTHING "
f"{sorted(RETIRED_PROVIDERS)}",
set(RETIRED_PROVIDERS) == {"engie_dynamic", "totalenergies_dynamic",
@@ -571,7 +575,7 @@ def canned(body):
"""A transport that serves recorded bytes and records that it was used."""
calls = []
async def t(url, timeout_s):
async def t(url, timeout_s, headers=None):
calls.append((url, timeout_s))
return body
t.calls = calls
@@ -603,6 +607,9 @@ def load_any(pid):
# is exactly what section 3 grades.
return load(pid, {"price_formula": json.dumps(ENECO_CARD)},
transport=canned(body))
if pid == "price_service":
return load(pid, {"price_service_token": "test-token-1234"},
transport=canned(body))
return load(pid, transport=canned(body))
@@ -898,7 +905,16 @@ _networked = sorted(pid for pid, cls in PROVIDERS.items()
# SAME CAPTURED BYTES (section 13c), because it reads the same endpoint with
# the same parser. A networked plugin with no capture is what this line exists
# to forbid.
_replayed = ["custom_formula", "generic_dayahead"]
#
# ⚠️ PRICE-01 PUT A THIRD ON IT, AND ITS CAPTURE IS A RESHAPE, SAID PLAINLY. The
# price server answers only to a token this session does not hold, so no real
# answer of its own exists to replay. Its answer is the energy-charts layout
# (design, API section), so test_price_service.py replays the REAL energy-charts
# two-day capture with the three extra keys the design lists (`covers`,
# `source`, `fetched_at`). The prices and instants are real; the wrapper is the
# design's. The first answer captured from the live server on the rig test
# replaces it - that is the open item, and it is in the PRICE-01 report.
_replayed = ["custom_formula", "generic_dayahead", "price_service"]
check(f"the networked plugins are {_networked}, and EVERY one of them is "
f"replayed from a captured real body ({_replayed}) - no shipped plugin "
f"reaches a network this file has never seen answer (TARIFF-07)",
@@ -1252,7 +1268,7 @@ check(f"a negative day-ahead price passes through, all-in {_neg}",
# (TARIFF-15): it reads the SAME columnar document, which has no per-point
# flag, and applies arithmetic to it - arithmetic cannot invent a provenance
# the document never carried.
_SETTLED_FEED = {"generic_dayahead", "custom_formula"}
_SETTLED_FEED = {"generic_dayahead", "custom_formula", "price_service"}
for pid in sorted(set(READS) - _SETTLED_FEED):
read = READS[pid]
flags = derived(lambda r=read: [p.provisional for p in r.points])
+7 -2
View File
@@ -1133,6 +1133,11 @@ _T17_KEYS = ["price_taxes_profile"]
# `export_price_eur_kwh` is untouched, every site on the scalar is unaffected,
# and it lives in this dict and in config.yaml and nowhere else.
_T16_KEYS = ["price_export_formula"]
# PRICE-01 added the eighth: `price_service_token`, the bearer token of the
# owner's price server. An ADDITION, like the three above: nothing was renamed,
# every other provider ignores it, and it lives in this dict and in config.yaml
# (as a password field) and nowhere else.
_P01_KEYS = ["price_service_token"]
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
f"TARIFF-15's, TARIFF-17's and TARIFF-16's keys - FCST-03 still added "
f"none of its own {_defaults}",
@@ -1142,7 +1147,7 @@ check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
"price_horizon_h", "price_timeout_s",
"static_profile"]
+ _OPT04_KEYS + _T15_KEYS + _T17_KEYS
+ _T16_KEYS))
+ _T16_KEYS + _P01_KEYS))
# ...and the later keys are genuinely NEW, not a rename of something that was
# already there, or the line above would pass while the mirror silently lost a
# key.
@@ -1151,7 +1156,7 @@ check("...and OPT-04's four, TARIFF-15's one, TARIFF-17's one and TARIFF-16's "
"are all still present",
isinstance(_defaults, list)
and len(set(_defaults) - set(_OPT04_KEYS) - set(_T15_KEYS)
- set(_T17_KEYS) - set(_T16_KEYS)) == 9)
- set(_T17_KEYS) - set(_T16_KEYS) - set(_P01_KEYS)) == 9)
# ⚠️ AND THE ONE TARIFF-17 IS THE HOUR-VARYING TWIN OF IS STILL THERE, BY NAME.
# The line above counts nine, and a rename of `price_taxes_eur_kwh` to
# `price_taxes_profile` would also count nine. The scalar path being unchanged
+412
View File
@@ -0,0 +1,412 @@
"""PRICE-01 round 2 (F1): a `password` option never reaches a page.
`python3 test_price_secret.py`
The real web app (`web.build_app`) runs over a fake Supervisor that holds a
site on `price_service` with a token. The token string must be absent from
`/config`, `/config/model` (`options`, `running`, `ui`), `/config/validate`,
a good save's answer and every refused save's answer. A save that sends the
placeholder, an empty field or no key keeps the stored token; a new value
replaces it. The same holds for any option the schema calls a password, not
only this one name.
⚠️ MUTATING A MODULE THIS FILE GRADES? Purge `__pycache__` first, or use
`tools/mutation_run.sh`. See MUTATION.md.
"""
import asyncio
import copy
import json
import logging
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
from aiohttp import ClientSession, web as aioweb # noqa: E402
import translations_lint as TL # noqa: E402
import ux13_demo as D # noqa: E402
from app import configui, price, web # noqa: E402
from app.configui import SECRET_SHOWN # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
TOKEN = "tk-SECRET-0123456789-abcdef"
NEW_TOKEN = "tk-REPLACED-9876543210-zyxw"
SCHEMA = D.schema_ui(D.CONFIG)
TRANSLATIONS = {"en": TL.parse_translation_file(
os.path.join(HERE, "translations", "en.yaml"))}
SITE = dict(D.defaults(D.CONFIG), setpoint_entity="number.goodwe_setpoint",
soc_entity="sensor.soc", batt_entity="sensor.batt",
price_provider="price_service", price_service_token=TOKEN)
class Sup:
def __init__(self, options, schema=SCHEMA):
self.options, self.schema, self.writes = copy.deepcopy(options), schema, []
async def info(self):
return {"options": copy.deepcopy(self.options), "schema": self.schema,
"translations": TRANSLATIONS}
async def write_options(self, o):
self.writes.append(copy.deepcopy(o))
self.options = copy.deepcopy(o)
async def restart(self, c):
raise AssertionError
class Hass:
session = None
token = ""
async def limits(self, _e):
return (-5000.0, 5000.0, 1.0)
async def states(self):
return []
class Ctl:
def __init__(self, o):
self.o, self.hass, self.inverters = dict(o), Hass(), []
def log_event(self, *a, **k):
pass
def status_payload(self):
return {"banner": "", "level": "ok", "rows": [], "checks": []}
async def serve(sup, ctl_options=None):
runner = aioweb.AppRunner(web.build_app(Ctl(ctl_options or sup.options), sup))
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
return runner, f"http://127.0.0.1:{port}/"
async def scenario():
out = {}
sup = Sup(SITE)
runner, root = await serve(sup)
seen = [] # every answer body, as text
try:
async with ClientSession() as s:
async def get(path):
async with s.get(root + path) as r:
t = await r.text()
seen.append(t)
return r.status, t
async def post(path, body):
async with s.post(root + path, json=body) as r:
t = await r.text()
seen.append(t)
return r.status, t
_, page = await get("config")
out["page"] = page
_, raw = await get("config/model?lang=en")
m = json.loads(raw)
out["model"] = m
out["raw_model"] = raw
out["field"] = [f for g in m["groups"] for f in g["fields"]
if f["key"] == "price_service_token"]
# a model of a site with NO token: shown empty, not as 'set'
sup0 = Sup(dict(SITE, price_service_token=""))
r0, root0 = await serve(sup0)
try:
async with s.get(root0 + "config/model?lang=en") as r:
out["model_unset"] = await r.json()
finally:
await r0.cleanup()
# --- classic page save: the page posts `options`, placeholder in
opts = copy.deepcopy(m["options"])
opts["peak_cap_w"] = 4100
st, body = await post("config/save",
{"options": opts, "fingerprint": m["fingerprint"]})
out["classic_placeholder"] = (st, sup.options["price_service_token"],
sup.options["peak_cap_w"], body)
# --- classic save with the field emptied
_, raw2 = await get("config/model?lang=en")
m2 = json.loads(raw2)
opts = copy.deepcopy(m2["options"])
opts["price_service_token"] = ""
opts["peak_cap_w"] = 4200
st, body = await post("config/save",
{"options": opts, "fingerprint": m2["fingerprint"]})
out["classic_empty"] = (st, sup.options["price_service_token"],
sup.options["peak_cap_w"])
# --- classic save with the key left out
_, raw2 = await get("config/model?lang=en")
m2 = json.loads(raw2)
opts = copy.deepcopy(m2["options"])
del opts["price_service_token"]
opts["peak_cap_w"] = 4250
st, body = await post("config/save",
{"options": opts, "fingerprint": m2["fingerprint"]})
out["classic_absent"] = (st, sup.options["price_service_token"])
# --- the new settings page: model in, with and without base
_, raw3 = await get("config/model?lang=en")
m3 = json.loads(raw3)
ui = copy.deepcopy(m3["ui"])
ui["peak_cap_w"] = 4300
st, body = await post("config/save", {
"model": ui, "base": copy.deepcopy(m3["ui"]),
"fingerprint": m3["fingerprint"]})
out["ui_base"] = (st, sup.options["price_service_token"])
_, raw3 = await get("config/model?lang=en")
m3 = json.loads(raw3)
ui = copy.deepcopy(m3["ui"])
ui["peak_cap_w"] = 4400
st, body = await post("config/save",
{"model": ui, "fingerprint": m3["fingerprint"]})
out["ui_nobase"] = (st, sup.options["price_service_token"])
# --- validate with the placeholder in: no 'too short', no echo
_, raw4 = await get("config/model?lang=en")
m4 = json.loads(raw4)
st, body = await post("config/validate", {"options": m4["options"]})
out["validate_placeholder"] = (st, json.loads(body)["problems"])
st, body = await post("config/validate", {"model": m4["ui"]})
out["validate_ui_placeholder"] = (st, json.loads(body)["problems"])
# --- a refused save (token too short) and a stale one
opts = copy.deepcopy(m4["options"])
opts["price_service_token"] = "short"
st, body = await post("config/save",
{"options": opts, "fingerprint": m4["fingerprint"]})
out["refused"] = (st, json.loads(body), sup.options["price_service_token"])
opts = copy.deepcopy(m4["options"])
opts["peak_cap_w"] = 4500
st, body = await post("config/save",
{"options": opts, "fingerprint": "not-current"})
out["stale"] = (st, json.loads(body))
# --- a refusal that is not about the token
opts = copy.deepcopy(m4["options"])
opts["max_w"] = -5
st, body = await post("config/save",
{"options": opts, "fingerprint": m4["fingerprint"]})
out["refused_other"] = (st, sup.options["price_service_token"],
sup.options["max_w"])
# --- a new value replaces
_, raw5 = await get("config/model?lang=en")
m5 = json.loads(raw5)
opts = copy.deepcopy(m5["options"])
opts["price_service_token"] = NEW_TOKEN
st, body = await post("config/save",
{"options": opts, "fingerprint": m5["fingerprint"]})
out["replaced"] = (st, sup.options["price_service_token"], body)
out["writes"] = len(sup.writes)
finally:
await runner.cleanup()
out["seen"] = seen
return out
S = asyncio.run(scenario())
print("1. the token is in no page and no JSON answer")
check("GET /config (the classic page) has no token",
TOKEN not in S["page"] and NEW_TOKEN not in S["page"])
check("GET /config/model has no token anywhere in its JSON",
TOKEN not in S["raw_model"])
for key in ("options", "running", "ui"):
check(f"...and {key} shows the placeholder, not the token",
json.dumps(S["model"][key]).count(SECRET_SHOWN) >= 1
and TOKEN not in json.dumps(S["model"][key]))
check("...the placeholder stands in the token's own place",
S["model"]["options"]["price_service_token"] == SECRET_SHOWN
and S["model"]["running"]["price_service_token"] == SECRET_SHOWN
and S["model"]["ui"]["price_service_token"] == SECRET_SHOWN)
check("a site with no token shows it empty, so the page can say 'not set'",
S["model_unset"]["options"]["price_service_token"] == ""
and S["model_unset"]["ui"]["price_service_token"] == "")
check("no answer the app gave in the whole run (page, model, validate, saves, "
"refusals, the stale one) holds the stored token",
not [t for t in S["seen"] if TOKEN in t])
check("...nor the new token, after it was saved",
not [t for t in S["seen"] if NEW_TOKEN in t])
print("\n2. the classic page draws it as a password field")
check("the field model types it as a password",
[f["type"] for f in S["field"]] == ["password"])
check("the classic page's script makes a password input for that type",
"'password'" in S["page"] and "input.type = 'password'" in S["page"])
print("\n3. saves keep the stored token unless given a new one")
st, tok, cap, body = S["classic_placeholder"]
check("a classic save with the placeholder keeps the token and saves the "
"other edit", st == 200 and tok == TOKEN and cap == 4100)
st, tok, cap = S["classic_empty"]
check("a classic save with the field emptied keeps the token",
st == 200 and tok == TOKEN and cap == 4200)
check("a classic save without the key keeps the token",
S["classic_absent"] == (200, TOKEN))
check("a save from the new settings page keeps it (with a base)",
S["ui_base"] == (200, TOKEN))
check("...and without a base", S["ui_nobase"] == (200, TOKEN))
st, tok, body = S["replaced"]
check("a new value replaces the token", st == 200 and tok == NEW_TOKEN)
check("...and the answer to that save does not echo it",
NEW_TOKEN not in body)
st, probs = S["validate_placeholder"]
check("validate with the placeholder in sees the stored token: nothing about "
"the token is refused", st == 200 and not [p for p in probs if "token" in p])
st, probs = S["validate_ui_placeholder"]
check("...also from the new page's model",
st == 200 and not [p for p in probs if "token" in p])
st, body, tok = S["refused"]
check("a refused save (token too short) is 422, says why in short plain "
"words, and writes nothing",
st == 422 and tok == TOKEN
and any("too short" in p for p in body["problems"]))
st, body = S["stale"]
check("a stale refusal is 409 and its `stored` shows the placeholder",
st == 409 and body["stored"]["price_service_token"] == SECRET_SHOWN)
check("a refusal about something else (422) writes nothing, so the token and "
"the rating stay",
S["refused_other"][0] == 422 and S["refused_other"][1] == TOKEN
and S["refused_other"][2] != -5)
print("\n4. every password option, not only this name")
other = [{"name": "mqtt_secret", "type": "string", "format": "password"},
{"name": "other_pw", "type": "password"},
{"name": "plain", "type": "string"}]
keys = configui.secret_keys(other)
check("a schema password is found in both spellings, a plain string is not",
keys == {"mqtt_secret", "other_pw"})
check("the price token is counted by name too, in case a schema is missing",
configui.secret_keys([], price.SECRET_OPTIONS)
== frozenset(price.SECRET_OPTIONS) and "price_service_token" in
price.SECRET_OPTIONS)
blob = {"mqtt_secret": "hunter2hunter2", "other_pw": "", "plain": "x",
"nested": [{"mqtt_secret": "deep-secret-value"}]}
shown = configui.hide_secrets(blob, keys)
check("hide_secrets: set -> placeholder, empty -> empty, other keys alone, "
"at any depth",
shown == {"mqtt_secret": SECRET_SHOWN, "other_pw": "", "plain": "x",
"nested": [{"mqtt_secret": SECRET_SHOWN}]}
and blob["mqtt_secret"] == "hunter2hunter2")
back = configui.keep_secrets({"mqtt_secret": SECRET_SHOWN, "other_pw": "new",
"plain": "y"}, blob, keys)
check("keep_secrets: the placeholder gives the stored value, a new value "
"stays",
back["mqtt_secret"] == "hunter2hunter2" and back["other_pw"] == "new"
and back["plain"] == "y")
check("keep_secrets: a placeholder with nothing stored is never saved as "
"the placeholder",
configui.keep_secrets({"mqtt_secret": SECRET_SHOWN}, {}, keys)
["mqtt_secret"] == "")
async def other_option_scenario():
"""A second password option, found from the schema, is hidden and kept."""
schema = SCHEMA + [{"name": "extra_secret", "type": "string",
"format": "password", "required": False,
"optional": True}]
sup = Sup(dict(SITE, extra_secret="extra-secret-value-12345"), schema)
runner, root = await serve(sup)
try:
async with ClientSession() as s:
async with s.get(root + "config/model?lang=en") as r:
raw = await r.text()
m = json.loads(raw)
opts = copy.deepcopy(m["options"])
opts["peak_cap_w"] = 4100
async with s.post(root + "config/save", json={
"options": opts, "fingerprint": m["fingerprint"]}) as r:
st = r.status
return raw, st, sup.options.get("extra_secret")
finally:
await runner.cleanup()
_raw, _st, _kept = asyncio.run(other_option_scenario())
check("a password option the add-on has no name for is hidden from the model",
"extra-secret-value-12345" not in _raw)
check("...and kept by a save that sends the placeholder",
_st == 200 and _kept == "extra-secret-value-12345")
async def named_scenario():
"""A schema that does not call the token a password (a Supervisor that
spells it another way): the add-on still hides it, by name."""
schema = [dict(n, format=None) if n["name"] == "price_service_token" else n
for n in SCHEMA]
sup = Sup(SITE, schema)
runner, root = await serve(sup)
try:
async with ClientSession() as s:
async with s.get(root + "config/model?lang=en") as r:
return await r.text()
finally:
await runner.cleanup()
check("the price token is hidden by name even when the schema does not mark "
"it as a password", TOKEN not in asyncio.run(named_scenario()))
print("\n5. PRICE-01 round 3: the fixed-tariff note on the real validate route")
_PROFILE = json.dumps({"periods": [
{"name": "all", "days": "all", "from": "00:00", "to": "24:00",
"eur_per_kwh": 0.25}]})
_NOTE = {"en": ("With the price service, the fixed tariff is only used if no "
"prices are available."),
"nl": ("Met de prijsdienst wordt het vaste tarief alleen gebruikt als "
"er geen prijzen zijn.")}
async def note_scenario(site):
runner, root = await serve(Sup(site))
out = {}
try:
async with ClientSession() as s:
for lang in ("en", "nl"):
async with s.get(root + f"config/model?lang={lang}") as r:
m = await r.json()
async with s.post(root + "config/validate",
json={"model": m["ui"], "lang": lang}) as r:
b = await r.json()
out[lang] = [x for x in b.get("messages", [])
if x["text"] in _NOTE.values()]
finally:
await runner.cleanup()
return out
_with = asyncio.run(note_scenario(dict(SITE, static_profile=_PROFILE)))
check("an English page gets the note in English, as info, beside a field",
[(x["text"], x["tier"]) for x in _with["en"]] == [(_NOTE["en"], "info")]
and _with["en"][0]["field"])
check("...and a Dutch page gets it in Dutch, beside the same field",
[(x["text"], x["tier"]) for x in _with["nl"]] == [(_NOTE["nl"], "info")]
and _with["nl"][0]["field"] == _with["en"][0]["field"])
_blank = asyncio.run(note_scenario(dict(SITE, static_profile=" ")))
check("a spaces-only fixed tariff: no note on either page",
_blank == {"en": [], "nl": []})
print("\n6. the shipped config.yaml")
cfg = open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read()
schema_text = cfg[cfg.index("\nschema:"):]
pw = {line.split(":")[0].strip() for line in schema_text.splitlines()
if line.split(":", 1)[-1].strip().rstrip("?") == "password"}
check(f"every password option of config.yaml is counted as a secret ({sorted(pw)})",
pw and pw <= configui.secret_keys(SCHEMA, price.SECRET_OPTIONS))
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")
File diff suppressed because it is too large Load Diff
+375
View File
@@ -0,0 +1,375 @@
"""SAFETY-118: the peak forecast is read in its own unit. `python3 -B test_safety118.py`
`peak_forecast_entity` used to be read as a bare number and anything under 50
was multiplied by 1000, so a W sensor reading 17 W was taken as 17 kW. It is
now read through `pv.watts()` by its own `unit_of_measurement`. No unit, or a
unit that is not power: the forecast is not used, as if the option were empty.
1. The reader through the real `read_inputs()`: W, kW, MW, no unit, kWh, bad state.
2. The warning: once, again only when the cause changes, never every cycle.
3. One state read per cycle, as before.
4. The maintenance charge headroom and the charge-only clamp act on the
converted value (real `cycle()`).
5. The validator, the status row and the strings.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import asyncio
import logging
from pathlib import Path
from app import main as _main
from app import messages, pv, validate
from app.main import Controller
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def st(value, unit=None):
attrs = {} if unit is None else {"unit_of_measurement": unit}
return {"state": str(value), "attributes": attrs}
class _Store:
def __init__(self, data=None):
self.data = dict(data or {})
def set(self, k, v):
self.data[k] = v
def get_time(self, *a):
return None
def set_time(self, *a):
pass
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class _Hass:
def __init__(self, fc=None, prices=None):
self.fc = fc
self.prices = prices or {}
self.reads = []
self.writes = []
async def limits(self, entity):
return (-5000.0, 5000.0, 1.0)
async def state(self, entity):
self.reads.append(entity)
if entity == "sensor.fc":
return self.fc
v = {"sensor.grid": 0.0, "sensor.soc": 50.0, "sensor.batt": 0.0,
**self.prices}.get(entity)
return None if v is None else st(v, "W")
async def number(self, entity, invert=False):
obj = await self.state(entity)
try:
return float(obj["state"]) if obj else None
except ValueError:
return None
async def set_number(self, entity, value):
self.writes.append(float(value))
return True
async def call(self, domain, service, data):
return True
OPTS = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "heartbeat_s": 10, "stale_input_s": 15,
"max_w": 5000.0, "gain": 0.6, "slew_w": 400.0, "deadband_w": 15,
"step_w": 10, "target_grid_w": -10.0, "stale_input_s": 15,
"peak_cap_w": 5000, "peak_margin_w": 0,
"peak_forecast_entity": "sensor.fc"}
class _Rec(logging.Handler):
def __init__(self):
super().__init__(logging.DEBUG)
self.rows = []
def emit(self, r):
self.rows.append((r.levelno, r.getMessage()))
def make(fc, store=None, prices=None, **over):
hass = _Hass(fc, prices)
c = Controller(dict(OPTS, **over), hass, _Store(store), _Pub(),
stop=asyncio.Event())
c.auto, c.dev_min, c.dev_max = True, -5000.0, 5000.0
return c, hass
def reads(c):
asyncio.run(c.read_inputs())
return c.peak_fc
def peak_of(state):
c, _h = make(state)
return reads(c)
print("1. the reader")
CASES = [("W 17", st(17, "W"), 17.0), ("W 820", st(820, "W"), 820.0),
("kW 0.82", st(0.82, "kW"), 820.0), ("kW 0.017", st(0.017, "kW"), 17.0),
("MW 0.0008", st(0.0008, "MW"), 800.0), ("W 49", st(49, "W"), 49.0),
("W 50", st(50, "W"), 50.0), ("kW 30", st(30, "kW"), 30000.0),
("W 0", st(0, "W"), 0.0)]
for label, s, want in CASES:
got = peak_of(s)
check(f"{label} -> {want} W (got {got})",
got is not None and abs(got - want) < 1e-6)
for label, s in [("no unit", st(820)), ("empty unit", st(820, "")),
("kWh", st(820, "kWh")), ("percent", st(20, "%")),
("unavailable", st("unavailable", "W")),
("unknown", st("unknown", "W")), ("missing entity", None),
("not a number", st("sunny", "W"))]:
check(f"{label} -> None (got {peak_of(s)})", peak_of(s) is None)
c, _h = make(st(820, "W"), peak_forecast_entity="")
check("option empty -> None and the entity is never read",
reads(c) is None and "sensor.fc" not in _h.reads)
print("2. the warning")
rec = _Rec()
lg = logging.getLogger("goodwe")
lg.addHandler(rec)
lg.setLevel(logging.DEBUG)
def warns():
return [m for lv, m in rec.rows if lv >= logging.WARNING]
c, h = make(st(820, "kWh"))
for _ in range(5):
reads(c)
check(f"kWh: five cycles, one WARNING ({warns()})", len(warns()) == 1)
check("...short, plain, and says it is not used",
warns()[0] == "The 15-minute demand forecast is not in a power unit, so "
"it is not used." and len(warns()[0].split()) <= 25)
h.fc = st(820, "MWh")
reads(c)
check("the same cause with another unit warns again (the cause changed)",
len(warns()) == 2)
h.fc = st(820)
reads(c)
check("no unit is a different cause and warns", len(warns()) == 3
and "has no unit" in warns()[2])
reads(c)
check("...and only once", len(warns()) == 3)
USED = (logging.INFO, "The 15-minute demand forecast is used again.")
def used():
return [r for r in rec.rows if r == USED]
h.fc = st("unavailable")
reads(c)
check("unavailable adds no warning and no 'used again'",
len(warns()) == 3 and not used())
h.fc = st(820, "W")
reads(c)
check("a good unit is read again: one INFO, no new warning",
c.peak_fc == 820.0 and len(warns()) == 3 and len(used()) == 1
and rec.rows[-1] == USED)
n = len(warns())
for s in (st(820, "W"), st(0.8, "kW")):
h.fc = s
reads(c)
check("W and kW never warn", len(warns()) == n)
rows = [r for r in c.checks() if r.get("id") == "peak_forecast"]
check("a good forecast has no peak_forecast row", not rows)
h.fc = st(820, "kWh")
reads(c)
rows = [r for r in c.checks() if r.get("id") == "peak_forecast"]
check("the status page carries a caution row for the unit fault",
len(rows) == 1 and rows[0]["warn"] and not rows[0]["ok"]
and rows[0]["text"] == messages.row_peak_forecast_unit("kWh"))
lg.removeHandler(rec)
print("3. one state read per cycle")
c, h = make(st(0.82, "kW"))
reads(c)
check(f"one read of the forecast entity per read_inputs() ({h.reads})",
h.reads.count("sensor.fc") == 1)
print("4. maintenance headroom and the charge-only clamp use watts")
seen = []
_real = _main.maintenance_charge_floor
def spy(charge_w, fc, cap):
out = _real(charge_w, fc, cap)
seen.append((charge_w, fc, cap, out))
return out
_main.maintenance_charge_floor = spy
try:
def cycle(fc, **kw):
c, h = make(fc, store={"phase": "charge"},
prices={"sensor.pnow": 1.0, "sensor.pavg": 2.0},
price_now_entity="sensor.pnow",
price_avg_entity="sensor.pavg", **kw)
seen.clear()
asyncio.run(c.read_inputs())
asyncio.run(c.cycle())
return c, h
# cap 2500 W, charge rate 2000 W: 820 W of forecast leaves 1680 W, 17 W
# leaves the whole 2000 W (the size rule read 17 as 17 kW and left 0).
c, h = cycle(st(0.82, "kW"), peak_cap_w=2500)
check(f"cheap-window headroom is cap - 820 W = 1680 W ({seen})",
len(seen) == 1 and seen[0][1] == 820.0 and seen[0][0] == 2000.0
and abs(seen[0][3] - 1680.0) < 1e-6)
c, h = cycle(st(17, "W"), peak_cap_w=2500)
check(f"a 17 W forecast leaves the full 2000 W charge rate ({seen})",
len(seen) == 1 and seen[0][1] == 17.0 and abs(seen[0][3] - 2000.0) < 1e-6)
# charge-only clamp: not cheap, shaping suspended only if the forecast is
# over the aim. 6 kW is over 5000 W; 17 W is not (the old rule read it
# as 17 kW and suspended).
def clamp(fc):
c, h = make(fc, store={"phase": "charge"})
asyncio.run(c.read_inputs())
asyncio.run(c.cycle())
return c._maint_shape_suspended
check("kW 6.0 (6000 W, over the 5000 W aim) suspends the charge-only clamp",
clamp(st(6.0, "kW")) is True)
check("W 17 leaves the charge-only clamp in place",
clamp(st(17, "W")) is False)
check("kW 0.017 leaves the charge-only clamp in place",
clamp(st(0.017, "kW")) is False)
check("no unit: forecast unused, clamp in place, as with the option empty",
clamp(st(6000)) is False)
finally:
_main.maintenance_charge_floor = _real
print("5. validator and strings")
def notes(unit, key="peak_forecast_entity"):
ent = {"sensor.fc": {"entity_id": "sensor.fc", "unit": unit,
"state": "1"}}
return [n for n in validate.config_notes(
{key: "sensor.fc", "meter_source": "off"}, entities=ent)
if key in n.keys]
check("validator: W no note", not notes("W"))
check("validator: kW no note", not notes("kW"))
check("validator: MW no note", not notes("MW"))
nu, kw = notes(""), notes("kWh")
check(f"validator: no unit warns ({nu})", len(nu) == 1 and nu[0].tier == "warn"
and "has no unit" in nu[0].text)
check(f"validator: kWh warns ({kw})", len(kw) == 1 and kw[0].tier == "warn"
and "not in a power unit" in kw[0].text)
check("validator: the false 'taken as watts' claim is gone for this entity",
not any("taken as watts" in n.text for n in nu + kw + notes("kW")))
check("validator: another watt key keeps its claim",
len(notes("kW", "batt_entity")) == 1
and "taken as watts" in notes("kW", "batt_entity")[0].text)
check("WATT_ENTITY_KEYS no longer holds the peak forecast",
"peak_forecast_entity" not in validate.WATT_ENTITY_KEYS
and "batt_entity" in validate.WATT_ENTITY_KEYS)
print(" EN no unit :", messages.peak_forecast_unit(""))
print(" EN bad unit:", messages.peak_forecast_unit("kWh"))
print(" NL no unit :", messages.peak_forecast_unit("", "nl"))
print(" NL bad unit:", messages.peak_forecast_unit("kWh", "nl"))
check("the strings differ by cause and by language",
len({messages.peak_forecast_unit(u, l) for u in ("", "kWh")
for l in ("en", "nl")}) == 4)
print("6. a missing reading leaves the unit fault as it was (F1)")
lg.addHandler(rec)
def prow(c):
return [r for r in c.checks() if r.get("id") == "peak_forecast"]
def flap(first, gap, last):
"""first, gap, first again (or last): the log rows and the status row
after each cycle."""
rec.rows.clear()
c, h = make(first)
out = []
for s in (first, gap, last):
h.fc = s
reads(c)
out.append((len(warns()), len(used()), len(prow(c)), c.peak_fc))
return out
for label, first, gap in [
("kWh -> unavailable -> kWh", st(820, "kWh"), st("unavailable", "kWh")),
("kWh -> unknown -> kWh", st(820, "kWh"), st("unknown", "kWh")),
("kWh -> failed read -> kWh", st(820, "kWh"), None),
("kWh -> non-numeric -> kWh", st(820, "kWh"), st("sunny", "kWh")),
("no unit -> failed read -> no unit", st(820), None),
("no unit -> unavailable -> no unit", st(820), st("unavailable"))]:
r = flap(first, gap, first)
check(f"{label}: one WARNING, no 'used again', row on all 3 ({r})",
[x[0] for x in r] == [1, 1, 1] and [x[1] for x in r] == [0, 0, 0]
and [x[2] for x in r] == [1, 1, 1]
and [x[3] for x in r] == [None, None, None])
r = flap(st(820, "kWh"), st("unavailable", "kWh"), st(820, "W"))
check(f"kWh -> unavailable -> W: the fault clears only on the W reading ({r})",
[x[1] for x in r] == [0, 0, 1] and [x[2] for x in r] == [1, 1, 0]
and [x[0] for x in r] == [1, 1, 1] and r[2][3] == 820.0)
rec.rows.clear()
c, h = make(st(820, "W"))
for s in (st(820, "W"), st("unavailable", "W"), None, st("sunny", "W")):
h.fc = s
reads(c)
check(f"good unit, reading {s and s['state']!r}: no row, no log",
not prow(c) and not warns() and not used())
rec.rows.clear()
c, h = make(st(820, "kWh"))
reads(c)
for s in (st("unavailable", "kWh"), None):
h.fc = s
reads(c)
check("fault row stays while the reading is missing",
len(prow(c)) == 1 and prow(c)[0]["text"]
== messages.row_peak_forecast_unit("kWh"))
c, h = make(st(820, "W"))
h.fc = st("unavailable", "W")
reads(c)
check("good-unit sensor only unavailable: the fault row stays away",
not prow(c))
lg.removeHandler(rec)
check("has_reading: numbers only",
pv.has_reading(st(0, "kWh")) and pv.has_reading(st("1.5"))
and not pv.has_reading(None) and not pv.has_reading(st("unavailable"))
and not pv.has_reading(st("unknown")) and not pv.has_reading(st("x")))
check("pv.unit_fault: None for a good unit and for a missing reading (has_reading tells them apart)",
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")
check("the size rule is gone from main.py", "abs(raw) < 50" not in src)
print()
print("FAILED: " + "; ".join(fails) if fails else "all checks passed")
raise SystemExit(1 if fails else 0)
+737
View File
@@ -0,0 +1,737 @@
"""Runnable check for SAFETY-119: no "Battery is not keeping up" when the
loop takes the wire back from another owner. `python3 test_safety119.py`
During a maintenance charge with no usable peak forecast, the measured peak
guard steps in during a quarter's last seconds and hands back. On the cycle
the loop gets the wire back, its integrator is re-seeded from the other
owner's command and kicked by a meter reading taken before the battery moved.
It crossed its rail for one or two cycles, the loop's ask beat the one-sided
limit, so the cycle counted as the loop's own and raised
`goodwe_integrator_bound`. The battery gave what it was told. Now the loop's
bound does not count until the plant's settle (SHORT_CYCLES cycles and
SHORT_SPAN_S) has passed since the hand-back. SAFETY-121: the settle is
HANDBACK_CYCLES (4) cycles and SHORT_SPAN_S (22 s), and across all
hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES (11) cycles in
any ROLLING_S (900 s), at every cycle length.
DECISIONS/SAFETY-119-bound-after-hand-back.md,
DECISIONS/SAFETY-121-bound-every-quarter.md.
1. The rig's shape (1 s meter that lags the battery, 1 s battery lag, house
800 W, rating 2500 W, limit 2500 W, margin 50, charge 2000 W, no
forecast), 2 h, three seeds: no notification, the cap held, every command
the same as without the fix; without the fix it warns.
2. A genuine integrator bound still warns: the loop in charge with the house
over the rating (at once, as before), and the loop at its rail after a
hand-back (after the settle).
3. With a usable forecast: the same commands and the same alarms as without
the fix.
4. Repeated hand-backs (N=2, the guard and the charge take turns), 2, 5, 10
and 15 s meters: it warns in every quarter, both with the meter a cycle
behind the battery reading and in the rig's shape (SAFETY-121, F-1: the
meter read in the same cycle, 1 to 5 bound cycles a quarter), where
SAFETY-119's settle of 12 cycles hid every quarter but one; the
quarter-edge case stays silent at 2, 5, 10 and 15 s; the budget's margins
(8 warns at the quarter edge, 14 misses quarters); a start or a blind
cycle is not a hand-back; the delay after one hand-back.
5. The rule at its boundaries (`Controller._settling`): the settle, the
rolling budget, and a stop that does not give the budget back.
6. test_safety84's and test_safety87's re-pinned digests: the same commands
and claims; only OUTPUT_CLAMP log rows differ.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import math
import os
import random
import sys
import time
from datetime import timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import safety84_identity as s84 # noqa: E402
import app.main as app_main # noqa: E402
from app import messages # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from named_absence import first, nth, present # noqa: E402
fails = []
OPT0 = dict(s84.OPT)
CAP = 2500.0
QUARTER = 900.0
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
async def _call(self, *a):
"""The rig's Home Assistant, recording each notification (time, id)."""
if len(a) >= 3 and a[1] == "create" and isinstance(a[2], dict):
self.__dict__.setdefault("notes", []).append(
(self.t, a[2].get("notification_id")))
return True
def _power(self, u, lag=1.0):
"""The TEST-23 plant's battery: 800 W/s ramp, 1 s first-order lag."""
st = self.__dict__.setdefault("_plant", {})
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
for _ in range(int(round((self.t - tl) / 0.1))):
d = self.cmd[u] - tgt
tgt += math.copysign(min(abs(d), 80.0), d)
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
st[u] = (a, tgt, self.t)
if self.cut(u, self.t) and self.cmd[u] >= -4.0:
return -4.0
return float(round(a))
_real_number, _real_power = rig._Hass.number, rig._Hass.power
_real_feed = s79._feed
async def _number(self, e, invert=False):
"""sensor.fc: a usable demand forecast of 800 W (part 3)."""
if e == "sensor.fc":
return 800.0
return await _real_number(self, e, invert)
rig._Hass.call, rig._Hass.power, rig._Hass.number = _call, _power, _number
rig.TICK = 1.0
DELAY = [1]
def _feed(c, t):
"""The P1 meter `DELAY` ticks behind the battery, as on the rig (the
meter reached HA after the battery reading). Records the true grid."""
h = c.hass.__dict__.setdefault("grid_true", [])
h.append((t, c.hass.grid()))
g = h[max(0, len(h) - 1 - DELAY[0])][1]
imp, exp = split_signed(g)
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=rig.BASE + timedelta(seconds=t),
ingest_mono=time.monotonic()))
s79._feed = _feed
def house(seed, base=800.0, step=None):
"""800 W +- 30 W, a new value each second; `step` = (t, W) from then."""
rnd = random.Random(seed)
noise = [rnd.uniform(-30.0, 30.0) for _ in range(4000)]
def load(t):
if step is not None and t >= step[0]:
return step[1]
return base + noise[int(t) % 4000]
return load
def run(load, *, n=1, dur=7200.0, delay=1, extra=None, cheap=True, cut=None,
charging=True, blind=0.0):
"""One run of `n` inverters of 2500 W; `charging=False`: no maintenance,
the loop in charge; `blind`: no meter reading until that second."""
extra = dict(extra or {})
s84.OPT = dict(OPT0, peak_cap_w=int(CAP * n), peak_margin_w=50,
maintenance_charge_w=2000)
if not charging:
extra.update(maintenance_enabled=False, peak_cap_w=20000)
for k in [k for k in extra if k in s84.OPT]:
s84.OPT[k] = extra.pop(k)
DELAY[0] = delay
try:
feed = s84.feed(cheap)
def before(c, t):
if t >= blind: # before that: no meter reading
return feed(c, t)
return s84.run(n, load=load, cheap=cheap,
store=(s79.store_charging(n) if charging
else rig._Store()),
soc=lambda u, t: 60.0, dur=dur, cut=cut,
before=before if blind else None,
extra=dict({"max_w": 2500, "slew_w": 0}, **extra))
finally:
s84.OPT = OPT0
def patched(obj, name, value):
def wrap(fn):
real = getattr(obj, name)
setattr(obj, name, value)
try:
return fn()
finally:
setattr(obj, name, real)
return wrap
no_fix = patched(Controller, "_settling", lambda self, owns, now: False)
def notes(c):
return [t for t, i in getattr(c.hass, "notes", [])
if i == messages.ID_LOOP_BOUND]
def quarters(c, tick=1.0):
"""Each whole quarter's true average import (W)."""
q = {}
for t, g in c.hass.grid_true:
q.setdefault(int(t // QUARTER), []).append(g)
return [sum(v) / len(v) for v in q.values()
if len(v) == QUARTER / tick]
def commands(rows):
return [(r[0], r[1], r[2]) for r in rows]
def guard_steps(c):
return sum(1 for e in c.events if "PEAK_GUARD:" in e)
# ============================================================================ #
print("1. a maintenance charge with no forecast, the guard steps in at each "
"quarter's end, 2 h")
NEW, OLD = {}, {}
for seed, delay in ((1, 1), (2, 1), (3, 2)):
NEW[seed] = run(house(seed), delay=delay)
OLD[seed] = no_fix(lambda: run(house(seed), delay=delay))
print(f" seed {seed} meter {delay} s behind: notifications "
f"{notes(NEW[seed][1])} (without the fix {notes(OLD[seed][1])}), "
f"highest quarter {max(quarters(NEW[seed][1])):.0f} W, guard "
f"step-ins in the last 40 log rows {guard_steps(NEW[seed][1])}")
check("⭐ no 'Battery is not keeping up' notification, cycle or episode",
all(notes(c) == [] and c.clamp_wd.bound == 0
and c.clamp_wd.bound_episodes == 0 for _r, c in NEW.values()))
check("⭐ the peak cap is held: every quarter's average is under 2500 W",
all(len(quarters(c)) == 8 and max(quarters(c)) < CAP
for _r, c in NEW.values()))
check("⭐ control is unchanged: every command the same as without the fix",
all(commands(NEW[k][0]) == commands(OLD[k][0]) for k in NEW))
check("...the charge ran and the guard stepped in near each quarter's end",
all(any(r[1] == -2000.0 for r in rows) and any(
r[-1][1] and r[-1][1][0] and r[0] % QUARTER >= QUARTER - 40
for r in rows) for rows, _c in NEW.values()))
check("non-vacuous: without the fix every run warns, at a quarter's edge",
all(len(notes(c)) >= 1 for _r, c in OLD.values())
and all(min(t % QUARTER, QUARTER - t % QUARTER) <= 40
for _r, c in OLD.values() for t in notes(c)))
# ============================================================================ #
print("\n2. a genuine integrator bound still warns")
_rail = run(house(1, base=4000.0), dur=600.0,
charging=False)
_rail_old = no_fix(lambda: run(house(1, base=4000.0), dur=600.0,
charging=False))
print(f" loop in charge, house 4000 W over the 2500 W rating: "
f"{notes(_rail[1])}, without the fix {notes(_rail_old[1])}")
check("⭐ the loop in charge at its rail warns (log and notification), at "
"the same cycle as before",
len(notes(_rail[1])) >= 1 and notes(_rail[1]) == notes(_rail_old[1])
and _rail[1].clamp_wd.bound == _rail_old[1].clamp_wd.bound
and any("OUTPUT_CLAMP episode" in e for e in _rail[1].events))
# The charge sets the wire until 600 s, then a solar surplus of 6000 W: the
# loop takes the wire back and stands at its -2500 W rail.
_hb = run(house(1, step=(600.0, -6000.0)), dur=1200.0,
extra={"peak_cap_w": 20000})
_hb_old = no_fix(lambda: run(house(1, step=(600.0, -6000.0)), dur=1200.0,
extra={"peak_cap_w": 20000}))
_first = notes(_hb[1])[:1]
print(f" hand-back to a loop at its rail at 600 s: first notification "
f"{_first}, without the fix {notes(_hb_old[1])[:1]}; command at the "
f"end {_hb[0][-1][1]}")
MB = 11 # the budget (at 1 s it ends the settle)
check(f"⭐ the loop at its rail after a hand-back still warns, once "
f"{MB} of its bound cycles are hidden (1 s meter: {MB} s)",
_first and 600.0 + MB <= _first[0] <= 600.0 + MB + 4.0
and _hb[0][-1][1] == -2500.0
and _hb[1].clamp_wd.bound > 100)
check("...without the fix it warned at once (non-vacuous)",
notes(_hb_old[1])[:1] and notes(_hb_old[1])[0] < 600.0 + MB)
# The same charge with no forecast; at 1795 s, under the guard's step-in at
# the quarter's end, the house goes to 4000 W, over the 2500 W rating.
_up = run(house(1, step=(1795.0, 4000.0)), dur=1900.0)
_up_old = no_fix(lambda: run(house(1, step=(1795.0, 4000.0)), dur=1900.0))
print(f" guard step-in, then the house over the rating from 1795 s: "
f"{notes(_up[1])}, without the fix {notes(_up_old[1])}")
check("⭐ a genuine bound in the rig's own scenario still warns, within the "
"settle of the guard's hand-back",
notes(_up[1]) and 1795.0 <= notes(_up[1])[0] <= 1795.0 + 40.0
and any("OUTPUT_CLAMP episode" in e for e in _up[1].events))
# ============================================================================ #
print("\n3. with a usable forecast (800 W): unchanged")
FC = {"peak_forecast_entity": "sensor.fc"}
_fc = run(house(1), extra=FC)
_fc_old = no_fix(lambda: run(house(1), extra=FC))
print(f" notifications {notes(_fc[1])} (without the fix "
f"{notes(_fc_old[1])}), highest quarter "
f"{max(quarters(_fc[1])):.0f} W, charge "
f"{min(r[1] for r in _fc[0][:300]):.0f} W")
check("⭐ the same commands and the same alarms as without the fix, and "
"the cap held",
commands(_fc[0]) == commands(_fc_old[0])
and notes(_fc[1]) == notes(_fc_old[1])
and _fc[1].clamp_wd.engagements == _fc_old[1].clamp_wd.engagements
and max(quarters(_fc[1])) < CAP)
check("...the forecast was used (the charge is shaped under -2000 W)",
-2000.0 < min(r[1] for r in _fc[0][:300]) < 0.0)
# ============================================================================ #
print("\n4. repeated hand-backs: the guard and the charge take turns")
def round1(self, owns, now):
"""Round 1's rule, kept here to show what it hid: every hand-back
restarts the window, so a loss within the settle never lets it end."""
d = self.__dict__
if not owns:
d["_r1"], d["_r1_n"] = True, None
return False
if d.get("_r1"):
d["_r1"], d["_r1_n"] = False, (now, 0)
if d.get("_r1_n") is None:
return False
t0, n = d["_r1_n"]
d["_r1_n"] = (t0, n + 1)
if n + 1 >= app_main.SHORT_CYCLES and (
now is None or t0 is None or now - t0 >= app_main.SHORT_SPAN_S):
d["_r1_n"] = None
return False
return True
# N=2 (2 x 2500 W), a cheap-window charge, no usable forecast, the house 50 W
# over the 5000 W rating from 300 s: the loop never held the wire for more
# than 10 cycles. Round 1: no notification in 2 h; round 2: none at 10 s or
# 15 s meters.
def never(tick, hw=5050.0, fix=True, seed=0, delay=1):
"""`seed` 0: a constant house; else 800 W +- 30 W, `hw` +- 30 W from
300 s. `delay` 0 is the rig's shape: the meter read in the same cycle
as the battery (SAFETY-121)."""
rig.TICK = tick
if seed:
load = (lambda t, h=house(seed):
h(t) + (hw - 800.0 if t >= 300.0 else 0.0))
else:
load = house(1, step=(300.0, hw))
try:
f = lambda: run(load, n=2, delay=delay)
return f() if fix else no_fix(f)
finally:
rig.TICK = 1.0
def per_quarter(c):
return [sum(1 for t in notes(c) if int(t // QUARTER) == q)
for q in range(8)]
def every_quarter(c):
return all(n >= 1 for n in per_quarter(c)[1:])
_nv = {t: never(t) for t in (2.0, 5.0, 10.0, 15.0)}
_nv_old = {t: never(t, fix=False) for t in (2.0, 5.0, 10.0, 15.0)}
_nv_r1 = patched(Controller, "_settling", round1)(lambda: never(5.0))
for _t in _nv:
print(f" N=2, {_t:.0f} s meter, house 5050 W: notifications "
f"{len(notes(_nv[_t][1]))} (per quarter "
f"{per_quarter(_nv[_t][1])}), without the fix "
f"{len(notes(_nv_old[_t][1]))}, highest quarter "
f"{max(quarters(_nv[_t][1], _t)):.0f} W")
print(f" round 1's rule at 5 s: {len(notes(_nv_r1[1]))}")
check("⭐ 2, 5, 10 and 15 s meters: it warns in every quarter after the "
"first (round 2 gave none at 10 and 15 s)",
all(every_quarter(_nv[t][1]) for t in _nv))
check("non-vacuous: round 1's rule (a window per hand-back) never warned "
"here, and the fix still hides some of what was raised without it",
notes(_nv_r1[1]) == [] and all(
_nv[t][1].clamp_wd.bound < _nv_old[t][1].clamp_wd.bound
for t in _nv))
check("...every command is the same as without the fix, at 2, 5, 10 and "
"15 s",
all(commands(_nv[t][0]) == commands(_nv_old[t][0]) for t in _nv))
check("...and at 5 s the cap held",
len(quarters(_nv[5.0][1], 5.0)) == 8
and max(quarters(_nv[5.0][1], 5.0)) < 5000.0)
# SAFETY-121, the rig's shape (TEST-23, F-1): the meter is read in the same
# cycle as the battery, so the guard's charge headroom is right after it
# hands back and the guard and the charge do not chase each other. The loop
# then holds the wire once a quarter, at its start, and is at its bound on
# 1 to 5 cycles (5 s), not 20. House 5100 W +- 30 W (5300 W at 15 s, where
# 5100 W is over the rating in one quarter only, without the fix too).
RIG = {1.0: 5100.0, 2.0: 5100.0, 5.0: 5100.0, 10.0: 5100.0, 15.0: 5300.0}
_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)(
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 "
f"quarter {per_quarter(_rg[_t][1])}, without the fix "
f"{per_quarter(_rg_old[_t][1])}, highest quarter "
f"{max(quarters(_rg[_t][1], _t)):.0f} W")
print(f" rig shape, 5 s, SAFETY-119's settle and budget: per quarter "
f"{per_quarter(_rg_119[1])}")
check("⭐ the rig's shape at 1, 2, 5, 10 and 15 s meters: it warns in every "
"quarter after the first, as without the fix",
all(every_quarter(_rg[t][1]) and every_quarter(_rg_old[t][1])
for t in _rg))
check("non-vacuous (F-1): with SAFETY-119's settle of 12 cycles and budget "
"of 8, the rig's shape at 5 s warns in at most one quarter",
sum(1 for n in per_quarter(_rg_119[1])[1:] if n) <= 1)
check("...every command the same as without the fix, and the cap held at "
"1 to 10 s",
all(commands(_rg[t][0]) == commands(_rg_old[t][0]) for t in _rg)
and all(max(quarters(_rg[t][1], t)) < 5000.0
for t in (1.0, 2.0, 5.0, 10.0)))
# The quarter-edge case (part 1) at the slower meters: still no warning.
# The meter's own lag limits the cap there, the same with and without the fix.
_qe = {}
for _t in (2.0, 5.0, 10.0, 15.0): # with part 1: 1, 2, 5, 10, 15 s
rig.TICK = _t
for _seed, _dl in ((1, 1), (2, 1), (3, 2)):
_qe[_t, _seed] = (run(house(_seed), delay=_dl),
no_fix(lambda: run(house(_seed), delay=_dl)))
rig.TICK = 1.0
print(f" {_t:.0f} s meter, quarter edge: notifications "
f"{[len(notes(r[0][1])) for k, r in _qe.items() if k[0] == _t]} "
f"(without the fix "
f"{[len(notes(r[1][1])) for k, r in _qe.items() if k[0] == _t]})")
check("⭐ the quarter-edge case at 2, 5, 10 and 15 s meters, seeds 1 to 3: "
"no notification",
all(notes(r[0][1]) == [] and r[0][1].clamp_wd.bound == 0
for r in _qe.values()))
check("...every command the same as without the fix; without it the false "
"alarm is there at each meter (non-vacuous)",
all(commands(r[0][0]) == commands(r[1][0]) for r in _qe.values())
and all(any(len(notes(_qe[_t, k][1][1])) >= 1 for k in (1, 2, 3))
for _t in (2.0, 5.0, 10.0, 15.0)))
# The budget's margins (DECISIONS/SAFETY-121): 9 to 13 met both cases in the
# sweep. 8 warns at the quarter edge (2 s, seed 25); 14 misses quarters in
# 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)(
lambda: run(house(25), delay=2)))
rig.TICK = 1.0
_m14 = patched(app_main, "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 "
f"{notes(_q25[1][1])}; rig shape 1 s 5050 W per quarter "
f"{per_quarter(_m11[1])}, budget 14 {per_quarter(_m14[1])}")
check("...the budget's margins: 11 is silent at 2 s seed 25 and warns every "
"quarter in the rig's shape at 1 s and 5050 W; 8 warns there, 14 "
"misses a quarter there",
notes(_q25[0][1]) == [] and len(notes(_q25[1][1])) >= 1
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
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
# 20 s of not running warns at once (R2: a restart counted as a hand-back).
_bl = run(house(1, base=4000.0), dur=120.0, charging=False, blind=20.0)
_bl_old = no_fix(lambda: run(house(1, base=4000.0), dur=120.0,
charging=False, blind=20.0))
print(f" the loop not running until 20 s, then at its rail: "
f"{notes(_bl[1])}, without the fix {notes(_bl_old[1])}")
check("⭐ a start after a blind spell is not a hand-back: it warns at the "
"same cycle as without the fix",
notes(_bl[1])[:1] and 20.0 <= notes(_bl[1])[0] <= 26.0
and notes(_bl[1]) == notes(_bl_old[1]))
# One hand-back to a loop at its rail at 600 s: the first warning comes when
# the settle ends, 22 s and 4 cycles after it (SAFETY-119: 12 cycles, 40 s at
# 5 s and 80 s at 10 s).
_sl = {}
for _t in (5.0, 10.0):
rig.TICK = _t
_sl[_t] = run(house(1, step=(600.0, -6000.0)), dur=900.0,
extra={"peak_cap_w": 20000})
rig.TICK = 1.0
_sl1 = {t: [x for x in notes(r[1]) if x >= 600.0][:1] for t, r in _sl.items()}
print(f" hand-back to a loop at its rail at 600 s, first notification: "
f"5 s {_sl1[5.0]}, 10 s {_sl1[10.0]}")
check("⭐ one hand-back (the loop has the wire from the cycle after the "
"step): the first warning comes 25 s after it at a 5 s meter (the "
"22 s span), 30 s after it at 10 s (4 cycles)",
_sl1[5.0] == [630.0] and _sl1[10.0] == [640.0])
# ============================================================================ #
print("\n5. the rule at its boundaries")
class _C:
_settling = Controller._settling
def __init__(self):
self._wire_lost, self._handback, self._hidden = False, None, ()
self.loop_clamped = (False, "", False)
def seq(c, steps):
"""(owned, time[, the loop's bound that cycle]) -> does it count not."""
out = []
for st in steps:
c.loop_clamped = (len(st) > 2 and st[2], "", False)
out.append(c._settling(st[0], st[1]))
return out
SPAN = app_main.SHORT_SPAN_S
K, M, ROLL = 4, 11, 900.0 # the chosen rule, written out on purpose
_c = _C()
check("from the start, owned every cycle: never settling (no hand-back)",
seq(_c, [(True, float(i)) for i in range(30)]) == [False] * 30)
_c = _C()
_out = seq(_c, [(False, 0.0)] + [(True, 2.0 * i) for i in range(1, 15)])
check(f"after another owner at 2 s: settling for 11 owned cycles, then not "
f"(the span, {SPAN:.0f} s, decides)",
_out == [False] + [True] * 11 + [False] * 3)
_c = _C()
_out = seq(_c, [(False, 0.0)] + [(True, 0.5 * i) for i in range(1, 60)])
_end = _out.index(False, 1) if False in _out[1:] else -1
check(f"at a 0.5 s meter the span decides: settling until {SPAN:.0f} s",
_end == 1 + int(SPAN / 0.5) and all(_out[1:_end]))
_set = {}
for _tick in (10.0, 30.0, 60.0):
_c = _C()
_set[_tick] = seq(_c, [(False, 0.0)]
+ [(True, _tick * i) for i in range(1, K + 2)])
check(f"at 10, 30 and 60 s the cycles decide: settling for {K - 1} owned "
f"cycles, then not (SAFETY-119: 11)",
all(o == [False] + [True] * (K - 1) + [False] * 2
for o in _set.values()))
_c = _C()
_out = seq(_c, [(False, 0.0), (True, 2.0), (True, 4.0), (False, 6.0),
(True, 8.0), (True, 10.0)])
check("losing the wire again restarts the settle",
_out == [False, True, True, False, True, True]
and _c._handback == (8.0, 2))
_c = _C()
check("no monotonic clock: the cycle count alone decides",
seq(_c, [(False, None)] + [(True, None)] * (K + 1))
== [False] + [True] * (K - 1) + [False] * 2)
# One hand-back, the loop's bound on every cycle: the settle (5 s) or the
# budget (1 s) ends the hiding.
_c = _C()
_o5 = seq(_c, [(False, 0.0)] + [(True, 5.0 * i, True) for i in range(1, 9)])
_c = _C()
_o1 = seq(_c, [(False, 0.0)] + [(True, 1.0 * i, True) for i in range(1, 21)])
check("one hand-back, the bound on every cycle: 5 hidden at 5 s (22 s), "
f"then counted; {M} hidden at 1 s (the budget), then counted",
_o5 == [False] + [True] * 5 + [False] * 3
and _o1 == [False] + [True] * M + [False] * (20 - M))
def turns(c, tick, lost, periods, first=0.0, bound=True):
"""A hand-back, then `periods` x (1 owned cycle with the loop's bound,
`lost` cycles away). Returns [(time, hidden)] of the owned cycles."""
t, out = first, []
for _ in range(lost):
c._settling(False, t)
t += tick
for _ in range(periods):
c.loop_clamped = (bound, "", False)
out.append((t, c._settling(True, t)))
t += tick
for _ in range(lost):
c._settling(False, t)
t += tick
return out, t
# The rolling budget: the loop's bound is hidden on at most M cycles in any
# ROLL seconds, across all hand-backs.
_c = _C()
_o, _ = turns(_c, 5.0, 3, 60)
_hid = [t for t, h in _o if h]
check(f"across hand-backs (one bound cycle per turn, 5 s): the first {M} "
"are hidden, then every one counts",
[h for _t, h in _o[:M + 5]] == [True] * M + [False] * 5)
check(f"...never more than {M} hidden in any {ROLL:.0f} s, and they come "
f"back as the oldest ages out (exactly {M} in the first {ROLL:.0f} s "
"after the first)",
all(sum(1 for x in _hid if t <= x < t + ROLL) <= M for t in _hid)
and sum(1 for x in _hid if x < _hid[0] + ROLL) == M
and any(x >= _hid[0] + ROLL for x in _hid))
_c = _C()
_c._hidden = (0.0,) * M
_c._wire_lost, _c._handback = False, (ROLL - 30.0, 0)
_c.loop_clamped = (True, "", False)
_a = _c._settling(True, ROLL - 1.0)
_c.loop_clamped = (True, "", False)
_b = _c._settling(True, ROLL)
check(f"the budget refills {ROLL:.0f} s after the oldest hidden cycle: "
f"counted at {ROLL - 1:.0f} s, hidden again at {ROLL:.0f} s",
_a is False and _b is True)
_c = _C()
_o1, _t = turns(_c, 2.0, 3, 20)
for _ in range(12): # the wire away a whole settle
_c._settling(False, _t)
_t += 2.0
_o2, _ = turns(_c, 2.0, 3, 5, first=_t)
check("the budget is not per hand-back or per run: after a whole settle "
"with the wire away, a new hand-back within the same "
f"{ROLL:.0f} s hides nothing more",
[h for _t2, h in _o1[:M]] == [True] * M
and not any(h for _t2, h in _o2))
_c = _C()
_o1, _t = turns(_c, 2.0, 3, 20)
_ = [_c._settling(True, _t + 2.0 * i) for i in range(13)]
_c._settling(False, _t + 40.0)
_c.loop_clamped = (True, "", False)
check("the loop holding the wire a whole settle ends the window: the next "
"hand-back hides nothing more while the budget is spent",
_c._settling(True, _t + 42.0) is False)
_c = _C()
_c._wire_lost = True
_t = 0.0
for _ in range(M + 4): # an over-rating: always counts
_c.loop_clamped = (True, "", True)
_c._settling(True, _t)
_c._wire_lost = True
_t += 5.0
_c._settling(False, _t)
_c.loop_clamped = (True, "", False)
check("an over-rating does not use up the budget",
_c._settling(True, _t + 5.0) is True and len(_c._hidden) == 1)
# The budget is one number at every cycle length (SAFETY-119 scaled it).
_bud = {}
for _tick in (1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 30.0):
_c = _C()
_o, _ = turns(_c, _tick, 1, 14)
_bud[_tick] = sum(1 for _t2, h in _o if h)
_c = _C()
_o60, _ = turns(_c, 60.0, 1, 14)
print(f" hidden before the first counts, one bound cycle per turn: {_bud}; "
f"at 60 s, turns of 2 cycles: {sum(1 for _t2, h in _o60 if h)} of 14 "
"hidden (the budget never runs out: DECISIONS limit)")
check(f"the budget does not follow the cycle length: {M} hidden at 1 to "
"30 s, then counted",
set(_bud.values()) == {M})
# No monotonic clock: the budget cannot refill, so a bound that stays is
# counted after the budget however often the wire changes hands.
_c = _C()
_nc = []
for _ in range(40):
for _ in range(3):
_c._settling(False, None)
_c.loop_clamped = (True, "", False)
_nc.append(_c._settling(True, None))
check(f"no monotonic clock, repeated hand-backs: the budget holds ({M} "
"hidden, then every bound counts)", _nc == [True] * M + [False] * (40 - M))
# An over-rating (a command over `max_w`) is a bug, never settled away.
_ctl = _hb[1]
_ctl.loop_clamped = (True, "x", True)
_ctl._wire_lost, _ctl._handback, _ctl._hidden = True, None, ()
class _Res:
winner, bound_by, target_w = "loop", "", 0.0
unit_bounds = {}
class _D:
held, target_w = False, 0.0
_ctl._loop_decision, _ctl.allocation = _D(), None
_ctl._loop_alarm_input(_Res())
check("an over-rating on a hand-back cycle still counts (bug=True kept)",
_ctl.loop_clamped == (True, "x", True) and _ctl._handback is not None)
_ctl.loop_clamped = (True, "y", False)
_ctl._loop_alarm_input(_Res())
check("...while the integrator bound on the next cycle is not counted",
_ctl.loop_clamped[0] is False)
# A stop ends the window but does not give the budget back (review F2, X3).
_now = _ctl._now_mono
_ctl._hidden = (_now,) * M
_ctl._wire_lost, _ctl._handback = True, (1.0, 3)
_ctl._loop_decision = None # stopped or blind
_ctl._loop_alarm_input(_Res())
_st, _kept = (_ctl._wire_lost, _ctl._handback), _ctl._hidden
_ctl._settling(False, _now + 1.0)
_ctl.loop_clamped = (True, "", False)
_after = _ctl._settling(True, _now + 2.0)
check("a stop clears the window (the next start is not a hand-back) and "
f"keeps the budget: with {M} hidden, the bound after the next "
"hand-back counts",
_st == (False, None) and _kept == (_now,) * M and _after is False)
print(f" after the stop: hidden kept {len(_kept)}, the next hand-back's "
f"bound hidden {_after}")
# ============================================================================ #
print("\n6. the pinned digests of test_safety84 and test_safety87: only the "
"log changes")
# Those harnesses run at the 5 s tick with a battery that follows at once.
rig._Hass.power, rig.TICK, s79._feed = _real_power, 5.0, _real_feed
rig._Hass.number = _real_number
import safety87_identity as s87 # noqa: E402
RUNS = dict({f"s84 {k}": (lambda v=v: s84.run(**v))
for k, v in s84.SCENARIOS.items()},
**{f"s87 {k}": (lambda v=v: s87_run(v))
for k, v in s87.SCENARIOS.items()})
def s87_run(v):
v = dict(v)
return s84.run(1, dur=s87.DUR, store=s87.store(v.pop("store", None)), **v)
_moved, _gone = [], 0
for k, fn in RUNS.items():
new, old = fn()[0], no_fix(fn)[0]
same = all(a[:-1] == b[:-1] and a[-1][:4] == b[-1][:4]
for a, b in zip(new, old)) and len(new) == len(old)
logs = {e for r in old for e in [r[-1][5]] if e} - {
e for r in new for e in [r[-1][5]] if e}
_gone += len(logs)
if not same or any("OUTPUT_CLAMP episode" not in e for e in logs):
_moved.append(k)
print(f" {k}: commands and claims "
f"{'the same' if same else 'DIFFER'}, log rows differ {len(logs)}, "
f"digest {s84.digest(new)[:16]}")
check("⭐ in all twelve, every command, loop field, claim, guard decision "
"and phase is the same as without the fix; the only log rows that "
"differ are OUTPUT_CLAMP rows (gone, or a lower count)",
not _moved and _gone > 0)
print()
if fails:
print(f"FAILED: {len(fails)} check(s)")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all SAFETY-119 checks passed")
+7 -2
View File
@@ -472,6 +472,10 @@ print("\n8. the N=1 and N=2 digests")
# charge pin stands. n2_charge and _banked change only the loop's reason and
# claim on 2 cycles; n2_charge_dear changes commands on 98 cycles, where A's
# pin lifts (t=1800) and B no longer jumps 1390 W, and after the surplus step.
# SAFETY-119 re-pinned all six: only the log. OUTPUT_CLAMP rows from the
# loop's bound just after the guard hands back are gone; every command and
# claim is the same (test_safety119.py part 6). SAFETY-121 re-pinned all six
# again: only OUTPUT_CLAMP rows (test_safety119.py part 6).
PINNED = {
"n1_charge":
"0ed25c856456b97acb07a39299a8cb7b1a762c4d0005b88a0823c03057a80be5",
@@ -482,7 +486,7 @@ PINNED = {
"n2_charge":
"b16d692128f14bcce3d87b142d290e0d6b13724289680e44a05215c9eaa24a7c",
"n2_charge_dear":
"00a197d89a5dd1e8b3ae6a5d1af66329a4dc4850476569c5931c125fa4d9eaee",
"aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db",
"n2_charge_banked":
"a6973faea916e4c88ca1770047f94478346eeb1efa4889b154fad0747f91267f",
}
@@ -492,7 +496,8 @@ for k in PINNED:
f"{_now[k][:16]}")
check("⭐ at N=1 a charge in and out of a cheap window and under a banked "
"month peak is byte-identical to 9d5b7d1 (commands, reasons and every "
"claim), and N=2 outside a cheap window matches its SAFETY-85 pin",
"claim; the log as re-pinned by SAFETY-121), and N=2 outside a cheap "
"window matches its SAFETY-85 pin",
all(_now[k] == PINNED[k] for k in ("n1_charge", "n1_charge_dear",
"n1_charge_banked",
"n2_charge_dear")))
+9 -3
View File
@@ -518,13 +518,18 @@ check("...a stopped controller still says so: the peak alarm does not take "
# ============================================================================ #
print("\n9. N=1 is byte-identical to release/1.0 b8ddfd7")
# SAFETY-119 re-pinned n1_crossing, n1_above and n1_charge_low: only the
# log. The loop's bound just after a hand-back is not counted, so OUTPUT_CLAMP
# rows are gone or count fewer; every command and claim is the same
# (test_safety119.py part 6). SAFETY-121 re-pinned the same three again:
# only OUTPUT_CLAMP rows (test_safety119.py part 6).
PINNED = { # tools/safety87_identity.py on b8ddfd7
"n1_under":
"d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f",
"n1_crossing":
"97f7321017d9d34ee17a40dfd4abf73d915f25b9faef086a483d2b5cd124b198",
"n1_above":
"45726a285aa84f40542d56482652397c3c1e212ba4fe2009f6cc52f537ccad52",
"9229b0f0e8addd1bd114461cc36c7f02e9734012b3b7e154d7747bd4cc69b773",
"n1_under_banked":
"a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b",
"n1_charge_low":
@@ -536,8 +541,9 @@ _now = s87.digests()
for k in PINNED:
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}")
check("⭐ every N=1 digest (claims, commands, the guard's decision and the "
"log) equals b8ddfd7's, under, crossing and above the reserve, in a "
"charge and a HOLD, and under a banked peak",
"log) equals b8ddfd7's (the log as re-pinned by SAFETY-121), under, "
"crossing and above the reserve, in a charge and a HOLD, and under a "
"banked peak",
_now == PINNED)
# ============================================================================ #
+5 -1
View File
@@ -454,7 +454,11 @@ check("the saved blob passes the normal gate: no refusal, not setup-only",
print("\n after the restart, on the saved settings:")
rc, ev, err, files = run_child("after", SAVED or {}, SAVED_STATE)
services = [e for e in ev if e[0] == "SERVICE"]
# Not the dismiss of PRICE-01's old fixed-tariff card, sent at every start.
# Only that dismiss: a `create` with the old id would still be counted.
services = [e for e in ev if e[0] == "SERVICE"
and not (e[1:3] == ["persistent_notification", "dismiss"]
and e[3].get("notification_id") == "goodwe_fixed_tariff_unused")]
writes = [e[3].get("value") for e in services
if e[1:3] == ["number", "set_value"]]
print(f" after: exit {rc}, {len(services)} service calls, set_value "
+16
View File
@@ -286,6 +286,22 @@ check("app/uitext.py falls back to English per key and reads nl-BE as nl",
and uitext._merge({"a": {"b": 1, "c": 2}}, {"a": {"b": 3}})
== {"a": {"b": 3, "c": 2}})
# ============================================================================ #
print("\n7. the dynamic contract choice says 15 minutes, never hourly (UX-44)")
# Belgian day-ahead prices are per 15 minutes since 2025-10-01.
HOURLY = re.compile(r"hourly|per hour|\bhour\b|per uur|\buur\b|uurprijs",
re.I)
_dyn = {"en": EN["choices"]["contract"]["dynamic"],
"nl": NL["choices"]["contract"]["dynamic"]}
check(f"the label reads 'Dynamic, every 15 minutes' / 'Dynamisch, per "
f"kwartier' ({_dyn})",
_dyn == {"en": "Dynamic, every 15 minutes",
"nl": "Dynamisch, per kwartier"})
check("neither language calls the dynamic contract hourly",
not any(HOURLY.search(t) for t in _dyn.values()))
check("mutation: the old labels are caught",
all(HOURLY.search(t) for t in ("Dynamic, hourly", "Dynamisch, per uur")))
print()
if fails:
print(f"{len(fails)} FAILED")
+60
View File
@@ -327,6 +327,66 @@ check("days the editor has no choice for stay in the Configuration tab, and "
["ui.periods"] is None and edit(dict(SITE, static_profile=_raw), gain=0.5)
["static_profile"] == _raw)
print("\n8. UX-43: tariff prices take five decimals")
from app import uilayout as _L # noqa: E402
_PX = 0.34512
_TARIFF = ("ui.fixed_price", "ui.day_price", "ui.night_price", "ui.periods")
_steps = {k: _L.field(k).step for k in _TARIFF}
check("the four tariff price fields step 0.00001 EUR/kWh",
sorted(_steps) == sorted(_TARIFF)
and all(v == 0.00001 for v in _steps.values()))
_f5 = edit(DEFAULTS, ui__contract="fixed", ui__fixed_price=_PX)
check("fixed price 0.34512 is saved exactly and read back exactly",
'"eur_per_kwh": 0.34512}' in _f5["static_profile"]
and U.options_to_ui(_f5)["ui.fixed_price"] == _PX
and trip(_f5) == _f5)
_d5 = edit(SITE, ui__contract="daynight", ui__day_price=_PX,
ui__night_price=0.26789)
check("day price 0.34512 and night price 0.26789 are saved and read back exactly",
'"eur_per_kwh": 0.34512' in _d5["static_profile"]
and '"eur_per_kwh": 0.26789' in _d5["static_profile"]
and U.options_to_ui(_d5)["ui.day_price"] == _PX
and U.options_to_ui(_d5)["ui.night_price"] == 0.26789
and not validate_startup_config(_d5))
_n5 = edit(SITE, ui__contract="daynight", ui__day_price=0.3, ui__night_price=_PX)
check("night price 0.34512 alone is saved and read back exactly",
U.options_to_ui(_n5)["ui.night_price"] == _PX)
_r3 = rows(("00:00", "07:00", "mon-sun", 0.12345), ("07:00", "22:00", "mon-sun", _PX),
("22:00", "24:00", "mon-sun", 0.05001))
_p3 = periods(_r3)
check("three windows with five-decimal prices save and read back exactly",
[p.eur_per_kwh for p in parse_static_profile(_p3["static_profile"]).periods]
== [0.12345, _PX, 0.05001]
and [r["price"] for r in U.options_to_ui(_p3)["ui.periods"]]
== [0.12345, _PX, 0.05001] and trip(_p3) == _p3
and "e-" not in _p3["static_profile"])
check("a two-decimal price still works", U.options_to_ui(
edit(DEFAULTS, ui__contract="fixed", ui__fixed_price=0.31))
["ui.fixed_price"] == 0.31)
_old = dict(SITE, price_provider="off", static_profile=json.dumps({"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
"eur_per_kwh": 0.34},
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
"eur_per_kwh": 0.26},
{"name": "night", "from": "00:00", "to": "24:00", "days": "sat,sun",
"eur_per_kwh": 0.26}]}))
check("a stored profile with two-decimal prices is unchanged by load and save",
trip(_old) == _old)
_js = open(os.path.join(HERE, "app", "static", "settings.js"),
encoding="utf-8").read()
check("the window editor's price input takes its step from the field, not a "
"hard-coded 0.01", 'step: "0.01"' not in _js
and "step: String(f.step || 0.01)" in _js)
for _lang in ("en", "nl"):
with open(os.path.join(HERE, "app", "ui_text", f"{_lang}.json"),
encoding="utf-8") as _fh:
_flds = json.load(_fh)["fields"]
_hint = "34.512" if _lang == "en" else "34,512"
_eq = "0.34512" if _lang == "en" else "0,34512"
check(f"[{_lang}] the c€/kWh hint sits on all four tariff price fields",
all(_hint in _flds[k]["description"] and _eq in _flds[k]["description"]
for k in ("fixed_price", "day_price", "night_price", "periods")))
print()
if fails:
print(f"{len(fails)} FAILED")
+583
View File
@@ -0,0 +1,583 @@
"""UX-46: the Dutch pages show Dutch status rows, notes and wizard lines.
`python3 -B test_ux46.py`
Before this, Home's status rows, the settings page's notes and the setup
wizard's checks were built in English and sent as they were, so the NL pages
showed English under Dutch titles (SAFETY-118 I-1, UX-44 I-2, UX-45 I-3).
1. Rows: every row in `messages.status_samples()` carries a Dutch twin
(`messages.Bi`), the Dutch follows STYLE.md, and the Home page's status
payload gives it for lang=nl and the English text for everything else.
2. Notes: every `_advise()` call in app/validate.py gives a Dutch twin, the
notes the validator really builds carry it, and /config/validate sends the
page's language, for the settings notes and for the wizard's check list.
3. The setting labels the Dutch notes quote are the labels in nl.yaml, and the
peak forecast label has no "(W)", since any power unit is read.
4. The rules above catch spoiled input.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import ast
import asyncio
import copy
import json
import logging
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from aiohttp import ClientSession, web as aioweb
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE / "tools"))
logging.disable(logging.WARNING)
import translations_lint as TL # noqa: E402
import ux13_demo as D # noqa: E402
from style_rules import sentences, text_problems, words # noqa: E402
from app import messages, uimodel as U, uitext, web # noqa: E402
from app.main import Controller # noqa: E402
from named_absence import field, nth, present # noqa: E402
from app.validate import (Note, note_text, setup_checks, # noqa: E402
config_notes)
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# STYLE.md for a Dutch row or note: the same length and shape rules, plus the
# Dutch words that test_ui_text.py bans for the same things. A quoted setting
# label may keep its own words ("Setpoint-entiteit" is the label in nl.yaml).
JARGON_NL = re.compile(
r"\b(?:setpoints?|setpunt\w*|SoC|laadtoestand|BMS|PV|failsafe|regelaar|"
r"regellus|controller|latch|ontlad\w*|payload|endpoint|JSON|API|"
r"time-?out|boolean|config|kwartuur\w*|commando\w*|geparkeerd|ESP32|"
r"master)\b", re.I)
# English function words that never belong in a Dutch sentence.
ENGLISH = re.compile(r"\b(?:the|not|and|for|with|from|reads|check|so|no|set|"
r"keeps|stays|has|have|ran|ago|seconds?|minutes?)\b", re.I)
QUOTED = re.compile(r"“[^”]*”")
def dutch_problems(text):
bare = re.sub(r"\b[a-z_]+\.[a-z0-9_]+\b", "X", QUOTED.sub("X", text))
out = []
if JARGON_NL.search(bare):
out.append("jargon: " + JARGON_NL.search(bare).group(0))
names = re.sub(r"\binverter-[a-z0-9]+\b|\b[a-z_]+\.[a-z0-9_]+\b", "X",
bare)
eng = ENGLISH.search(names)
if eng:
out.append("English word: " + eng.group(0))
return out
def row_problems_nl(colour, text):
n_s, n_w = sentences(text), len(words(text))
out = []
if colour == messages.RED:
if n_s > 3 or n_w > 30:
out.append(f"red row over 3 sentences / 30 words ({n_s}/{n_w})")
elif n_s > 1 or n_w > 15:
out.append(f"row over 1 sentence / 15 words ({n_s}/{n_w})")
if ";" in text:
out.append("semicolon")
return out + text_problems(text, keys=False, entities=False) \
+ dutch_problems(text)
# --------------------------------------------------------------------------- #
print("1. every status row has a Dutch twin, and the page picks one")
# --------------------------------------------------------------------------- #
ROWS = [(fn, c, t) for fn, c, t in messages.status_samples() if c is not None]
check(f"there are rows to grade ({len(ROWS)})", len(ROWS) > 80)
_none = [(fn, str(t)) for fn, c, t in ROWS
if not (isinstance(t, messages.Bi) and t.nl)]
check(f"every row has a Dutch text ({_none})", not _none)
_same = [(fn, str(t)) for fn, c, t in ROWS
if isinstance(t, messages.Bi) and t.nl == str(t)]
check(f"no Dutch row is the English one ({_same})", not _same)
_bad = [(fn, p) for fn, c, t in ROWS if isinstance(t, messages.Bi) and t.nl
for p in row_problems_nl(c, t.nl)]
for x in _bad:
print(f" STYLE {x}")
check(f"every Dutch row follows STYLE.md ({len(_bad)} problems)", not _bad)
BANNERS = [(fn, t) for fn, c, t in messages.status_samples() if c is None]
_nob = [(fn, str(t)) for fn, t in BANNERS
if not (isinstance(t, messages.Bi) and t.nl and t.nl != str(t))]
check(f"every banner has a Dutch text ({_nob})", BANNERS and not _nob)
_bb = [(fn, t.nl) for fn, t in BANNERS if isinstance(t, messages.Bi) and t.nl
and (len(words(t.nl)) > 12 or ";" in t.nl or dutch_problems(t.nl)
or text_problems(t.nl, keys=False, entities=False))]
check(f"every Dutch banner is at most 12 words and follows STYLE.md ({_bb})",
not _bb)
_cat = {k for k, v in vars(messages).items()
if callable(v) and k.startswith("row_")}
check("the samples cover every row_ function, so none can skip the check",
_cat <= {fn for fn, _c, _t in ROWS})
_lat = {"row_direction_trip": "herstart heft dit niet op",
"row_site_direction_trip": "regeling starten heft dit op",
"row_circulating_trip": "regeling starten heft dit op",
"row_not_delivering": "stoppen en starten heft dit op"}
_unclear = [fn for fn, _c, t in ROWS if fn in _lat and _lat[fn] not in t.nl]
check(f"a Dutch latch row says how it clears too ({_unclear})", not _unclear)
check("pick() gives the English for en, the Dutch for nl, and leaves other "
"text alone",
messages.pick(messages.row_estop_fitted(), "en") == "Emergency stop: fitted"
and type(messages.pick(messages.row_estop_fitted(), "en")) is str
and messages.pick(messages.row_estop_fitted(), "nl") == "Noodstop: geplaatst"
and messages.pick("Plain", "nl") == "Plain"
and messages.pick(messages.Bi("A"), "nl") == "A")
check("a Bi survives copy and pickle, and JSON",
copy.deepcopy(messages.row_estop_fitted()).nl == "Noodstop: geplaatst"
and json.dumps(messages.row_estop_fitted()) == '"Emergency stop: fitted"')
# --- the page: a real Controller -------------------------------------------- #
class _Store:
def __init__(self):
self.data = {}
def set(self, k, v):
self.data[k] = v
def get(self, k, d=None):
return self.data.get(k, d)
def set_time(self, k, v):
self.data[k] = v.isoformat()
def get_time(self, k):
v = self.data.get(k)
return None if v is None else datetime.fromisoformat(v)
class _Pub:
enabled = False
def publish(self, _v):
pass
def close(self):
pass
class _Hass:
session = None
token = ""
async def limits(self, _e):
return (-5000.0, 5000.0, 1.0)
async def number(self, _e, invert=False):
return None
async def state(self, _e):
return None
async def set_number(self, _e, _v):
return True
async def call(self, *_a):
return True
async def states(self):
return []
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
"maintenance_enabled": False, "peak_forecast_entity": "sensor.fc"}
TWO = dict(ONE, inverters=[
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500},
{"name": "Battery B", "setpoint_entity": "", "soc_entity":
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500}])
def make(opts, soc=(62.0, 35.0), batt=(-2500.0, -300.0)):
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
for i, inv in enumerate(c.inverters):
inv.soc, inv.batt = soc[i], batt[i]
inv.dev_min, inv.dev_max = -5000.0, 5000.0
c.grid = 10.0
return c
def attention(payload, rid):
return next((a for a in payload["attention"] if a["id"] == rid), None)
c = make(ONE, soc=(62.0,), batt=(960.0,))
c._peak_fc_fault = "kWh"
s_en, s_nl = c.status_payload(), c.status_payload(lang="nl")
def fc_row(payload):
return nth([r for r in payload["checks"] if r.get("id") == "peak_forecast"])
_en, _nl = fc_row(s_en), fc_row(s_nl)
check("Home, English: the peak forecast row is the English sentence",
present(_en) and field(_en, "text") == messages.peak_forecast_unit("kWh")
and attention(s_en, "peak_forecast")["body"]
== messages.peak_forecast_unit("kWh"))
check("Home, Dutch: the row, the Needs-attention entry and the title are "
"Dutch",
present(_nl) and field(_nl, "text") == messages.peak_forecast_unit("kWh", "nl")
and attention(s_nl, "peak_forecast")["body"]
== messages.peak_forecast_unit("kWh", "nl")
and attention(s_nl, "peak_forecast")["title"]
== uitext.words("nl")["home_words"]["attn"]["peak_forecast"])
c._peak_fc_fault = ""
_nl0 = fc_row(c.status_payload(lang="nl"))
check("...and with no unit at all, the Dutch is the 'no unit' sentence",
present(_nl0) and field(_nl0, "text") == messages.peak_forecast_unit("", "nl")
and messages.peak_forecast_unit("", "nl")
!= messages.peak_forecast_unit("kWh", "nl"))
check("a language that is not Dutch gets the English, and the default call "
"is unchanged",
c.status_payload(lang="fr")["checks"] == c.status_payload()["checks"]
and all(type(r["text"]) is str for r in c.status_payload()["checks"]))
# A site with faults of many kinds: every row it shows is Dutch on the NL page.
SCEN = {}
SCEN["two, one without a setpoint, meter stale"] = make(TWO)
SCEN["one, nothing read"] = make(ONE, soc=(None,), batt=(None,))
SCEN["one, no forecast set"] = make(dict(ONE, peak_forecast_entity=""))
SCEN["one, shadow, estop fitted"] = make(dict(ONE, shadow_mode="on",
estop_fitted=True))
SCEN["one, solar sources"] = make(dict(ONE, pv_entity="sensor.pv"))
SCEN["two, solar list"] = make(dict(TWO, pv_entities=["sensor.a", "sensor.b"]))
SCEN["one, peak forecast unit fault"] = c
SCEN["one, control stopped"] = make(ONE)
SCEN["one, control stopped"].auto = False
_English, _rows_seen = [], 0
for name, ctl in SCEN.items():
raw = ctl.checks()
en = ctl.status_payload()["checks"]
nl = ctl.status_payload(lang="nl")["checks"]
check(f"[{name}] {len(raw)} rows: same rows in both languages",
len(raw) == len(en) == len(nl))
for r, e, n in zip(raw, en, nl):
_rows_seen += 1
if not (isinstance(r["text"], messages.Bi) and r["text"].nl):
_English.append((name, r.get("id"), str(r["text"])))
elif e["text"] != str(r["text"]) or n["text"] != r["text"].nl:
_English.append((name, r.get("id"), "page did not pick"))
_banners = {n: (ctl.status_payload()["banner"],
ctl.status_payload(lang="nl")["banner"])
for n, ctl in SCEN.items()}
print(f" banners: {_banners}")
check("the banner at the top of Home is Dutch on the Dutch page, English on "
"the English one", all(e != n and not dutch_problems(n)
for e, n in _banners.values()))
check(f"{_rows_seen} rows over {len(SCEN)} sites: none shows English on the "
f"Dutch page when it has no Dutch text ({_English})", not _English)
check("the row's details list in /status is picked the same way",
all(isinstance(v, str) and not isinstance(v, messages.Bi)
for ctl in SCEN.values()
for _k, v in ctl.status_payload(lang="nl")["rows"]))
# --------------------------------------------------------------------------- #
print("\n2. every settings note and wizard line has a Dutch twin")
# --------------------------------------------------------------------------- #
tree = ast.parse((HERE / "app" / "validate.py").read_text(encoding="utf-8"))
_calls = [n for n in ast.walk(tree) if isinstance(n, ast.Call)
and ast.unparse(n.func) == "_advise"]
_bare = [n.lineno for n in _calls if not any(k.arg == "nl" for k in n.keywords)]
check(f"every _advise() call in validate.py gives `nl` ({len(_calls)} calls, "
f"without: {_bare})", len(_calls) >= 30 and not _bare)
OPTS = dict(D.defaults(D.CONFIG), setpoint_entity="number.sp",
meter_entity="sensor.grid", soc_entity="sensor.soc",
batt_entity="sensor.batt", pv_entity="sensor.pv",
peak_forecast_entity="sensor.fc")
def ent(state, unit=None):
return {"state": state, "unit": unit}
GOOD = {"number.sp": ent("0"), "sensor.grid": ent("402", "W"),
"sensor.soc": ent("62", "%"), "sensor.batt": ent("-800", "W"),
"sensor.pv": ent("-1200", "W"), "sensor.fc": ent("820", "kWh")}
NOTES = []
NOTES += setup_checks(OPTS, entities=GOOD, running=None)
NOTES += setup_checks(OPTS, entities=None)
NOTES += setup_checks(dict(OPTS, meter_invert=True, pv_invert_entities=["sensor.pv"]),
entities=dict(GOOD, **{"sensor.batt": ent("0", "W")}))
NOTES += setup_checks(dict(OPTS, soc_entity="sensor.x", batt_entity="sensor.y",
setpoint_entity="number.typo"),
entities=GOOD)
NOTES += setup_checks(dict(OPTS, meter_source="homewizard_local"),
entities=GOOD, running=None)
NOTES += setup_checks(dict(OPTS, meter_source="homewizard_local",
price_provider="generic_dayahead"),
entities=GOOD, running=dict(OPTS, meter_source="homewizard_local",
price_provider="generic_dayahead"),
meter_now_w=-350.0, prices_known=18)
NOTES += setup_checks(dict(OPTS, price_provider="generic_dayahead"), entities=GOOD,
running=None)
NOTES += setup_checks(dict(OPTS, price_provider="off", static_profile=""),
entities=GOOD)
NOTES += setup_checks(dict(OPTS, price_provider="off",
static_profile='{"periods":[]}'), entities=GOOD)
NOTES += setup_checks(dict(OPTS, batt_entity="sensor.batt2"),
entities=dict(GOOD, **{"sensor.batt2": ent("unavailable")}))
NOTES += setup_checks(dict(OPTS, soc_entity="sensor.soc2"),
entities=dict(GOOD, **{"sensor.soc2": ent("unavailable")}))
NOTES += setup_checks(dict(OPTS, meter_entity="sensor.m2"),
entities=dict(GOOD, **{"sensor.m2": ent("unavailable")}))
_TWO = dict(OPTS, inverters=[
{"name": "a", "setpoint_entity": "number.sp", "soc_entity": "sensor.soc",
"batt_entity": "sensor.batt", "max_w": 2000, "bms_soc_floor": 10},
{"name": "b", "setpoint_entity": "number.b", "max_w": 2000,
"bms_soc_floor": 10}])
NOTES += setup_checks(_TWO, entities=dict(GOOD, **{"sensor.batt": ent("-0.5", "kW")}))
NOTES += setup_checks(dict(OPTS, meter_source="ha_signed",
p1_net_entity="sensor.net"),
entities=dict(GOOD, **{"sensor.net": ent("-3.1", "kW")}))
NOTES += config_notes(dict(OPTS, integrator_max_w=9000, max_w=2000,
peak_cap_w=2000), entities=GOOD, month_peak_w=3000)
NOTES += config_notes(dict(OPTS, pv_forecast_entity="sensor.f1",
pv_forecast_tomorrow_entity="sensor.f2",
peak_forecast_entity="sensor.fc2"),
entities=dict(GOOD, **{"sensor.f1": ent("5", "W"),
"sensor.f2": ent("6", "kWh"),
"sensor.fc2": ent("5")}))
NOTES += config_notes(dict(OPTS, meter_entity="sensor.gone"), entities=GOOD)
NOTES += config_notes(dict(OPTS, pv_forecast_tomorrow_entity="sensor.f2"),
entities=dict(GOOD, **{"sensor.f2": ent("6", "kWh")}))
NOTES += config_notes(dict(OPTS, batt_entity="sensor.kw"),
entities=dict(GOOD, **{"sensor.kw": ent("1", "kW")}))
print(f" {len(NOTES)} notes built")
_nonl = [n.text for n in NOTES if not n.nl]
check(f"every note the validator builds has a Dutch twin ({_nonl})",
NOTES and not _nonl)
_same = [n.text for n in NOTES if n.nl == n.text]
check(f"...which is not the English ({_same})", not _same)
_bad = []
for n in NOTES:
nw, ns = len(words(n.nl)), sentences(n.nl)
p = text_problems(n.nl, keys=True, entities=True) + dutch_problems(n.nl)
if nw > 25 and not n.text.startswith(("The 15-minute", "Home Assistant")):
p.append(f"over 25 words ({nw})")
if ns > 2:
p.append(f"over 2 sentences ({ns})")
if ";" in n.nl:
p.append("semicolon")
if len(words(n.nl)) > len(words(n.text)) + 6:
p.append("much longer than the English")
_bad += [(n.nl, x) for x in p]
for x in _bad:
print(f" STYLE {x}")
check(f"every Dutch note follows STYLE.md ({len(_bad)} problems)", not _bad)
_l = {t: [n for n in NOTES if n.text.startswith(t)] for t in (
"Prices for today are in", "The 15-minute demand forecast")}
check("the wizard's price line, and the peak forecast unit note, are among "
"them", all(_l.values()))
check("note_text() picks: Dutch for nl, English for en, the old twin table "
"for a note with no `nl`",
note_text(_l["Prices for today are in"][0], "nl")
== "De prijzen van vandaag zijn binnen, voor 18 van 24 uur."
and note_text(_l["Prices for today are in"][0], "en")
== "Prices for today are in, for 18 of 24 hours."
and note_text(Note("info", (), messages.FIXED_TARIFF_NOTE), "nl")
== messages.FIXED_TARIFF_NOTE_NL
and note_text(Note("info", (), "Other"), "nl") == "Other")
# --- over HTTP: the settings page and the wizard, in both languages --------- #
SCHEMA = D.schema_ui(D.CONFIG)
TRANSLATIONS = {l: TL.parse_translation_file(HERE / "translations" / f"{l}.yaml")
for l in ("en", "nl")}
STATES = [{"entity_id": e, "state": v["state"],
"attributes": ({"unit_of_measurement": v["unit"]} if v["unit"]
else {})} for e, v in GOOD.items()]
class Hass:
session = None
token = ""
async def limits(self, _e):
return (-5000.0, 5000.0, 1.0)
async def states(self):
return copy.deepcopy(STATES)
async def state(self, entity):
return next((copy.deepcopy(s) for s in STATES
if s["entity_id"] == entity), None)
class Supervisor:
def __init__(self, options):
self.options = copy.deepcopy(options)
async def info(self):
return {"options": copy.deepcopy(self.options), "schema": SCHEMA,
"translations": TRANSLATIONS}
async def write_options(self, options):
pass
async def restart(self, confirm):
pass
class Ctl:
def __init__(self, opts):
self.o, self.hass, self.inverters = dict(opts), Hass(), []
self.store, self.auto, self.grid = None, True, 402.0
def log_event(self, text, hint=None):
pass
def status_payload(self, lang="en"):
return {"banner": "", "level": "ok", "rows": [], "checks": [],
"plan": {"prices": [{"label": f"{h:02d}:00", "eur_kwh": 0.2}
for h in range(20)]
+ [{"label": f"{h:02d}:00", "eur_kwh": None}
for h in range(20, 24)]}}
async def serve(opts, steps):
runner = aioweb.AppRunner(web.build_app(Ctl(opts), Supervisor(opts)))
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
root = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/"
try:
async with ClientSession() as s:
return await steps(s, root)
finally:
await runner.cleanup()
async def post(s, url, body):
async with s.post(url, json=body) as r:
return await r.json()
RUN = dict(OPTS, meter_source="homewizard_local", p1_host="192.0.2.50",
price_provider="generic_dayahead")
async def pages(s, root):
out = {}
# The settings notes read the entity list the picker has cached, so the
# first call only warms it.
await post(s, root + "config/validate",
{"model": U.options_to_ui(RUN), "setup": True})
for lang in ("en", "nl"):
async with s.get(root + f"config/model?lang={lang}") as r:
m = await r.json()
model = U.options_to_ui(RUN)
out[lang] = await post(s, root + "config/validate",
{"model": model, "setup": True, "lang": lang})
out["none"] = await post(s, root + "config/validate", {
"model": U.options_to_ui(RUN), "setup": True})
return out
R = asyncio.run(serve(RUN, pages))
_texts = {l: [m["text"] for m in R[l].get("messages", [])] for l in ("en", "nl")}
_setup = {l: [m["text"] for m in R[l].get("setup", [])] for l in ("en", "nl")}
FC_EN = messages.peak_forecast_unit("kWh") + " Pick a sensor in W or kW."
FC_NL = messages.peak_forecast_unit("kWh", "nl") + " Kies een sensor in W of kW."
print(f" settings notes en: {_texts['en']}")
print(f" settings notes nl: {_texts['nl']}")
print(f" wizard lines nl: {_setup['nl']}")
check("settings page, English: the peak forecast unit note is English",
FC_EN in _texts["en"])
check("settings page, Dutch: it is the Dutch sentence, and no English "
"sentence of it is left", FC_NL in _texts["nl"]
and FC_EN not in _texts["nl"])
check("wizard, English: the price line and the checks are English",
"Prices for today are in, for 20 of 24 hours." in _setup["en"])
check("wizard, Dutch: the price line is Dutch",
"De prijzen van vandaag zijn binnen, voor 20 van 24 uur."
in _setup["nl"] and "Prices for today are in, for 20 of 24 hours."
not in _setup["nl"])
_left = [t for t in _setup["nl"] + _texts["nl"]
if ENGLISH.search(QUOTED.sub("X", t))
and not t.startswith("Home Assistant has no entity")]
check(f"no wizard line or settings note on the Dutch page has English words "
f"left ({_left})", not _left)
check("a request with no language gets English (an old page keeps working)",
[m["text"] for m in R["none"].get("setup", [])]
== [m["text"] for m in R["en"].get("setup", [])])
check("the raw `notes` list keeps the English `text` and adds `nl`",
R["nl"]["notes"] and all(n["text"] != n["nl"] for n in R["nl"]["notes"]))
# --------------------------------------------------------------------------- #
print("\n3. labels")
# --------------------------------------------------------------------------- #
for lang in ("en", "nl"):
name = TRANSLATIONS[lang]["configuration"]["peak_forecast_entity"]["name"]
check(f"[{lang}] the peak forecast label has no unit: {name!r}",
"(W)" not in name and "(kW)" not in name
and name.split("·")[-1].strip() == (
"15-minute demand forecast entity" if lang == "en"
else "Entiteit kwartierprognose"))
check("DOCS.md shows the same label, so the table cannot drift",
"| Limits | 15-minute demand forecast entity | `peak_forecast_entity` |"
in (HERE / "DOCS.md").read_text(encoding="utf-8"))
def stems(key):
tr = TRANSLATIONS["nl"]["configuration"]
out = []
for c in (tr, tr["inverters"]["fields"]):
if isinstance(c.get(key), dict):
n = c[key]["name"].split("·")[-1].strip()
while re.search(r"\s*\([^()]*\)$", n):
n = re.sub(r"\s*\([^()]*\)$", "", n)
out.append(n)
return out
_drift = sorted(k for k, v in messages.LABELS_NL.items() if v not in stems(k))
check(f"every Dutch label a note quotes is the setting's name in nl.yaml "
f"(drifted: {_drift})", not _drift
and set(messages.LABELS_NL) == set(messages.LABELS))
_unit = sorted(k for k, v in messages.UNIT_LABELS_NL.items() if v not in stems(k))
check(f"...and so are the two the inverter list names differently ({_unit})",
not _unit)
check("setting() in Dutch keeps the key, so the config panel can find it",
messages.setting("max_w", "", "nl") == "“Vermogen van de omvormer” (max_w)"
and messages.setting("max_w", "inverter-b", "nl")
== "“Vermogen van deze omvormer” voor inverter-b (inverters.max_w)"
and messages.setting("max_w") == "“Inverter rating” (max_w)")
# --------------------------------------------------------------------------- #
print("\n4. the rules catch spoiled input")
# --------------------------------------------------------------------------- #
check("a Dutch row left in English is caught",
row_problems_nl(messages.AMBER, "Control: last ran 2 seconds ago"))
check("an over-long Dutch row is caught",
row_problems_nl(messages.AMBER, " ".join(["woord"] * 16) + "."))
check("Dutch jargon is caught", dutch_problems("Het commando is geweigerd"))
check("a row with no Dutch is caught",
not isinstance(messages.pick("x", "nl"), messages.Bi)
and not getattr(messages.Bi("x"), "nl"))
check("the untranslated-call scan sees a bare _advise",
any(isinstance(n, ast.Call) and not any(k.arg == "nl" for k in n.keywords)
for n in ast.walk(ast.parse('_advise(notes, "warn", (), "x")'))))
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(" -", f)
sys.exit(1)
print("all passed")
+11 -2
View File
@@ -1111,8 +1111,17 @@ check("...carrying the reason with it, so the operator who has to decide "
check("...and it is still commanding 0 W on the far side of the restart",
_b.target == 0.0 and "circulation" in _b.reason)
_notes1 = [c for c in _h1.calls if c[0] == "persistent_notification"]
_notes2 = [c for c in _h2.calls if c[0] == "persistent_notification"]
# Not the dismiss of PRICE-01's old fixed-tariff card, sent at every start.
# Only that dismiss: a `create` with the old id would still be counted.
def _not_old_dismiss(c):
return not (c[1] == "dismiss"
and c[2].get("notification_id") == "goodwe_fixed_tariff_unused")
_notes1 = [c for c in _h1.calls if c[0] == "persistent_notification"
and _not_old_dismiss(c)]
_notes2 = [c for c in _h2.calls if c[0] == "persistent_notification"
and _not_old_dismiss(c)]
print(f" notifications raised: {len(_notes1)} before the restart, "
f"{len(_notes2)} after it")
check("the alarm was raised in the first process", len(_notes1) >= 1)
@@ -105,6 +105,13 @@ class _Hass:
return self.power(u)
return None
async def state(self, e):
"""SAFETY-118: the peak forecast is read as a state object; every
number this rig serves is in watts."""
v = await self.number(e)
return None if v is None else {
"state": str(v), "attributes": {"unit_of_measurement": "W"}}
async def set_number(self, e, v):
self.cmd[e[-1]] = float(v)
return True
+160
View File
@@ -0,0 +1,160 @@
"""OPT-20: the refusal-probe scenarios, on the REAL Controller in the lagged
replay (tools/opt11p3_replay.py). Uses only the API bf9019e has, so the same
file runs on both trees (test_opt20.py pins the digests printed on bf9019e).
Battery B is Cheap hours and refuses to charge; it is probed every 15 minutes
inside the cheap run (OPT-15). A is, per scenario, in its maintenance charge
phase, idle (Solar first), or a normal charger in the same run.
"""
import hashlib
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.dirname(HERE))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from app import packmode # noqa: E402
from app.maintenance import CHARGE, DRAIN, MaintResult # noqa: E402
L = timezone(timedelta(hours=2))
A, B = "inverter-a", "inverter-b"
LAG = rp.Lag(tau_s=0.05, ramp_w_s=1e9)
SF_CH = {"a": (True, packmode.SOLAR_FIRST, False),
"b": (True, packmode.CHEAP_HOURS, False)}
CH_CH = {"a": (True, packmode.CHEAP_HOURS, False),
"b": (True, packmode.CHEAP_HOURS, False)}
# A day of the owner's kind: cheap 02:00-04:00 local, flat otherwise.
D0 = datetime(2026, 9, 9, 0, 0, tzinfo=L)
UTC0 = D0.astimezone(timezone.utc)
_PR = [0.30] * 192
for _i in range(8, 16):
_PR[_i] = 0.10
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p) for i, p in enumerate(_PR)]
# OPT-15 rig g7: prices 0 from 17:45 to 18:45 local, flat 0.06 otherwise.
G0 = datetime(2026, 10, 13, 0, 0, tzinfo=L)
GUTC0 = G0.astimezone(timezone.utc)
_GP = [0.06] * 192
for _i in range(71, 75):
_GP[_i] = 0.0
for _i in range(76, 78):
_GP[_i] = 0.5
G7PTS = [(GUTC0 + timedelta(minutes=15 * i), 900, p)
for i, p in enumerate(_GP)]
def at(h, base=UTC0):
return base + timedelta(hours=h)
def _rows(rows):
def cb(c, h, now, pr):
sh = dict(c.allocation.shares) if c.allocation is not None \
and c.allocation.shares else {}
rows.append(dict(
now=now, grid=h.grid(), shares=sh,
action=getattr(c.arb, "action", None),
reason=getattr(c.arb, "reason", None) or "",
probing=c.inverters[1].probe_from is not None,
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
p={u: p.p for u, p in h.plants.items()}))
return cb
def day(choice, *, a_maint=None, shaped=False, refuse=("b",), hours=0.5,
house_w=800.0):
"""02:00..: a cheap run, `house_w` house. `a_maint` is A's forced maintenance
result from 5 minutes in (B's refusal stands by then); `shaped` closes the
maintenance charge's own watts (charge-only shaping, hi = 0)."""
rows = []
def patch(c):
if shaped:
c._charge_headroom = lambda floor, claims: 0.0
if a_maint is None:
return
m, real = c.maints[0], c.maints[0].tick
def tick(now, soc, blocked_by=None, gate_open=True):
if now >= at(1.9 + 5 / 60):
return a_maint
return real(now, soc, blocked_by=blocked_by, gate_open=gate_open)
m.tick = tick
rp.run("p3_ch_sell", [(UTC0.timestamp() - 10, house_w)], WPTS, at(1.9),
3600 * hours, check=_rows(rows), lag=LAG, n=2,
soc0={"a": 50.0, "b": 30.0}, choice=choice, refuse=refuse,
patch=patch, extra={"cycle_cost_threshold_eur": 0.10})
return rows
def g7():
"""The OPT-15 rig's g7: B refuses from the start of the cheap run, A
drains 18:00 to 18:17:04 and then sits in its maintenance charge phase
(charge-only) at 0 W. The probe after the drain used to put the trade's
2500 W on A."""
rows = []
t_end = at(18 + 17 / 60 + 4 / 3600, GUTC0)
def patch(c):
m, real = c.maints[0], c.maints[0].tick
def tick(now, soc, blocked_by=None, gate_open=True):
if at(18.0, GUTC0) <= now < t_end:
return MaintResult(DRAIN, True, 2000.0, False, [])
if now >= t_end:
return MaintResult(CHARGE, False, None, True, [])
return real(now, soc, blocked_by=blocked_by, gate_open=gate_open)
m.tick = tick
rp.run("p3_ch_sell", [(GUTC0.timestamp() - 10, 800.0)], G7PTS,
at(17.6, GUTC0), 3600 * 1.1, check=_rows(rows), lag=LAG, n=2,
soc0={"a": 17.0, "b": 73.0}, choice=SF_CH, refuse=("b",),
patch=patch, extra={"cycle_cost_threshold_eur": 0.10})
return rows
MAINT_CHARGE = MaintResult(CHARGE, False, None, True, [])
def windows(rows):
"""[(first, last)] row indexes of each probe window."""
out, i = [], 0
while i < len(rows):
if rows[i]["probing"]:
j = i
while j + 1 < len(rows) and rows[j + 1]["probing"]:
j += 1
out.append((i, j))
i = j + 1
else:
i += 1
return out
def digest(rows) -> str:
keep = [(r["now"].isoformat(), sorted(r["shares"].items()),
round(r["grid"], 3), r["action"], r["reason"],
sorted(r["roles"].items()), sorted(r["p"].items()))
for r in rows]
return hashlib.sha256(repr(keep).encode()).hexdigest()
def no_probe_digests() -> dict:
"""Battery modes on, no refusal, so no probe: a scripted cheap run."""
return {
"sf_ch": digest(day(SF_CH, refuse=(), hours=1.2)),
"ch_ch": digest(day(CH_CH, refuse=(), hours=1.2)),
"ch_ch_maint": digest(day(CH_CH, a_maint=MAINT_CHARGE, refuse=(),
hours=1.2)),
}
if __name__ == "__main__":
for k, v in no_probe_digests().items():
print(k, v)
+2
View File
@@ -141,6 +141,8 @@ def _node(name, spec):
lengthMin=float(m.group(2)), lengthMax=float(m.group(3)))
elif spec.startswith("list("):
out.update(type="select", options=spec[5:-1].split("|"))
elif spec == "password":
out.update(type="string", format="password")
elif spec in ("bool",):
out["type"] = "boolean"
elif spec in ("int", "float"):
+5 -2
View File
@@ -164,8 +164,8 @@ configuration:
name: "Limits · Margin below the peak limit (W)"
description: "The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it."
peak_forecast_entity:
name: "Limits · 15-minute demand forecast entity (W)"
description: "Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit."
name: "Limits · 15-minute demand forecast entity"
description: "Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit."
peak_soc_floor:
name: "Limits · Peak reserve (%)"
description: "Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged."
@@ -199,6 +199,9 @@ configuration:
price_feed_url:
name: "Tariff · Price feed URL override"
description: "Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day."
price_service_token:
name: "Tariff · Price service token"
description: "For the price service only. The token you were given for it. Leave it empty with any other price source."
price_horizon_h:
name: "Tariff · Price look-ahead (hours)"
description: "How many hours of prices to ask the source for."
+5 -2
View File
@@ -145,8 +145,8 @@ configuration:
name: "Grenzen · Marge onder de piekgrens (W)"
description: "De add-on mikt zoveel onder de piekgrens, zodat een kwartier dat iets hoger uitvalt er toch onder blijft."
peak_forecast_entity:
name: "Grenzen · Entiteit kwartierprognose (W)"
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier. Laat u dit leeg, dan is er geen piekgrens."
name: "Grenzen · Entiteit kwartierprognose"
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier, in W of kW. Zonder vermogenseenheid wordt ze niet gebruikt. Laat u dit leeg, dan is er geen piekgrens."
peak_soc_floor:
name: "Grenzen · Piekreserve (%)"
description: "De piekbeperking laat de batterij niet onder dit niveau zakken, gewoon verbruik wel. Afgeleide waarde, laat ze ongewijzigd."
@@ -180,6 +180,9 @@ configuration:
price_feed_url:
name: "Tarief · Afwijkende URL voor de prijsfeed"
description: "Laat leeg om de eigen feed van de bron te gebruiken. Een afwijkende URL wordt letterlijk verstuurd en geeft vaak maar één dag terug."
price_service_token:
name: "Tarief · Token van de prijsdienst"
description: "Alleen voor de prijsdienst. Het token dat u ervoor kreeg. Laat leeg bij een andere prijsbron."
price_horizon_h:
name: "Tarief · Prijzen vooruit (uren)"
description: "Hoeveel uur aan prijzen de add-on bij de bron opvraagt."