Compare commits

..
Author SHA1 Message Date
admin 22d09f8fe7 Merge pull request 'OPT-36: Leftover solar counts Cheap hours and Solar first batteries before buying' (#229) from OPT-36 into hotfix-0.20.1 2026-11-02 14:49:45 +01:00
glenn schrooyenandClaude eb9ccc2730 OPT-36 round 4: count only charge control will make; Solar first drain at the night base
checks / unit (pull_request) Successful in 44m14s
infra-pin / fresh (pull_request) Successful in 2s
Review F1: a slot after B's run counts only if no slot from the expensive
hours on is cheaper (control plans B's charge left in the cheapest window
ahead), and x 0.95 of B's charge is stored. Three-price night: 1ac7fe9 left
the expensive hours 2.61 kWh short; now 0.
Review F2: a Solar first battery's drain until the expensive hours reads each
hour at no less than the night base (the profile is net of the sun). Read net,
a dark morning drained the battery the plan still counted; A's level fell,
the cheap run moved and was lost before it started (+0.40 EUR per 3 days).
The two real-price replays now cost what 0.20.0 does.
F3: the ux54 sweep's peak limit is 15 kW (the option's top). F4: the rest
comes from the grid (EN/NL). F5: CHANGELOG and DECISIONS wording.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 12:56:17 +01:00
glenn schrooyenandClaude 1ac7fe98d7 OPT-36 round 3: test_opt36 reads the mid_flat level through nth()
checks / unit (pull_request) Successful in 26m38s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 10:57:22 +01:00
glenn schrooyenandClaude e4b9dee699 OPT-36 round 3: B's charge counts the cheap time left; OPT-34 nights on their own shapes
checks / unit (pull_request) Failing after 36m29s
infra-pin / fresh (pull_request) Successful in 2s
The run is sized at the batteries' ratings, not the peak limit, so a run too
short to fill a Cheap hours battery is planned again later in the night.
Counting that run alone made B's give grow through the night and A's level
fall after A had bought (round 2: 55 -> 69 -> 41 -> 37 %, A to 56 %).
dayplan._cheap_give_kwh() now also counts the later firm slots before the
expensive hours that a run would still be worth charging in (price + the
spread control asks at most the give run's dearest slot); the Controller
passes the slots and the spread.

test_opt34: back to the original house shapes, fixture and expected values.
B is Off in the nights that test A's level (a Cheap hours B now covers
these hours); B's checks run in new `_b` nights; the 03:00 check runs in a
night from midnight; tail starts A at 60 %; late_fresh reads the kept 6.0.
test_opt36: slot cases, a whole night on one figure (level never moves),
F-1 with B counted (raised level reached and held). test_ux54: the run end
is read from the plan and asserted (02:30); the F1/F2 sweep uses a peak
limit that does not bind and reads a level shown as the target as at it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 10:15:20 +01:00
glenn schrooyenandClaude 3a18475a5a OPT-36 round 2: test_opt34 keeps its tolerances; fresh sensors read the day they are on
checks / unit (pull_request) Successful in 20m36s
infra-pin / fresh (pull_request) Successful in 2s
The silent and fresh nights diverged only because the test's fresh sensors
kept reading the evening's 0.4 kWh 'left today' after midnight, while the
kept figure is the 4th's 6.0 kWh (solarfc.Hold.stand_in, as_today): two
different shortfalls (16.5 and 12.7 kWh) that A's ceiling hid until B was
counted. Fresh sensors now read 6.0 after midnight, so fresh and kept are
the same figure; the 0.3 tolerance is back, the other exact values are
recomputed. test_ux54 literals are recomputed too.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 05:34:32 +01:00
glenn schrooyenandClaude 6a527de851 OPT-36: test_opt34 and test_ux54 scenarios keep a shortfall B does not cover
checks / unit (pull_request) Successful in 27m17s
infra-pin / fresh (pull_request) Successful in 2s
With Cheap hours B counted, A buys only what B cannot reach. The OPT-34
night scenarios use a 1.6 x larger house profile, and the ux54 tab scenarios
a 4.8 kW evening (ux54_scenarios.make(evening=)), so A still charges; the
hard-coded levels become relations.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 03:30:39 +01:00
glenn schrooyenandClaude 60674e45ba OPT-36: test_opt36 reads possibly-missing values through nth()
checks / unit (pull_request) Failing after 35m3s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 02:50:33 +01:00
glenn schrooyenandClaude 490f67924b OPT-36: test a give block before tonight's charge, Solar first refusing and in maintenance
checks / unit (pull_request) Failing after 26m32s
infra-pin / fresh (pull_request) Successful in 4s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 02:43:02 +01:00
glenn schrooyenandClaude 05746c0564 OPT-36: Leftover solar counts what Cheap hours and Solar first batteries will give before buying
checks / unit (pull_request) Failing after 59m31s
infra-pin / fresh (pull_request) Successful in 2s
Live 0.20.0: A (Leftover solar, 60 %) charged to 95 % while B (Cheap hours,
94 %) already covered the expensive hours. dayplan.plan() now takes off the
shortfall what Cheap hours batteries will hold when the hours start (the
ceiling or what their cheap run reaches under the peak aim) and what Solar
first batteries will still hold after the night's use. Home reads a give
block after the planned charge, plan_cover() is the plan's own figure, and
the Why line says other batteries cover.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 02:03:04 +01:00
admin 01e88a24f2 Release 0.20.0: release/1.0 -> master (#227) 2026-11-01 18:58:04 +01:00
24 changed files with 1192 additions and 1431 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
### Not in a release yet
- When Home Assistant passes readings on late, control no longer swings on them: it waits until the battery shows its last power request, and the peak limit no longer asks for full power on them. The page says so, and if the readings stay too late every inverter goes to 0 W ([[SAFETY-128]]).
- Leftover solar now counts what Cheap hours only and Solar first batteries will still hold when the expensive hours start, and buys from the grid only the rest. The Batteries screen says when other batteries cover those hours.
**A minor bump: a Forecast tab, a night grid charge sized to what the sun will not give, a kept evening forecast, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options). It is a minor bump because Leftover solar now buys different amounts from the grid and the night charge keeps its level when the forecast goes quiet; every saved setting still starts.
@@ -6,6 +6,7 @@
- **Shortfall.** `sum_h max(0, house_h - trust x sun_h)` over every expensive hour, `/ EFF`, from the floor. It replaces the use over the run with no sun counted, which on a day and night tariff bought for the whole day (G1, `reports/UX-69-design-report.md`). The sun before the run is unchanged. A run with its dear hours after sunset gives the old number.
- **House use.** An hour with sun is read at no less than the night base, as the sun sum reads it (the profile is net of the sun). An hour without is read as measured. An hour with no forecast counts no sun.
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by another battery (Cheap hours, Solar first) or the grid, as the screen says.
- **Cheap hours** still fills up and is not counted in the share; **Solar first** still never charges from the grid. The mode help says so.
- **Not done:** the run length (OPT-25); netting what a Cheap hours or Solar first battery will hold against the shortfall.
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by the grid (OPT-36 counts the other batteries first), as the screen says.
- **Cheap hours** still fills up; **Solar first** still never charges from the grid. The mode help says so.
- **OPT-36 (0.20.1):** Cheap hours and Solar first batteries give first; Leftover solar shares only the rest of the shortfall (`dayplan._cheap_give_kwh()`, `_solar_first_give_kwh()`). A Cheap hours battery counts the level its charge reaches: the cheap run, then later slots still worth charging in and no dearer than any slot from the expensive hours on, under the peak aim, x EFF stored. A Solar first battery counts what is left after the house's use until the hours start, each hour at no less than the night base. Live, 2026-11-02: A at 60 % went to 95 % while B at 94 % covered the hours. Owner, 2026-11-02: Solar first counts too. Test: `test_opt36.py`.
- **Not done:** the run length (OPT-25). On a dynamic tariff the Cheap hours count guesses where control will charge; a counted slot can still go unused, and that gap comes from the grid.
@@ -1,84 +0,0 @@
# SAFETY-128 - a reading that does not answer the last command is not current
**Status: FIXED on release/1.0 (after 0.20.0).** Code: `app/lateness.py`,
`control.compute(hold=, hold_w=)`, `peakguard.cap_floor()`,
`Controller._readings_late()`, `Controller.read_inputs()` (feeds the fits).
Tests: `test_safety128.py` (`tools/safety128_rig.py`, TEST-23's plant).
**Cause** (SAFETY-127 report). Home Assistant can deliver readings late but
still changing. Every gate judges arrival (SAFETY-01) or a value that stops
changing (SAFETY-18, SAFETY-37), so a late reading passes them all. The loop
then corrects, each cycle, an error its last command has already fixed: a
loop with a dead time far longer than its cycle, which swings and grows. The
peak guard projects the quarter from the same late figures and pushes to the
inverter limit in its last seconds (OPT-34 rig F-2).
**Rule.**
1. *Find late readings* (`Answer.watch()`, every 2 s, from the 1 s reads).
- **Batteries** (`Delay`): fit one `d` so that each battery's reading minus
its own request at `t - d` (averaged over 2 s for the battery's lag and
the read) is flattest. The house does not move that reading, so a
repeating load cannot line up with it (review round 1, B1). Needs 5 new
readings in 60 s and the request and the reading each to move 300 W.
Late from 9.5 s, after 2 late fits in a row. A late fit must explain 75 %
and beat every fit up to 6.5 s by 2x; the shortest delay that fits about
as well (within 2x, or within 2 500 W^2) is taken instead, since a swing's
period fits again. A slow battery (3-10 s lag) answering at once gives no
late fit; at a 15-20 s lag some fits read late (see "What it costs").
No late battery fit counts for 60 s
after a battery gave nothing under its ask: an emptied battery lined its
old movement up as 85 s late.
- **Meter** (`MeterFit`, round 1's fit): `meter(t) + request(t - d)` is the
house alone at the right `d`. Quick, but confounded by the house. Needs
16 new values in 60 s, so only a meter of about 3.5 s or faster: at 5 s
it gave false delays. Late over 8 s.
- A meter fit says late only with evidence from the batteries' side too:
a request more than 100 W from the battery reading took 3 s or more to
move a reading towards it (`SLOW_START_S`; on time they start within
0-1 s in the recorded house at 1 and 2 s ticks), and no battery fit in
the last 60 s said on time (under 9.5 s). An ask during which a battery
gave nothing under its ask is no slow evidence. A repeating load fools
the meter fit but not these. No meter verdict in the first 150 s.
- On time again on a battery fit under 6.5 s, or a meter fit within 8 s
unless a battery fit said late (hysteresis). With no fit for 120 s the
verdict is dropped.
2. *Hold.* While late, a request more than 100 W from the battery reading is
pending until that reading comes within 100 W of it, or half way. Meanwhile
the loop may not lead the reading by more than 100 W that way, and the peak
guard's floor is capped at the request that stands. The loop is not held
while a battery gives nothing under its ask or refuses (SAFETY-52/94).
3. *Late verdict.* Pending for `LATE_S` (23 s) with no reading moving 100 W
towards it: control holds, then after `stale_input_s` goes to 0 W
("readings late"), as for a missing input.
4. *Seen.* While late an amber row says "Home Assistant readings show requests
about N seconds late, so control holds the batteries." (NL too). N is the
batteries' fitted delay: Home Assistant's lag plus the battery's own
answer. While only the meter has shown it the row gives no figure. One
WARNING (`READINGS_LATE`) per episode and one line when it ends.
**With readings on time** no verdict was reached in any reviewer scenario
(owner-shaped house, ovens 20/40 to 60/60 s, clouds, a 300 W/s battery, 6-8 s
dead times, HA jitter 0-4 s, an EV following the meter, capped batteries, HA
restarts, meter pauses; 1, 2 and 5 s meters) nor in 2 days of the recorded
house at 1, 2 and 5 s ticks, so the commands are the same as without the gate.
The fits run all the time; the pull request has the counts.
**What it costs.**
- Found 36-68 s after a lag starts at 1-2 s meters, about 3 minutes at a 5 s
meter (battery fit only; the meter fit needs a faster meter). Until then control is as before. At 5 s and 30 s
late the export is cut to about a third, not removed.
- A battery that answers slowly is read as late readings and held: a dead
time of 10 s or more (8 s reads 7-8; 10 s reads 10 at 1-2 s meters, 9 at
5 s), or a 15-20 s lag (review round 2). The GoodWe measured settles in 6 s
(`freshness.PLANT_SETTLE_S`); the row then blames Home Assistant wrongly.
- The row can stay up to 180 s after the readings are on time on a steady
site. While only the meter has shown the delay it gives no figure.
- A battery that cannot follow while the readings are late (at a limit, not
reading 0 W) is held, then set to 0 W after 38 s.
- The fits cost a few ms every 2 s (x86, measured after 3 idle days); the
history they read is bounded (60 s of readings, 150 s of requests).
**Left out.** The setpoint -> Home Assistant round trip: the fitted delay gives
the figure from readings the add-on already has. The hold's step-and-pull-back
at extreme lag and the arbitrage charge's sizing on late readings
(`charge_room_w()`) are follow-up cards.
+1 -1
View File
@@ -549,7 +549,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
- *Leftover solar* then charges from the grid only the shortfall: for each expensive hour, what the house uses less the sun counted for that hour (never below 0), added up over all the expensive hours, early morning and evening. With a day and night tariff that is 07:00 to 22:00. Not up to the top level.
- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. In the expensive hours the rest comes from another battery (*Cheap hours only* or *Solar first*) or from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought.
- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. *Cheap hours only* and *Solar first* batteries give first: what they will hold when the expensive hours start (a *Cheap hours only* battery full, or as far as it can charge under the peak limit in the cheap hours left; a *Solar first* battery after the night's use) is taken off the shortfall. In the expensive hours the rest comes from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought.
- A forecast that gives only one figure for the day (Forecast.Solar) is spread over the hours from now, so the hours add up to that figure.
- *Cheap hours only* ignores the forecast and fills to the top level.
- *Solar first* never charges from the grid.
-13
View File
@@ -298,9 +298,6 @@ PROBE_S = 34.0
# command: every battery that may move this way is at its mode's limit.
MODE_BOUND = "mode-bound"
# SAFETY-128. The loop's reason while it waits for the readings to answer.
WAITING = "waiting for readings"
@dataclass(frozen=True)
class Tuning:
@@ -489,8 +486,6 @@ def compute(
bound: tuple | None = None,
gave_nothing: bool | None = None,
idle_w: float = 0.0,
hold: int = 0,
hold_w: float | None = None,
) -> Decision:
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
@@ -511,9 +506,6 @@ def compute(
`idle_w` SAFETY-97: what `actual_w` reads while the batteries give
nothing (app/idle.py). The refusal's band and its end are
centred on it. 0 is the law as it was.
`hold` SAFETY-128: +1 / -1, the batteries have not answered the last
command that way (app/lateness.py), so the loop may not go
past `hold_w` (None: `prev_w`) that way. 0 is the law as it was.
"""
reason = "tracking"
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
@@ -630,11 +622,6 @@ def compute(
i_w = min(moved, i_w)
else:
i_w = max(moved, i_w)
# SAFETY-128: no further than `hold_w` until the readings answer the ask.
held_at = float(prev_w if hold_w is None else hold_w)
if hold and hold * (i_w - held_at) > 0:
i_w = held_at
reason = WAITING
# OPT-11: the integrator does not wind past what the modes let the
# batteries place. Only a side tighter than the rail is left in `bound`
+124 -9
View File
@@ -22,7 +22,10 @@ OPT-28 for the second):
all of them cannot hold (`Plan.uncovered_kwh`) is not bought: another
battery (Cheap hours, Solar first) or the grid carries it.
* Cheap hours only ignores the plan and fills to the ceiling, as in P3 and
the design (section 1). Skipping the sun's share cost money in the replay
the design (section 1). OPT-36: what it, and a Solar first battery after
the night's use, will give in the expensive hours is taken off `short`
before the Leftover solar batteries share it (`_cheap_give_kwh()`,
`_solar_first_give_kwh()`). Skipping the sun's share cost money in the replay
(PR #156 review: 29.72 against 29.41 EUR on real 2026 prices).
* A Leftover solar battery charges from the grid up to the level it needs
for its part of `short`, less its share of the sun before the run:
@@ -45,7 +48,7 @@ import statistics
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
from .packmode import CHEAP_HOURS, LEFTOVER_SOLAR, SOLAR_FIRST
from .tariff import BILLING_TZ
EFF = 0.95 # charge (and discharge) efficiency
@@ -170,6 +173,14 @@ class Plan:
short_kwh: float = 0.0
cover_kwh: dict = field(default_factory=dict)
uncovered_kwh: float = 0.0
# OPT-36: what Cheap hours batteries will give in those hours (kWh on the
# house's side), and, per Leftover solar battery, the grid kWh it buys
# less because of it.
cheap_kwh: float = 0.0
saved_kwh: dict = field(default_factory=dict)
# ...and the same for Solar first batteries (owner, 2026-11-02): their
# level when the expensive hours start, after the night's use.
solar_first_kwh: float = 0.0
def _hour_slices(t0, t1):
@@ -219,12 +230,100 @@ def _room(p, to_pct):
p.capacity_kwh)
def _cheap_give_kwh(runs, now, house, packs, *, floor_pct, ceiling_pct,
aim_w, until=None, slots=None, spread=None):
"""OPT-36: what the Cheap hours batteries in `packs` give in the
expensive hours, in kWh on the house's side: (the level at the start of
those hours - the floor) x capacity x `EFF`.
That level is the one its cheap charging reaches: the ceiling, or less
when the time left cannot get there. That time is the cheap run from now
(or the whole run ahead of it) and, given the firm price `slots`, each
later slot before the expensive hours (`until`, a give run) still worth
charging in (its price plus the spread control asks, `spread`, at most
the give run's dearest slot, `priceruns.plan()`'s test) and no dearer
than every slot from the start of those hours on: control plans the
charge left in the cheapest window ahead, so a slot with a cheaper one
after it may never be used (round 4, a three-price night). The run is
sized at the batteries' ratings (`priceruns.need_hours()`), not the peak
limit, so a run too short to fill them is planned again in that time;
counting the run alone let A's level fall in the night after A had
bought. In that time the batteries together charge at their ratings,
held to what the peak aim leaves above the house's use that hour
(`aim_w`, None: no limit), shared by rating; x `EFF` of it is stored.
No cheap run worth charging in: the level now.
A battery that refuses a direction counts 0."""
ps = [p for p in packs if p.mode == CHEAP_HOURS and p.refused == 0]
if not ps:
return 0.0
house_w, base_w = house
gain = {p.name: 0.0 for p in ps}
rate = {p.name: abs(float(p.rating_w)) for p in ps}
total = sum(rate.values())
run = runs.cheap
spans = [(max(now, run.start), run.end)]
top = max((eur for s, _e, eur in slots or ()
if until is not None and until.start <= s < until.end),
default=None)
low = min((eur for s, _e, eur in slots or ()
if until is not None and s >= until.start), default=None)
if top is not None and spread is not None:
spans += [(max(s, now, run.end), min(e, until.start))
for s, e, eur in slots
if eur + float(spread) <= top + 1e-9
and (low is None or eur <= low + 1e-9)
and e > run.end and s < until.start]
if runs.charge_useful() and total > 0.0:
for t0, t1 in spans:
for s, e, h in _hour_slices(t0, t1):
w = total
if aim_w is not None:
w = min(w, max(0.0, float(aim_w)
- max(house_w(h), base_w, 0.0)))
hrs = (e - s).total_seconds() / 3600.0
for p in ps:
gain[p.name] += w * rate[p.name] / total * hrs \
/ 1000.0 * EFF
out = 0.0
for p in ps:
cap = float(p.capacity_kwh)
lvl = max(float(p.soc), min(float(ceiling_pct),
float(p.soc) + gain[p.name] / cap * 100.0))
out += max(0.0, lvl - float(floor_pct)) / 100.0 * cap * EFF
return out
def _solar_first_give_kwh(house, now, start, packs, *, floor_pct):
"""OPT-36 (owner, 2026-11-02): what the Solar first batteries in `packs`
will still give when the expensive hours start at `start`, in kWh on the
house's side. They give at any hour, so the house's use from now to
`start` is taken off first, shared by the energy each holds above the
floor, and what is left x `EFF` counts. Each hour is read at no less than
the night base: the profile is net of the sun the house used, and read
net it let a dark day's morning drain a battery that the plan still
counted (round 4, real-price replays). A battery
that runs down to the floor before then counts 0; one that refuses a
direction counts 0."""
ps = [p for p in packs if p.mode == SOLAR_FIRST and p.refused == 0]
if not ps:
return 0.0
house_w, base_w = house
use = sum(max(0.0, house_w(h), base_w) * (e - s).total_seconds() / 3.6e6
for s, e, h in _hour_slices(now, start))
have = {p.name: max(0.0, float(p.soc) - float(floor_pct)) / 100.0
* float(p.capacity_kwh) for p in ps}
took, _ = _spread(use / EFF, have, have)
return sum(have[n] - took[n] for n in have) * EFF
def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
ceiling_pct):
ceiling_pct, aim_w=None, slots=None, spread=None):
"""The day plan, or None when there is none to make: no forecast, no
house profile yet, no price plan, or no expensive run after the cheap
run. `house` is `HouseProfile.estimator()`'s answer. `packs` are
`modetrade.Pack`s."""
`modetrade.Pack`s. `aim_w`, `slots` (`priceruns.firm_slots()`) and
`spread` (the price gap control asks, EUR/kWh) size a Cheap hours
battery's charge (`_cheap_give_kwh()`)."""
if forecast is None or house is None or runs is None or not runs.on \
or runs.cheap is None:
return None
@@ -273,11 +372,23 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
- trust * pv)
caps = {p.name: float(p.capacity_kwh) for p in price}
span = max(0.0, float(ceiling_pct) - float(floor_pct))
held, left = _spread(short / EFF,
{n: span / 100.0 * c for n, c in caps.items()},
caps)
# OPT-36: the Cheap hours and Solar first batteries give first;
# Leftover solar holds the rest. A Leftover solar battery's own stored energy counts through its
# level: it buys from where it is to the level it needs.
cheap = _cheap_give_kwh(runs, now, house, usable, floor_pct=floor_pct,
ceiling_pct=ceiling_pct, aim_w=aim_w,
until=until, slots=slots, spread=spread) \
if price else 0.0
cheap = min(cheap, short)
sfirst = _solar_first_give_kwh(house, now, until.start, usable,
floor_pct=floor_pct) if price else 0.0
sfirst = min(sfirst, short - cheap)
others = cheap + sfirst
room_of = {n: span / 100.0 * c for n, c in caps.items()}
held, left = _spread((short - others) / EFF, room_of, caps)
held0 = _spread(short / EFF, room_of, caps)[0] if others > 0.0 else held
targets, need, share, cover = {}, {}, {}, {}
targets, need, share, cover, saved = {}, {}, {}, {}, {}
for p in price:
sh = excess * rooms[p.name] / total_room if total_room > 0 else 0.0
sh_pct = sh / caps[p.name] * 100.0
@@ -285,6 +396,9 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
level = max(0.0, top - sh_pct)
targets[p.name] = round(level, 2)
need[p.name] = round(_room(p, level), 3)
top0 = float(floor_pct) + held0[p.name] / caps[p.name] * 100.0
saved[p.name] = round(
_room(p, max(0.0, top0 - sh_pct)) - need[p.name], 3)
share[p.name] = round(sh, 3)
cover[p.name] = round(held[p.name] * EFF, 3)
if sun < sf_room:
@@ -295,7 +409,8 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
verdict = SUNNY
return Plan(verdict, round(sun, 3), round(fc_sum, 3), until.start,
targets, need, share, round(short, 3), cover,
round(left * EFF if price else 0.0, 3))
round(left * EFF if price else 0.0, 3), round(cheap, 3),
saved, round(sfirst, 3))
def hold(old: dict | None, new: dict, capacity: dict) -> dict:
+26 -9
View File
@@ -444,9 +444,16 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
return got if got is not None else (
floor if kind == "grid" else ceiling)
grids = [] # (end, kWh) of the grid blocks drawn
def room(kind, hrs, start):
kwh = inv.max_w / 1000.0 * hrs
lvl = start_pct(kind, start) if ahead else soc
if kind != "grid" and not ahead and cap and lvl is not None:
# OPT-36: a give block reads the level after the grid block
# before it, not the level now.
lvl = min(float(ceiling), lvl + sum(
k for e, k in grids if e <= start) / cap * 100.0)
if cap and lvl is not None:
kwh = min(kwh, max(0.0, (ceiling - lvl) if kind == "grid"
else (lvl - floor)) / 100.0 * cap)
@@ -474,6 +481,8 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
kwh = min(kwh, need)
if kwh < MIN_KWH:
continue
if kind == "grid":
grids.append((run.end, kwh))
price = run.mean_eur_kwh
if kind == "grid":
cov = plan_cover(c, inv.name)
@@ -522,17 +531,16 @@ def plan_cover(c, name):
grid: the level it charges to, and the expensive-hour use of the house
that charge covers and the sun will not. Read from the level control
holds (`plan_targets()`), so Home says what control does. None when the
plan buys nothing for it."""
plan buys nothing for it. OPT-36: the kWh is the plan's own cover figure
(`Plan.cover_kwh`), not one rebuilt from the level."""
dp = getattr(c, "day_plan", None)
targets = c.plan_targets() if hasattr(c, "plan_targets") else None
inv = next((i for i in c.inverters if i.name == name), None)
if dp is None or not targets or name not in targets or inv is None \
or not inv.capacity_kwh or targets[name] <= 0.0:
or not inv.capacity_kwh or targets[name] <= 0.0 \
or name not in dp.cover_kwh:
return None
floor, _ceil = arbitrage.soc_bounds(c.o)
cap = float(inv.capacity_kwh)
top = targets[name] + dp.share_kwh.get(name, 0.0) / cap * 100.0
return targets[name], max(0.0, top - floor) / 100.0 * cap * EFF
return targets[name], dp.cover_kwh[name]
def _latched_below(c) -> bool:
@@ -931,7 +939,8 @@ def battery_rows(c, shadow, running, held=False):
def day_key(c, name):
"""P4: the day plan's word for one Leftover solar or Cheap hours
battery: `sun_fills`, `tops_up`, `no_forecast`, or None."""
battery: `sun_fills`, `others_cover`, `tops_up`, `no_forecast`, or
None."""
st = plan_state(c)
if st == "no_forecast":
mode = c.pack_mode(next(i for i in c.inverters if i.name == name))
@@ -941,7 +950,14 @@ def day_key(c, name):
need = plan_need(c, name)
if need is None:
return None
return "tops_up" if need >= MIN_KWH else "sun_fills"
if need >= MIN_KWH:
return "tops_up"
# OPT-36: no grid charge because other batteries (Cheap hours only,
# Solar first) cover the hours, not the sun.
dp = getattr(c, "day_plan", None)
if dp is not None and dp.saved_kwh.get(name, 0.0) >= MIN_KWH:
return "others_cover"
return "sun_fills"
def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
@@ -1473,7 +1489,8 @@ def line_solar(fc_kwh, count_kwh, at, kept=False) -> str:
def line_uncovered(kwh) -> str:
"""OPT-28: the Leftover solar batteries cannot hold the expensive hours'
shortfall. The rest comes from another battery or the grid."""
shortfall. The rest comes from the grid: OPT-36 counts the other
batteries first."""
return _t("solar.uncovered", kwh=_kwh(kwh))
-373
View File
@@ -1,373 +0,0 @@
"""SAFETY-128: a reading that does not answer the last command is not current.
When Home Assistant delivers readings late but still changing, every gate that
judges arrival (SAFETY-01) or an unchanged value (SAFETY-18, SAFETY-37) passes
them, and the loop corrects again, each cycle, an error its last command has
already fixed. The readings show it: they answer each power request late.
`Answer.watch()` (each 1 s read) fits two delays every FIT_EVERY_S:
- `Delay`: how long after its request each battery's reading shows it. The
house does not move that reading, so a repeating load cannot fool it. It
needs the request to move FIT_MOVE_W; one reading per 10 s makes it slow.
- `MeterFit`: how long after the site request the meter shows it. Quick at a
1-3 s meter, none at a slower one, and a house load that repeats can fool it.
The meter counts only when an ask was slow to start and no battery fit said
"on time"; with hysteresis (ON_S / OFF_S) the result is `late_seen`. Then (`observe()`, each cycle):
- a new command more than ANSWER_W from the battery reading is PENDING until
that reading comes within ANSWER_W of it, or half way;
- while pending the loop may not lead the battery reading by more than
ANSWER_W that way (`hold`, `hold_w`; not while `exempt`: a battery that
gives nothing under its ask or refuses is SAFETY-52/94's case) and the peak
guard may not push past what stands (`peak_cap`). Pending for LATE_S with
no reading moving towards it is LATE: control holds, then 0 W after
`stale_input_s`, as for a missing input.
Not `late_seen`, nothing here acts. DECISIONS/SAFETY-128-late-readings.md.
"""
import bisect
from collections import deque
from . import freshness
from .control import PROBE_ANSWER_W
# Within this of the command the reading has answered it (a probe's answer).
ANSWER_W = PROBE_ANSWER_W # 100
# The slowest healthy battery answer: SAFETY-75's 6 s dead time, the 6 s plant
# settle, one 10 s publish and one 1 s read.
LATE_S = (2 * freshness.PLANT_SETTLE_S + freshness.PUBLISH_PERIOD_S
+ freshness.READ_PERIOD_S) # 23.0
# Each battery reading's delay is fitted over this much history, up to this.
FIT_S = 60.0
MAX_DELAY_S = 90
FIT_EVERY_S = 2.0
# A fit needs this many new readings (all batteries) and this much movement
# of the reading and the request.
FIT_ROWS = 5
FIT_MOVE_W = 3 * ANSWER_W # 300
# Late from ON_S, on time again under OFF_S (hysteresis).
ON_S = 9.5
OFF_S = 6.5
# ...after this many late fits in a row.
ON_FITS = 2
# With no fit for this long the evidence is dropped (a steady site shows none).
FIT_GONE_S = 120.0
# A fit counts only if the commands explain this share of the reading's swing.
FIT_SHARE = 0.5
# A late delay must explain this share.
FIT_SHARE_LATE = 0.75
# ...and a late delay only if it fits this much better than any fresh one.
FIT_BETTER = 0.5
# A shorter delay within this factor of the best fit is taken instead.
FIT_NEAR = 2.0
# ...or within this of it (W^2): two fits this good are both exact.
FIT_FLOOR_W2 = (ANSWER_W / 2) ** 2 # 2 500
def per_second(cmds, first, last):
"""The request standing at each whole second from `first` to `last`."""
ts = [t for t, _c in cmds]
return [cmds[max(0, bisect.bisect_right(ts, k) - 1)][1]
for k in range(first, last + 1)]
def pooled(sets, d):
"""Pooled variance over [(rows, c, first, sign)] of value + sign *
c[t - d], each set about its own mean."""
tot, n = 0.0, 0
for rows, c, first, sign in sets:
e = [v + sign * c[int(t) - d - first] for t, v in rows]
m = sum(e) / len(e)
tot += sum((x - m) ** 2 for x in e)
n += len(e)
return tot / n
class UnitFit:
"""One battery's power requests and readings, as they were seen."""
def __init__(self):
self.cmds = deque() # (t, request) when it changed
self.reads = deque() # (t, reading) when it changed
def add(self, now, cmd, reading):
if cmd is not None and (not self.cmds or self.cmds[-1][1] != cmd):
self.cmds.append((now, float(cmd)))
if reading is not None and (not self.reads
or self.reads[-1][1] != reading):
self.reads.append((now, float(reading)))
while self.reads and self.reads[0][0] < now - FIT_S:
self.reads.popleft()
while (len(self.cmds) > 1
and self.cmds[1][0] <= now - FIT_S - MAX_DELAY_S):
self.cmds.popleft()
def series(self):
"""(rows, request each second, first second) for the fit, or None:
too few new readings, or a reading or a request that has not moved
FIT_MOVE_W. The request is averaged over the 2 s before each second:
the battery's own lag and the read."""
rows = [r for r in self.reads
if self.cmds and r[0] >= self.cmds[0][0] + MAX_DELAY_S]
vals = [v for _t, v in rows]
if len(rows) < 3 or max(vals) - min(vals) < FIT_MOVE_W:
return None
cs = [c for _t, c in self.cmds]
if max(cs) - min(cs) < FIT_MOVE_W:
return None
first, last = int(rows[0][0]) - MAX_DELAY_S - 2, int(rows[-1][0])
c = per_second(self.cmds, first, last)
avg = [c[0], c[0]] + [(c[k - 2] + c[k - 1] + c[k]) / 3
for k in range(2, len(c))]
return rows, avg, first
class Delay:
"""How long after a power request the batteries' readings show it.
A reading follows its battery's own request and the house does not move
it, so `reading(t) - request(t - d)` is flattest at the right `d`. Each new
reading counts once, at the second it was seen, so the 10 s publish adds no
delay. One `d` for every battery (Home Assistant delays them all). None
with too few readings, or when the requests explain too little of them (a
battery that does not follow); not late unless it fits clearly better than
any fresh delay, and then the shortest delay that fits about as well."""
def __init__(self):
self.units = {}
def add(self, now, unit, cmd, reading):
self.units.setdefault(unit, UnitFit()).add(now, cmd, reading)
def estimate(self):
ser = [x for x in (u.series() for u in self.units.values()) if x]
if sum(len(rows) for rows, _c, _f in ser) < FIT_ROWS:
return None
sets = [(rows, c, first, -1.0) for rows, c, first in ser]
r_var = pooled([(rows, [0.0] * len(c), first, -1.0)
for rows, c, first in ser], 0)
pure = [pooled(sets, d) for d in range(0, MAX_DELAY_S + 1)]
# The shortest that fits about as well: a swing's period fits again.
best = next(d for d, v in enumerate(pure)
if v <= FIT_NEAR * min(pure) + FIT_FLOOR_W2)
if r_var <= 0 or pure[best] > (1 - FIT_SHARE) * r_var:
return None
if best < ON_S:
return float(best)
if (pure[best] > (1 - FIT_SHARE_LATE) * r_var
or pure[best] > FIT_BETTER * min(pure[:int(OFF_S) + 1])):
return None
return float(best)
# The meter's delay: fitted over this much history, from this many new
# values (a meter of about 3.5 s or faster: a slower one gives too few to
# fit without false delays); late over M_LATE_S (the plant's 6 s settle and one 2 s meter tick),
# but only when the batteries' own readings have not just shown them on time.
M_FIT_S = 60.0
M_ROWS = 16
M_LATE_S = freshness.PLANT_SETTLE_S + 2.0 # 8.0
M_SHARE, M_BETTER = 0.4, 0.7
# A meter fit counts only after an ask that took this long to start moving a
# reading: on time they start within 1-2 s (the recorded house, at 1-5 s).
SLOW_START_S = 3.0
class MeterFit:
"""How long after a site request the meter shows it (round 1's fit).
`meter(t) + request(t - d)` is the house alone at the right `d`. Quick,
as the meter reads every 1-5 s, but a house load that repeats can line
up with an earlier request, so it only counts while the batteries' own
fit is not on time (`Answer.watch`)."""
def __init__(self):
self.cmds = deque() # (t, request) when it changed
self.grid = deque() # (t, meter) new values only
def add(self, now, cmd, grid):
if cmd is not None and (not self.cmds or self.cmds[-1][1] != cmd):
self.cmds.append((now, float(cmd)))
if grid is not None and (not self.grid or self.grid[-1][1] != grid):
self.grid.append((now, float(grid)))
while self.grid and self.grid[0][0] < now - M_FIT_S:
self.grid.popleft()
while (len(self.cmds) > 1
and self.cmds[1][0] <= now - M_FIT_S - MAX_DELAY_S):
self.cmds.popleft()
def estimate(self):
if len(self.cmds) < 2:
return None
rows = [(t, g) for t, g in self.grid
if t >= self.cmds[0][0] + MAX_DELAY_S]
cs = [c for _t, c in self.cmds]
if len(rows) < M_ROWS or max(cs) - min(cs) < ANSWER_W:
return None
first, last = int(rows[0][0]) - MAX_DELAY_S, int(rows[-1][0])
c = per_second(self.cmds, first, last)
g_var = pooled([(rows, [0.0] * len(c), first, 1.0)], 0)
pure = [pooled([(rows, c, first, 1.0)], d)
for d in range(0, MAX_DELAY_S + 1)]
best = min(range(len(pure)), key=lambda d: (pure[d], d))
if g_var <= 0 or pure[best] > (1 - M_SHARE) * g_var:
return None
if best <= M_LATE_S:
return float(best)
if pure[best] > M_BETTER * min(pure[:int(M_LATE_S) + 1]):
return None
return float(best)
class Answer:
"""The answer gate. `observe()` once per cycle, before the loop."""
def __init__(self):
self.ask = None # the pending command, W (site, + = give)
self.from_w = None # the battery reading when it was asked
self.from_grid = None # the meter when it was asked
self.since = None # when it was asked
self.started = False # a reading has moved towards it
self.late_seen = False # the batteries' readings run late
self.late_at = None # when the late verdict started
self.exempt = False # a battery that cannot follow explains it
self._was = None # (command, reading, meter) at the last call
self.delay = Delay()
self.meter = MeterFit()
self._batt_fit = None # (when, d): the batteries' last fit
self._b_run = 0 # late battery fits in a row
self._start_age = None # (when, how long the last ask took to start)
self._exempt_at = None # when a battery last gave nothing under its ask
self._ask_exempt = False
self._start = None
self.delay_s = None # the last fitted delay (either fit), s
self.shown_s = None # the batteries' last late delay, for the page
self.fits = 0 # fits made (instrumentation)
self._fit_at = self._fit_ok = None
@property
def active(self) -> bool:
return self.ask is not None and self.late_seen
@property
def hold(self) -> int:
"""+1 / -1: the loop may not push further that way. 0: no hold."""
if not self.active or self.exempt:
return 0
return 1 if self.ask > self.from_w else -1
@property
def hold_w(self):
"""How far the loop may go while held: ANSWER_W past the reading."""
if not self.hold:
return None
return self._was[1] + self.hold * ANSWER_W
def peak_cap(self, standing_w):
"""The most the peak guard may ask, or None."""
return max(float(standing_w), 0.0) if self.active else None
def late_s(self, now):
"""Seconds the late verdict has stood, or None."""
return None if self.late_at is None else max(0.0, now - self.late_at)
def watch(self, now, units, standing_w=None, grid_w=None):
"""Each read (1 s): `units` = [(name, request on its wire, its battery
reading)], the site request and the meter. Every FIT_EVERY_S, fit both
delays. Late on ON_FITS battery fits from ON_S, or on a meter fit
over M_LATE_S when an ask was slow to start (SLOW_START_S) and no
battery fit within FIT_S said on time: a house load that repeats can
fool the meter fit, not these. On time again on a battery fit under
OFF_S, or a meter fit within M_LATE_S unless a battery fit said late.
"""
if self._start is None:
self._start = now
for name, cmd, reading in units:
self.delay.add(now, name, cmd, reading)
self.meter.add(now, standing_w, grid_w)
if self._fit_at is not None and now - self._fit_at < FIT_EVERY_S:
return
self._fit_at = now
b = self.delay.estimate()
if (b or 0.0) >= ON_S and self._exempt_at is not None \
and now - self._exempt_at <= FIT_S:
b = None # a battery that stopped following: no late fit
m = self.meter.estimate()
if b is not None:
self._batt_fit = (now, b)
self.shown_s = b if b >= ON_S else None
recent = (self._batt_fit is not None
and now - self._batt_fit[0] <= FIT_S)
b_fresh = recent and self._batt_fit[1] < ON_S
b_late = recent and self._batt_fit[1] >= ON_S
self._b_run = self._b_run + 1 if (b or 0.0) >= ON_S else 0
d = b if b is not None else m
if d is None:
if self._fit_ok is None or now - self._fit_ok > FIT_GONE_S:
self.late_seen = False # nothing to tell it by
return
self.fits += 1
self.delay_s, self._fit_ok = d, now
warm = now - self._start >= MAX_DELAY_S + FIT_S
m_late = (m or 0.0) > M_LATE_S
slow = (self._start_age is not None
and now - self._start_age[0] <= M_FIT_S
and self._start_age[1] >= SLOW_START_S)
if self._b_run >= ON_FITS or (
m_late and warm and slow and not b_fresh):
self.late_seen = True
elif (b is not None and b < OFF_S) or (
m is not None and m <= M_LATE_S and not b_late):
self.late_seen = False
def observe(self, now, standing_w, batt_w, *, grid_w=None, exempt=False,
idle_w=0.0):
"""`standing_w` the site command on the wire, `batt_w` the site
battery reading (None: unknown, nothing changes), `grid_w` the meter
(+ = import), `idle_w` what the batteries read while giving nothing.
`exempt`: a battery gives nothing under its ask, or refuses."""
if batt_w is None or standing_w is None:
return
cmd, r = float(standing_w), float(batt_w) - float(idle_w)
g = None if grid_w is None else float(grid_w)
self.exempt = bool(exempt)
if self.exempt:
self._exempt_at = now
was, self._was = self._was, (cmd, r, g)
if self.ask is None:
if was is None or cmd == was[0] or abs(cmd - was[1]) < ANSWER_W:
return
self.ask, self.from_w, self.from_grid = cmd, was[1], was[2]
self.since, self.started = now, False
self._ask_exempt = False
self.ask = cmd
# A battery that gives nothing under its ask explains a slow start.
self._ask_exempt = self._ask_exempt or self.exempt
age = now - self.since
gap = self.ask - self.from_w
way = 1.0 if gap > 0 else -1.0
batt_to = (r - self.from_w) * way
grid_to = (-1e9 if g is None or self.from_grid is None
else (self.from_grid - g) * way)
if not self.started and max(batt_to, grid_to) >= ANSWER_W:
self.started, self.late_at = True, None
self._start_age = (now, 0.0 if self._ask_exempt else age)
elif (not self.started and age >= SLOW_START_S
and not self._ask_exempt):
self._start_age = (now, age)
if (self.ask - r) * way < ANSWER_W or batt_to >= abs(gap) / 2:
self._clear() # answered
elif abs(gap) < ANSWER_W:
self._clear() # withdrawn
elif exempt:
self.late_at = None
elif (self.late_at is None and self.late_seen and not self.started
and age >= LATE_S):
self.late_at = now
def _clear(self):
self.ask = self.from_w = self.from_grid = self.since = None
self.started, self.late_at = False, None
+3 -57
View File
@@ -64,7 +64,6 @@ from .clamp import (
shortfall_note, write_clamp_note,
)
from . import freshness
from . import lateness
from . import dayview, homeview
from .daycurve import SolarToday
from . import messages
@@ -1282,8 +1281,6 @@ class Controller:
self.cycle_at = None
self._arb_action = None
self.inputs_bad_since = None
self.answer = lateness.Answer() # SAFETY-128
self._late_logged = self._late_zero = False
# SAFETY-03. What the loop's OWN bounds did last cycle - a
# (engaged, note) pair so the alarm can say WHICH bound bit without
# re-deriving it from a string. ⚠️ THIS ONE STAYS SITE-LEVEL, and it is
@@ -2378,7 +2375,9 @@ class Controller:
else self.house_profile.estimator(),
priceruns.keep_started(self.mode_runs, runs, now), now, packs,
trust_pct=self.solar_trust_pct, floor_pct=floor_pct,
ceiling_pct=ceiling_pct)
ceiling_pct=ceiling_pct, aim_w=self.peak_aim_w(),
slots=priceruns.firm_slots(plan.read.points, now),
spread=required)
self.day_plan = dp
if dp is None:
self._plan_targets = None
@@ -3119,36 +3118,6 @@ class Controller:
return (decision if self._peak_needed
else dataclasses.replace(decision, within_aim=True))
def _readings_late(self, standing_w):
"""SAFETY-128: update the answer gate; seconds the late verdict has
stood, or None. Readings found late are logged once per episode.
DECISIONS/SAFETY-128-late-readings.md."""
a = self.answer
if self._now_mono is None:
return None
a.observe(self._now_mono, standing_w, self.site_batt_w,
grid_w=self.grid, exempt=bool(self.batt_refused) or any(
i.refused or (
i.batt is not None
and abs(i.standing_w()) >= lateness.ANSWER_W
and abs(i.batt - i.idle_w) < lateness.ANSWER_W)
for i in self.inverters),
idle_w=self._site_idle_w())
if a.late_seen != self._late_logged:
self._late_logged = a.late_seen
if a.late_seen:
self.log_event("readings late - holding",
hint=messages.hint("readings_late"))
_LOG.warning("Home Assistant readings arrive late, so control "
"holds the batteries: READINGS_LATE.")
_LOG.debug("READINGS_LATE: the readings show a power request "
"%.0f s after it is sent.", a.delay_s)
else:
self.log_event("readings on time", hint=messages.hint(
"readings_back"))
_LOG.info("Home Assistant readings are on time again.")
return a.late_s(self._now_mono)
def _peak_reserve(self) -> frozenset:
"""SAFETY-87: the batteries that give nothing for the peak this
cycle: under their reserve while the others carry a peak floor."""
@@ -3760,10 +3729,6 @@ class Controller:
else:
_LOG.debug("Meter readings are back.")
self.answer.watch(now_mono, [(inv.name, inv.standing_w(), inv.batt)
for inv in self.inverters], # SAFETY-128
sum(inv.standing_w() for inv in self.inverters),
self.grid)
if self.inputs_ok:
if self.inputs_bad_since is not None:
self.log_event("inputs recovered",
@@ -4684,8 +4649,6 @@ class Controller:
"away a partly delivering unit's output.")
stale_after = float(self.o.get("stale_input_s", 15))
late_s = self._readings_late(site_standing) # SAFETY-128
self._late_zero = late_s is not None and late_s >= stale_after
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
# does not become a fault; a LATCHED inverter must still reach the claim
# list below, because its `failsafe` SET is what parks it. One tripped
@@ -4711,13 +4674,6 @@ class Controller:
self.why_words = messages.why_waiting(bad_for)
self._why_waiting_since = now - timedelta(seconds=bad_for)
return
if late_s is not None and late_s < stale_after and self.inputs_ok \
and (self.auto or self.shadow) \
and not any(i.sign_wd.tripped for i in self.inverters) \
and not (n_units > 1 and self.site_sign_wd.tripped):
self.reason = f"readings late {late_s:.0f}s" # SAFETY-128: hold
self.why_words = messages.why_late()
return
claims = []
@@ -4896,9 +4852,6 @@ class Controller:
hint=messages.hint("inputs_missing"))
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing",
actuator=self.actuator_of("safety")))
elif late_s is not None:
claims.append(Claim.set("safety", P_SAFETY, 0.0, "readings late",
actuator=self.actuator_of("safety")))
# --- SAFETY-07: the quarter-peak projection guard --------------------
# ONE `lo` limit at P_PEAK, and peakguard.py carries the whole argument.
@@ -5022,8 +4975,6 @@ class Controller:
self.peak_guard = self._peak_on_able(self.peak_guard,
maint_results)
self.peak_guard = self._peak_landing(self.peak_guard)
self.peak_guard = peakguard.cap_floor( # SAFETY-128
self.peak_guard, self.answer.peak_cap(site_standing))
# SITE, and peakguard.claim() says so on the object it builds. The
# quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION,
# which is what the capacity tariff bills; there is one quarter-hour
@@ -5488,7 +5439,6 @@ class Controller:
if len(self.inverters) == 1 else None),
# SAFETY-97: "gives nothing" is centred on the idle reading.
idle_w=self._site_idle_w(),
hold=self.answer.hold, hold_w=self.answer.hold_w, # SAFETY-128
# OPT-11: None unless battery modes are on. SAFETY-85: or while
# maintenance holds a battery at N>=2, so the loop does not
# wind past what the others can give and jump when it ends.
@@ -7385,10 +7335,6 @@ class Controller:
(inv.refusal_note(), False)):
if note:
out.append({"ok": False, "warn": not red, "text": note})
if self.answer.late_seen: # SAFETY-128
out.append({"ok": False, "warn": True, "id": "late",
"text": messages.row_readings_late(
self.answer.shown_s, self._late_zero)})
# SAFETY-03. Red while an over-rating clamp is engaged (amber for the
# integrator bound, SAFETY-40), and the engagement COUNT is shown even
-37
View File
@@ -644,31 +644,6 @@ def row_controller_stalled(age_s, veto_s) -> str:
f"stuurmodule zet de omvormer na {seconden(veto_s)} op 0 W.")
def row_readings_late(delay_s, zero=False) -> str:
"""SAFETY-128: readings show the power request late; `delay_s` the
batteries' fitted delay (their own answer included), or None when only
the meter has shown it; past the hold, 0 W."""
if delay_s is None:
if zero:
return Bi("Home Assistant readings show requests late, so every "
"inverter is at 0 W.",
"Metingen uit Home Assistant tonen vermogensvraag te "
"laat, omvormers op 0 W.")
return Bi("Home Assistant readings show requests late, so control "
"holds the batteries.",
"Metingen uit Home Assistant tonen vermogensvraag te laat, "
"dus de batterijen wachten.")
if zero:
return Bi(f"Home Assistant readings show requests {seconds(delay_s)} "
f"late, so every inverter is at 0 W.",
f"Metingen uit Home Assistant tonen vermogensvraag "
f"{seconden(delay_s)} te laat, omvormers op 0 W.")
return Bi(f"Home Assistant readings show requests about {seconds(delay_s)} "
f"late, so control holds the batteries.",
f"Metingen uit Home Assistant tonen vermogensvraag zo'n "
f"{seconden(delay_s)} te laat, dus de batterijen wachten.")
def row_meter_waiting(limit_s) -> str:
"""UX-10: no reading yet since the start, and still inside the limit."""
return Bi(f"Meter: waiting for the first reading, limit "
@@ -1295,7 +1270,6 @@ WHY_WINNER = {
"circulation": "Safety stop, inverters worked against each other",
"stopped": "Control is stopped",
"inputs missing": "A reading is missing",
"readings late": "Home Assistant readings arrive late",
"no claim was made": "Nothing asked for power",
# UX-10: the claim reasons main.why_key() can return for a safety or
# failsafe winner. test_messages.py part 7 finds every one in the code.
@@ -1346,11 +1320,6 @@ def why_missing(what, limit_s) -> str:
return f"No usable {what} reading for over {seconds(limit_s)}: 0 W"
def why_late() -> str:
"""SAFETY-128: readings arrive late, so control holds the batteries."""
return "Home Assistant readings arrive late, control holds"
def why_waiting(missing_s) -> str:
"""A reading went missing a moment ago: control holds and waits."""
return f"A reading is missing for {seconds(missing_s)}, control waits"
@@ -1368,8 +1337,6 @@ HINTS = {
"roster": "Inverter list read: {n} inverters",
"maint_history": "Maintenance history did not move over cleanly",
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
"readings_late": "Home Assistant readings arrive late, control holds",
"readings_back": "Home Assistant readings are on time again",
"battery_stuck": "{unit}: battery power reading stopped changing",
"battery_back": "{unit}: battery power reading is used again",
"battery_refuses": "The battery refuses to give power, the add-on asks 0 W",
@@ -1521,10 +1488,6 @@ def status_samples() -> list:
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
("row_meter_stale", RED, row_meter_stale(40, 10)),
("row_meter_waiting", AMBER, row_meter_waiting(30)),
("row_readings_late", AMBER, row_readings_late(30)),
("row_readings_late", AMBER, row_readings_late(30, True)),
("row_readings_late", AMBER, row_readings_late(None)),
("row_readings_late", AMBER, row_readings_late(None, True)),
("row_meter", GREEN, row_meter(-350, 2, 3, 340)),
("row_meter_frozen", RED, row_meter_frozen()),
*(("row_freeze_off", AMBER, row_freeze_off(k)) for k in FREEZE_OFF),
-13
View File
@@ -446,19 +446,6 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
return decision is not None and decision.engaged
def cap_floor(decision, cap_w):
"""SAFETY-128: the floor no higher than `cap_w` (the command standing while
the readings have not answered it), or `decision` unchanged when None.
DECISIONS/SAFETY-128-late-readings.md."""
if (cap_w is None or decision is None or decision.floor_w is None
or decision.floor_w <= cap_w):
return decision
return replace(
decision, floor_w=float(cap_w), saturated=False,
reason=decision.reason + f", held at {float(cap_w):+.0f} W until the "
"readings answer the last power request")
def claim(decision: PeakDecision) -> Claim | None:
"""The decision as one arbiter claim (`lo` only, `hi` stays `+inf`), or None."""
if not decision.engaged or decision.floor_w is None:
+2 -2
View File
@@ -357,10 +357,11 @@
"no_prices": "No prices, so it follows the house.",
"no_spread": "Prices differ too little, so it follows the house.",
"sun_fills": "Enough sun is forecast, so it does not charge from the grid.",
"others_cover": "Other batteries cover the expensive hours. It does not charge from the grid.",
"tops_up": "It charges from the grid to {pct}. That covers {kwh} of expensive hours the sun will not.",
"no_forecast": "No solar forecast, so it charges from solar only."
},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from another battery or the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "latched": "No solar forecast now. The charge under way goes on to its planned level.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "latched": "No solar forecast now. The charge under way goes on to its planned level.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"today": {
"not_planned": "Not planned yet.",
"no_runs": "No cheap or expensive hours left today.",
@@ -476,7 +477,6 @@
"stopped": "Control is stopped. Every inverter is at 0 W."
},
"attn": {
"late": "Readings arrive late",
"home_assistant": "Home Assistant is not answering",
"controller": "Control is not running",
"meter": "Meter reading",
+2 -2
View File
@@ -297,10 +297,11 @@
"no_prices": "Geen prijzen, dus ze volgt het huis.",
"no_spread": "De prijzen verschillen te weinig, dus ze volgt het huis.",
"sun_fills": "Er is genoeg zon voorspeld, dus ze laadt niet van het net.",
"others_cover": "Andere batterijen dekken de dure uren. Ze laadt niet van het net.",
"tops_up": "Ze laadt van het net tot {pct}. Dat dekt {kwh} dat de zon in dure uren niet levert.",
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van een andere batterij of het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "latched": "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "latched": "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"today": {
"not_planned": "Nog niet gepland.",
"no_runs": "Vandaag geen goedkope of dure uren meer.",
@@ -397,7 +398,6 @@
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
"attn": {
"late": "Metingen komen te laat",
"home_assistant": "Home Assistant antwoordt niet",
"controller": "Regeling is niet actief",
"meter": "Meting van de meter",
@@ -323,6 +323,9 @@ _cases = [
day="sun_fills"), "sun_fills"),
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="tops_up"),
"tops_up"),
# OPT-36: a other batteries cover the hours, not the sun.
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="others_cover"),
"others_cover"),
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="no_forecast"),
"no_forecast"),
(dict(mode=packmode.LEFTOVER_SOLAR, role="charge", day="tops_up"),
+24 -10
View File
@@ -254,9 +254,16 @@ check("B on Solar first never gets a level (never charges from the grid)",
more = owner_plan(6.0, [pk(A, packmode.LEFTOVER_SOLAR),
pk(B, packmode.CHEAP_HOURS, cap=CAP_B,
rating=4600.0)])
check("B on Cheap hours fills up and is no part of A's share: A's level is "
"the same, B is not in the plan",
more.targets == res[6.0].targets and B not in more.targets
# OPT-36 (on purpose, was: "B on Cheap hours ... A's level is the same"): a
# Cheap hours battery still fills up and is still not in the plan, but what
# its own cheap run will give in the expensive hours is taken off the
# shortfall before Leftover solar shares it. Here B (10 %, 4.6 kW, the run
# 22:00-07:00) reaches the ceiling and covers all of the 6 kWh case.
check("B on Cheap hours fills up and gives first (OPT-36): A buys nothing, "
"B is not in the plan",
more.need_kwh[A] == 0.0 and more.targets[A] == FLOOR
and more.cheap_kwh > more.short_kwh - 1e-6
and more.uncovered_kwh == 0.0 and B not in more.targets
and modetrade.charge_to(pk(B, packmode.CHEAP_HOURS, cap=CAP_B), CEIL,
more.targets) == CEIL)
# The profile is net of the sun the house used, so an hour with sun is read
@@ -341,8 +348,8 @@ print(f" 4 kW house, 2 kWh: shortfall {sh4:.1f} kWh; both hold "
f"levels {p4.targets}")
check("more than all of them hold: both go to the ceiling, never above",
p4.targets[A] == CEIL and p4.targets[B] == CEIL)
check("...and the rest is said, not bought: another battery or the grid "
"carries it (`uncovered_kwh` = shortfall less what they cover)",
check("...and the rest is said, not bought: the grid carries it (OPT-36: "
"the other batteries are counted before) (`uncovered_kwh` = shortfall less what they cover)",
abs(p4.uncovered_kwh - (sh4 - rooms * EFF)) < 0.01
and abs(sum(p4.cover_kwh.values()) - rooms * EFF) < 0.01)
# The pass-through itself: a battery that reaches its ceiling passes the rest.
@@ -439,6 +446,10 @@ print("5. the screens say what control does (EN and NL)")
now = DAY0 + timedelta(minutes=18, seconds=5)
c = S.make("dynamic", now, soc=(20.0, 20.0))
c.solar_fc = S.forecast(now, 3.0, 3.0)
# OPT-36: B (Cheap hours) now gives first, so A buys only what B's cheap run
# cannot reach. A 2.5 kW house leaves B 0.95 kW of peak room in the 3.5 h run:
# B gets part of the way, A buys the rest.
c.house_profile = profile_of({h: 2500.0 for h in range(24)})
c._runs_key = None
c._mode_roles(now, None)
tg = c.plan_targets()
@@ -494,8 +505,9 @@ for lang in ("en", "nl"):
check(f"{lang}: '{key}' is at most 2 sentences of at most 12 words",
len(sentences) <= 2 and all(
len(s.split()) <= 12 for s in sentences))
# More than all hold: the Batteries screen says another battery or the grid
# carries the rest (here B is on Cheap hours: "the grid" alone is not true).
# More than all hold: the Batteries screen says the grid carries the rest.
# OPT-36 (on purpose, was "another battery or the grid"): B on Cheap hours is
# counted before, so what is left comes from the grid.
c2 = S.make("dynamic", now, soc=(20.0, 20.0))
c2.solar_fc = S.forecast(now, 1.0, 1.0)
c2.house_profile = profile_of({h: 3500.0 for h in range(24)})
@@ -508,14 +520,16 @@ print(f" a 3.5 kW house: shortfall {dp2.short_kwh:.1f} kWh, unheld "
check("the plan finds more than the battery holds", dp2.uncovered_kwh > 1.0
and c2.plan_targets()[A] == 95.0)
check("EN: the line under the Batteries cards says Leftover solar cannot "
"hold it all and the rest comes from another battery or the grid",
"hold it all and the rest comes from the grid",
"more than Leftover solar can hold" in sol["line"]
and "another battery or the grid" in sol["line"]
and "comes from the grid." in sol["line"]
and "another battery" not in sol["line"]
and homeview._kwh(dp2.uncovered_kwh) in sol["line"])
with homeview.in_lang("nl"):
sol_nl = homeview.solar_block(c2, now)
check("NL: and in Dutch", "meer dan Overschot zonne-energie kan opslaan"
in sol_nl["line"] and "andere batterij of het net" in sol_nl["line"]
in sol_nl["line"] and "komt van het net." in sol_nl["line"]
and "andere batterij" not in sol_nl["line"]
and "{" not in sol_nl["line"])
check("a covered plan has no such sentence",
"can hold" not in homeview.solar_block(c, now)["line"])
+38 -15
View File
@@ -26,7 +26,9 @@ DECISIONS note and the dev report.
solar + B Cheap hours, both 10 % at the start of the night, the night rate
22:00-07:00, tomorrow 6 kWh, a 2.5 kW peak limit so the charge takes hours),
sensors silent from 17:05, whole nights at 5 s per cycle (run in parallel
processes, about 2.5 minutes each):
processes, about 2.5 minutes each). OPT-36: B is Off in the nights that
test A's level (a Cheap hours B now counts before A buys and would cover
these hours); the `_b` nights keep it on for B's checks:
a. silent from 17:05: both batteries end the night where a fresh figure
ends them (the base left A at 57 % against 87 %), and the screens agree;
b. restarts at 01:00 and 04:00 change nothing;
@@ -267,6 +269,10 @@ PROFILE_LATE = ('{"periods": ['
' "to": "22:00", "eur_per_kwh": 0.34}]}')
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
# OPT-36: a Cheap hours B now gives first, and with these house shapes it would
# cover the dear hours alone, so A would not charge. The nights that test A's
# level have B Off (`b_off`, the default; the house is as written); `_b`
# nights keep B on Cheap hours for B's own checks.
LAST = at(3, 17, 5)
EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
+ [at(3, 21, 55)]
@@ -301,7 +307,10 @@ def run_night(spec):
soc0 = spec.get("soc", {"a": 10.0, "b": 10.0})
house_w = spec.get("house_w", 700.0)
store = rp._Store()
for u, (use, mode, sell) in CHOICE.items():
choice = dict(CHOICE)
if spec.get("b_off", True):
choice["b"] = (False, "cheap_hours", False)
for u, (use, mode, sell) in choice.items():
for k, val in (("pack_use", use), ("pack_mode", mode),
("pack_sell", sell)):
store.data[unit_key(f"inverter-{u}", k)] = val
@@ -419,15 +428,24 @@ SCEN = {
marks=[at(4, 4, 30)]),
"silent": dict(start=NIGHT, hours=9,
marks=[at(4, 1, 0), at(4, 4, 30), at(4, 6, 30)]),
# OPT-36: B on Cheap hours, for B's checks
"fresh_b": dict(start=NIGHT, hours=9, silent=False, b_off=False),
"silent_b": dict(start=NIGHT, hours=9, b_off=False),
# OPT-36: alone, A is full by 02:00 from 22:00; from midnight its charge
# runs through 03:00
"midnight": dict(start=at(4, 0, 0), hours=7),
"restart": dict(start=NIGHT, hours=9,
restarts=[at(4, 1, 0), at(4, 4, 0)],
marks=[at(4, 1, 30), at(4, 4, 30)]),
# c. the charge cannot start before 03:00 (the price dips at 04:00)
"late": dict(start=at(4, 2, 30), hours=4.5, late=True, house_w=500.0,
soc={"a": 40.0, "b": 70.0}, marks=[at(4, 3, 30)]),
# OPT-36: the fresh sensors read the kept figure's 6.0 kWh for the 4th.
# Alone, A reaches its level, so the runs must plan on the same sun (with
# B charging too, A reached neither level by 07:00 and they matched).
"late_fresh": dict(start=at(4, 2, 30), hours=4.5, late=True,
house_w=500.0, soc={"a": 40.0, "b": 70.0},
silent=False),
fresh_from=at(4, 2, 30), fresh=(6.0, 6.0)),
# d. a fresh, darker figure at 02:00 during the charge (a lighter house
# use, so the level has room to rise)
"dark": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
@@ -446,8 +464,10 @@ SCEN = {
house_w=500.0, fresh_from=at(4, 2, 40),
fresh=(30.0, 30.0), marks=[at(4, 2, 44)]),
# d. the last hour, after the kept figure ended at 06:00
# (OPT-36: A at 60 %, not 70 %: alone it charges twice as fast and from
# 70 % is full at 06:38, before the 06:40 mark)
"tail": dict(start=at(4, 5, 40), hours=80 / 60.0,
soc={"a": 70.0, "b": 70.0}, restarts=[at(4, 6, 30)],
soc={"a": 60.0, "b": 70.0}, restarts=[at(4, 6, 30)],
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 40)]),
# a fresh, brighter figure at 03:10 lowers A's level below its SoC (A
# stops, B goes on), then the sensors go quiet: the forecast ends at 06:10
@@ -461,12 +481,12 @@ SCEN = {
latch={"until": at(3, 7, 15).isoformat(),
"born": at(2, 23, 0).isoformat(),
"levels": {A: 80.0, B: 80.0}},
marks=[at(3, 22, 20)]),
marks=[at(3, 22, 20)], b_off=False),
"stale_live": dict(start=at(3, 22, 0), hours=0.5, gone=True,
latch={"until": at(4, 7, 15).isoformat(),
"born": at(3, 22, 0).isoformat(),
"levels": {A: 80.0, B: 80.0}},
marks=[at(3, 22, 20)]),
marks=[at(3, 22, 20)], b_off=False),
# the same, B already full and a 1.5 kW limit: the run is sized at the
# battery's 2.5 kW rating and ends at 06:33, before A is there, so the
# next slice is planned after 06:00 and is only A's
@@ -506,17 +526,20 @@ for k in R:
fa, si, rr = R["fresh"], R["silent"], R["restart"]
# a. silent from 17:05
fb_, sb_ = R["fresh_b"], R["silent_b"]
check("a fresh figure all night: A ends at its planned level (87 %), B "
"full", abs(fa["soc"][0] - 86.9) < 0.5 and fa["soc"][1] > 94.5)
"(Cheap hours, on in the `_b` night) full",
abs(fa["soc"][0] - 86.9) < 0.5 and fb_["soc"][1] > 94.5)
check("sensors silent from 17:05: A ends where the fresh run ends it "
f"({si['soc'][0]:.1f} against {fa['soc'][0]:.1f} %)",
abs(si["soc"][0] - fa["soc"][0]) < 0.3)
check("... and so does B (Cheap hours; the wide no-forecast gap does not "
f"stop its slices): {si['soc'][1]:.1f} against {fa['soc'][1]:.1f} %",
abs(si["soc"][1] - fa["soc"][1]) < 0.3)
f"stop its slices): {sb_['soc'][1]:.1f} against {fb_['soc'][1]:.1f} %",
abs(sb_["soc"][1] - fb_["soc"][1]) < 0.3)
mn = R["midnight"]
check("A charges through 03:00 without a break (no step at 03:00)",
si["soc_at"]["03:30"][0] > si["soc_at"]["03:00"][0] + 4.0
and si["soc_at"]["03:00"][0] > si["soc_at"]["02:55"][0] + 0.3)
mn["soc_at"]["03:30"][0] > mn["soc_at"]["03:00"][0] + 4.0
and mn["soc_at"]["03:00"][0] > mn["soc_at"]["02:55"][0] + 0.3)
m1, m4 = si["marks"]["01:00"], si["marks"]["04:30"]
check("01:00 and 04:30: the kept figure is in use, with a day plan, and the "
"line says so (EN)",
@@ -601,12 +624,12 @@ check("a fresh, brighter figure lowers A's level below its SoC during the "
and lo["soc_at"]["03:30"][0] < lo["soc_at"]["03:20"][0] + 0.5)
check("the sensors go quiet, the forecast ends (06:30): the latch holds the "
"LOWERED level, so A does not charge back up to the old one "
f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %), "
"while B goes on",
f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %)"
" (OPT-36: B is Off here; B's slices with no forecast are the `_b` "
"night's check above)",
l2["fc_none"] and l2["target_a"] is not None
and l2["target_a"] < l2["soc_a"] and l2["p_a"] > -200.0
and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0
and lo["soc"][1] > lo["soc_at"]["03:30"][1] + 15.0)
and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0)
st_, sl_ = R["stale"], R["stale_live"]
check("F1: a latch from the night before (ended 07:15) loaded at a restart "
"inside a cheap run, with no forecast: nothing grid-charges "
+919
View File
@@ -0,0 +1,919 @@
"""Runnable check for OPT-36. `python3 test_opt36.py`
Leftover solar counts what Cheap hours only batteries will give in the
expensive hours before it buys from the grid at night (hotfix 0.20.1).
Live 0.20.0, 2026-11-02 01:01: A (Leftover solar, 60 %) went to 95 %
(5.38 kWh) while B (Cheap hours only, 94 %) already covered the expensive
hours. DECISIONS/OPT-23-28 had left "netting what a Cheap hours battery will
hold" out; this adds it.
1. The live figures through the real Controller: A buys 0, nothing is
"uncovered", and Home says the same in EN and NL.
2. `dayplan.plan()` alone: B low with a short cheap run under the peak limit,
A buys only what B cannot reach; the numbers written out. Round 3: B's
charge counts the cheap slots left before the expensive hours, not only
the run (sized at the ratings), so the level does not fall in the night.
3. A Cheap hours battery that is off, refusing, in maintenance, not live or
without capacity counts 0; Solar first counts after the night's use
(owner, 2026-11-02); N=1 and Leftover solar only are unchanged.
4. The screens: the give line reads the level after the planned charge,
`plan_cover()` is the plan's own figure, the Why line says who covers.
5. The real Controller through a whole night (parallel processes, 5 s per
cycle): A's commands follow the new level, which holds all night while
the figure does; OPT-34 F-1 with B counted (a raised level is reached and
held); the OPT-34 latch still holds a started run (a lowered level stays
lowered). Round 4: a three-price night holds the shortfall at 07:00, and
two real-price replays with a Solar first battery cost no more than
0.20.0.
Mutations (MUTATION.md, purged copies): listed in the pull request.
"""
import asyncio
import json
import logging
import os
import sys
import tempfile
import time
from concurrent.futures import ProcessPoolExecutor
from datetime import datetime, timedelta, timezone
from multiprocessing import get_context
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)
from named_absence import nth, present # noqa: E402
import ux54_scenarios as S # noqa: E402
import opt11p3_replay as rp # noqa: E402
import app.main as m # noqa: E402
from app import dayplan, homeview, modetrade, packmode # noqa: E402
from app import priceruns, solarfc, uitext # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.peakguard import PeakDecision # noqa: E402
from app.static_tariff import static_plan # noqa: E402
from app.store import unit_key # noqa: E402
from app.tariff import BILLING_TZ # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
UTC = timezone.utc
EFF = dayplan.EFF
A, B = "inverter-a", "inverter-b"
FLOOR, CEIL = 20.0, 95.0
CAP_A, CAP_B = 15.36, 20.48
# ============================================================================ #
print("1. the live figures through the real Controller")
# ============================================================================ #
NOW = datetime(2026, 11, 2, 1, 1, 26, tzinfo=BILLING_TZ).astimezone(UTC)
def shape_of(scale=1.0):
"""The brief's owner example (0.6 kW at night, 1.2 kW 06-10, 0.9 kW by
day, 1.8 kW 17-22), scaled."""
return {h: scale * (600.0 if h < 6 or h >= 22 else 1200.0 if h < 10
else 900.0 if h < 17 else 1800.0) for h in range(24)}
def profile_of(shape, now=NOW):
d = now.astimezone(BILLING_TZ).date()
return dayplan.HouseProfile(
{(d - timedelta(days=k)).isoformat():
{str(h): w for h, w in shape.items()} for k in (1, 2, 3)})
def make(shape, soc=(60.0, 94.0), modes=("leftover_solar", "cheap_hours"),
today=10.2, tomorrow=10.0, now=NOW, peak_cap=None):
"""A Controller at `now`: day/night tariff, floor 20 %, ceiling 95 %,
three state-only sensors (10.2 kWh today), its mode roles run once."""
opts = dict(S.BASE)
opts.update(battery_modes_enabled=True,
pv_forecast_entities=[f"{S.FC1}_{i}" for i in range(3)],
pv_forecast_tomorrow_entities=[f"{S.FCT1}_{i}"
for i in range(3)],
static_profile=json.dumps(S.DN), arbitrage_enabled=False,
arbitrage_soc_floor=20, arbitrage_soc_ceiling=95,
cycle_cost_threshold_eur=0.04)
if peak_cap is not None:
opts.update(peak_cap_w=peak_cap)
plan = static_plan(opts, S.day0_of(now), S.day0_of(now) + timedelta(
hours=48), now=now)
c = Controller(opts, S.Hass(), S.Store(), S.Pub(), stop=S.asyncio.Event())
c.auto = True
for i, inv in enumerate(c.inverters):
inv.soc, inv.batt, inv.last_write = soc[i], 0.0, 0.0
inv.dev_min, inv.dev_max = -5000.0, 5000.0
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = modes[i]
c.grid, c.pv_w = 3000.0, 0.0
c.tariff_plan, c.tariff_at = plan, now
c.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=7000.0,
ceiling_from="cap", projected_w=800.0)
c.arb_headroom_w = 3450.0 - 3000.0 + 2000.0
c.solar_fc = S.forecast(now, today, tomorrow, members=3)
c.solar_fc_why = None
c.house_profile = profile_of(shape, now)
c.tariff_at = now
c._mode_roles(now, None)
return c
def screen(c, lang="en", now=NOW):
st = homeview.build(c, level="ok", checks=[], now=now, lang=lang)
rows = {r["name"]: r for r in st["batteries"]}
return st, rows
LIVE = shape_of(0.973) # the scale whose plan matches live (11.3 kWh)
c = make(LIVE)
dp = c.day_plan
tg = c.plan_targets()
print(f" short {dp.short_kwh:.2f} kWh, Cheap hours give {dp.cheap_kwh:.2f}, "
f"A to {tg[A]} %, A buys {dp.need_kwh[A]:.2f} kWh, uncovered "
f"{dp.uncovered_kwh:.2f}")
check("live: A (Leftover solar 60 %) buys nothing while B (Cheap hours 94 %) "
"covers the expensive hours (was: to 95 %, 5.38 kWh)",
dp.need_kwh[A] == 0.0 and tg[A] <= 60.0
and homeview.plan_need(c, A) == 0.0)
check("live: nothing is left uncovered (was 0.4 kWh), and B's give is "
"counted up to the shortfall and no more",
dp.uncovered_kwh == 0.0 and abs(dp.cheap_kwh - dp.short_kwh) < 1e-6)
check("live: control gives A no grid charge at all (the run charges B only)",
[p.name for p in modetrade.chargers(
c._mode_packs(None), CEIL, targets=c.plan_targets())] == [B])
for lang in ("en", "nl"):
st, rows = screen(c, lang)
mine = [r for r in st["plan"]["runs"]
if r["battery"] == 0 and r["kind"] != "house"]
theirs = [r for r in st["plan"]["runs"]
if r["battery"] == 1 and r["kind"] != "house"]
line = st["solar"]["line"]
check(f"{lang}: A has no grid block, only its give block "
f"({[r['kind'] for r in mine]})",
[r["kind"] for r in mine] == ["give"])
check(f"{lang}: A's give block is what A holds (60 % -> 6.1 kWh)",
abs(nth(mine, 0)["kwh"] - (60.0 - FLOOR) / 100.0 * CAP_A) < 0.01)
check(f"{lang}: B's give block reads B after its charge (to 95 %: "
"15.4 kWh, not 15.2 from 94 %)",
abs(nth(theirs, -1)["kwh"] - (CEIL - FLOOR) / 100.0 * CAP_B) < 0.01)
check(f"{lang}: the solar line has no 'the rest' sentence",
"0.4" not in line and "0,4" not in line
and "rest, " not in line and "rest van" not in line
and "rest," not in line)
check(f"{lang}: no line anywhere says Leftover solar charges from the "
"grid to 95",
all("95" not in r["why"] for r in rows.values())
and all("95 %" not in t["what"] for r in rows.values()
for t in r["today"] if "grid" in t["kind"]
and r is rows[A]))
check("live: while B charges, A's Why says another battery charges (it "
"rests)", screen(c, "en")[1][A]["why_key"] == "rests_charge")
# B at 100 % of its run done: the cheap run is over, B only gives. A's Why
# says who covers the hours, not the sun.
c2 = make(LIVE, soc=(60.0, 95.0))
c2._mode_roles(NOW, None)
role_a = c2.mode_tick.roles[A]
key_a = homeview.day_key(c2, A)
print(f" B full: A's role {role_a}, day key {key_a}")
check("B already at the ceiling: A has no charge, and the day key says the "
"Cheap hours battery covers, not the sun",
key_a == "others_cover" and c2.day_plan.need_kwh[A] == 0.0
and c2.day_plan.saved_kwh[A] > 5.0)
for lang in ("en", "nl"):
with homeview.in_lang(lang):
txt = homeview.why_words("others_cover")
sentences = [x for x in txt.split(".") if x.strip()]
check(f"{lang}: the 'others_cover' line is at most 2 sentences of at "
f"most 12 words and has no raw placeholder: {txt!r}",
len(sentences) <= 2 and all(len(x.split()) <= 12
for x in sentences) and "{" not in txt)
check("EN and NL say that other batteries cover, in plain words",
"Other batteries" in uitext.words("en")["home_words"]["why"][
"others_cover"]
and "Andere batterijen" in uitext.words("nl")["home_words"]["why"][
"others_cover"])
# Owner, 2026-11-02: a Solar first battery counts too, at its level when the
# expensive hours start: level now less the house's night use to 07:00
# (01:01 to 07:00: 0.58 kW to 06:00, then 1.17 kW), shared among Solar first
# batteries, x 0.95. A Leftover solar at its floor buys only what is left.
c3 = make(LIVE, soc=(20.0, 94.0), modes=("leftover_solar", "solar_first"))
# Round 4: each hour at no less than the night base (the profile is net of
# the sun the house used).
_hw, base_n = c3.house_profile.estimator()
use_n = sum(max(LIVE[h_], base_n) / 1000.0 * (e - s_).total_seconds() / 3600.0
for s_, e, h_ in dayplan._hour_slices(
NOW, datetime(2026, 11, 2, 7, 0, tzinfo=BILLING_TZ)))
have_b = (94.0 - FLOOR) / 100.0 * CAP_B
give_sf = (have_b - use_n / EFF) * EFF
d3 = c3.day_plan
want_hold = (d3.short_kwh - give_sf) / EFF
print(f" B Solar first 94 %: night use {use_n:.2f}, B gives {give_sf:.2f} "
f"of {d3.short_kwh:.2f}; A (20 %) buys {d3.need_kwh[A]:.3f}")
check("B on Solar first at 94 % counts: its level at 07:00 is 94 % less the "
"night's use, (level - floor) x cap x 0.95",
abs(d3.solar_first_kwh - give_sf) < 0.01 and d3.cheap_kwh == 0.0)
check("...and A (at its floor) buys only the shortfall after that "
"(was 5.38 kWh and to 95 %)",
abs(d3.need_kwh[A] - want_hold) < 0.02 and d3.need_kwh[A] < 1.5
and d3.uncovered_kwh == 0.0)
c3b = make(LIVE, soc=(60.0, 94.0), modes=("leftover_solar", "solar_first"))
check("...and A at 60 % with B Solar first 94 % buys nothing",
c3b.day_plan.need_kwh[A] == 0.0 and c3b.day_plan.uncovered_kwh == 0.0)
# ============================================================================ #
print("2. dayplan.plan() alone: B low, a short cheap run under the peak limit")
# ============================================================================ #
def loc(day, hour, minute=0):
return datetime(2026, 10, day, hour, minute,
tzinfo=BILLING_TZ).astimezone(UTC)
SHAPE = shape_of(1.0)
T = loc(30, 4, 0) # 04:00, three cheap hours left
RUNS = priceruns.RunPlan(
priceruns.Run(loc(29, 22), loc(30, 7), 0.2333),
(priceruns.Run(loc(30, 7), loc(30, 22), 0.2801),
priceruns.Run(loc(31, 7), loc(31, 22), 0.2801)),
None, 0.2801, None, True)
def pk(name, mode, soc, cap, rating=4600.0, **kw):
return modetrade.Pack(name, True, mode, False, rating, soc, cap, **kw)
def fc_of(kwh, now=T):
d0, _d1 = solarfc._bounds(now)
return solarfc.Forecast(solarfc.Day(d0, kwh),
solarfc.Day(d0 + timedelta(days=1), kwh), now)
def mk_plan(packs, *, aim=3450.0, runs=RUNS, now=T, kwh=6.0, shape=SHAPE):
return dayplan.plan(fc_of(kwh, now), profile_of(shape, now).estimator(),
runs, now, packs, trust_pct=70, floor_pct=FLOOR,
ceiling_pct=CEIL, aim_w=aim)
def expect_short(kwh, shape=SHAPE, trust=0.7):
"""The shortfall written out: per hour 07..21 max(0, house - 0.7 x sun),
an hour with sun read at no less than the night base."""
base = sorted(shape[h] for h in (0, 1, 2, 3, 4, 5, 22, 23))
base = (base[3] + base[4]) / 2.0
tot = 0.0
for h in range(7, 22):
pv = kwh * (solarfc._bell(h + 1.0) - solarfc._bell(float(h)))
use = (max(shape[h], base) if pv > 0 else shape[h]) / 1000.0
tot += max(0.0, use - trust * pv)
return tot
def expect_b_level(soc, cap, gain_kwh):
"""`gain_kwh` drawn from the grid; x 0.95 of it is stored (round 4)."""
return max(soc, min(CEIL, soc + gain_kwh * EFF / cap * 100.0))
SH = expect_short(6.0)
# 04:00-07:00 at the night 0.6 kW (04, 05) and 1.2 kW (06): the peak aim 3450
# leaves 2850, 2850 and 2250 W of the battery's 4600 W.
GAIN = 2.85 + 2.85 + 2.25
LB = expect_b_level(30.0, CAP_B, GAIN)
GIVE_B = (LB - FLOOR) / 100.0 * CAP_B * EFF
A_LVL = FLOOR + (SH - GIVE_B) / EFF / CAP_A * 100.0
p_low = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)])
print(f" short {SH:.2f}; B 30 % + {GAIN:.2f} kWh -> {LB:.1f} % gives "
f"{GIVE_B:.2f}; A to {A_LVL:.1f} % (plan: {p_low.targets[A]} %, B "
f"gives {p_low.cheap_kwh})")
check("the shortfall is the written-out sum", abs(p_low.short_kwh - SH) < 0.005)
check("B's give is (its level at the start of the dear hours - floor) x "
"capacity x 0.95, the level being what 3 h under the peak aim reach",
abs(p_low.cheap_kwh - GIVE_B) < 0.005 and LB < CEIL - 20.0)
check("A buys only what B cannot reach: its level is floor + (shortfall - "
"B's give) / 0.95",
abs(p_low.targets[A] - A_LVL) < 0.05
and abs(p_low.need_kwh[A] - (A_LVL - FLOOR) / 100.0 * CAP_A) < 0.01
and p_low.uncovered_kwh == 0.0)
alone = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A)])
check("...which is less than A would buy with no Cheap hours battery",
alone.need_kwh[A] > p_low.need_kwh[A] + 4.0 and alone.cheap_kwh == 0.0)
check("the grid kWh A saves is reported (`saved_kwh`)",
abs(p_low.saved_kwh[A] - (alone.need_kwh[A] - p_low.need_kwh[A])) < 0.01)
# No peak limit given: the batteries' ratings alone (4.6 kW x 3 h reaches 95).
p_free = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)], aim=None)
GIVE_FREE = (expect_b_level(30.0, CAP_B, 4.6 * 3.0) - FLOOR) / 100.0 \
* CAP_B * EFF
check("with no peak aim the run is held by the rating only: 4.6 kW x 3 h "
"x 0.95 stored takes B to 94 %; A buys only what is left",
abs(p_free.cheap_kwh - GIVE_FREE) < 0.005
and abs(p_free.need_kwh[A] - (SH - GIVE_FREE) / EFF) < 0.01
and p_free.uncovered_kwh == 0.0)
# A house at the aim leaves no room: B cannot charge, counts what it holds.
p_full = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)], aim=500.0)
check("a house at the peak aim leaves no room: B counts its level now "
"((30 - 20) % x 20.48 x 0.95)",
abs(p_full.cheap_kwh - 10.0 / 100.0 * CAP_B * EFF) < 0.005)
# The run is not worth charging in: B stays where it is.
NOCH = priceruns.RunPlan(RUNS.cheap, RUNS.give, None, RUNS.level_eur_kwh,
None, False)
p_noch = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)], runs=NOCH,
aim=None)
check("a cheap run not worth charging in: B counts its level now",
abs(p_noch.cheap_kwh - 10.0 / 100.0 * CAP_B * EFF) < 0.005)
# A cheap run that is already over (now past its end): the level now.
p_over = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 80.0, CAP_B)],
now=loc(30, 7, 0) - timedelta(seconds=1), aim=None)
check("the last second of the run: B counts its level (80 %), no more",
abs(p_over.cheap_kwh - (80.0 - FLOOR) / 100.0 * CAP_B * EFF) < 0.01)
# Two Cheap hours batteries share the peak room by rating.
p_two = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B, rating=3000.0),
pk("inverter-c", packmode.CHEAP_HOURS, 30.0, CAP_B,
rating=1500.0)])
g_b = GAIN * 3000.0 / 4500.0
g_c = GAIN * 1500.0 / 4500.0
give_two = sum((expect_b_level(30.0, CAP_B, g) - FLOOR) / 100.0 * CAP_B * EFF
for g in (g_b, g_c))
# 2.85 kW is under the 4.5 kW of the two together: shared 2:1. Hours 04-05
# take 2.85 of 4.5, hour 06 takes 2.25 of 4.5.
check("two Cheap hours batteries share the peak room by rating",
abs(p_two.cheap_kwh - min(give_two, p_two.short_kwh)) < 0.01)
# Round 3: the run is sized at the ratings, not the peak limit, so a run too
# short to fill B is planned again later in the night. B's charge counts the
# firm price slots after the run, up to the expensive hours, that a run would
# still be worth charging in: price + the spread control asks (`spread`, 0.04
# here) at most the give run's dearest slot (0.2801). Without them A's level
# fell through the night after A had bought.
RUN1 = priceruns.RunPlan(
priceruns.Run(loc(30, 4), loc(30, 5), 0.2333), RUNS.give, None,
RUNS.level_eur_kwh, None, True)
def quarters(t0, t1, eur):
out, t = [], t0
while t < t1:
out.append((t, t + timedelta(minutes=15), eur))
t += timedelta(minutes=15)
return out
NIGHT_Q = quarters(loc(30, 4), loc(30, 7), 0.2333)
DAY_Q = quarters(loc(30, 7), loc(30, 22), 0.2801)
LATE_Q = quarters(loc(30, 22), loc(31, 7), 0.2333)
PK2 = [pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)]
def mk_slots(slots, runs=RUN1, spread=0.04):
return dayplan.plan(fc_of(6.0, T), profile_of(SHAPE, T).estimator(),
runs, T, PK2, trust_pct=70, floor_pct=FLOOR,
ceiling_pct=CEIL, aim_w=3450.0, slots=slots,
spread=spread)
p_run1 = mk_slots(None)
p_q = mk_slots(NIGHT_Q + DAY_Q + LATE_Q)
GIVE_1H = (expect_b_level(30.0, CAP_B, 2.85) - FLOOR) / 100.0 * CAP_B * EFF
print(f" a 1 h run (04:00-05:00): B gives {p_run1.cheap_kwh:.3f} without "
f"the slots, {p_q.cheap_kwh:.3f} with the night's slots to 07:00")
check("a 1 h run with no slots given: B counts what 1 h reaches (as before)",
abs(p_run1.cheap_kwh - GIVE_1H) < 0.005)
check("...and with the slots but no spread given, the same",
abs(mk_slots(NIGHT_Q + DAY_Q, spread=None).cheap_kwh - GIVE_1H)
< 0.005)
check("the same run with the night's quarters (0.2333) to 07:00: B "
"counts what 04:00-07:00 reach, as the 3 h run does (and A buys the "
"same); the day and the next night are not counted",
abs(p_q.cheap_kwh - GIVE_B) < 0.005
and abs(p_q.targets[A] - p_low.targets[A]) < 0.01)
p_dear = mk_slots(quarters(loc(30, 5), loc(30, 7), 0.2500) + DAY_Q)
check("slots after the run not worth charging in (0.25 + 0.04 over the "
"dearest 0.2801) are not counted: B counts the 1 h run only",
abs(p_dear.cheap_kwh - GIVE_1H) < 0.005)
p_part = mk_slots(quarters(loc(30, 5), loc(30, 6), 0.2400)
+ quarters(loc(30, 6), loc(30, 7), 0.2500) + DAY_Q)
GIVE_2H = (expect_b_level(30.0, CAP_B, 2.85 + 2.85) - FLOOR) / 100.0 \
* CAP_B * EFF
check("...and only those are: 05:00-06:00 at 0.24 (dearer than the run, "
"still worth it) adds one hour (04:00-06:00 reach)",
abs(p_part.cheap_kwh - GIVE_2H) < 0.005)
# ============================================================================ #
print("3. who counts, and who does not")
# ============================================================================ #
base_pk = [pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A)]
want = mk_plan(base_pk)
for label, b in (
("off", modetrade.Pack(B, False, packmode.CHEAP_HOURS, False, 4600.0,
94.0, CAP_B)),
("refusing to give", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B,
refused=1)),
("refusing to charge", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B,
refused=-1)),
("in maintenance", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B,
idle=False)),
("not live", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B, live=False)),
("without capacity", modetrade.Pack(B, True, packmode.CHEAP_HOURS,
False, 4600.0, 94.0, None)),
("without a level", modetrade.Pack(B, True, packmode.CHEAP_HOURS,
False, 4600.0, None, CAP_B)),
("Solar first refusing to give",
pk(B, packmode.SOLAR_FIRST, 94.0, CAP_B, refused=1)),
("Solar first in maintenance",
pk(B, packmode.SOLAR_FIRST, 94.0, CAP_B, idle=False)),
("Solar first with the night draining it to the floor",
pk(B, packmode.SOLAR_FIRST, 24.0, CAP_B))):
pl = mk_plan(base_pk + [b])
check(f"B {label}: counts 0, A's plan is as with no B",
pl.cheap_kwh == 0.0 and pl.targets == want.targets
and pl.need_kwh == want.need_kwh
and pl.uncovered_kwh == want.uncovered_kwh
and pl.cover_kwh == want.cover_kwh)
on = mk_plan(base_pk + [pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B)])
check("(control: the same B, on and idle, counts)",
on.cheap_kwh > 5.0 and on.need_kwh[A] < want.need_kwh[A] - 1.0)
# Solar first at 94 % at 04:00: three night hours (0.6, 0.6, 1.2 kW) are used
# before 07:00, shared with nobody else; the rest counts.
p_sf = mk_plan(base_pk + [pk(B, packmode.SOLAR_FIRST, 94.0, CAP_B)])
use_t = 0.6 + 0.6 + 1.2
give_t = ((94.0 - FLOOR) / 100.0 * CAP_B - use_t / EFF) * EFF
check("Solar first at 94 % at 04:00: counts its level at 07:00 "
f"({give_t:.2f} kWh), A buys only the rest",
abs(p_sf.solar_first_kwh - give_t) < 0.005 and p_sf.cheap_kwh == 0.0
and p_sf.need_kwh[A] < want.need_kwh[A] - 4.0)
# Two Solar first batteries share the night's use by the energy they hold.
p_sf2 = mk_plan(base_pk + [pk(B, packmode.SOLAR_FIRST, 60.0, CAP_B),
pk("inverter-c", packmode.SOLAR_FIRST, 40.0,
CAP_B)])
tot_h = (60.0 - FLOOR) / 100.0 * CAP_B + (40.0 - FLOOR) / 100.0 * CAP_B
check("two Solar first batteries: the night's use is shared by the energy "
"held; the total is the same sum",
abs(p_sf2.solar_first_kwh - min(
p_sf2.short_kwh, (tot_h - use_t / EFF) * EFF)) < 0.005)
# Both kinds: Cheap hours and Solar first add up (capped at the shortfall).
p_both = mk_plan(base_pk + [pk(B, packmode.SOLAR_FIRST, 60.0, CAP_B),
pk("inverter-c", packmode.CHEAP_HOURS, 30.0,
CAP_B)])
check("Cheap hours and Solar first batteries both count",
p_both.cheap_kwh > 5.0 and p_both.solar_first_kwh > 3.0
and abs(p_both.cheap_kwh + p_both.solar_first_kwh
- min(p_both.short_kwh, p_both.cheap_kwh
+ p_both.solar_first_kwh)) < 1e-6)
# A Cheap hours battery with Leftover solar A: when A is not Leftover solar
# (N=1 on Cheap hours alone) the plan has no Leftover solar to size.
solo = mk_plan([pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B)])
check("Cheap hours only sites: no Leftover solar battery, nothing is sized",
solo.targets == {} and solo.need_kwh == {} and solo.cheap_kwh == 0.0
and solo.uncovered_kwh == 0.0)
# Everything the plan says about a Leftover solar only site is the same as
# before OPT-36: the new fields are at rest.
two_ls = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.LEFTOVER_SOLAR, 94.0, CAP_B)])
check("Leftover solar only (two batteries): cheap_kwh 0, nothing saved",
two_ls.cheap_kwh == 0.0
and all(v == 0.0 for v in two_ls.saved_kwh.values())
and abs(sum(two_ls.cover_kwh.values()) - two_ls.short_kwh) < 0.01)
# More than the Cheap hours battery holds: A buys the rest, "the rest" is said
# only for what nothing holds.
big = shape_of(2.0)
p_big = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B)], shape=big,
aim=None)
want_big = expect_short(6.0, big)
cheap_big = (CEIL - FLOOR) / 100.0 * CAP_B * EFF
check("a shortfall B cannot cover: A holds the rest, to the ceiling; what "
"neither holds is 'uncovered'",
abs(p_big.cheap_kwh - cheap_big) < 0.005
and p_big.targets[A] == CEIL
and abs(p_big.uncovered_kwh - max(
0.0, want_big - cheap_big - (CEIL - FLOOR) / 100.0 * CAP_A * EFF))
< 0.01)
check("the plan is pure: the same call twice gives the same plan",
mk_plan(base_pk + [pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)])
== mk_plan(base_pk + [pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)]))
# ============================================================================ #
print("4. the screens agree with control")
# ============================================================================ #
# A tops up (B low, the house large): the give block reads A after its charge.
cs = make(shape_of(1.5), soc=(20.0, 30.0), now=NOW)
dps = cs.day_plan
tgs = cs.plan_targets()
print(f" A 20 %, B 30 %: B gives {dps.cheap_kwh:.2f}, A to {tgs[A]} %, "
f"buys {dps.need_kwh[A]:.2f}, uncovered {dps.uncovered_kwh:.2f}")
check("(A does buy here: B cannot cover the larger house)",
dps.need_kwh[A] > 1.0 and dps.cheap_kwh > 5.0)
for lang in ("en", "nl"):
st, rows = screen(cs, lang)
runs_a = [r for r in st["plan"]["runs"] if r["battery"] == 0]
grid = next((r for r in runs_a if r["kind"] == "grid"), None)
give = next((r for r in runs_a if r["kind"] == "give"), None)
cov = homeview.plan_cover(cs, A)
with homeview.in_lang(lang):
pct, kwh = homeview._pct(tgs[A]), homeview._kwh(dps.cover_kwh[A])
check(f"{lang}: A tops up: the grid block names the plan's level and "
"the plan's own cover figure",
grid is not None and pct in grid["what"] and kwh in grid["why"]
and cov == (tgs[A], dps.cover_kwh[A]))
check(f"{lang}: the give block is A at the level it charges to "
f"({(tgs[A] - FLOOR) / 100.0 * CAP_A:.2f} kWh), not at 20 % (0)",
give is not None and abs(
give["kwh"] - (tgs[A] - FLOOR) / 100.0 * CAP_A) < 0.02)
# The Batteries line for a battery that holds (`tops_up`) is made from the
# same figures: the plan's level and its own cover figure.
check("(A's day key is tops_up)", homeview.day_key(cs, A) == "tops_up")
for lang in ("en", "nl"):
with homeview.in_lang(lang):
words = homeview.plan_words(cs, A, "tops_up")
txt = homeview.why_words("tops_up", **words)
pct, kwh = homeview._pct(tgs[A]), homeview._kwh(dps.cover_kwh[A])
check(f"{lang}: A's tops_up line names the plan's level ({pct}) and "
f"cover figure ({kwh}): {txt!r}", pct in txt and kwh in txt)
# The controller hands the plan its peak aim (peak_cap_w - peak_margin_w): a
# lower peak limit leaves B less room in its run, so it covers less.
lo_cap = make(shape_of(1.5), soc=(20.0, 30.0), peak_cap=2000)
hi_cap = make(shape_of(1.5), soc=(20.0, 30.0), peak_cap=20000)
print(f" peak limit 2000 W: B gives {lo_cap.day_plan.cheap_kwh:.2f} kWh; "
f"20000 W: {hi_cap.day_plan.cheap_kwh:.2f} kWh")
check("the Controller passes its peak aim: a 2 kW limit leaves B less room "
"than a 20 kW one, and A buys the difference",
lo_cap.day_plan.cheap_kwh < hi_cap.day_plan.cheap_kwh - 2.0
and lo_cap.day_plan.need_kwh[A] > hi_cap.day_plan.need_kwh[A] + 1.0)
# A hysteresis-held level: the cover figure is still the plan's own.
real = cs.plan_targets
cs.plan_targets = lambda: {A: tgs[A] + 0.7}
cv = homeview.plan_cover(cs, A)
check("plan_cover() is the plan's cover figure whatever level control holds "
"(hysteresis), the level being the held one",
cv == (tgs[A] + 0.7, dps.cover_kwh[A]))
cs.plan_targets = real
# A give run before the cheap run (10:00: tomorrow night's charge is after
# today's give): the give block reads the level now.
c10 = make(shape_of(2.0), soc=(40.0, 25.0), modes=("leftover_solar", "solar_first"),
now=datetime(2026, 11, 2, 10, 0, tzinfo=BILLING_TZ).astimezone(UTC))
st10 = homeview.build(c10, level="ok", checks=[],
now=datetime(2026, 11, 2, 10, 0,
tzinfo=BILLING_TZ).astimezone(UTC),
lang="en")
g10 = [r for r in st10["plan"]["runs"]
if r["battery"] == 0 and r["kind"] == "give"]
check("a give run that comes BEFORE the next cheap run is read at the level "
"now (40 % -> 3.1 kWh), not at tonight's charge",
g10 and abs(g10[0]["kwh"] - (40.0 - FLOOR) / 100.0 * CAP_A) < 0.02
and any(r["kind"] == "grid" and r["battery"] == 0
for r in st10["plan"]["runs"]))
# ============================================================================ #
print("5. the real Controller through a whole night")
# ============================================================================ #
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
def at(day, hh, mm=0):
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(UTC)
def st_of(kwh, when, *, unit="kWh"):
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
"last_updated": when.isoformat(),
"last_reported": when.isoformat()}
GONE = {"state": "unavailable", "attributes": {}}
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.26},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.34}]}')
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
+ [at(3, 21, 55)]
def night_opts(cap, profile=PROFILE):
o = dict(rp.options(True, False, 2))
o.update(cycle_cost_threshold_eur=0.07, static_profile=profile,
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT,
peak_cap_w=cap)
return o
def run_night(spec):
"""One whole night in this process -> a plain dict. `spec`: start (UTC),
hours, soc, house_w, cap, fresh (today, tomorrow) before `change`,
change (UTC) and the figures after it, silent_from (UTC): the sensors
show their last reading from then on."""
start = spec["start"]
store = rp._Store()
for u, (use, mode, sell) in CHOICE.items():
for k, val in (("pack_use", use), ("pack_mode", mode),
("pack_sell", sell)):
store.data[unit_key(f"inverter-{u}", k)] = val
clock = [EVENING[0]]
figs = spec.get("fresh", (1.0, 5.0))
change = spec.get("change")
after = spec.get("after", figs)
silent = spec.get("silent_from")
def sens(now, ent):
f = after if change is not None and now >= change else figs
when = now if silent is None else min(now, silent)
return st_of(f[0] if ent == T_ENT else f[1], when)
class H(rp.Hass):
async def state(self, e):
return sens(clock[0], e)
hass = H(("a", "b"), lambda t: 300.0, spec["soc"])
out = dict(marks={}, soc_at={}, p_a=[])
tmp = tempfile.mkdtemp(prefix="o36-")
c = Controller(dict(night_opts(spec.get("cap", 2500),
spec.get("profile", PROFILE))), hass, store,
rp._Pub(), stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
hw = spec.get("house_w", 700.0)
c.house_profile.load({d: {str(hh): hw for hh in range(24)}
for d in ("2026-10-31", "2026-11-01", "2026-11-02")})
async def go():
real_dt, real_hv = m.datetime, homeview.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
try:
for t_pre in EVENING:
clock[0] = t_pre
await c._read_forecast()
marks = sorted(spec.get("marks", ()))
first, t = True, 0.0
hz = spec["hours"] * 3600.0
while t < hz:
now = start + timedelta(seconds=t)
clock[0] = now
if first or int(t) % 600 == 0:
hor = float(m.price_option(c.o, "price_horizon_h"))
c.tariff_plan = await m.resolve_tariff(
c.o, now, now + timedelta(hours=hor), now=now,
history=c.price_history, watch=c.price_watch)
c.tariff_at = now
first = False
hass.t = t
imp, exp = split_signed(hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(5.0, 10.0)
if int(t) % 300 == 0:
tgt = c.plan_targets()
out["soc_at"][now.astimezone(L).strftime("%H:%M")] = (
hass.plants["a"].soc, hass.plants["b"].soc,
hass.plants["a"].p, c.solar_fc is None,
None if tgt is None else tgt.get(A))
if int(t) % 60 == 0:
out["p_a"].append(hass.plants["a"].p)
while marks and now >= marks[0]:
marks.pop(0)
cards = homeview.battery_rows(c, False, True)
blk = homeview.solar_block(c, now)
a_card = next(b for b in cards if b["name"] == A)
dpl = c.day_plan
out["marks"][now.astimezone(L).strftime("%H:%M")] = dict(
line=blk["line"], why_a=a_card["why_key"],
fc_none=c.solar_fc is None,
target_a=(c.plan_targets() or {}).get(A),
need_a=None if dpl is None else dpl.need_kwh.get(A),
cheap=None if dpl is None else dpl.cheap_kwh,
short=None if dpl is None else dpl.short_kwh,
soc_a=hass.plants["a"].soc,
p_a=hass.plants["a"].p)
t += 5.0
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
out["latch"] = getattr(c, "_charge_latch", None) is not None
finally:
m.datetime, homeview.datetime = real_dt, real_hv
return out
return asyncio.run(go())
# Round 4 (review F1): three night prices, the cheapest in the middle.
PROFILE3 = ('{"periods": ['
'{"name": "eve", "days": "all", "from": "22:00", "to": "01:00",'
' "eur_per_kwh": 0.15},'
'{"name": "low", "days": "all", "from": "01:00", "to": "03:00",'
' "eur_per_kwh": 0.10},'
'{"name": "dawn", "days": "all", "from": "03:00", "to": "07:00",'
' "eur_per_kwh": 0.15},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.34}]}')
def run_replay(variant, fc):
"""One 3-day opt11p4 replay (real 2026 prices, 2026-09-02) -> net EUR."""
import contextlib
import io
import opt11p4_replay as p4
rp.VARIANTS["own_sf"] = (True, False, {
"a": (True, "leftover_solar", False),
"b": (True, "solar_first", False)})
argv = sys.argv
sys.argv = ["p4", "--variant", variant, "--fc", fc, "--prices",
"real2026", "--shape", "periods", "--first", "2", "--days",
"3"]
buf = io.StringIO()
try:
with contextlib.redirect_stdout(buf):
p4.main()
finally:
sys.argv = argv
return json.loads(buf.getvalue().strip().splitlines()[-1])
NIGHT = at(3, 22, 0)
HOURLY = [NIGHT + timedelta(minutes=30 * i) for i in range(18)]
SCEN = {
# The live night: A 60 % Leftover solar, B 94 % Cheap hours, a fresh
# figure all night. A must not charge from the grid.
"live": dict(start=NIGHT, hours=9, soc={"a": 60.0, "b": 94.0},
house_w=700.0, marks=[at(4, 1, 0), at(4, 5, 0)]),
# A mid case: both low (25 %), the house 0.8 kW, a 2.5 kW peak limit
# leaves B 1.65 kW in a 3 h run (the run is sized to the batteries'
# ratings, not the limit), so B counts for part and A buys the rest.
"mid": dict(start=NIGHT, hours=9, soc={"a": 25.0, "b": 25.0},
house_w=800.0, marks=HOURLY),
# Round 3: the same with one figure for the 4th all night (today 5.0
# after midnight = tomorrow 5.0 before): A's level must not move at all.
# On round 2's code it went 55 -> 69 -> 41 -> 37 % and A bought to 56 %.
"mid_flat": dict(start=NIGHT, hours=9, soc={"a": 25.0, "b": 25.0},
house_w=800.0, fresh=(5.0, 5.0), marks=HOURLY),
# Round 3, OPT-34 F-1 with B on Cheap hours and counted: a darker fresh
# figure at 02:00, then silent (no forecast from 05:00). The level rises,
# A charges to it, and the latch holds it after 05:00.
# Round 4 (review F1): the three-price night. B's charge after the run is
# planned in the next night's 0.10 hours, not in tonight's 0.15 ones, so
# those may not count. On 1ac7fe9 the plan counted B to 95 %, B ended at
# 67.5 % and the expensive hours were 2.61 kWh short; 0.20.0: none.
"three": dict(start=NIGHT, hours=9, soc={"a": 30.0, "b": 20.0},
house_w=700.0, cap=10000, fresh=(1.0, 1.0),
profile=PROFILE3, marks=HOURLY),
"f1_dark_b": dict(start=at(4, 0, 30), hours=6.5, cap=3500,
soc={"a": 50.0, "b": 50.0}, house_w=900.0,
fresh=(5.0, 5.0), change=at(4, 2, 0),
after=(1.0, 1.0), silent_from=at(4, 2, 0),
marks=[at(4, 1, 55)] + [at(4, 2, 15) + timedelta(
minutes=15 * i) for i in range(19)]),
# OPT-34 with the new level: a fresh, brighter figure at 03:10 lowers A's
# level below its SoC (A stops), then the sensors stay silent: the
# forecast ends and the latch holds the LOWERED level to 07:00.
"lowered": dict(start=at(4, 2, 0), hours=5.0, soc={"a": 25.0, "b": 25.0},
house_w=1200.0, fresh=(1.0, 5.0), change=at(4, 3, 10),
after=(30.0, 30.0), silent_from=at(4, 3, 10),
marks=[at(4, 3, 5), at(4, 3, 20), at(4, 6, 30)]),
}
t0 = time.time()
with ProcessPoolExecutor(max_workers=min(4, os.cpu_count() or 2),
mp_context=get_context("fork")) as pool:
futs = {k: pool.submit(run_night, v) for k, v in SCEN.items()}
# Round 4 (review F2): the real-price replays with a Solar first battery.
reps = {k: pool.submit(run_replay, *v) for k, v in (
("ownsf_hourly_real", ("own_sf", "perfect")),
("p4_ls_dark_real", ("p4_ls", "zero")))}
R = {k: f.result() for k, f in futs.items()}
REP = {k: f.result() for k, f in reps.items()}
print(f" {len(R)} nights in {time.time() - t0:.0f} s")
lv = R["live"]
print(f" live: A {lv['soc'][0]:.2f} % B {lv['soc'][1]:.2f} % "
f"marks {lv['marks']}")
check("live night: A takes no grid charge: it never charges (its power "
"stays above -200 W) and ends where it started",
min(lv["p_a"]) > -200.0 and abs(lv["soc"][0] - 60.0) < 1.5)
check("live night: B fills to the ceiling as before",
lv["soc"][1] > 94.0)
check("live night: the plan gives A a level at or under its SoC and the "
"screen says nothing about 'the rest'",
all(mk["need_a"] == 0.0 and mk["cheap"] > 0.0
and "rest" not in mk["line"] for mk in lv["marks"].values()))
md = R["mid"]
m1, m2 = md["marks"]["22:30"], md["marks"]["05:00"]
print(f" mid: A level {m1['target_a']} % at 22:30, {m2['target_a']} % at "
f"05:00; A ends {md['soc'][0]:.2f} %, B {md['soc'][1]:.2f} %; B gives "
f"{m1['cheap']}")
check("mid night: the plan counts B's give and A's level is a partial one "
"(not the ceiling, as 0.20.0 gave: 95 %)",
m1["target_a"] is not None and 25.0 < m1["target_a"] < CEIL - 15.0
and m1["cheap"] > 3.0)
check("mid night: B's counted give is the same at every mark (B reaches the "
"ceiling in the night left), and A's level moves only at midnight, "
"when the 4th's figure changes from tomorrow's 5.0 to today's 1.0",
len({mk["cheap"] for mk in md["marks"].values()}) == 1
and len({mk["target_a"] for h, mk in md["marks"].items()
if h >= "12"}) == 1
and len({mk["target_a"] for h, mk in md["marks"].items()
if h < "12"}) == 1)
check("mid night: A's commands follow the levels: it charges to its level "
"and stops there, well under the ceiling (0.20.0: 95 %)",
md["soc"][0] < 85.0 and abs(md["soc"][0] - m2["target_a"]) < 0.5)
check("mid night: B fills as before (to the ceiling)", md["soc"][1] > 94.0)
mf = R["mid_flat"]
lv_f = [mk["target_a"] for mk in mf["marks"].values()]
lv0 = nth(lv_f, 0)
print(f" mid_flat: A level(s) {sorted(set(map(str, lv_f)))}; A ends "
f"{mf['soc'][0]:.2f} %")
check("one figure all night: A's level never moves (round 2: 55 -> 69 -> "
"41 -> 37 %), A charges to it and stops (round 2: 56 %)",
present(lv0) and lv0 is not None and len(set(lv_f)) == 1
and abs(mf["soc"][0] - lv0) < 0.5)
fd = R["f1_dark_b"]
dk = fd["marks"]
held = [dk[h]["target_a"] for h in dk if "05:15" <= h < "07:00"]
print(f" f1_dark_b: A level 01:55 {dk['01:55']['target_a']} -> 02:15 "
f"{dk['02:15']['target_a']}, held {sorted(set(held))}; A "
f"{fd['soc_at']['06:55'][0]:.1f} % at 06:55, B {fd['soc'][1]:.1f} %")
check("F-1 with B counted: a darker fresh figure at 02:00 raises A's level, "
"the level does not move after that (B's give is the same), it is held "
"with no forecast from 05:15, and A charges to it",
dk["01:55"]["target_a"] is not None
and dk["02:15"]["target_a"] > dk["01:55"]["target_a"] + 3.0
and dk["01:55"]["cheap"] > 3.0
and all(dk[h]["target_a"] == dk["02:15"]["target_a"] for h in dk
if h >= "02:15")
and all(dk[h]["fc_none"] for h in dk if "05:15" <= h < "07:00")
and abs(fd["soc_at"]["06:55"][0] - dk["02:15"]["target_a"]) < 1.5
and fd["soc"][1] > 94.0)
th = R["three"]
sh3 = [mk["short"] for mk in th["marks"].values() if mk["short"] is not None]
held3 = sum(max(0.0, x - FLOOR) for x in th["soc"]) / 100.0 \
* rp.CAP_KWH * EFF
sh3_0 = nth(sh3, 0)
print(f" three prices: A {th['soc'][0]:.2f} %, B {th['soc'][1]:.2f} %; "
f"held at 07:00 {held3:.2f} kWh for a shortfall of {sh3_0} kWh "
"(1ac7fe9: 7.20 for 9.81, 0.20.0: 13.89)")
check("three night prices: B's charge is not counted in the 0.15 hours "
"after its run (the next night's 0.10 hours are cheaper), so A and B "
"together hold the expensive hours' shortfall at 07:00 (1ac7fe9: "
"2.61 kWh short, bought at 0.34)",
present(sh3_0) and held3 >= sh3_0 - 0.05)
for k, base_eur in (("ownsf_hourly_real", 4.00), ("p4_ls_dark_real", 3.84)):
r_ = REP[k]
print(f" replay {k}: {r_['net_eur']} EUR (0.20.0: {base_eur}), end "
f"{r_['end_soc']}")
check(f"real 2026 prices, 3 days, a Solar first battery counted ({k}): "
f"it costs no more than 0.20.0 ({base_eur} EUR; 1ac7fe9: "
f"{base_eur + 0.40:.2f})", r_["net_eur"] <= base_eur + 0.005)
lw = R["lowered"]
l0, l1, l2 = (lw["marks"][k] for k in ("03:05", "03:20", "06:30"))
print(f" lowered: A level {l0['target_a']} -> {l1['target_a']} -> "
f"{l2['target_a']}; SoC {l1['soc_a']:.1f} -> {lw['soc'][0]:.1f} %")
check("a fresh, brighter figure lowers A's level below its SoC during the "
"charge, and A stops",
l0["target_a"] is not None and l1["target_a"] is not None
and l1["target_a"] < l0["target_a"] - 5.0
and lw["soc_at"]["03:30"][0] < lw["soc_at"]["03:20"][0] + 0.5)
check("the sensors go quiet and the forecast ends (06:30): the latch holds "
"the LOWERED level, A does not charge back up to the old one",
l2["fc_none"] and l2["target_a"] is not None
and l2["target_a"] < l0["target_a"] - 5.0 and l2["p_a"] > -200.0
and lw["soc_at"]["06:55"][0] < lw["soc_at"]["03:30"][0] + 1.0)
# ============================================================================ #
print("\n" + ("all checks passed" if not fails
else f"FAILED: {len(fails)}"))
for f in fails:
print(" -", f)
sys.exit(1 if fails else 0)
+2 -2
View File
@@ -3080,13 +3080,13 @@ _unit_sources_unrouted = [
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
print(f" {len(_direct)} direct appends, each read at its own line: "
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
check("all EIGHTEEN of `Controller.cycle()`'s DIRECT appends route through "
check("all SEVENTEEN of `Controller.cycle()`'s DIRECT appends route through "
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
"of EVERY one of them. An append that computed its actuator, or omitted "
"the argument, or hand-wrote a string, fails HERE rather than being "
"silently skipped - which is how a scan like this goes quietly vacuous, "
"and 10f is in this file because that already happened once",
len(_direct) == 18
len(_direct) == 17
and all(a is not None and a.startswith("self.actuator_of('")
for _s, a, _ln in _routing))
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
-493
View File
@@ -1,493 +0,0 @@
"""Runnable check for SAFETY-128: control does not act on late readings.
`python3 test_safety128.py`
When Home Assistant runs behind, its readings arrive late but still change, so
every staleness gate passes them. The loop corrected again, each cycle, an
error its last command had already fixed, and the evening give run swung with
growing export (SAFETY-127 rig: 10 s late, commands -450..+3 790 W, grid to
-1 947 W). The peak guard pushed to the inverter limit on the same late figures
(OPT-34 rig F-2). Now a command the battery reading has not answered holds the
loop and caps the peak guard (app/lateness.py), and readings found late hold,
then go to 0 W. DECISIONS/SAFETY-128-late-readings.md.
The REAL Controller on TEST-23's plant (tools/safety128_rig.py). "Without the
gate" is the same run with `Controller._readings_late` returning None, which
is the tree before this card (the rig runs unchanged on 77fe31b).
1. The give run (two batteries, house 1 800 W), readings 10 s late for 150 s
and 30 s late for 300 s: without the gate the swing grows to export below
-1 kW; with it the command stays near the last good one, no export below
-200 W, and it settles once the lag stops. N=1 too.
2. The peak guard in a quarter's last 30 s (a charge at the peak aim, readings
30 s late, the house steps): it does not go to the inverter limit.
3. Seen: the late hold says so on the page (EN and NL) and logs once per
episode.
4. Nothing else moves: no-lag runs at 1, 2 and 5 s meters, a load step on a
battery with a 6 s dead time (SAFETY-75), start-up, a battery at its
cut-out level: within the stated tolerance of the run without the gate.
5. `Answer` and `compute(hold=)` at their edges.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import sys
from datetime import timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
import safety128_rig as R # noqa: E402
import app.main as app_main # noqa: E402
from app import control, lateness, messages # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def no_gate(fn):
"""`fn()` with the answer gate off: the tree before SAFETY-128."""
real = app_main.Controller._readings_late
app_main.Controller._readings_late = lambda self, standing_w: None
try:
return fn()
finally:
app_main.Controller._readings_late = real
def window(rows, t0, t1):
return [r for r in rows if t0 <= r[0] < t1]
def last_good(rows, t):
"""The site command standing just before `t`."""
return [r for r in rows if r[0] < t][-1][2]
def settled_at(rows, t0, band=150.0):
"""First second from `t0` after which the grid stays within `band` W."""
out = None
for r in rows:
if r[0] < t0:
continue
if abs(r[1]) > band:
out = None
elif out is None:
out = r[0]
return out
def swing(rows, t0, t1):
w = window(rows, t0, t1)
return max(r[1] for r in w) - min(r[1] for r in w)
# --------------------------------------------------------------------------- #
print("1. the give run with readings late")
ON = 300.0
CASES = ((10.0, 150.0), (30.0, 300.0))
for p1, units in ((2.0, ("a", "b")), (2.0, ("a",)), (1.0, ("a", "b")),
(5.0, ("a", "b"))):
house = 1800.0 if len(units) == 2 else 1500.0
for lag, span in CASES:
off = ON + span
seen = []
new = R.Rig(house=house, lag_s=lag, lag_on=ON, lag_off=off,
units=units, p1_s=p1)
new.run(off + 120.0, lambda rig, c, t: seen.append(
(t, c.answer.late_seen)) if c.answer.late_seen else None)
old = R.Rig(house=house, lag_s=lag, lag_on=ON, lag_off=off,
units=units, p1_s=p1)
no_gate(lambda: old.run(off + 120.0))
n = f"N={len(units)}, {p1:.0f} s meter, {lag:.0f} s late for {span:.0f} s"
wo, wn = window(old.rows, ON, off), window(new.rows, ON, off)
good = last_good(new.rows, ON)
half = ON + span / 2
red = min(r[1] for r in wo) < -1000.0
found = min([t for t, _l in seen] or [None])
print(f" {n}: without the gate cmd {min(r[2] for r in wo):.0f}.."
f"{max(r[2] for r in wo):.0f} W, grid {min(r[1] for r in wo):.0f}"
f"..{max(r[1] for r in wo):.0f} W (swing {swing(old.rows, ON, half):.0f}"
f" then {swing(old.rows, half, off):.0f}); with it cmd "
f"{min(r[2] for r in wn):.0f}..{max(r[2] for r in wn):.0f} W "
f"(last good {good:.0f}), grid {min(r[1] for r in wn):.0f}.."
f"{max(r[1] for r in wn):.0f} W, found late "
f"{None if found is None else found - ON} s after the start, "
f"settled {settled_at(new.rows, off) - off:.0f} s after")
if len(units) == 2 and p1 == 2.0:
check(f"{n}: without the gate the export goes below -1 kW and the "
f"swing grows (red on 77fe31b)",
red and swing(old.rows, half, off) > swing(old.rows, ON, half))
if red:
check(f"{n}: found late while the lag lasts", found is not None
and found < off)
if p1 < 5.0:
# Found within about a minute: at 2 s the brief's bounds; at 1 s
# the swing grows faster before it is found, so twice them.
lim = 250.0 if p1 == 2.0 else 500.0
check(f"{n}: with it the command stays within {lim:.0f} W of the "
f"last good one and no export below -{lim - 50:.0f} W",
all(abs(r[2] - good) <= lim for r in wn)
and min(r[1] for r in wn) >= -(lim - 50.0))
elif red:
# 5 s meter: only the batteries' fit runs (too few meter values),
# so it is found later; the export is cut to under a third.
check(f"{n}: with it the export is under a third of the run "
f"without the gate",
min(r[1] for r in wn) >= min(r[1] for r in wo) / 3.0)
check(f"{n}: settled within 30 s after the lag stops",
settled_at(new.rows, off) is not None
and settled_at(new.rows, off) - off <= 30.0)
# --------------------------------------------------------------------------- #
print("\n2. the peak guard in a quarter's last 30 s")
# A charge sized to the peak aim (arbitrage on a cheap hour, the shape of the
# owner's night charge), readings 30 s late from 300 s; the house steps
# +2 000 W 100 s before the quarter ends (900 s).
PTS = [(R.BASE + timedelta(hours=h), 3600, 0.02 if h < 2 else 0.40)
for h in range(-3, 30)]
def peak_run(lag, gate=True, step_at=800.0):
seen = []
def sample(rig, c, t):
now = rig.start + timedelta(seconds=t)
if int(t) % 300 == 0:
c.tariff_plan = rp.plan_at(now, PTS)
c.tariff_at = now
g = c.peak_guard
if g is not None and 870.0 <= t < 900.0 and g.engaged:
seen.append((t, g.saturated, float(g.floor_w or 0.0),
float(c.target)))
r = R.Rig(house=lambda t: 1000.0 + (2000.0 if t >= step_at else 0.0),
lag_s=lag, lag_on=ON, lag_off=2000.0, soc=30.0,
extra={"arbitrage_enabled": True,
"cycle_cost_threshold_eur": 0.03})
(lambda f: f() if gate else no_gate(f))(lambda: r.run(900.0, sample))
return seen, r
RATING = sum(R.MAXW.values())
for lag in (10.0, 30.0):
pg_new, _r = peak_run(lag)
pg_old, _r = peak_run(lag, gate=False)
top_new = max([s[2] for s in pg_new] or [0.0])
top_old = max([s[2] for s in pg_old] or [0.0])
print(f" {lag:.0f} s late: the guard's largest ask in the last 30 s "
f"without the gate {top_old:.0f} W ({sum(s[1] for s in pg_old)} "
f"cycles at the limit), with it {top_new:.0f} W "
f"({sum(s[1] for s in pg_new)})")
if lag == 30.0:
check("without the gate the guard goes to the inverter limit on the "
"late projection (red on 77fe31b)",
top_old >= RATING and any(s[1] for s in pg_old))
check(f"{lag:.0f} s late: with it the guard never goes to the limit and "
f"never says it is saturated",
top_new < RATING and not any(s[1] for s in pg_new))
# --------------------------------------------------------------------------- #
print("\n3. the page and the log say so")
rows_seen = []
def watch(rig, c, t):
rows_seen.extend((t, r) for r in c.checks() if r.get("id") == "late")
late = R.Rig(lag_s=30.0, lag_on=ON, lag_off=ON + 300.0)
c = late.run(ON + 300.0 + lateness.FIT_GONE_S + 60.0, watch)
log = [e for e in c.events if "readings late" in e or "readings on time" in e]
n_late = sum(1 for e in log if "readings late" in e)
n_back = sum(1 for e in log if "readings on time" in e)
first = min([t for t, _r in rows_seen] or [None])
lastr = max([t for t, _r in rows_seen] or [None])
print(f" 30 s late for 300 s: {n_late} late episode(s), {n_back} end(s), "
f"the row from {first} to {lastr} s ({len(rows_seen)} s); "
f"{rows_seen[0][1]['text'] if rows_seen else ''}")
_ok_texts = {str(messages.row_readings_late(None))} | {
str(messages.row_readings_late(d)) for d in range(20, 33)}
check("found late within 70 s of the lag starting, and an amber row says "
"so in plain words, EN and NL, with the batteries' delay once they have "
"shown it (20-32 s for 30 s late; never a figure from the meter alone)",
first is not None and first - ON <= 70.0
and all(r["warn"] and not r["ok"] for _t, r in rows_seen)
and {str(r["text"]) for _t, r in rows_seen} <= _ok_texts
and rows_seen[-1][1]["text"].nl.startswith(
"Metingen uit Home Assistant tonen vermogensvraag"))
rows5 = []
late5 = R.Rig(lag_s=30.0, lag_on=ON, lag_off=ON + 300.0, p1_s=5.0)
late5.run(ON + 300.0, lambda rig, c, t: rows5.extend(
str(r["text"]) for r in c.checks() if r.get("id") == "late"))
print(f" 5 s meter, 30 s late: {sorted(set(rows5))}")
check("at a 5 s meter (the batteries' fit) the row names the delay: about "
"20-32 s for 30 s late",
rows5 and set(rows5) <= _ok_texts and any("about" in x for x in rows5))
check("logged once for the episode, and once when it ends, not per cycle",
n_late == 1 and n_back == 1)
check("the row goes once a fit shows the readings on time, or 2 minutes "
"after the last fit, at most 160 s after the lag (a steady site shows "
"nothing to fit)",
lastr is not None
and lastr - (ON + 300.0) <= lateness.FIT_S + lateness.FIT_GONE_S
and not [r for r in c.checks() if r.get("id") == "late"])
# The late verdict through the real cycle(): readings found late (30 s), then
# the gate is frozen with its verdict 5 s old (held) or 20 s old (past
# stale_input_s, 15 s).
seen_zero = {}
def verdict(after_s):
def at(rig, c, t):
if t == 500.0:
c.answer.observe = lambda *a, **k: None
c.answer.late_seen, c.answer.late_at = True, c._now_mono - after_s
seen_zero[after_s] = [c.target]
if t == 506.0:
seen_zero[after_s] += [
c.target, c.reason, [r["text"] for r in c.checks()
if r.get("id") == "late"], c.why_words]
v = R.Rig(lag_s=30.0, lag_on=ON, lag_off=2000.0)
v.run(507.0, at)
verdict(5.0)
verdict(20.0)
held, zero = seen_zero[5.0], seen_zero[20.0]
print(f" late verdict 5 s old: {held[:3]}; 20 s old: {zero[:3]}")
check("the late verdict holds the request until stale_input_s, then every "
"inverter goes to 0 W, and the row says so (EN and NL)",
held[1] == held[0] and held[2].startswith("readings late")
and zero[1] == 0.0 and zero[4].startswith(
"Home Assistant readings arrive late: 0 W")
and len(zero[3]) == 1 and "every inverter is at 0 W" in zero[3][0]
and "omvormers op 0 W" in zero[3][0].nl)
on_time = R.Rig(house=lambda t: 600.0 + (2500.0 if t >= 200.0 else 0.0))
c = on_time.run(600.0)
check("readings on time, a load step: no late episode, no row",
not [e for e in c.events if "readings late" in e]
and not c.answer.late_seen)
# --------------------------------------------------------------------------- #
print("\n4. nothing else moves")
STEP = dict(house=lambda t: 600.0 + (2500.0 if t >= 200.0 else 0.0))
def compare(name, tol_w, **kw):
a = R.Rig(**kw)
a.run(500.0)
b = R.Rig(**kw)
no_gate(lambda: b.run(500.0))
diff = max(abs(x[2] - y[2]) for x, y in zip(a.rows, b.rows))
sa, sb = settled_at(a.rows, 200.0), settled_at(b.rows, 200.0)
print(f" {name}: largest command difference {diff:.0f} W, settled "
f"{sa - 200.0 if sa else None} s after the step (without the gate "
f"{sb - 200.0 if sb else None} s)")
return diff, sa, sb, a
for p1 in (1.0, 2.0, 5.0):
for units in (("a", "b"), ("a",)):
d, sa, sb, _a = compare(f"no lag, {p1:.0f} s meter, N={len(units)}, "
f"house 600 -> 3 100 W", 0.0, p1_s=p1,
units=units, **STEP)
check(f"{p1:.0f} s meter, N={len(units)}: settles within 10 s of the "
f"run without the gate",
sa is not None and sb is not None and sa - sb <= 10.0)
d, sa, sb, a75 = compare("SAFETY-75 shape, 6 s dead time", 0.0, dead_s=6.0,
**STEP)
check("6 s dead time (SAFETY-75): the same commands as without the gate, and "
"no late episode (the fit says 5-7 s, under 8 s)",
d == 0.0 and not any("readings late" in e for e in a75.c.events))
d, sa, sb, a0 = compare("start-up, the house at 2 000 W from the first second",
0.0, house=2000.0)
check("start-up: the first 120 s reach the house within 30 s, as without the "
"gate, and no late episode",
settled_at(a0.rows, 0.0) is not None and settled_at(a0.rows, 0.0) <= 30.0
and not any("readings late" in e for e in a0.c.events))
for on, off in ((45, 60), (30, 30)):
house = (lambda t, on=on, off=off:
400.0 + (2000.0 if t % (on + off) < on else 0.0))
oven = R.Rig(house=house, noise=80.0)
oven.run(1500.0)
oven0 = R.Rig(house=house, noise=80.0)
no_gate(lambda: oven0.run(1500.0))
check(f"an oven cycling 2 kW {on} s on, {off} s off (2 s meter, readings "
f"on time): never taken for late readings ({oven.c.answer.fits} "
"fits), the same commands as without the gate",
oven.c.answer.fits > 100 and not any(
"readings late" in e for e in oven.c.events)
and [r[2] for r in oven.rows] == [r[2] for r in oven0.rows])
# One battery that empties to its cut-out level under an oven (1 s meter):
# its fit lined the old movement up as 85 s late (round 2, before the fix).
emp = R.Rig(house=lambda t: 400.0 + (2000.0 if t % 105 < 45 else 0.0),
noise=150.0, soc=10.6, units=("a",), p1_s=1.0)
emp.run(1800.0)
check("one battery emptying to its cut-out level under a cycling oven (1 s "
"meter): not taken for late readings",
not any("readings late" in e for e in emp.c.events))
emp2 = R.Rig(house=lambda t: 400.0 + (2000.0 if t % 60 < 30 else 0.0),
noise=150.0, soc=10.2, units=("a",), p1_s=2.0)
emp2.run(1800.0)
check("one empty battery under an oven cycling 30/30 s (2 s meter): its "
"requests do not start, but it gives nothing, so that is no evidence "
"for the meter fit",
not any("readings late" in e for e in emp2.c.events))
# The recorded house (2026-09-02, its first 2 hours, modes off) at a 2 s
# meter: the meter fit alone read it as up to 65 s late here (review, B1).
from datetime import datetime as _dt, timezone as _tz # noqa: E402
_tick, rp.TICK = rp.TICK, 2.0
_late = []
try:
_st, _rc = rp.run("modes_off", rp.load_house(["2026-09-02"]),
rp.epex_points(),
_dt.fromisoformat("2026-09-02T00:00:00+02:00")
.astimezone(_tz.utc), 7200.0,
check=lambda c, h, now, pr: _late.append(
c.answer.late_seen))
finally:
rp.TICK = _tick
check(f"the recorded house, 2 hours at a 2 s meter: {_rc.answer.fits} fits, "
"never taken for late readings",
_rc.answer.fits > 100 and not any(_late))
import safety94_rig as s94 # noqa: E402
slow = [(n, tau, tick) for n in (1, 2) for tau in (10.0, 20.0)
for tick in (1.0, 2.0)
if any("readings late" in e for e in s94.run(
n, s94.reversal(300.0, -2500.0, 800.0), 600.0, tau=tau,
tick=tick)["c"].events)]
check("a slow battery (10 or 20 s lag, SAFETY-85/94's rig) reversing out of a "
"charge, readings on time: never taken for late readings, N=1 and N=2, "
f"1 and 2 s meters (taken: {slow})", not slow)
for name, kw in (("at their cut-out level", dict(soc=10.5)),
("A refusing from 200 s",
dict(cut=lambda u, t: u == "a" and t >= 200.0))):
x = R.Rig(house=1800.0, **kw)
x.run(600.0)
y = R.Rig(house=1800.0, **kw)
no_gate(lambda: y.run(600.0))
check(f"readings on time, batteries {name}: not taken for late "
f"readings, the same commands as without the gate",
not any("readings late" in e for e in x.c.events)
and [r[2] for r in x.rows] == [r[2] for r in y.rows])
# --------------------------------------------------------------------------- #
print("\n5. the rules at their edges")
check("LATE_S is SAFETY-75's slowest healthy answer: 6 + 6 + 10 + 1 = 23 s",
lateness.LATE_S == 23.0)
check("ANSWER_W is a probe's answer (100 W)",
lateness.ANSWER_W == control.PROBE_ANSWER_W == 100.0)
check("late from ON_S 9.5 s, on time again under OFF_S 6.5 s",
(lateness.ON_S, lateness.OFF_S) == (9.5, 6.5))
def fit(delay, *, follows=True, step_every=20, dur=300, tau=0.0, units="ab"):
"""`Delay` on batteries whose readings, published every 10 s at their own
phase, show each request `delay` s late (through a `tau` s lag), or never
(`follows` False)."""
import math
import random
rng, d = random.Random(7), lateness.Delay()
edges = [0]
while edges[-1] < dur:
edges.append(edges[-1] + step_every * rng.uniform(0.5, 1.5))
cmd = lambda t: 1500.0 + (600.0 if sum(e <= t for e in edges) % 2 else 0.0)
act, shown = 1500.0, {}
for t in range(0, dur):
want = cmd(t - delay) if t >= delay else 1500.0
act = want if not tau else act + (want - act) * (1 - math.exp(-1 / tau))
for k, u in enumerate(units):
if (t + 4 * k) % 10 == 0:
shown[u] = round(act + rng.uniform(-3, 3)) if follows else 900
d.add(float(t), u, cmd(t), shown.get(u))
return d.estimate()
got = {k: fit(k) for k in (0, 6, 10, 30, 60)}
print(f" fitted delays: {got}; one battery {fit(10, units='a')}")
check("the fit finds the readings' delay (0, 6, 10, 30, 60 s) to within 2 s "
"(it reads the request over the 2 s before), also with one battery",
all(v is not None and abs(v - k) <= 2.0 for k, v in got.items())
and abs((fit(10, units="a") or 0.0) - 10.0) <= 2.0)
slow = [fit(0, tau=tau, step_every=s) for tau in (3.0, 10.0, 20.0)
for s in (10, 20, 40)]
check("a battery that does not follow gives no fit, nor does a request that "
f"does not move, and a slow battery (3, 10, 20 s lag) is never late "
f"({slow})",
fit(10, follows=False) is None and fit(10, step_every=10_000) is None
and all((d or 0.0) < lateness.ON_S for d in slow))
def gate(steps, exempt=(), late_seen=True):
"""[(t, command, battery reading, meter)] through one Answer, its fit
replaced by `late_seen`; after each: (pending, hold, late s, peak cap)."""
a = lateness.Answer()
a.late_seen = late_seen
def one(t, cmd, batt, grid):
a.observe(float(t), cmd, batt, grid_w=grid, exempt=t in exempt)
return (a.ask is not None, a.hold, a.late_s(float(t)),
a.peak_cap(cmd))
return a, [one(*x) for x in steps]
STEP = [(0, 1000, 1000, 500), (2, 1500, 1000, 500)]
HELD = STEP + [(t, 1500, 1000, 500) for t in range(4, 40, 2)]
a, out = gate(HELD, late_seen=False)
check("readings on time: an unanswered ask holds nothing, caps nothing and "
"is never late (a battery at its limit)",
out[-1][0] and all(o[1] == 0 and o[2] is None and o[3] is None
for o in out))
a, out = gate(STEP + [(4, 1500, 1000, 500), (6, 1500, 1450, 60)])
check("readings late: the ask holds the loop up and caps the peak guard at "
"what stands until the battery reading answers",
out[1][1:] == (1, None, 1500.0) and out[2][1:] == (1, None, 1500.0)
and out[3] == (False, 0, None, None))
a, out = gate(HELD)
check("...and with no move towards it for 23 s it is late (control holds, "
"then 0 W)",
[o[2] for o in out].index(0.0) == len([t for t, *_ in HELD
if t - 2 < 23]))
a, out = gate(HELD + [(40, 1500, 1000, 380)])
check("...which ends when a reading starts towards the ask",
out[-2][2] is not None and out[-1][2] is None and out[-1][0])
a, out = gate(HELD, exempt=set(range(2, 40, 2)))
check("exempt (a battery gives nothing under its ask, or refuses): no hold "
"and no late verdict, the cap stays",
all(o[1] == 0 and o[2] is None for o in out) and out[-1][3] == 1500.0)
a, out = gate([(0, 1000, 1000, 500), (2, 1090, 1000, 500),
(4, 1000, 1000, 500)])
check("a move under 100 W is not an ask", not any(o[0] for o in out))
a, out = gate(STEP + [(4, 1050, 1000, 500)])
check("an ask taken back within 100 W of the reading is withdrawn",
out[2][0] is False)
T = control.Tuning(max_w=5000.0)
base = dict(prev_w=1500.0, grid_w=800.0, actual_w=1000.0, tuning=T,
i_w=1500.0)
check("compute(hold=+1): the loop may not push past the standing command, "
"but may come back",
control.compute(**base).target_w > 1500.0
and control.compute(**base, hold=1).target_w == 1500.0
and control.compute(**base, hold=1).reason == control.WAITING
and control.compute(**dict(base, grid_w=-800.0), hold=1).target_w
< 1500.0)
check("compute(hold=-1) mirrors it, and hold=0 is the law as it was",
control.compute(**dict(base, grid_w=-800.0), hold=-1).target_w == 1500.0
and control.compute(**base, hold=0) == control.compute(**base))
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")
-4
View File
@@ -61,9 +61,6 @@ def without_fix(fn):
"""Run `fn` with the gate's old clock and the old refusal evidence."""
real_f, real_c = app_inverter.Freshness, app_main.compute
real_p = app_main.REFUSED_PROGRESS_W
real_l = app_main.Controller._readings_late
# SAFETY-128: that tree had no late-readings gate either.
app_main.Controller._readings_late = lambda self, standing_w: None
app_inverter.Freshness = Before94
app_main.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None))
app_main.REFUSED_PROGRESS_W = float("inf")
@@ -72,7 +69,6 @@ def without_fix(fn):
finally:
app_inverter.Freshness, app_main.compute = real_f, real_c
app_main.REFUSED_PROGRESS_W = real_p
app_main.Controller._readings_late = real_l
# ============================================================================ #
-4
View File
@@ -68,14 +68,10 @@ def without(fn):
Controller._held_out_w = lambda self, live: 0.0
Controller._reseed_drop = lambda self, live: setattr(
self, "_live_was", frozenset(live))
real_late = Controller._readings_late
# SAFETY-128: that tree had no late-readings gate either.
Controller._readings_late = lambda self, standing_w: None
try:
return fn()
finally:
Controller._held_out_w, Controller._reseed_drop = fixed, real_drop
Controller._readings_late = real_late
def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None,
+37 -13
View File
@@ -222,13 +222,21 @@ check("Leftover solar and Cheap hours: a give block exactly on the give run "
all([(r["kind"], r["from"], r["to"]) for r in b["blocks"]]
== [("give", "17:00", "21:00")] for b in dd)
and rp.give[0].start == at(17, 0, 0) and rp.give[0].end == at(21, 0, 0))
c_n = S.make("dynamic", at(0, 18))
# OPT-36: a larger evening (4.8 kW) so that B (Cheap hours) does not cover the
# dear hours alone and A still buys from the grid; A starts at 30 %.
c_n = S.make("dynamic", at(0, 18), soc=(30.0, 40.0), evening=4800.0)
# The run's end, read from the plan; for this house it is 02:30, as in 0.20.0.
END = c_n.mode_runs.cheap.end
END_H = (END - DAY0).total_seconds() / 3600.0
END_S = S.brussels(END).strftime("%H:%M")
check(f"the cheap run is 01:00 to {END_S} (02:30 for this house)",
c_n.mode_runs.cheap.start == at(1, 0, 0) and END == at(2, 30, 0))
nb = tab(c_n, at(0, 18))["days"][0]["batteries"]
check("in the night before the cheap run both charge from the grid in it "
"(01:00 to 02:30), then give at 17:00",
f"(01:00 to {END_S}), then give at 17:00",
all([r["kind"] for r in b["blocks"]] == ["grid", "give"] for b in nb)
and all(b["blocks"][0]["from"] == "01:00"
and b["blocks"][0]["to"] == "02:30" for b in nb))
and b["blocks"][0]["to"] == END_S for b in nb))
# UX-53: no grid block while the gate refuses.
for why in ("peak", "not_worth"):
c_n.mode_gate = why
@@ -246,12 +254,13 @@ def at_h(b, h):
return dict((round(x, 3), v) for x, v in b["levels"]).get(h, float("nan"))
check("the curve charges in a grid block: A (planned level 64 %) and B "
"(Cheap hours, to the end of its 01:30 run) are well above where they "
LVL_A = c_n.plan_targets()[c_n.inverters[0].name]
check(f"the curve charges in a grid block: A (planned level {LVL_A:.0f} %) "
"and B (Cheap hours, to the end of its run) are well above where they "
"were at 01:00, and stay put after the run",
all(at_h(b, 2.5) - at_h(b, 1.0) > 8.0
and abs(at_h(b, 3.0) - at_h(b, 2.5)) < 1e-6
for b in nb) and nb[0]["levels"][0][1] == 55.0)
all(at_h(b, END_H) - at_h(b, 1.0) > 8.0
and abs(at_h(b, END_H + 0.5) - at_h(b, END_H)) < 1e-6
for b in nb) and nb[0]["levels"][0][1] == 30.0)
# Modes off: the verdict's legs.
c_f = S.make("off", at(10, 7))
fb = tab(c_f, at(10, 7))
@@ -681,7 +690,7 @@ check("Dutch: the notes, the totals and the blocks are in Dutch, with a "
and say("estimate", "nl") in on["days"][0]["notes"]
and on["days"][1]["line"].startswith("Verwachte zon morgen: 16,0 kWh")
and oe["days"][0]["line"].startswith("Solar still to come today: 14.4"))
c = S.make("dynamic", at(0, 18))
c = S.make("dynamic", at(0, 18), soc=(30.0, 40.0), evening=4800.0)
nl_b = tab(c, at(0, 18), "nl")["days"][0]["batteries"][0]["blocks"]
check("...and so are the plan's words on a block",
nl_b[0]["what"].startswith("Laadt maximaal") and " %" in nl_b[0]["what"]
@@ -824,14 +833,17 @@ def may_give(inv):
return packshare.envelope(pack_energy_of(inv)).hi > 0.0
def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None):
def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None,
evening=None, peak_w=None):
"""Walk the real control cycle (`_mode_roles()`) every 15 minutes from
`now0` to the end of tomorrow, each battery at the level the tab's curve
has at that time, and return the tab and the role seen per battery and
quarter. A GIVE role on a pack that control's discharge envelope closes
is seen as "closed": the pack is not asked to give. The tab is built
once, at `now0`."""
c = S.make(name, now0, soc=soc)
once, at `now0`. `peak_w`: the site's peak limit (OPT-36, below)."""
c = S.make(name, now0, soc=soc, evening=evening)
if peak_w is not None:
c.o = dict(c.o, peak_cap_w=peak_w)
cut_out_at_floor(c)
c.tariff_plan = S.quarters(now0, night_evening, days=3)
c.tariff_at = now0
@@ -852,6 +864,13 @@ def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None):
b = day["batteries"][i]
inv.soc = level_at(b, h if h < 24 else h - 24) if b["levels"] \
else inv.soc
# OPT-36: the tab shows a level to 0.1 % (`dayview.LEVEL_DP`); a
# battery whose curve shows its planned level (50.0 for 50.01) is
# at it, or control charges it for the last 0.01 %.
tg = (c.plan_targets() or {}).get(inv.name)
if tg is not None and inv.soc is not None and round(
inv.soc, dayview.LEVEL_DP) == round(tg, dayview.LEVEL_DP):
inv.soc = max(inv.soc, tg)
c.tariff_at = t
tick = c._mode_roles(t, None)
for inv in c.inverters:
@@ -890,7 +909,12 @@ def tomorrow_match(out, seen, d0, role, kind):
t22 = at(22, 0, 5)
c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0))
# OPT-36: the plan counts B's charge under the peak limit, the tab's curve
# charges at the batteries' ratings. Walked along the curve, B runs ahead of
# the plan and A's level falls behind the tab's block. A limit that does not
# bind (15 kW, the option's top) makes the curve the charge control plans.
c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0), evening=4800.0,
peak_w=15000.0)
tm1 = o1["days"][1]["batteries"]
print(" 22:00, both batteries at 20 %: tomorrow", [[(r["kind"], r["from"], r["to"]) for r in b["blocks"]] for b in tm1])
check("F1, modes on: at 22:00 with both batteries at 20 % tomorrow's give "
-284
View File
@@ -1,284 +0,0 @@
#!/usr/bin/env python3
"""SAFETY-128: the REAL Controller with Home Assistant readings arriving late.
python3 tools/safety128_rig.py # prints a few runs' summaries
The plant is TEST-23's (SAFETY-127 rig, t127_plant.py): two batteries
(A 5 000 W, B 4 600 W) that ramp at 5 000 W/s and then follow a 1 s lag; each
battery reading is published every 10 s (A at :x6, B at :x1.6); the meter every
2 s; the house is `house` W plus seeded +-`noise` W, new each second.
`p1_s` sets the meter's period, `dead_s` a command dead time (SAFETY-75's
laggy battery), `units` one battery ("a") or two.
Home Assistant runs `lag_s` seconds behind (a number, or a function of t) between `lag_on` and `lag_off`:
every reading the add-on gets (meter, battery power, battery level) is the
one published `lag_s` seconds earlier. When the lag starts, nothing new comes
for `lag_s` seconds, then the backlog; when it stops, the readings jump to
now. Commands reach the plant at once (t127_shim.py's lag mode).
The add-on reads every 1 s and runs a cycle on a new meter value or after
`cycle_floor_s`, as `run_control()` does. Uses only the Controller API that
release/1.0 77fe31b has, so the same file runs on the tree before SAFETY-128.
"""
import asyncio
import math
import os
import random
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
# 18:00 local (CET) on a weekday: the evening give run.
BASE = datetime(2026, 11, 4, 17, 0, tzinfo=timezone.utc)
UNITS = ("a", "b")
MAXW = {"a": 5000.0, "b": 4600.0}
CAPWH = {"a": 15360.0, "b": 20480.0}
PHASE = {"a": 6.0, "b": 1.6}
BPER = 10.0
P1PER = 2.0
RAMP = 5000.0
LAG = 1.0
def options(units=UNITS):
o = dict(OPTIONS)
o["inverters"] = [
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
"max_w": int(MAXW[u]), "bms_soc_floor": 10,
"capacity_kwh": CAPWH[u] / 1000.0} for u in units]
return o
OPTIONS = {
"meter_entity": "sensor.grid", "meter_source": "ha_signed",
"p1_net_entity": "sensor.grid", "meter_phases": 1, "meter_max_age_s": 30,
"peak_cap_w": 3500, "peak_margin_w": 50, "peak_soc_floor": 20,
"gain": 0.6, "slew_w": 0, "target_grid_w": -10, "deadband_w": 15,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"cycle_floor_s": 15, "saturation_w": 500, "saturation_cycles": 3,
"auto_start": True, "battery_modes_enabled": False,
"arbitrage_enabled": False, "bms_soc_floor": 10,
}
class _Store:
def __init__(self):
from app.store import DEFAULTS
self.data = dict(DEFAULTS)
def set(self, k, v):
self.data[k] = v
def get_time(self, k):
v = self.data.get(k)
return datetime.fromisoformat(v) if v else None
def set_time(self, k, when):
self.data[k] = when.isoformat() if when else None
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class Plant:
"""The batteries, their registers and the meter, in 0.1 s steps."""
def __init__(self, house, noise, seed, soc, cut=None, units=UNITS,
p1_s=P1PER, dead_s=0.0):
self.house_fn, self.noise, self.seed = house, noise, seed
self.units, self.p1_s, self.dead_s = units, p1_s, dead_s
self.cut = cut or (lambda u, t: False)
self.cmd = {u: 0.0 for u in units}
self.queue = [] # (t, unit, command), dead time
self.tgt = {u: 0.0 for u in units}
self.act = {u: 0.0 for u in units}
self.soc = {u: float(soc) for u in units}
self.reg = {u: 0.0 for u in units}
self.slot = {u: None for u in units}
self.p1 = None
self.p1_n = 0
self.t = 0.0
self.house = float(house(0.0))
self.hist = [] # (t, p1 value, p1 index, {reg}, {soc})
def grid(self):
return self.house - sum(self.act.values())
def advance(self, t):
while self.t < t - 1e-9:
self.t = round(self.t + 0.1, 6)
sec = int(math.floor(self.t + 1e-9))
rs = random.Random(self.seed * 1000003 + sec)
self.house = (float(self.house_fn(self.t))
+ rs.uniform(-self.noise, self.noise))
while self.queue and self.queue[0][0] <= self.t + 1e-9:
_t, u, v = self.queue.pop(0)
self.cmd[u] = v
for u in self.units:
want = max(-MAXW[u], min(MAXW[u], self.cmd[u]))
if self.cut(u, self.t):
want = 0.0
if self.soc[u] <= 10.0 and want > 0:
want = 0.0
if self.soc[u] >= 100.0 and want < 0:
want = 0.0
d = want - self.tgt[u]
self.tgt[u] += math.copysign(min(abs(d), RAMP * 0.1), d)
self.act[u] += ((self.tgt[u] - self.act[u])
* (1 - math.exp(-0.1 / LAG)))
self.soc[u] = max(0.0, min(100.0, self.soc[u] - self.act[u]
* 0.1 / 3600.0 / CAPWH[u] * 100.0))
slot = math.floor((self.t - PHASE[u]) / BPER + 1e-9)
if slot != self.slot[u]:
self.slot[u] = slot
self.reg[u] = float(round(self.act[u]))
if abs(self.t / self.p1_s - round(self.t / self.p1_s)) < 1e-6:
self.p1 = round(self.grid(), 1)
self.p1_n += 1
self.hist.append((self.t, self.p1, self.p1_n, dict(self.reg),
{u: round(v, 2) for u, v in self.soc.items()}))
del self.hist[:-3000]
def view(self, t, lag_s, lag_on, lag_off):
"""What Home Assistant shows at `t`: (p1, p1 index, regs, socs)."""
lag_s = lag_s(t) if callable(lag_s) else lag_s
if lag_s and lag_on <= t < lag_off:
want = max(lag_on, t - lag_s)
for row in reversed(self.hist):
if row[0] <= want + 1e-9:
return row[1:]
return self.hist[0][1:]
return (self.p1, self.p1_n, dict(self.reg),
{u: round(v, 2) for u, v in self.soc.items()})
class _Hass:
def __init__(self, rig):
self.rig = rig
async def limits(self, e):
return (-MAXW[e[-1]], MAXW[e[-1]], 1.0)
async def call(self, *a):
return True
async def state(self, e):
return None
async def number(self, e, invert=False):
_p1, _n, regs, socs = self.rig.seen
u = e[-1]
if e.startswith("sensor.soc"):
return socs[u]
if e.startswith("sensor.batt"):
return regs[u]
return None
async def set_number(self, e, v):
p = self.rig.plant
p.queue.append((p.t + p.dead_s, e[-1], float(v)))
self.rig.writes.append((self.rig.plant.t, e[-1], float(v)))
return True
class Rig:
def __init__(self, house=1800.0, noise=50.0, seed=90, soc=60.0,
lag_s=0.0, lag_on=0.0, lag_off=0.0, extra=None, cut=None,
start=BASE, units=UNITS, p1_s=P1PER, dead_s=0.0):
house_fn = house if callable(house) else (lambda t, h=house: h)
self.plant = Plant(house_fn, noise, seed, soc, cut, units, p1_s,
dead_s)
self.lag = (lag_s, lag_on, lag_off)
self.o = dict(options(units), **(extra or {}))
self.start = start
self.writes = []
self.rows = []
self.seen = None
def run(self, dur, sample=None):
"""Run `dur` s. Rows: (t, true grid, site command, site actual,
loop reason, controller reason). `sample(rig, c, t)` per second."""
async def go():
db = os.path.join(tempfile.mkdtemp(prefix="s128-"), "tariff.db")
c = Controller(self.o, _Hass(self), _Store(), _Pub(),
stop=asyncio.Event(), peak_db_path=db)
for i in c.inverters:
i.dev_min, i.dev_max = -MAXW[i.name[-1]], MAXW[i.name[-1]]
c.auto = True
self.c = c
real_dt = m.datetime
clock = [self.start]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = _DT
floor = float(self.o.get("cycle_floor_s", 15))
last_grid, last_cycle, last_n = object(), None, None
try:
t = 0.0
while t < dur:
self.plant.advance(t)
now = self.start + timedelta(seconds=t)
clock[0] = now
self.seen = self.plant.view(t, *self.lag)
p1, n = self.seen[0], self.seen[1]
if p1 is not None and n != last_n:
last_n = n
imp, exp = split_signed(p1)
c.p1.submit(make_sample(
"ha_signed", imp, exp, phases=1, ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
if (c.grid != last_grid or last_cycle is None
or t - last_cycle >= floor):
last_grid, last_cycle = c.grid, t
await c.cycle()
self.rows.append((
t, self.plant.grid(), sum(self.plant.cmd.values()),
sum(self.plant.act.values()), c.loop_reason,
c.reason))
if sample is not None:
sample(self, c, t)
t += 1.0
finally:
m.datetime = real_dt
return c
return asyncio.run(go())
def summary(rows, t0, t1):
"""Over [t0, t1): (min cmd, max cmd, min grid, max grid, s of export
below -200 W, s below -1 000 W)."""
sel = [r for r in rows if t0 <= r[0] < t1]
return (min(r[2] for r in sel), max(r[2] for r in sel),
min(r[1] for r in sel), max(r[1] for r in sel),
sum(1 for r in sel if r[1] < -200), sum(1 for r in sel
if r[1] < -1000))
if __name__ == "__main__":
for lag in (0.0, 10.0, 30.0):
r = Rig(lag_s=lag, lag_on=300.0, lag_off=450.0)
r.run(600.0)
print(f"lag {lag:4.0f} s: before {summary(r.rows, 200, 300)}")
print(f" during {summary(r.rows, 300, 450)}")
print(f" after {summary(r.rows, 480, 600)}")
+6 -2
View File
@@ -206,7 +206,8 @@ def measure(c, now, peak_w):
def make(name, now, *, soc=(55.0, 40.0), modes=("solar_first",
"solar_first")):
"solar_first"),
evening=None):
"""The controller of scenario `name` at `now` (UTC), its cycle run once."""
assert name in SCENARIOS, name
opts = dict(BASE)
@@ -271,7 +272,10 @@ def make(name, now, *, soc=(55.0, 40.0), modes=("solar_first",
members=members, tomorrow=tomorrow)
c.solar_fc_why = None
if name != "learning":
c.house_profile = profile(now)
# OPT-36: `evening` (W) makes the dear hours' use larger than
# what a Cheap hours battery covers, so Leftover solar still buys.
c.house_profile = profile(now) if evening is None \
else profile(now, evening=evening)
if name in ("owner", "dynamic", "sunny", "periods", "learning",
"notomorrow", "cycle"):
measure(c, now, 7000.0 if name != "dynamic" else 2500.0)