Compare commits

...
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 b171ada894 Merge release/1.0 (SAFETY-78, #154) into opt11-p4-solar-forecast
checks / unit (pull_request) Successful in 23m54s
infra-pin / fresh (pull_request) Successful in 1s
Conflicts: modetrade.decide() takes both the day plan (targets) and
SAFETY-78's draining flag; the Why line keeps SAFETY-78's other_cycle
ahead of the plan's word (test added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:37:09 +02:00
glenn schrooyenandClaude Opus 5.5 0b96e3191c OPT-11 P4 review: records and text
checks / unit (pull_request) Failing after 4s
infra-pin / fresh (pull_request) Successful in 1s
- DECISIONS: the Cheap hours change and why; the no-sun replay (the gain is
  Leftover solar buying the evening's shortfall; the forecast added 0.14
  EUR); the reconstruction bias stated plainly.
- DOCS: Cheap hours ignores the forecast; a reading from before midnight
  is stale.
- The forecast line: "Solar still to come today: ..." (it is what is
  left, and it stays within the 12-word limit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:29:00 +02:00
admin 35cdffe766 Merge pull request 'SAFETY-78: maintenance drain at N=2 no longer stalls' (#154) from safety-78-drain-at-two into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-29 22:20:29 +02:00
glenn schrooyenandClaude Opus 5.5 a692560960 Merge release/1.0 (SAFETY-81, #155) into opt11-p4-solar-forecast
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:08:52 +02:00
glenn schrooyenandClaude Opus 5.5 fbd884797d OPT-11 P4 review: Cheap hours ignores the plan; midnight-stale forecast; stricter ids
- Cheap hours only fills to the ceiling with or without a forecast, as the
  design (section 1) says. Skipping the sun's share cost 0.31 EUR on real
  2026 prices (29.72 against P3's 29.41) and saved nothing on the P3 set.
- A forecast reading from before local midnight is stale: until the
  integration refreshes, "left today" is still yesterday's 0 and
  "tomorrow" is really today.
- Forecast entity ids must be sensor.[a-z0-9_]+ (they go into the
  /api/states/<id> path).
- House profile: a stored day that is not a date, or is after today, is
  dropped instead of kept forever.
- Text: the forecast line says "for the rest of today"; the learning line
  no longer promises "tomorrow" (a plan needs one full day).
- Tests for four surviving review mutants (hour sample floor, the dear run
  after the cheap run, share by room, daytime use) and the fixes above.
- Replay tool: `--fc zero` (a forecast of no sun).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:08:47 +02:00
goodwe-review 516bc8b4ec Merge release/1.0 into safety-78-drain-at-two
checks / unit (pull_request) Successful in 18m49s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:53:47 +02:00
admin 4f25b24aa6 Merge pull request 'SAFETY-81: no false Load too high alarms' (#155) from safety-81-load-too-high into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-09-29 21:45:19 +02:00
goodwe-review 044fc0d5bd SAFETY-78 review: card truth while the drain waits, more checks
checks / unit (pull_request) Successful in 16m15s
infra-pin / fresh (pull_request) Successful in 1s
- homeview: no "This one does not charge" while the draining battery waits
  (no floor then, so the other battery may charge from solar).
- test_safety78: one wait line across a probe, a second episode logs again,
  the card while waiting, and a seeded sweep of the floor against every
  other claim (part 6).
- DECISIONS: the wait case, and the measured N=1 peak and N=2 charge
  follow-ups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:13:31 +02:00
Claude aabc2a27fd OPT-11 P4: tests for eight surviving mutants; guarded reads
checks / unit (pull_request) Successful in 23m10s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:22:16 +02:00
Claude af39c10893 SAFETY-78: test_safety78 points at MUTATION.md (test_bytecode)
checks / unit (pull_request) Successful in 15m23s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:14:33 +02:00
Claude 5d27f8496f SAFETY-78: maintenance drain at N=2 no longer stalls
checks / unit (pull_request) Failing after 17m13s
infra-pin / fresh (pull_request) Successful in 2s
At two inverters the drain pins its pack and the site target is split
around the pin. Below the pin the other pack had to charge, the allocator
refused the opposite directions, and the site sat at 0 W with an ARBITER
line every cycle (house below or above the drain rate, or a grid charge).

main.py now files a site limit `drain` (lo = what the pin places, at
P_MAINTENANCE) at N>=2 while a pack drains, so the other pack only adds
power in the same direction. A drain that cannot give power waits with
one log line. With battery modes on, no grid charge while a pack drains,
and the other battery's card says why. N=1 is byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:52:51 +02:00
Claude eb10537aee OPT-11 P4: solar forecast and day plan
A Home Assistant solar forecast (Forecast.Solar, Solcast or Open-Meteo
shapes) and the add-on's own hourly house use decide, before each cheap
run, whether the sun will fill each battery before the expensive hours.
Leftover solar then buys from the grid only the shortfall; Cheap hours only
skips the part the sun covers; Solar first never grid-charges. No, stale or
failed forecast: P3 option A exactly (12 digests pinned from 5a4cdc2).

Replay (16 recorded days, forecast errors from Elia's day-ahead BE solar):
year-earlier prices P4 19.15 vs P2 19.33 / option A 19.44 / old trade 18.14;
real 2026 prices P4 29.19 vs 32.26 / 32.01 / 31.40 EUR net.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:29:32 +02:00
34 changed files with 2860 additions and 125 deletions
@@ -285,32 +285,11 @@ cross-inverter `lo > hi` contradiction at `arbiter.py:114`. The only line this
card changed there is **which** roster entry the claim is filed on: the one whose
state machine produced the result, rather than `inverters[0]`.
## 6. FOUND AND NOT FIXED: the site loop still contradicts a drain at N>=2
## 6. FIXED BY SAFETY-78: the site loop contradicted a drain at N>=2
Measured while wiring this card, and **pre-existing on `release/1.0` at
`2d025ee`** — the same drain filed on `inverters[0]` produces the identical
refusal with the two unit names swapped.
The maintenance SET **pins** its pack. The loop's target is a **site** claim.
When the site target is *below* the pin — a 2000 W drain while the house imports
less than that, which is most of a night — the only split that sums to the site
target asks the sibling to **charge** while the maintained pack **discharges**,
and `allocator.plan()` refuses it by name as circulating power. The refusal is
correct and fails safe: the whole site goes to 0 W with an `ARBITER: REFUSED`
line in the action log.
**The consequence an operator would see is a misdiagnosis.** The pack is held at
0 W while its state machine believes it is draining, so after 12 h the DRAIN
timeout fires and reports *"drain stuck"* — §12's words for a battery that is not
following commands. The battery is fine; the site refused to allocate.
**Why it is not fixed here.** The fix is to change what the site loop's target
*means* while a unit is pinned — regulate the meter using only the unpinned
packs — which is loop and allocator territory and changes the integrator's
subject. MAINT-05 moves which pack is pinned and nothing else about it. It is
pinned as a running check (`test_maintenance.py` part 12) rather than left as a
comment, so it cannot be forgotten or quietly tuned away, and it wants a card of
its own.
Found here and pre-existing: below the pin the other pack had to charge, the
allocator refused it and the site sat at 0 W. Fixed by a site `drain` floor;
see [[SAFETY-78]] (`DECISIONS/SAFETY-78-drain-at-two.md`).
## 7. FOUND AND NOT FIXED: `migrate_unit_keys()` has the same hole, and it is a LATCH
@@ -75,3 +75,55 @@ P1 is its section 6 P1, and the pull request lists what was measured.
P2's 19.33; Solar first + Cheap hours only 19.57.
- Not done in P3: the forecast (P4), and a headroom too short for the run
moving the rest to the next cheap periods.
## P4: the solar forecast and the day plan (2026-09-29)
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
today; `pv_forecast_tomorrow_entity` optional), read by `app/solarfc.py`.
Supported shapes: Forecast.Solar (state only), Solcast (`detailedForecast`)
and Open-Meteo Solar Forecast (`wh_period`). The unit is read, not guessed;
older than 3 h is stale (one missed hourly poll must not flag it).
- Trust: `solar_trust_pct`, default 70 (owner answer 5).
- House use: nothing the add-on recorded fitted. `tariff.db` holds grid
import per quarter, which the batteries shape. So `dayplan.HouseProfile`
keeps grid + battery per local hour for 7 days in the store
(`house_profile`), median per hour. It is net of the solar the house used,
so the solar sum never takes it below the night median. Solar readings are
not used (TEL-11). The first plan needs one day of it.
- Verdict (`dayplan.plan()`): trust x sum max(0, forecast - house) x 0.95
until the expensive run the cheap run is for. Solar first takes it first.
- Leftover solar may then charge from the grid up to min(ceiling, floor +
the house's use in the expensive runs / 0.95), less its sun share: only
the shortfall.
- Cheap hours only ignores the plan and fills to the ceiling (design
section 1; review of #156, see the replay below).
- Solar first never charges from the grid (owner, 2026-09-29).
- The level is kept unless it moves by min(1 kWh, 10 %). With no plan
(no, stale or failed forecast, no house profile, no capacity) every mode
is P3 option A exactly: `test_modes_p4.py` part 1 pins 12 digests from
5a4cdc2 (modes on, with and without an unavailable forecast).
- The plan is made with the runs (every 15 min, or at once when the forecast
comes or goes). A lost forecast drops the levels at the next cycle.
- Not done: the design's scaling by measured solar from 10:00 (it needs the
solar readings TEL-11 keeps out), the HA `goodwe_<inv>_plan` sensor, and
FCST-01.
- Replay (`tools/opt11p4_replay.py`, 16 recorded days, Solar first + Leftover
solar, net EUR, lower is better; site solar rebuilt from the recording,
forecast errors from Elia's day-ahead Belgian solar forecast):
- year-earlier hourly prices: P2 19.33, P3 option A 19.44, old one-period
trade 18.14, P4 19.15 (perfect forecast 19.14, 1.5 x too high 19.16,
missing 19.44);
- the days' real 2026 quarter-hourly prices (held out): P2 32.26, option A
32.01, old trade 31.40, P4 29.19 (perfect 29.21, too high 29.28, missing
32.01).
- Solar first + Cheap hours only, skipping its sun share (the first
build): 19.57 (same as P3) and 29.72 against P3's 29.41. The shorter,
moved runs left B lower, so later midday top-ups rested A while the sun
shone. Cheap hours now ignores the plan, which is P3 exactly.
- A forecast of no sun at all gives 19.14 and 29.33. So nearly all of the
gain is Leftover solar buying the evening's shortfall in the cheap run;
the forecast itself was worth 0.14 EUR in 16 September days.
- Bias: the site's solar is rebuilt from the same net use the replay
plays, and "recorded" keeps its hourly shape and adds only the national
daily error. Both favour P4; "national" (no house leakage) gives 29.25.
No threshold was tuned to these days. The 2026-09-24 PV recording could not
be replayed: it is only in the live Home Assistant.
@@ -0,0 +1,44 @@
# SAFETY-78 - a maintenance drain at two inverters
**Status: DECIDED 2026-09-29.** Fixes MAINT-05 §6 (OPT-11 P1 F1, UX-13 P3
check 5, OPT-11 P3 F4). Tests: `test_safety78.py`.
**The defect.** At N>=2 the drain pins its pack (a unit SET at
`P_MAINTENANCE`) and the site target is split around the pin. Whenever the
site target was below the pin, the other pack had to charge. The allocator
refuses opposite directions (SAFETY-25 §0a, SAFETY-31), so the site went to
0 W and logged `ARBITER: REFUSED` every cycle. That covers a house below the
drain rate, a grid-charge trade, and a house above it too: after a refusal the
loop restarts from 0 W and its 400 W slew step is below the pin again.
**The fix.** While a pack drains at N>=2, main.py files a site limit
`drain`, `lo` = what the pin really places (`allocator.pin_w()`: the drain
rate inside that pack's own hard limits), at `P_MAINTENANCE`. So the site
target is never below the pin and the other pack only adds power in the same
direction. Opposite splits stay forbidden.
- The order is the arbiter's own: a safety SET (100) outranks the limit and
the pin, so everything parks; the peak floor (50) and the drain floor are
both applied, so the higher wins; a trade or the loop below the drain is
lifted to it.
- Below the drain rate the drained pack exports the rest, as the drain does at
N=1 (`maintenance.py`: "forced discharge, exports the surplus"). Holding it
to the house load would make N=2 drain differently from N=1; that is an
owner question, not this fix.
- A draining pack that can place nothing (a refusal, a stale link) adds no
floor, and the wait is logged once per episode.
- With battery modes on, no pack is a grid charger while another drains
(`modetrade.decide(draining=)`, why `cycle`), and the other battery's card
says why (`other_cycle`). Not while the drain waits: no floor is filed
then, so the other battery may charge from solar.
- Not filed at N=1, where the drain is the site SET itself: byte-identical
(`tools/safety78_identity.py`, which hashes the claims too).
**Not changed (measured in the PR #154 review, follow-ups).**
- At N=1 a drain (60) outranks the peak floor (50), so the guard does not add
to it: a 5000 W house imports 3000 W against a 2500 W limit, a 6000 W house
4000 W. Pre-existing, kept by the N=1 identity.
- At N=2 a maintenance CHARGE in a cheap window has the same stall: the
charging pack's `hi = -floor` against the loop asking the other to give is
refused on 141 of 261 cycles. The mirror fix is a site `hi`, which can meet
the peak floor's `lo` (0 W), so it is not this card.
+19 -1
View File
@@ -184,6 +184,9 @@ The label and description each setting shows on the Configuration tab, in tab or
| Hardware · Inverter list | Usable battery capacity (kWh) (optional) | `inverters.capacity_kwh` | This battery's usable capacity from its datasheet. Fill it in on every inverter or on none. The add-on then shares the work by the energy each battery has left. |
| Hardware | RS485 emergency stop is fitted | `estop_fitted` | Switch this on if the emergency stop is installed. It only changes a warning on the status page. |
| Hardware | Battery modes | `battery_modes_enabled` | Give each battery an on and off switch and a mode, set on the Batteries screen. |
| Hardware | Solar forecast, today (kWh) | `pv_forecast_entity` | Optional. A forecast sensor with today's remaining solar energy, such as Forecast.Solar or Solcast. A battery then buys only what the sun will not give. |
| Hardware | Solar forecast, tomorrow (kWh) | `pv_forecast_tomorrow_entity` | Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight. |
| Hardware | Trust in the forecast (%) | `solar_trust_pct` | Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast. |
| Hardware | Solar production entity (W) | `pv_entity` | Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control. |
| Hardware | Solar production entities, per inverter (W) | `pv_entities` | Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded. |
| Hardware | Reversed-sign solar entities | `pv_invert_entities` | Optional. Add each solar entity above that reads negative while producing, spelled the same way. Its sign is flipped before adding up. |
@@ -522,7 +525,7 @@ Off by default. Turn it on, restart the add-on, and the Batteries screen (and Se
- **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover.
- **Mode.**
- *Solar first* charges from spare solar before the other batteries and covers the house at any hour. It never charges from the grid and never sells.
- *Leftover solar* charges from the solar the others cannot take, never from the grid (until the solar forecast arrives). It saves its power for the expensive hours and covers the house then.
- *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but only what the sun will not give (see below). It saves its power for the expensive hours and covers the house then.
- *Cheap hours only* charges from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then.
- **Selling** is off until it can be judged on the price the grid pays (owner decision 2026-09-29, option A).
@@ -535,10 +538,21 @@ With battery modes on, these choices replace the *Charging and selling on price*
- A carried (not yet published) price is never used. A run that has started runs to its end.
- With no prices, or too small a gap, every battery follows the house.
**Solar forecast** (`pv_forecast_entity`, optional). A Home Assistant sensor with the solar energy still to come today, in kWh. These work: Forecast.Solar (`sensor.energy_production_today_remaining`), Solcast PV Forecast (`sensor.solcast_pv_forecast_forecast_remaining_today`) and Open-Meteo Solar Forecast (`sensor.energy_production_today_remaining`). Set `pv_forecast_tomorrow_entity` to the matching *tomorrow* sensor to plan a cheap run before midnight.
- 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: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level.
- *Cheap hours only* ignores the forecast and fills to the top level.
- *Solar first* never charges from the grid.
- The house use is the add-on's own measurement (grid + battery power) per hour, over the last 7 days. It needs one day of it before the first plan.
- The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it.
- No forecast, one older than 3 hours or from before midnight, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so.
- A battery needs its capacity (`inverters.capacity_kwh`) for a plan.
**What always comes first.**
- The peak limit, the safety stops and the monthly cycle come before a battery's mode.
- A grid charge is sized so the 15-minute period stays under the peak limit less its margin (`peak_margin_w`). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period.
- While one battery charges from the grid, the others rest, and the house uses the grid.
- While one battery empties for its monthly cycle, no battery charges from the grid.
- A battery that refuses to charge or give power takes no part in a run in that direction.
**In Home Assistant.** With MQTT set up, each battery also gets a *Mode* list and a *Use* switch, for example `select.goodwe_inverter_a_mode` and `switch.goodwe_inverter_a_use`. They do the same as the Batteries screen. Turn battery modes off and restart, and they are removed.
@@ -1647,6 +1661,10 @@ under `peak_cap_w`, and if the forecast goes over the cap the charge-only clamp
is dropped so the battery can shave the peak instead. Money outranks the
maintenance schedule.
With two or more batteries, the one being emptied gives its drain rate and the
others only add power when the house needs more; none charges while it drains
(SAFETY-78). Below the drain rate the rest is exported, as with one battery.
#### Capacity-tariff exposure of the grid-following loop
⚠️ **This section described the exposure BEFORE SAFETY-07. The loop is now
+12 -5
View File
@@ -344,6 +344,16 @@ class _Narrowed:
self.lo, self.hi = lo, hi
def pin_w(b):
"""The watts a unit's own SET places once held inside its hard limits,
as `plan()` places a standing pin, or None when the unit has no SET."""
pv = getattr(b, "pinned_w", None)
if getattr(b, "gate", None) is None or pv is None:
return None
lo, hi = _int_envelope(b)
return float(pv) if lo > hi else float(_clamp_int(round(float(pv)), lo, hi))
def loop_bound(bounds, live=None):
"""OPT-11. `(lo, hi)`: what the units can place when the LOOP decides.
@@ -364,11 +374,8 @@ def loop_bound(bounds, live=None):
continue
if getattr(b, "contradiction", False):
continue
pv = getattr(b, "pinned_w", None)
if getattr(b, "gate", None) is not None and pv is not None:
lo, hi = _int_envelope(b)
p = float(pv) if lo > hi else float(_clamp_int(round(float(pv)),
lo, hi))
p = pin_w(b)
if p is not None:
lo_sum += min(0.0, p)
hi_sum += max(0.0, p)
continue
+6
View File
@@ -184,6 +184,12 @@ CLAIM_SCOPE = {
# the banner and the claim an install has had since SAFETY-02 shipped.
# `Controller.cycle()` does not even arm the site detector there.
"sign_site": SCOPE_SITE,
# main.py: `Claim.limit("drain", P_MAINTENANCE, lo=...)`, SAFETY-78. At
# N>=2, while a pack's maintenance drain is pinned, the site target may
# not go below what that pin places: below it a sibling would have to
# charge against the drain (circulation, refused, 0 W). A site `lo`,
# never filed at N=1. DECISIONS/SAFETY-78-drain-at-two.md.
"drain": SCOPE_SITE,
# --- UNIT: about one pack, one inverter, one device -------------------
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
+262
View File
@@ -0,0 +1,262 @@
"""OPT-11 P4: the day plan. Will the sun fill each battery before the
expensive hours? If not, how much should it take from the grid?
Pure: the forecast (`solarfc`), the house profile, the price runs and the
batteries in; a level per battery (`Plan.targets`) out. `modetrade.charge_to()`
turns the level into permission to charge from the grid; main.py rebuilds the
plan with the price runs (at most every 15 minutes) and keeps a level unless
it moves by `HYST_KWH` or `HYST_FRAC` (`hold()`).
The sums (OPT-11-design section 1, "Excess solar", with the owner's answers):
* `sun`: trust x the solar the house will not use, between now and the
expensive run the cheap run is for, x the charge efficiency:
`trust x sum_h max(0, forecast_h - house_h) x EFF`.
* Solar first batteries take it first, up to the ceiling. What is left is
shared by the Leftover solar batteries by their room.
* 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
(PR #156 review: 29.72 against 29.41 EUR on real 2026 prices).
* A Leftover solar battery charges only up to what the house will draw from
it in the expensive runs before the next cheap run, less its share:
`min(ceiling, floor + house use / EFF) - share`. So it buys only the
shortfall, not up to the ceiling.
* Solar first never charges from the grid (owner, 2026-09-29).
The house profile is the add-on's own measurement, grid + battery power, per
local hour, over the last `PROFILE_DAYS` days; the estimate for an hour is the
median of that hour. It is net of the solar the house used itself, so for
the solar sum it is never taken below the night-time median (`base()`).
The site's own solar readings are not used: TEL-11 keeps them out of every
control decision.
"""
import statistics
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
from .tariff import BILLING_TZ
EFF = 0.95 # charge (and discharge) efficiency
HYST_KWH = 1.0 # a kept level moves only by this much...
HYST_FRAC = 0.10 # ...or this share of the battery's shortfall
PROFILE_DAYS = 7
MIN_HOURS = 20 # hours of a day recorded before it counts
NIGHT_HOURS = (0, 1, 2, 3, 4, 5, 22, 23)
DEFAULT_TRUST_PCT = 70
# Site verdicts (the page's forecast line).
SUNNY, PARTLY, DARK = "sunny", "partly", "dark"
# --------------------------------------------------------------------------- #
# The house profile
# --------------------------------------------------------------------------- #
def _local(t: datetime):
return t.astimezone(BILLING_TZ)
class HouseProfile:
"""Hourly means of the house's net use (grid + battery power), kept per
local day and hour: `{"YYYY-MM-DD": {hour: [mean_w, samples]}}`. The
store holds `dump()`; `load()` takes it back and drops what is bad."""
def __init__(self, data=None):
self.days = {}
self._cur = None # (day, hour, sum, n)
if isinstance(data, dict):
self.load(data)
def load(self, data):
for day, hours in data.items():
try:
datetime.strptime(str(day), "%Y-%m-%d")
except ValueError:
continue
if not isinstance(hours, dict):
continue
row = {}
for h, v in hours.items():
try:
hi, w = int(h), float(v)
except (TypeError, ValueError):
continue
if 0 <= hi < 24 and abs(w) < 1e6:
row[hi] = w
if row:
self.days[str(day)] = row
def dump(self) -> dict:
return {d: {str(h): round(w, 1) for h, w in sorted(r.items())}
for d, r in sorted(self.days.items())}
def add(self, now: datetime, house_w) -> bool:
"""One reading. Returns True when an hour closed (then store it)."""
lt = _local(now)
key = (lt.strftime("%Y-%m-%d"), lt.hour)
closed = False
if self._cur is not None and self._cur[:2] != key:
day, hour, s, n = self._cur
if n >= 60: # about 5 minutes of readings
self.days.setdefault(day, {})[hour] = s / n
closed = True
self._cur = None
self._prune(lt)
if house_w is None:
return closed
if self._cur is None:
self._cur = (key[0], key[1], 0.0, 0)
d, h, s, n = self._cur
self._cur = (d, h, s + float(house_w), n + 1)
return closed
def _prune(self, lt):
"""Keep the last `PROFILE_DAYS` days; a day after today (the clock
was wrong once) would never age out, so it goes too."""
keep = (lt.date() - timedelta(days=PROFILE_DAYS)).isoformat()
today = lt.date().isoformat()
for d in [d for d in self.days if d < keep or d > today]:
del self.days[d]
def ready(self) -> bool:
"""At least one day with `MIN_HOURS` hours recorded."""
return any(len(r) >= MIN_HOURS for r in self.days.values())
def hour_w(self, hour: int):
vals = [r[hour] for r in self.days.values() if hour in r]
return statistics.median(vals) if vals else None
def base(self):
vals = [w for h in NIGHT_HOURS if (w := self.hour_w(h)) is not None]
return statistics.median(vals) if vals else None
def estimator(self):
"""`f(hour) -> W`, or None when not ready. Fixed at call time."""
if not self.ready():
return None
base = self.base() or 0.0
table = {}
for h in range(24):
w = self.hour_w(h)
table[h] = base if w is None else w
return lambda h: table[h], max(0.0, base)
# --------------------------------------------------------------------------- #
# The plan
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Plan:
verdict: str # SUNNY / PARTLY / DARK
sun_kwh: float # solar the batteries can count on
fc_kwh: float # the forecast over the same span
until: datetime # the expensive run the plan is for
targets: dict # name -> level % (Leftover solar only)
need_kwh: dict # name -> kWh from the grid (0: none)
share_kwh: dict # name -> kWh of solar counted for it
def _hour_slices(t0, t1):
"""Local-hour slices of [t0, t1): (start, end, local hour)."""
out, t = [], t0
while t < t1:
lt = _local(t)
nxt = (lt.replace(minute=0, second=0, microsecond=0)
+ timedelta(hours=1)).astimezone(timezone.utc)
e = min(nxt, t1)
out.append((t, e, lt.hour))
t = e
return out
def _room(p, to_pct):
if p.capacity_kwh is None or p.soc is None:
return 0.0
return max(0.0, float(to_pct) - float(p.soc)) / 100.0 * float(
p.capacity_kwh)
def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
ceiling_pct):
"""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."""
if forecast is None or house is None or runs is None or not runs.on \
or runs.cheap is None:
return None
house_w, base_w = house
until = next((g for g in runs.give if g.start >= runs.cheap.end), None)
if until is None:
return None
trust = max(0.0, min(1.0, float(trust_pct) / 100.0))
sun = fc_sum = 0.0
for s, e, h in _hour_slices(now, until.start):
pv = forecast.between(s, e)
if pv is None:
return None # the span reaches a day with no forecast
hrs = (e - s).total_seconds() / 3600.0
use = max(house_w(h), base_w) * hrs / 1000.0
sun += max(0.0, pv - use)
fc_sum += pv
sun *= trust * EFF
usable = [p for p in packs if p.use and p.live and p.idle
and p.soc is not None and p.capacity_kwh]
sf = [p for p in usable if p.mode == SOLAR_FIRST]
price = [p for p in usable if p.mode == LEFTOVER_SOLAR]
sf_room = sum(_room(p, ceiling_pct) for p in sf)
excess = max(0.0, sun - sf_room)
rooms = {p.name: _room(p, ceiling_pct) for p in price}
total_room = sum(rooms.values())
# What the house draws in the expensive runs from the one this plan is
# for until the next cheap run (or a day), from the profile.
horizon = until.start + timedelta(hours=24)
use_kwh = 0.0
for g in runs.give:
if g.start < until.start or g.start >= horizon:
continue
for s, e, h in _hour_slices(g.start, g.end):
use_kwh += max(0.0, house_w(h)) * (
e - s).total_seconds() / 3600.0 / 1000.0
caps = {p.name: float(p.capacity_kwh) for p in price}
cap_sum = sum(caps.values()) or 1.0
targets, need, share = {}, {}, {}
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
want = use_kwh * caps[p.name] / cap_sum / EFF
top = min(float(ceiling_pct),
float(floor_pct) + want / caps[p.name] * 100.0)
level = max(0.0, top - sh_pct)
targets[p.name] = round(level, 2)
need[p.name] = round(_room(p, level), 3)
share[p.name] = round(sh, 3)
if sun < sf_room:
verdict = DARK
elif any(v > 0.05 for v in need.values()):
verdict = PARTLY
else:
verdict = SUNNY
return Plan(verdict, round(sun, 3), round(fc_sum, 3), until.start,
targets, need, share)
def hold(old: dict | None, new: dict, capacity: dict) -> dict:
"""Hysteresis: a battery keeps its old level unless the new one differs
by at least `HYST_KWH`, or by `HYST_FRAC` of the larger level's energy
(whichever is less). A battery new to the plan takes its level at once;
one that left the plan is dropped at once."""
out = {}
for name, level in new.items():
was = (old or {}).get(name)
cap = float(capacity.get(name) or 0.0)
if was is None or cap <= 0:
out[name] = level
continue
moved = abs(level - was) / 100.0 * cap
step = min(HYST_KWH, HYST_FRAC * max(was, level) / 100.0 * cap)
out[name] = level if moved >= step else was
return out
+97 -4
View File
@@ -345,7 +345,10 @@ def mode_runs(c, day0, day1, now, shadow):
else (soc - floor)) / 100.0 * cap)
return kwh
blocks = []
if rp.charge_useful() and pm.mode in GRID_CHARGE_MODES:
# P4: with a day plan, the grid block is the planned shortfall.
need = plan_need(c, inv.name)
if rp.charge_useful() and (need > 0.0 if need is not None
else pm.mode in GRID_CHARGE_MODES):
blocks.append(("grid", rp.cheap))
sell = rp.sell if pm.sell and pm.can_sell else None
for g in rp.give:
@@ -358,6 +361,8 @@ def mode_runs(c, day0, day1, now, shadow):
continue
hrs = (run.end - run.start).total_seconds() / 3600.0
kwh = room(kind, hrs)
if kind == "grid" and need is not None:
kwh = min(kwh, need)
if kwh < MIN_KWH:
continue
price = run.mean_eur_kwh
@@ -382,6 +387,56 @@ def mode_runs(c, day0, day1, now, shadow):
return out, None
def plan_need(c, name):
"""P4: kWh this battery buys from the grid by the day plan, or None when
there is no plan for it (then P3 option A applies)."""
targets = c.plan_targets() if hasattr(c, "plan_targets") else None
if not targets or name not in targets:
return None
inv = next((i for i in c.inverters if i.name == name), None)
if inv is None or inv.soc is None or not inv.capacity_kwh:
return None
return max(0.0, targets[name] - float(inv.soc)) / 100.0 * float(
inv.capacity_kwh)
def plan_state(c):
"""P4: `"plan"` (a day plan is made), `"learning"` (no house profile
yet), `"no_forecast"` (set but missing or stale), or None (not set, or
battery modes off)."""
if not getattr(c, "modes_on", False) or \
getattr(c, "fc_entity", None) is None:
return None
if getattr(c, "solar_fc", None) is None:
return "no_forecast"
hp = getattr(c, "house_profile", None)
if hp is not None and not hp.ready():
return "learning"
return "plan" if getattr(c, "day_plan", None) is not None else None
def solar_block(c, now):
"""P4: the solar forecast line for the Batteries screen and the Simple
settings view: `{state, fc_kwh, count_kwh, updated, line}`."""
st = plan_state(c)
out = {"state": st, "fc_kwh": None, "count_kwh": None, "updated": None,
"line": ""}
if st is None and getattr(c, "solar_fc", None) is None:
return out
fc = getattr(c, "solar_fc", None)
if fc is not None:
trust = float(getattr(c, "solar_trust_pct", 70)) / 100.0
out.update(fc_kwh=round(fc.today.kwh, 1),
count_kwh=round(fc.today.kwh * trust, 1),
updated=hhmm(fc.at))
if st in ("no_forecast", "learning"):
out["line"] = _t("solar." + st)
elif fc is not None:
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
out["updated"])
return out
def maintenance_runs(c, day0, day1, now, shadow):
"""The monthly battery cycle: under way now, or starting later today.
The cycle starts in the hour `maintenance_start_hour` names, read as the
@@ -640,6 +695,14 @@ def battery_rows(c, shadow, running, held=False):
# carries its share (SAFETY-73)?
other = next((o.name for o in c.inverters if o is not inv
and refusing(c, o) > 0 and not _off(c, o)), None)
# SAFETY-78: another battery drains for its monthly cycle, so this
# one never charges now. Not while that drain waits: it files no
# floor then, so this battery may charge from solar.
waits = getattr(c, "_drain_waits", ())
cycling = next((o.name for j, o in enumerate(c.inverters)
if o is not inv and j < len(c.maints)
and c.maints[j].phase == M_DRAIN
and o.name not in waits), None)
role = None if tick is None else tick.roles.get(inv.name)
# Only once a cycle has run with modes on (`tick`): before that
# nothing is known, so nothing is claimed about the prices.
@@ -655,7 +718,10 @@ def battery_rows(c, shadow, running, held=False):
phase=phase, shadow=shadow, running=running, held=held,
refused=refusing(c, inv), peak=peak,
giving=flow(inv.batt) == "giving", other_refuses=other,
sf_ahead=sf_ahead, role=role, plan=plan, has_at=bool(at))
sf_ahead=sf_ahead, role=role, plan=plan, has_at=bool(at),
other_cycle=cycling,
day=None if pm is None or pm.mode not in PRICE_MODES
else day_key(c, inv.name))
rows.append({
"name": inv.name,
"level_pct": None if inv.soc is None else float(inv.soc),
@@ -673,7 +739,8 @@ def battery_rows(c, shadow, running, held=False):
# UX-13 P3: why it does what "now" says, from what the add-on
# knows this cycle. Price and forecast reasons are OPT-11 P3/P4.
"why_key": why,
"why": why_words(why, name=other, at=at),
"why": why_words(why, name=cycling if why == "other_cycle"
else other, at=at),
})
if modes_on:
# OPT-11 P1: the battery's switch and mode, for the Batteries
@@ -689,10 +756,25 @@ def battery_rows(c, shadow, running, held=False):
return rows
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."""
st = plan_state(c)
if st == "no_forecast":
mode = c.pack_mode(next(i for i in c.inverters if i.name == name))
return "no_forecast" if mode.mode == LEFTOVER_SOLAR else None
if st != "plan":
return None
need = plan_need(c, name)
if need is None:
return None
return "tops_up" if need >= MIN_KWH else "sun_fills"
def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
held, refused, peak, sf_ahead, giving=False,
other_refuses=None, role=None, plan=None,
has_at=False) -> str:
has_at=False, other_cycle=None, day=None) -> str:
"""The key of one battery's "why" line (`home_words.why`), most urgent
first. Pure, so every branch is tested without a controller."""
if not reading:
@@ -712,11 +794,15 @@ def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
return "refuses_give" if refused > 0 else "refuses_charge"
if peak:
return "peak"
if other_cycle:
return "other_cycle"
if giving and other_refuses:
return "other_refuses"
if not modes_on:
return "modes_off"
# OPT-11 P3: the battery's role in the price runs this cycle.
if role == "hold" and day is not None:
return day # P4: what the day plan decided
if role in ROLE_WHY:
if role == "hold" and not has_at:
return "saves_later"
@@ -864,6 +950,7 @@ def _build(c, *, level, checks, now):
modes_on=bool(getattr(c, "modes_on", False)),
running=running, held=held, shadow=shadow)
return {
"solar": solar_block(c, now),
"state": {"kind": kind, "pill": pill(kind),
"ran": ctl.get("text", "") if ctl else "",
"sub": banner_sub(kind)},
@@ -1160,6 +1247,10 @@ def run_house_amount() -> str:
return _t("run.house_amount")
def line_solar(fc_kwh, count_kwh, at) -> str:
return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at)
def line_no_trade(key) -> str:
return "" if key == "off" else _t("no_trade." + key)
@@ -1302,6 +1393,8 @@ def _samples() -> list:
off=off, peak=peak)))
out += [("plan", why_words(k, name="Battery A")) for k in words["why"]]
out += [("plan", today_words(k)) for k in words["today"]]
out += [("plan", line_solar(9.2, 6.4, "10:15")),
("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning"))]
out += [("plan", line_no_trade(k)) for k in
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
"no_level")]
+163 -7
View File
@@ -41,9 +41,11 @@ from .arbiter import (
P_SAFETY, SET, Claim, Resolution, actuator_for, pre_resolve, resolve,
)
from . import allocator
from . import dayplan
from . import modetrade
from . import packmode
from . import priceruns
from . import solarfc
from . import control as _ctl
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from .delivery import (
@@ -71,7 +73,7 @@ from .hass import HomeAssistant
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
DRAIN, IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
arbitrate as maint_arbitrate,
)
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
@@ -234,6 +236,11 @@ def load_options() -> dict:
# valued at (`priceruns.plan(ref_eur_kwh=)`): the rest of that day.
REF_CHEAP_KEEP = timedelta(hours=12)
# OPT-11 P4: the store key of the house profile (dayplan.HouseProfile), and
# how often the forecast sensors are read (they change hourly at most).
HOUSE_PROFILE_KEY = "house_profile"
FORECAST_READ_S = 60.0
def why_raised(resolution, claims) -> bool:
"""F2 (OPT-11 P1 test): did the limit that bound the site target push it
@@ -947,12 +954,34 @@ class Controller:
self._mode_trade_was = False
self._charge_blocked_q = None
self._ref_cheap = None
# OPT-11 P4: the solar forecast and the day plan. Only with battery
# modes on AND `pv_forecast_entity` set; otherwise none of this runs
# and every mode acts as in P3 option A.
self.fc_entity = (opts.get("pv_forecast_entity") or None) \
if self.modes_on else None
self.fc_tomorrow_entity = opts.get("pv_forecast_tomorrow_entity") \
or None
self.solar_trust_pct = opts.get("solar_trust_pct",
dayplan.DEFAULT_TRUST_PCT)
self.solar_fc = None
self.solar_fc_why = (solarfc.NOT_SET if self.fc_entity is None
else solarfc.MISSING)
self._fc_read_mono = None
self.day_plan = None
self._plan_targets = None
self.house_profile = None
if self.fc_entity is not None:
self.house_profile = dayplan.HouseProfile(
(getattr(store, "data", None) or {}).get(HOUSE_PROFILE_KEY))
# OPT-11 P2: (topic, reason) of MQTT commands refused and logged once.
self._mqtt_refused = set()
self._mqtt_refused_more = 0
# [(pack name, why)] for packs that are due and waiting their turn.
# Empty at a roster of one, where nothing can defer anything.
self._maint_deferred = []
# SAFETY-78: names of draining packs that cannot give power now, so
# the "waits" line is logged once, not every cycle.
self._drain_waits = set()
# Set by amain() once the transport is built, so the status page can show
# what only the transport knows (homewizard_local's unchanged_s). Stays
# None when P1 is off, or under a transport that has no such counter.
@@ -2078,10 +2107,14 @@ class Controller:
plan = self.fresh_tariff_plan(now)
if plan is None or not plan.has_prices:
self.mode_runs, self._runs_key = None, None
self.day_plan = None
return None
slot = int(now.timestamp() // 900)
key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell)
for p in packs))
if self.fc_entity is not None:
# P4: a forecast arriving or going rebuilds the plan at once.
key += (self.solar_fc is None,)
if key != self._runs_key:
done_c = self._run_done.get("charge", (None, set()))[1]
done_s = self._run_done.get("sell", (None, set()))[1]
@@ -2099,6 +2132,9 @@ class Controller:
charge_kwh=e[0], charge_kw=e[1], sell_kwh=e[2],
sell_kw=e[3],
ref_eur_kwh=None if ref is None else ref.mean_eur_kwh)
if self.fc_entity is not None:
new = self._day_plan(now, packs, floor_pct, ceiling_pct,
new, plan, required, ref, done_c, e)
self.mode_runs = priceruns.keep_started(self.mode_runs, new, now)
self._runs_key = key
runs = self.mode_runs
@@ -2113,6 +2149,42 @@ class Controller:
self._ref_cheap = runs.cheap
return runs
def _day_plan(self, now, packs, floor_pct, ceiling_pct, runs, plan,
required, ref, done_c, e):
"""OPT-11 P4: the day plan (`dayplan.plan()`) on the runs just made,
its levels held by the hysteresis (`dayplan.hold()`), then the runs
made again with each battery's shortfall as the charge, so the cheap
run is only as long as the shortfall needs. With no plan (no or stale
forecast, no house profile yet) the levels are dropped: P3 option A.
Returns the runs to keep."""
dp = dayplan.plan(
self.solar_fc,
None if self.house_profile is None
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)
self.day_plan = dp
if dp is None:
self._plan_targets = None
return runs
self._plan_targets = dayplan.hold(
self._plan_targets, dp.targets,
{p.name: p.capacity_kwh for p in packs})
e2 = modetrade.energy(packs, floor_pct, ceiling_pct, done_c, (),
targets=self._plan_targets)
return priceruns.plan(
plan.read.points, now, required_eur_kwh=required,
charge_kwh=e2[0], charge_kw=e2[1], sell_kwh=e[2], sell_kw=e[3],
ref_eur_kwh=None if ref is None else ref.mean_eur_kwh)
def plan_targets(self):
"""P4: each battery's planned level, or None (no day plan). Dropped
at once when the forecast goes (stale or missing)."""
if self.solar_fc is None:
return None
return self._plan_targets
def _mode_roles(self, now, maint_results):
"""OPT-11 P3: each battery's role and range this tick. Grid charging
and selling need a fresh plan that `TariffPlan.arbitrage()` allows
@@ -2151,9 +2223,22 @@ class Controller:
price_now = None
house = (None if self.grid is None or self.site_batt_w is None
else float(self.grid) + float(self.site_batt_w))
done_c = self._run_mark("charge", runs and runs.cheap, packs,
lambda p: p.soc is not None
and p.soc >= ceiling_pct)
if self.house_profile is not None and \
self.house_profile.add(now, house):
self.store.set(HOUSE_PROFILE_KEY, self.house_profile.dump())
targets = self.plan_targets()
if targets is None:
done_c = self._run_mark("charge", runs and runs.cheap, packs,
lambda p: p.soc is not None
and p.soc >= ceiling_pct)
else:
# P4: out of the run at its planned level. A level the
# hysteresis moved opens the run to it again (a new mark key).
def full(p):
to = modetrade.charge_to(p, ceiling_pct, targets)
return p.soc is not None and (to is None or p.soc >= to)
done_c = self._run_mark("charge", runs and runs.cheap, packs,
full, tuple(sorted(targets.items())))
done_s = self._run_mark("sell", runs and runs.sell, packs,
lambda p: p.soc is not None
and p.soc <= floor_pct)
@@ -2166,17 +2251,21 @@ class Controller:
headroom_w=self.arb_headroom_w, floor_pct=floor_pct,
ceiling_pct=ceiling_pct, house_w=house, charging_now=was,
blocked=self._charge_blocked_q == quarter,
done_charge=done_c, done_sell=done_s)
done_charge=done_c, done_sell=done_s, targets=targets,
draining=any(r.phase == DRAIN for r in maint_results or ()))
# A charge the guard or the headroom stopped stays off this period.
if was and self.mode_tick.action != modetrade.CHARGE and \
self.mode_tick.why in ("peak", "headroom"):
self._charge_blocked_q = quarter
return self.mode_tick
def _run_mark(self, kind, run, packs, out) -> set:
def _run_mark(self, kind, run, packs, out, extra=None) -> set:
"""The batteries that left run `run` (full, or at the floor); they
stay out until it ends, so a level edge does not re-arm them."""
stay out until it ends, so a level edge does not re-arm them.
`extra` (P4: the planned levels) is part of the run's key."""
key = None if not run else (run.start, run.end)
if key is not None and extra is not None:
key += (extra,)
was_key, done = self._run_done.get(kind, (None, set()))
if key != was_key:
done = set()
@@ -2388,6 +2477,42 @@ class Controller:
except Exception: # noqa: BLE001
_LOG.exception("Home Assistant command could not be handled")
def _drain_floor(self, claims, maint_results):
"""SAFETY-78: the site `lo` for a pinned maintenance drain, or None.
At N>=2 the drain pins its pack and the site target is split around
the pin. Below the pin a sibling would have to charge (opposite
directions, refused, 0 W, and the drain never moves). So the site
target may not go below what the pin places: the drained pack covers
the house up to its drain rate and exports the rest, as at N=1, and
a sibling only ever adds power in the same direction. At N=1 the
drain is the site SET itself, so nothing is filed.
A draining pack whose own limits leave it nothing to give (a refusal,
a stale link) adds no floor, and that is logged once.
"""
if len(self.inverters) < 2:
return None
live = set(self.live_units())
floor, waits = 0.0, set()
for inv, res in zip(self.inverters, maint_results):
if res.phase != DRAIN or not res.owns_setpoint \
or not (res.setpoint_w or 0.0) > 0.0:
continue
u = self.actuator_of("maintenance", inv)
b = pre_resolve([c for c in claims if c.actuator == u])
p = (None if u not in live or b.contradiction
or b.pinned_by != "maintenance" else allocator.pin_w(b))
if p is not None and p > 0.0:
floor += p
else:
waits.add(inv.name)
for name in sorted(waits - self._drain_waits):
self.log_event(f"maintenance: {name} cannot give power now, so "
f"its monthly cycle waits",
hint=messages.hint("maint_waits", unit=name))
self._drain_waits = waits
return floor if floor > 0.0 else None
def _maint_stop_off(self, machine, now) -> None:
"""OPT-11: stop this battery's maintenance cycle because it is off."""
if machine.phase == IDLE:
@@ -2492,6 +2617,22 @@ class Controller:
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
list(zip(roster, states)), pv_read.inverted(self.o))
async def _read_forecast(self) -> None:
"""OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A
failed or stale read is no forecast, never a zero."""
today = await self.hass.state(self.fc_entity)
tomorrow = (await self.hass.state(self.fc_tomorrow_entity)
if self.fc_tomorrow_entity else None)
now = datetime.now(timezone.utc)
fc, why = solarfc.read(today, tomorrow, now)
if (fc is None) != (self.solar_fc is None) or why != self.solar_fc_why:
self.log_event(
"solar forecast: " + ("read" if fc is not None else
f"none ({why})"),
hint=messages.hint("forecast_read" if fc is not None
else "forecast_none"))
self.solar_fc, self.solar_fc_why = fc, why
async def read_inputs(self, now_mono: float | None = None) -> None:
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
@@ -2543,6 +2684,14 @@ class Controller:
_reads = [self._read_unit(inv, now_mono) for inv in self.inverters]
if self.pv_roster:
_reads.append(self._read_pv())
# OPT-11 P4: the solar forecast, in the same gather (a slow sensor
# must not delay control), at most once per FORECAST_READ_S.
if self.fc_entity is not None and (
self._fc_read_mono is None
or now_mono - self._fc_read_mono >= FORECAST_READ_S
or now_mono < self._fc_read_mono):
self._fc_read_mono = now_mono
_reads.append(self._read_forecast())
await asyncio.gather(*_reads)
if o.get("peak_forecast_entity"):
raw = await self.hass.number(o["peak_forecast_entity"])
@@ -4098,6 +4247,13 @@ class Controller:
_ctl.REFUSED,
actuator=self.actuator_of("refused",
inv)))
# --- SAFETY-78: a pinned drain is a floor under the site target -----
# After every unit claim, so the floor is what the pin really places.
drain = self._drain_floor(claims, maint_results)
if drain is not None:
claims.append(Claim.limit("drain", P_MAINTENANCE, lo=drain,
reason="maintenance drain",
actuator=self.actuator_of("drain")))
# --- OPT-11 P3: the site trade, after every unit claim --------------
# Sized from what the batteries may place with their modes and hard
# limits (the loop bound), so none is asked for watts it may not give.
+6
View File
@@ -906,6 +906,8 @@ HINTS = {
"trade_charge": "Trading: charging while the price is low",
"trade_give": "Trading: giving power while the price is high",
"trade_off": "Trading: waiting for a better price",
"forecast_read": "Solar forecast read, the day plan uses it",
"forecast_none": "No solar forecast, so Leftover solar takes solar only",
"split": "The power split between inverters changed",
"clash": "Two limits clash, inverters set to 0 W",
"no_setpoint": "An inverter has no “Setpoint entity”, inverters stay at 0 W",
@@ -929,6 +931,7 @@ HINTS = {
"pack_mode": "{unit}: battery mode changed",
"pack_sell": "{unit}: selling switched",
"maint_off": "{unit}: maintenance stopped, the battery is switched off",
"maint_waits": "{unit} cannot give power now, so its monthly cycle waits",
"mode_fault": "{unit}: battery mode ignored because of a software fault",
}
@@ -1172,6 +1175,9 @@ LABELS = {
"batt_entity": "Battery power entity",
"pv_entity": "Solar production entity",
"pv_entities": "Solar production entities, per inverter",
"pv_forecast_entity": "Solar forecast, today",
"pv_forecast_tomorrow_entity": "Solar forecast, tomorrow",
"solar_trust_pct": "Trust in the forecast",
"p1_phase_import_entities": "Import entities per phase",
"p1_phase_export_entities": "Export entities per phase",
}
+55 -17
View File
@@ -23,7 +23,11 @@ Roles (the page's Why line names them):
Rules that keep this safe (tests: test_modes_p3.py):
* every range holds 0 W (a mode may forbid a direction, never force one);
* a Solar first battery never charges from the grid (no forecast yet, P4);
* a Solar first battery never charges from the grid;
* P4: with a day plan (`targets`), a Leftover solar battery charges from
the grid only up to its planned level, never above the ceiling; with
none it takes solar only (P3 option A). Cheap hours only fills to the
ceiling either way (`charge_to()`);
* grid charging needs a measured peak guard that is clear and a measured
headroom (SAFETY-45); the trade is sized to that headroom, whose aim is
`peak_cap_w - peak_margin_w` (SAFETY-79). It starts only at START_W or
@@ -39,13 +43,15 @@ Rules that keep this safe (tests: test_modes_p3.py):
* a battery out of a run (full, or at the floor) stays out until the run
ends, so the ceiling and the floor do not re-arm within one run;
* a battery that refuses a direction (SAFETY-52/73) takes no part in a
run in that direction: the refusal wins over the run.
run in that direction: the refusal wins over the run;
* no grid charge while another battery drains for its monthly cycle
(`draining`, SAFETY-78): it would charge against the drain.
"""
from dataclasses import dataclass
from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR,
SOLAR_FIRST)
PLAN_CHARGE_MODES, SOLAR_FIRST)
OFF, HOUSE, HOLD, GIVE, SELL, CHARGE, REST = (
"off", "house", "hold", "give", "sell", "charge", "rest")
@@ -88,13 +94,35 @@ def _room_kwh(p: Pack, pct: float) -> float:
return max(0.0, abs(pct - p.soc)) / 100.0 * float(p.capacity_kwh)
def chargers(packs, ceiling_pct, done=()) -> list:
"""Batteries that may charge from the grid in a cheap run: the modes in
`packmode.GRID_CHARGE_MODES` (Cheap hours only until P4's forecast)."""
return [p for p in packs
if p.use and p.idle and p.live and p.mode in GRID_CHARGE_MODES
and p.soc is not None and p.soc < ceiling_pct
and p.refused >= 0 and p.name not in done]
def charge_to(p: Pack, ceiling_pct, targets=None):
"""The level this battery may charge to from the grid, or None.
`targets` is the day plan (`dayplan.plan()`: name -> level), or None
when there is none (no forecast, stale, or not configured). With no plan
this is P3 option A exactly: Cheap hours to the ceiling, the others never.
With a plan, a Leftover solar battery charges only to its planned level
(the sun fills the rest); a battery the plan leaves out, Cheap hours only
included, gets the no-plan answer. Solar first never charges."""
if p.mode in GRID_CHARGE_MODES:
return float(ceiling_pct)
if targets is None or p.name not in targets \
or p.mode not in PLAN_CHARGE_MODES:
return None
t = targets[p.name]
return None if t is None else min(float(ceiling_pct), float(t))
def chargers(packs, ceiling_pct, done=(), targets=None) -> list:
"""Batteries that may charge from the grid in a cheap run: below the
level `charge_to()` allows them (P4: the day plan's)."""
out = []
for p in packs:
to = charge_to(p, ceiling_pct, targets)
if (to is not None and p.use and p.idle and p.live
and p.soc is not None and p.soc < to
and p.refused >= 0 and p.name not in done):
out.append(p)
return out
def sellers(packs, floor_pct, done=()) -> list:
@@ -106,11 +134,15 @@ def sellers(packs, floor_pct, done=()) -> list:
and p.refused <= 0 and p.name not in done]
def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=()):
"""`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`."""
ch = chargers(packs, ceiling_pct, done_charge)
def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(),
targets=None):
"""`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`.
With a day plan the charge is each battery's shortfall, so the cheap run
is only as long as the shortfall needs."""
ch = chargers(packs, ceiling_pct, done_charge, targets)
se = sellers(packs, floor_pct, done_sell)
return (sum(_room_kwh(p, ceiling_pct) for p in ch),
return (sum(_room_kwh(p, charge_to(p, ceiling_pct, targets))
for p in ch),
sum(abs(p.rating_w) for p in ch) / 1000.0,
sum(_room_kwh(p, floor_pct) for p in se),
sum(abs(p.rating_w) for p in se) / 1000.0)
@@ -118,7 +150,8 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=()):
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
blocked=False, done_charge=(), done_sell=()) -> Tick:
blocked=False, done_charge=(), done_sell=(),
targets=None, draining=False) -> Tick:
"""This tick's ranges, roles and site ask.
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
@@ -129,7 +162,9 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
`house_w` is the measured house (grid + batteries), or None.
`charging_now` says last tick charged (then any headroom above 0 keeps
it going); `blocked` that the guard or the headroom stopped a charge in
this 15-minute period.
this 15-minute period. `targets` is the day plan (`charge_to()`).
`draining` says a battery drains for its monthly cycle, so none may
charge from the grid (why "cycle").
"""
plan_on = runs is not None and runs.on
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
@@ -137,7 +172,10 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
in_sell = plan_on and runs.sell is not None and runs.sell.holds(now)
why = None
ch = chargers(packs, ceiling_pct, done_charge) if in_cheap else []
ch = chargers(packs, ceiling_pct, done_charge, targets) if in_cheap \
else []
if ch and draining:
ch, why = [], "cycle"
charging = False
if ch:
need = 0.0 if charging_now else START_W
+6 -3
View File
@@ -46,10 +46,13 @@ SELL_KEY = "pack_sell" # OPT-11 P3: sell in the sell run
# refused; the sell code in modetrade/priceruns stays, unreachable.
SELLING_ENABLED = False
# The modes that may charge from the grid in a cheap run. Leftover solar joins
# when the solar forecast exists (P4); until then it takes solar only (owner
# decision 2026-09-29, option A).
# The modes that may charge from the grid in a cheap run with no day plan.
# Leftover solar takes solar only then (owner decision 2026-09-29, option A).
GRID_CHARGE_MODES = (CHEAP_HOURS,)
# OPT-11 P4: with a fresh solar forecast, the day plan (dayplan.py) lets
# Leftover solar charge from the grid, by the shortfall only. Cheap hours only
# ignores the plan (fills to the ceiling); Solar first never grid-charges.
PLAN_CHARGE_MODES = (LEFTOVER_SOLAR,)
def enabled(opts) -> bool:
+254
View File
@@ -0,0 +1,254 @@
"""OPT-11 P4: the solar forecast, read from a Home Assistant sensor.
Pure: HA state dicts and the time in, a `Forecast` (or None and why) out.
main.py reads the entities; dayplan.py uses the answer. FCST-01 replaces
this later with the add-on's own forecast (owner answer, 2026-09-27).
Two options name the sensors: `pv_forecast_entity`, the solar energy still
to come TODAY, and `pv_forecast_tomorrow_entity`, TOMORROW's (optional; it
is only needed for a cheap run before midnight). Supported, by the entities
they create (DOCS.md lists them):
* Forecast.Solar (HA core): `sensor.energy_production_today_remaining` and
`sensor.energy_production_tomorrow`. The state only; no hourly split.
* Solcast PV Forecast (HACS `solcast_solar`):
`sensor.solcast_pv_forecast_forecast_remaining_today` and
`..._forecast_tomorrow`, with `detailedForecast` (or `detailedHourly`):
`[{period_start, pv_estimate}]`, kW per period.
* Open-Meteo Solar Forecast (HACS `open_meteo_solar_forecast`):
`sensor.energy_production_today_remaining` / `..._tomorrow`, with
`wh_period`: `{period start: Wh}`.
Any other sensor whose state is energy in Wh, kWh or MWh works too, as a
state only. The unit is read, never guessed (the rule `pv.watts()` has).
Without an hourly split, the day's energy is spread over a fixed bell from
06:00 to 20:00 local time (`BELL_FROM_H`, `BELL_TO_H`).
A reading older than `FRESH_S`, or from before local midnight, is stale,
and a stale or missing forecast is no forecast: the day plan is off and
every mode acts as in P3 option A.
"""
import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from .hass import BAD_STATES
from .tariff import BILLING_TZ
KWH_PER = {"Wh": 0.001, "kWh": 1.0, "MWh": 1000.0}
# Stale after 3 h. The integrations above refresh at least hourly, so a
# 2 h limit would flag a site whose hourly poll was missed once.
FRESH_S = 3 * 3600.0
# The bell a state-only forecast is spread over, in local hours.
BELL_FROM_H, BELL_TO_H = 6.0, 20.0
# Why there is no forecast (the page words are in ui_text `home_words`).
NOT_SET, MISSING, UNIT, STALE, BAD = (
"not_set", "missing", "unit", "stale", "bad")
@dataclass(frozen=True)
class Day:
"""One day's forecast: `kwh` still to come (today) or in all (tomorrow),
and the hourly split if the sensor has one: `((start, end, kwh), ...)`."""
day0: datetime # local midnight, as UTC
kwh: float
periods: tuple = ()
@dataclass(frozen=True)
class Forecast:
today: Day
tomorrow: Day | None
at: datetime # when the sensor last reported
def between(self, t0: datetime, t1: datetime) -> float | None:
"""kWh the sun should give between `t0` (now or later) and `t1`.
None when the span reaches the sunny hours of a day with no
forecast (tomorrow, when its sensor is not set)."""
if t1 <= t0:
return 0.0
last = self.tomorrow or self.today
if t1 > last.day0 + timedelta(days=1, hours=BELL_FROM_H):
return None
total = 0.0
for day in (self.today, self.tomorrow):
if day is None:
continue
a = max(t0, day.day0)
b = min(t1, day.day0 + timedelta(days=1))
if b > a:
total += _day_between(day, a, b, t0)
return total
def _local_hour(t: datetime) -> float:
lt = t.astimezone(BILLING_TZ)
return lt.hour + lt.minute / 60.0 + lt.second / 3600.0
def _bell(h: float) -> float:
"""Cumulative share of a day's sun by local hour `h` (0..1)."""
if h <= BELL_FROM_H:
return 0.0
if h >= BELL_TO_H:
return 1.0
x = (h - BELL_FROM_H) / (BELL_TO_H - BELL_FROM_H)
return (1.0 - math.cos(math.pi * x)) / 2.0
def _hours_since(day0, t):
return (t - day0).total_seconds() / 3600.0
def _day_between(day: Day, a: datetime, b: datetime, now: datetime) -> float:
if day.periods:
out = 0.0
for s, e, kwh in day.periods:
lo, hi = max(a, s), min(b, e)
if hi > lo:
out += kwh * (hi - lo).total_seconds() / (e - s).total_seconds()
return out
# State only: `kwh` is what is left from `max(now, day0)` to the day's
# end, spread over the bell.
h_from = _hours_since(day.day0, max(now, day.day0))
left = 1.0 - _bell(h_from)
if left <= 1e-9:
return 0.0
share = (_bell(_hours_since(day.day0, b))
- _bell(_hours_since(day.day0, a))) / left
return day.kwh * max(0.0, share)
def _parse_time(v):
try:
t = datetime.fromisoformat(str(v).replace("Z", "+00:00"))
except (TypeError, ValueError):
return None
return t if t.tzinfo else t.replace(tzinfo=timezone.utc)
def _periods(attrs: dict, day0: datetime) -> tuple:
"""The hourly split, if the sensor has one, for the day at `day0`."""
d1 = day0 + timedelta(days=1)
out = []
for key in ("detailedForecast", "detailedHourly"):
rows = attrs.get(key)
if isinstance(rows, list) and rows:
starts = []
for r in rows:
if not isinstance(r, dict):
continue
t = _parse_time(r.get("period_start"))
try:
kw = float(r.get("pv_estimate"))
except (TypeError, ValueError):
continue
if t is not None and math.isfinite(kw) and kw >= 0:
starts.append((t, kw))
starts.sort()
for i, (t, kw) in enumerate(starts):
step = (starts[i + 1][0] - t if i + 1 < len(starts)
else timedelta(minutes=30 if key ==
"detailedForecast" else 60))
if step <= timedelta(0) or step > timedelta(hours=1):
step = timedelta(minutes=30 if key == "detailedForecast"
else 60)
if day0 <= t < d1:
out.append((t, t + step,
kw * step.total_seconds() / 3600.0))
if out:
return tuple(out)
wh = attrs.get("wh_period")
if isinstance(wh, dict) and wh:
rows = []
for k, v in wh.items():
t = _parse_time(k)
try:
e = float(v)
except (TypeError, ValueError):
continue
if t is not None and math.isfinite(e) and e >= 0:
rows.append((t, e / 1000.0))
rows.sort()
for i, (t, kwh) in enumerate(rows):
nxt = rows[i + 1][0] if i + 1 < len(rows) else t + timedelta(
hours=1)
if nxt - t > timedelta(hours=1) or nxt <= t:
nxt = t + timedelta(hours=1)
if day0 <= t < d1:
out.append((t, nxt, kwh))
return tuple(out)
def _day(state, day0, now, *, today):
"""`(Day, None)` or `(None, why)` from one sensor's state dict."""
if state is None:
return None, MISSING
raw = str(state.get("state", "")).strip()
if raw.lower() in BAD_STATES:
return None, MISSING
try:
v = float(raw)
except ValueError:
return None, BAD
attrs = state.get("attributes") or {}
unit = str(attrs.get("unit_of_measurement") or "").strip()
if unit not in KWH_PER:
return None, UNIT
kwh = v * KWH_PER[unit]
if not math.isfinite(kwh) or kwh < 0:
return None, BAD
periods = _periods(attrs, day0)
if periods and today:
# The split is the whole day; keep what is still to come, scaled so
# it adds up to the state (the state is what the integration says
# is left).
left = sum(k * max(0.0, min(1.0, (e - max(s, now)).total_seconds()
/ (e - s).total_seconds()))
for s, e, k in periods)
if left > 1e-6:
f = kwh / left
periods = tuple((s, e, k * f) for s, e, k in periods)
return Day(day0, kwh, periods), None
def _reported(state):
ts = [_parse_time(state.get(k)) for k in
("last_reported", "last_updated", "last_changed")]
ts = [t for t in ts if t is not None]
return max(ts) if ts else None
def read(today_state, tomorrow_state, now: datetime, *,
configured=True):
"""`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None
(the option is not set, or the sensor is missing): the forecast then
covers today only."""
if not configured:
return None, NOT_SET
here = now.astimezone(BILLING_TZ)
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
nd = d0.date() + timedelta(days=1)
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
day0, day1 = d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
today, why = _day(today_state, day0, now, today=True)
if today is None:
return None, why
at = _reported(today_state)
# A reading from before local midnight is yesterday's "left today" (0
# after sunset) until the integration refreshes: stale, not a dark day.
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
or (at - now).total_seconds() > 300:
return None, STALE
tomorrow = None
if tomorrow_state is not None:
t, _w = _day(tomorrow_state, day1, now, today=False)
t_at = _reported(tomorrow_state)
if t is not None and t_at is not None and t_at >= day0 \
and (now - t_at).total_seconds() <= FRESH_S:
tomorrow = t
return Forecast(today, tomorrow, at), None
+3
View File
@@ -403,6 +403,9 @@
else $("#batt-banner").replaceChildren();
keepFocus(() => {
$("#bcards").replaceChildren(...S.batteries.map(batteryCard));
// OPT-11 P4: the solar forecast line under the cards.
const sl = $("#batt-solar"), line = modes && S.solar ? S.solar.line : "";
if (sl) { sl.textContent = line || ""; sl.hidden = !line; }
if (simpleHost && simpleHost.isConnected) {
simpleHost.replaceChildren(...(modes ? S.batteries.map(simpleRow)
: [h("p", { class: "small muted", style: "margin:4px 0" }, t("batteries.modes_off.name") + ". " + t("batteries.description"))]));
+1
View File
@@ -101,6 +101,7 @@
<a class="btn" href="#settings" data-t="batteries.settings"></a>
</div>
<div id="bcards"></div>
<p class="small muted" id="batt-solar" hidden></p>
<details class="grp" id="batt-how" hidden>
<summary><span class="grow"><strong data-t="batteries.how.name"></strong><span class="small muted gdesc" data-t="batteries.how.description"></span></span><span class="chev" aria-hidden="true">&#8250;</span></summary>
<div class="body small">
+11
View File
@@ -446,7 +446,17 @@
e.textContent = ok ? e.dataset.kwNote.replace("{kw}", window.UX13.kw(mp.peak_w) + " kW") : "";
});
}
// OPT-11 P4: the solar forecast card shows the add-on's forecast line.
function fillSolar() {
const st = window.UX13.status();
const line = st && st.solar ? st.solar.line : "";
document.querySelectorAll("#simple [data-solar-note]").forEach((e) => {
e.textContent = line || "";
e.hidden = !line;
});
}
document.addEventListener("ux13:status", fillNotes);
document.addEventListener("ux13:status", fillSolar);
function simpleField(f) {
const row = fieldRow(f, null, "simple");
row.querySelector(".head").append(chip(f.applies));
@@ -458,6 +468,7 @@
const help = row.querySelector(".help");
if (help) help.textContent = n.description;
}
if (f.note === "forecast") row.append(h("div", { class: "small muted", "data-solar-note": "", hidden: true }));
return h("div", { class: "card" }, row);
}
function renderSimple() {
+7 -2
View File
@@ -119,7 +119,7 @@
"description": "Every setting, grouped by task.",
"view": {"name": "Settings view", "simple": "Simple", "installer": "Installer"},
"simple": {"description": "The choices a home owner makes.",
"note": {"month_peak": {"description": "This month's peak so far: {kw}."}, "shadow": {"description": "Use Always on while you test a new installation."}},
"note": {"month_peak": {"description": "This month's peak so far: {kw}."}, "shadow": {"description": "Use Always on while you test a new installation."}, "forecast": {"description": "With a forecast, a battery buys only what the sun will not give."}},
"batteries": {"name": "Batteries"},
"more": {"description": "Looking for the meter, inverters or prices?"},
"installer": "Open the installer view"},
@@ -327,6 +327,7 @@
"leftover_alone": "No Solar first battery is on, so it gets solar first.",
"covers_house": "It gives power to cover the house.",
"other_refuses": "{name} refuses to give power, so this battery takes over.",
"other_cycle": "{name} is emptying for its monthly cycle. This one does not charge.",
"grid_charge": "Prices are low, so it charges from the grid.",
"rests_charge": "Another battery charges from the grid, so it rests.",
"selling": "Prices are high, so it sells to the grid.",
@@ -334,8 +335,12 @@
"saves": "Saves its power for {at}.",
"saves_later": "Saves its power for higher prices.",
"no_prices": "No prices, so it follows the house.",
"no_spread": "Prices differ too little, 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.",
"tops_up": "Too little sun is forecast, so it charges some from the grid.",
"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}.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "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.",
+7 -2
View File
@@ -114,7 +114,7 @@
"description": "Elke instelling, per taak.",
"view": {"name": "Weergave van de instellingen", "simple": "Eenvoudig", "installer": "Installateur"},
"simple": {"description": "De keuzes die een eigenaar maakt.",
"note": {"month_peak": {"description": "De piek van deze maand tot nu: {kw}."}, "shadow": {"description": "Kies Altijd aan terwijl u een nieuwe installatie test."}},
"note": {"month_peak": {"description": "De piek van deze maand tot nu: {kw}."}, "shadow": {"description": "Kies Altijd aan terwijl u een nieuwe installatie test."}, "forecast": {"description": "Met een prognose koopt een batterij alleen wat de zon niet geeft."}},
"batteries": {"name": "Batterijen"},
"more": {"description": "Zoekt u de meter, de omvormers of de prijzen?"},
"installer": "De installateursweergave openen"},
@@ -269,6 +269,7 @@
"leftover_alone": "Geen batterij met Eerst zonne-energie staat aan, dus zij krijgt eerst zonne-energie.",
"covers_house": "Ze levert stroom om het huis te dekken.",
"other_refuses": "{name} weigert te leveren, dus deze batterij neemt het over.",
"other_cycle": "{name} loopt leeg voor haar maandelijkse cyclus. Deze laadt niet.",
"grid_charge": "De prijs is laag, dus ze laadt van het net.",
"rests_charge": "Een andere batterij laadt van het net, dus deze rust.",
"selling": "De prijs is hoog, dus ze verkoopt aan het net.",
@@ -276,8 +277,12 @@
"saves": "Spaart haar stroom voor {at}.",
"saves_later": "Spaart haar stroom voor hogere prijzen.",
"no_prices": "Geen prijzen, dus ze volgt het huis.",
"no_spread": "De prijzen verschillen te weinig, 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.",
"tops_up": "Te weinig zon voorspeld, dus ze laadt wat van het net.",
"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}.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "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.",
+8 -1
View File
@@ -129,7 +129,14 @@ GROUPS = (
wizard=3),)),
# OPT-11: the switch that turns battery modes on. Each battery's mode
# and on/off switch are runtime state on the Batteries screen.
Part("modes", (Field("battery_modes_enabled", TOGGLE),)),
Part("modes", (
Field("battery_modes_enabled", TOGGLE),
# OPT-11 P4: the solar forecast; today's also on Settings, Simple.
Field("pv_forecast_entity", ENTITY, kind="energy", simple=True,
note="forecast"),
Field("pv_forecast_tomorrow_entity", ENTITY, kind="energy"),
Field("solar_trust_pct", SLIDER, "%", step=5),
)),
)),
Group("tariff", (
Part("", (
+49 -1
View File
@@ -246,6 +246,10 @@ NOT READY banner already names the problem, (b) does not apply.
12. `peak_margin_w` outside 0-500 W, not whole, or not below `peak_cap_w`.
SAFETY-79; the Supervisor checks the range too, but not for sim/inject.py.
13. OPT-11 P4: `solar_trust_pct` outside 30-100 or not whole, or a solar
forecast entity that is not a `sensor.` id. A trust of 0 or 500 % would
make the day plan buy everything or nothing, which no one means.
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
@@ -324,9 +328,10 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
"""
import logging
import re
from typing import NamedTuple
from . import messages, pv
from . import dayplan, messages, pv, solarfc
from .arbitrage import enabled as arbitrage_on
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
from .custom_formula import (
@@ -1199,6 +1204,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
# --- 12. SAFETY-79: the margin the peak guard keeps under the limit -----
problems += _margin_problems(opts)
problems += _forecast_problems(opts)
# --- warnings: real, but not worth refusing over -------------------------
integ = opts.get("integrator_max_w")
@@ -1751,6 +1757,8 @@ def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
add("pv_entities" if solar else "pv_entity", list(pv.entities(opts)))
for key in ("peak_forecast_entity", "price_now_entity", "price_avg_entity"):
add(key, opts.get(key))
for key in FORECAST_KEYS:
add(key, opts.get(key))
return out
@@ -1790,6 +1798,19 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
_advise(notes, "warn", (key,),
f"{entity} reads in {unit}, but {S(key, where)} is "
"taken as watts, so pick a sensor in W.")
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
_advise(notes, "warn", (key,),
f"{entity} does not read in kWh, so pick a solar "
"forecast sensor in kWh.")
if any(opts.get(k) for k in FORECAST_KEYS) and \
opts.get("battery_modes_enabled") is not True:
_advise(notes, "info", ("pv_forecast_entity",),
"The solar forecast is used only with battery modes on.")
if opts.get("pv_forecast_tomorrow_entity") and \
not opts.get("pv_forecast_entity"):
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
"Tomorrow's forecast is used only with today's, so set "
f"{S('pv_forecast_entity')} too.")
# --------------------------------------------------------------------------- #
@@ -2045,6 +2066,33 @@ def _margin_problems(opts: dict) -> list[str]:
return []
TRUST_RANGE_PCT = (30, 100)
# A Home Assistant sensor id; it goes into the /api/states/<id> path.
_SENSOR_ID = re.compile(r"sensor\.[a-z0-9_]+")
FORECAST_KEYS = ("pv_forecast_entity", "pv_forecast_tomorrow_entity")
def _forecast_problems(opts: dict) -> list[str]:
"""Rule 13 (OPT-11 P4): the trust is a whole percentage in range, and
each forecast entity set is a sensor."""
out = []
raw = opts.get("solar_trust_pct")
trust = _number(opts, "solar_trust_pct", dayplan.DEFAULT_TRUST_PCT)
lo, hi = TRUST_RANGE_PCT
if trust is None or trust != int(trust) or not lo <= trust <= hi:
out.append(f"{S('solar_trust_pct')} must be a whole number from {lo} "
f"to {hi} %. It is {messages.shown(raw)}.")
for key in FORECAST_KEYS:
e = opts.get(key)
if e is None or e == "":
continue
if not isinstance(e, str) or not _SENSOR_ID.fullmatch(e):
out.append(f"{S(key)} must be a sensor, like "
f"sensor.energy_production_today_remaining. It is "
f"{messages.shown(e)}.")
return out
def _number(opts: dict, key, default):
raw = opts.get(key, default)
if raw is None or raw == "":
+10
View File
@@ -151,6 +151,13 @@ options:
# switch and a mode on the Batteries screen, kept in the add-on's store.
# DECISIONS/OPT-11-battery-modes.md.
battery_modes_enabled: false
# OPT-11 P4: a Home Assistant solar forecast (kWh left today; tomorrow's is
# optional) and how much of it to count on. Read only with battery modes
# on; empty means no day plan (P3 option A). app/solarfc.py lists the
# integrations whose sensors fit.
pv_forecast_entity: ""
pv_forecast_tomorrow_entity: ""
solar_trust_pct: 70
# TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT
# DECIDES ANYTHING. Optional, empty on every install that does not set it, and
@@ -951,6 +958,9 @@ schema:
estop_fitted: bool
battery_modes_enabled: bool
pv_forecast_entity: str?
pv_forecast_tomorrow_entity: str?
solar_trust_pct: int(30,100)
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
# `price_now_entity`/`price_avg_entity`, which are legal only while
# `price_provider` is `off` and make the add-on REFUSE TO START otherwise.
File diff suppressed because one or more lines are too long
+9 -5
View File
@@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what "
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
print()
print("ARB-03 2. the split: thirteen sources, each classified where it is BUILT")
print("ARB-03 2. the split: fourteen sources, each classified where it is BUILT")
# The classification is graded against what each claim is ABOUT, restated here
# so that the table in arbiter.py and the expectation in a test are not the same
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's
@@ -171,6 +171,10 @@ SITE_SOURCES = {
"offending number is the SITE target, which no single "
"inverter owns - parking one of them would leave the rest "
"pushing the same way with the guard satisfied",
# SAFETY-78.
"drain": "the site target may not go below a pinned maintenance "
"drain, or a sibling would charge against it. Filed at N>1 "
"only",
}
UNIT_SOURCES = {
"device": "one inverter's own reported min/max rating",
@@ -202,9 +206,9 @@ for _src, _why in sorted(SITE_SOURCES.items()):
print(f" site {_src:<11} {_why}")
for _src, _why in sorted(UNIT_SOURCES.items()):
print(f" unit {_src:<11} {_why}")
check("all THIRTEEN claim sources are classified, and only those thirteen",
check("all FOURTEEN claim sources are classified, and only those fourteen",
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
check("the six that are about the GRID CONNECTION are site claims",
check("the seven that are about the GRID CONNECTION are site claims",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
check("the seven that are about a PACK, a DEVICE or one write are per-inverter",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
@@ -225,8 +229,8 @@ check("with one inverter every source routes to the site, per-inverter ones "
check("...and with none configured at all, likewise",
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("with TWO the identity breaks and the split becomes real: the seven "
"per-inverter sources route to the unit this controller drives, the six "
"site sources stay on the grid connection",
"per-inverter sources route to the unit this controller drives, the "
"seven site sources stay on the grid connection",
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
== {**{s: "inverter-a" for s in UNIT_SOURCES},
**{s: ACTUATOR_SITE for s in SITE_SOURCES}})
+21 -1
View File
@@ -93,7 +93,8 @@ WANT = {
"off": ("solar_first", "off"),
"off_peak": ("peak", "off_peak"),
"refusal": ("refuses_give", "other_refuses"),
"maint": ("cycle_mode", "leftover"),
# SAFETY-78: the other battery says why it does not charge.
"maint": ("cycle_mode", "other_cycle"),
}
# ============================================================================ #
@@ -317,6 +318,25 @@ _cases = [
(dict(role="charge", refused=-1), "refuses_charge"),
(dict(role="sell", peak=True), "peak"),
(dict(role="hold", off=True), "off"),
# OPT-11 P4: the day plan's word, while holding only.
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", has_at=True,
day="sun_fills"), "sun_fills"),
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="tops_up"),
"tops_up"),
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="no_forecast"),
"no_forecast"),
(dict(mode=packmode.LEFTOVER_SOLAR, role="charge", day="tops_up"),
"grid_charge"),
# SAFETY-78: another battery drains for its monthly cycle.
(dict(other_cycle="A"), "other_cycle"),
(dict(other_cycle="A", modes_on=False, giving=True), "other_cycle"),
(dict(other_cycle="A", mode=packmode.CHEAP_HOURS, role="charge"),
"other_cycle"),
(dict(other_cycle="A", peak=True), "peak"),
(dict(other_cycle="A", refused=-1), "refuses_charge"),
(dict(other_cycle="A", off=True), "off"),
(dict(other_cycle="A", mode=packmode.LEFTOVER_SOLAR, role="hold",
day="tops_up"), "other_cycle"),
]
_wrong = [(kw, want, why(**kw)) for kw, want in _cases if why(**kw) != want]
print(f" {_wrong}")
+12 -38
View File
@@ -1363,50 +1363,24 @@ check("...while the second pack's write reaches only its own namespace",
# --------------------------------------------------------------------------- #
print()
print("12. RECORDED, NOT FIXED: the site loop still contradicts a drain at N>=2")
print("12. FIXED (SAFETY-78): a drain at N>=2 no longer parks the site")
# ⚠️ THIS IS A DEFECT THIS CARD FOUND AND DELIBERATELY DID NOT FIX, AND IT IS
# WRITTEN DOWN AS A RUNNING CHECK RATHER THAN AS A COMMENT SO IT CANNOT BE
# FORGOTTEN OR QUIETLY "FIXED" BY A TUNING CHANGE.
#
# WHAT IT IS. The maintenance SET is a per-unit claim and PINS its pack. The
# loop's target is a SITE claim. When the site target is BELOW the pin - a
# drain of 2000 W while the house is importing less than that, which is most of
# a night - the only split that sums to the site target asks the sibling to
# CHARGE while the maintained pack DISCHARGES, and `allocator.plan()` refuses it
# by name as circulating power. The refusal is correct and fails safe: the whole
# site goes to 0 W with an ARBITER line in the action log.
#
# WHY IT IS NOT THIS CARD'S. It is PRE-EXISTING - measured on release/1.0 at
# 2d025ee, where the same drain filed on `inverters[0]` produces the identical
# refusal with the two unit names swapped - and fixing it means changing what
# the site loop's target MEANS while a unit is pinned (regulate the meter using
# only the unpinned packs), which is loop and allocator territory, not
# maintenance's. MAINT-05 moves which pack is pinned and nothing else about it.
#
# ⚠️ AND THE CONSEQUENCE AN OPERATOR WOULD SEE IS A MISDIAGNOSIS, WHICH IS WHY
# IT MATTERS ENOUGH TO PIN. The pack is held at 0 W while its FSM believes it is
# draining, so after 12 h the DRAIN timeout fires and reports "drain stuck" -
# §12's words for "the battery is not following commands, a fault". The battery
# is fine; the site refused to allocate. That is the same class of wrong story
# this card's own notes warn about for the deferral.
# This section used to pin the defect: with the house below the drain, the
# only split summing to the site target asked the sibling to CHARGE, the
# allocator refused it and the site sat at 0 W while the FSM believed it was
# draining. SAFETY-78 files a site `drain` floor at the pin, so the sibling
# only ever adds power the same way. test_safety78.py grades it in full.
_cX, _ = controller(TWO_OPTS, grid=0.0)
_cX.maints[1].force_start(_now)
one_cycle(_cX)
print(f" house at 0 W, B draining 2000 W -> reason {_cX.reason!r}; "
f"{_cX.allocation.explain()}")
check("a maintenance drain BELOW the site target is refused by the allocator "
"and the site parks at 0 W - recorded here so the next card can find it, "
"not asserted as desirable",
_cX.target == 0.0 and _cX.allocation.refused
and "circulating power" in _cX.allocation.explain())
check("...and the pack that is supposedly draining is commanded 0 W, so its "
"12 h DRAIN timeout would eventually report 'drain stuck' - a fault "
"message about a healthy battery",
_cX.maints[1].phase == DRAIN and _cX.inverters[1].target == 0.0)
check("...while the SAME site with the house importing more than the drain "
"allocates cleanly, which is what makes this a CONDITION rather than "
"'maintenance does not work at two inverters'",
check("a maintenance drain above the loop's site target is allocated, not "
"refused: B drains at its rate and A is held at 0 W",
_cX.allocation.refused is None and _cX.target == 2000.0
and _cX.inverters[1].target == 2000.0 and _cX.inverters[0].target == 0.0)
check("...and the SAME site with the house importing more than the drain "
"still allocates cleanly",
_cB.allocation.refused is None and _cB.inverters[1].target == 2000.0)
+12 -2
View File
@@ -112,6 +112,11 @@ try:
finally:
_undo_c()
_undo_b()
# SAFETY-78 changes n2_maint ON PURPOSE: on a5dddad and 5a4cdc2 its N=2 drain
# was refused on 392 of 540 cycles (site at 0 W); now none is. Printed on the
# SAFETY-78 tree the same way. Every other digest is unchanged.
SAFETY78_N2_MAINT = \
"cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1"
for k in A5DDDAD:
print(f" {k:7} {'same' if _now[k] == A5DDDAD[k] else 'DIFFERS'} "
f"{_now[k][:16]}")
@@ -119,8 +124,13 @@ check("⭐ at N=1 and N=2, with the option absent and with it false, every "
"command, reason and allocation record over 540 cycles each is "
"byte-identical to a5dddad (house steps, a solar surplus, a battery "
"at its cut-out level, at N=2 a refusal with probes, both batteries "
"due for maintenance), and so is the arbiter on 6000 claim lists",
_now == A5DDDAD)
"due for maintenance; n2_maint apart, below), and so is the arbiter "
"on 6000 claim lists",
{k: v for k, v in _now.items() if k != "n2_maint"}
== {k: v for k, v in A5DDDAD.items() if k != "n2_maint"})
check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad "
"refused on 392 of 540 cycles now runs (test_safety78.py)",
_now["n2_maint"] == SAFETY78_N2_MAINT != A5DDDAD["n2_maint"])
check("...and no cycle in those runs filed a `mode` claim or handed "
"`compute()` a bound",
_claims and not any(c.source == "mode" for cl in _claims for c in cl)
+800
View File
@@ -0,0 +1,800 @@
"""Runnable check for OPT-11 P4, the solar forecast and the day plan.
`python3 test_modes_p4.py`
Drives the pure modules (solarfc, dayplan, modetrade) and the REAL Controller
(tools/opt11p3_replay.py's engine, with a forecast sensor in the fake Home
Assistant), in simulated time. DECISIONS/OPT-11-battery-modes.md has the
record.
1. No usable forecast (none set, or set and unavailable): every command,
trade and role is byte-identical to release/1.0 5a4cdc2 (digests measured
there), and the harness sees a change there.
2. The forecast sensors: the three supported shapes, units, stale, missing.
3. The house profile: hours, days kept, the night floor, ready.
4. The day plan: sunny, partly, dark; Solar first never gets a level; a
Leftover solar level is the shortfall; hysteresis.
5. modetrade with a plan: a fuzz against the invariants, and no plan = P3.
6. The real controller over a night and a day: sunny (no grid charge),
dark (Leftover solar buys the shortfall only, and stops there), Cheap
hours skips the part the sun covers, a forecast that goes away mid-run,
and every P1-P3 and SAFETY-79 invariant on every cycle.
7. The Batteries screen and the Simple view: the forecast line and Why.
8. Options: validation of the trust and the entities.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import asyncio
import logging
import os
import random
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
from named_absence import nth, present # noqa: E402
import opt11p3_replay as rp # noqa: E402
from app import arbitrage, dayplan, homeview, modetrade, packmode # noqa
from app import priceruns, solarfc, validate # noqa: E402
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)
L = timezone(timedelta(hours=2))
A, B = "inverter-a", "inverter-b"
D0 = datetime(2026, 9, 21, tzinfo=L)
UTC0 = D0.astimezone(timezone.utc)
# ============================================================================ #
print("1. no usable forecast: byte-identical to 5a4cdc2 (P3 option A)")
# Printed by tools/opt11p4_identity.py on release/1.0 5a4cdc2 (2026-09-29),
# goodwe-addon-checks:1. 1080 cycles each, battery modes on.
BASE_DIGESTS = {
"n1_ls":
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
"n1_ch":
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
"n2_sf_ls":
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
"n2_ch_ls":
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
"n2_ls_ls":
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
"n2_sf_ch":
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
"n1_ls_fc_down":
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
"n1_ch_fc_down":
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
"n2_sf_ls_fc_down":
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
"n2_ch_ls_fc_down":
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
"n2_ls_ls_fc_down":
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
"n2_sf_ch_fc_down":
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
}
import opt11p4_identity as ident # noqa: E402
got = ident.digests()
for k, v in BASE_DIGESTS.items():
check(f"{k}: same digest as 5a4cdc2", got.get(k) == v)
check("every scenario is pinned", set(got) == set(BASE_DIGESTS))
for k in [k for k in got if k.endswith("_fc_down")]:
check(f"{k}: a set but unavailable forecast equals no forecast",
got[k] == got[k[:-len('_fc_down')]])
def _moved(scn, patch, undo):
patch()
try:
return ident.p3.digest(ident.run(**ident.SCENARIOS[scn])[0]) \
!= BASE_DIGESTS[scn]
finally:
undo()
_gcm = modetrade.GRID_CHARGE_MODES
check("the harness bites: Leftover solar grid-charging with no plan moves "
"n2_sf_ls",
_moved("n2_sf_ls",
lambda: setattr(modetrade, "GRID_CHARGE_MODES",
(packmode.LEFTOVER_SOLAR, packmode.CHEAP_HOURS)),
lambda: setattr(modetrade, "GRID_CHARGE_MODES", _gcm)))
_read = solarfc.read
def _as_dark(today, tomorrow, now, **kw):
return _read({"state": "0", "attributes": {"unit_of_measurement":
"kWh"},
"last_updated": now.isoformat()}, None, now)
check("...and an unavailable forecast read as a dark one moves "
"n2_sf_ls_fc_down (Leftover solar would buy its shortfall)",
_moved("n2_sf_ls_fc_down", lambda: setattr(solarfc, "read", _as_dark),
lambda: setattr(solarfc, "read", _read)))
# ============================================================================ #
print("2. the forecast sensors")
NOW = datetime(2026, 9, 21, 9, 0, tzinfo=L).astimezone(timezone.utc)
def st(state, unit="kWh", at=NOW, **attrs):
a = dict(attrs)
if unit is not None:
a["unit_of_measurement"] = unit
return {"state": state, "attributes": a,
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
fc, why = solarfc.read(st("12.5"), None, NOW)
check("Forecast.Solar shape (state only, kWh): read", fc is not None
and abs(fc.today.kwh - 12.5) < 1e-9 and fc.today.periods == ())
check("...the rest of the day adds up to the state",
abs(fc.between(NOW, UTC0 + timedelta(days=1)) - 12.5) < 1e-6)
check("...and nothing comes after sunset (20:00 local)",
fc.between(datetime(2026, 9, 21, 20, tzinfo=L), UTC0
+ timedelta(days=1)) < 1e-9)
fc2, _ = solarfc.read(st("12500", unit="Wh"), None, NOW)
fc3, _ = solarfc.read(st("0.0125", unit="MWh"), None, NOW)
check("Wh and MWh are converted, never guessed",
abs(fc2.today.kwh - 12.5) < 1e-9 and abs(fc3.today.kwh - 12.5) < 1e-9)
check("a sensor with no unit, or in W, is refused (unit)",
solarfc.read(st("12.5", unit=None), None, NOW) == (None, solarfc.UNIT)
and solarfc.read(st("12.5", unit="W"), None, NOW)[1] == solarfc.UNIT)
check("unavailable, unknown and a missing entity are no forecast",
solarfc.read(st("unavailable"), None, NOW)[1] == solarfc.MISSING
and solarfc.read(st("unknown"), None, NOW)[1] == solarfc.MISSING
and solarfc.read(None, None, NOW)[1] == solarfc.MISSING)
check("a negative or non-numeric state is no forecast",
solarfc.read(st("-1"), None, NOW)[1] == solarfc.BAD
and solarfc.read(st("lots"), None, NOW)[1] == solarfc.BAD)
old = NOW - timedelta(seconds=solarfc.FRESH_S + 1)
check("older than 3 h is stale, and stale is no forecast",
solarfc.read(st("12.5", at=old), None, NOW) == (None, solarfc.STALE)
and solarfc.read(st("12.5", at=NOW - timedelta(hours=2, minutes=59)),
None, NOW)[0] is not None)
check("a report from the future is stale too",
solarfc.read(st("12.5", at=NOW + timedelta(hours=1)), None,
NOW)[1] == solarfc.STALE)
check("not configured says so", solarfc.read(None, None, NOW,
configured=False)[1]
== solarfc.NOT_SET)
# Solcast: detailedForecast, kW per 30 min, the whole day.
sol = [{"period_start": (UTC0 + timedelta(minutes=30 * i)).isoformat(),
"pv_estimate": 2.0 if 20 <= i < 36 else 0.0} for i in range(48)]
fc4, _ = solarfc.read(st("8.0", detailedForecast=sol), None, NOW)
# 10:00-18:00 local at 2 kW = 16 kWh in all; from 09:00 all 16 is to come,
# scaled to the state's 8.
check("Solcast shape: periods read and scaled to the state (kWh left)",
fc4.today.periods and abs(fc4.between(NOW, UTC0 + timedelta(days=1))
- 8.0) < 1e-6)
check("...the split is used: 10:00-12:00 is a quarter of the day's sun",
abs(fc4.between(datetime(2026, 9, 21, 10, tzinfo=L),
datetime(2026, 9, 21, 12, tzinfo=L)) - 2.0) < 1e-6)
# Open-Meteo: wh_period, Wh per hour.
wh = {(UTC0 + timedelta(hours=h)).isoformat(): (1000.0 if 8 <= h < 16
else 0.0)
for h in range(24)}
fc5, _ = solarfc.read(st("7.0", wh_period=wh), None, NOW)
check("Open-Meteo shape: wh_period read (8 x 1 kWh, 7 left at 09:00)",
abs(fc5.between(NOW, UTC0 + timedelta(days=1)) - 7.0) < 1e-6
and abs(fc5.between(datetime(2026, 9, 21, 14, tzinfo=L),
datetime(2026, 9, 21, 16, tzinfo=L)) - 2.0) < 1e-6)
TOM = UTC0 + timedelta(days=1)
check("without tomorrow's sensor, a span into tomorrow's sun is unknown",
fc.between(NOW, TOM + timedelta(hours=9)) is None
and fc.between(NOW, TOM + timedelta(hours=5)) is not None)
fct, _ = solarfc.read(st("12.5"), st("20.0"), NOW)
check("with it, tomorrow's sun is counted",
abs(fct.between(TOM, TOM + timedelta(days=1)) - 20.0) < 1e-6)
fcs, _ = solarfc.read(st("12.5"), st("20.0", at=old), NOW)
check("a stale tomorrow is dropped, today is kept",
fcs is not None and fcs.tomorrow is None)
# Review (PR #156): just after local midnight the integration may not have
# refreshed yet, so "left today" is still yesterday's 0 and "tomorrow" is
# really today. That is stale, not a dark day.
MID = datetime(2026, 9, 22, 0, 10, tzinfo=L).astimezone(timezone.utc)
EVE = datetime(2026, 9, 21, 21, 30, tzinfo=L).astimezone(timezone.utc)
check("a today reading from before local midnight is stale (not 0 kWh)",
solarfc.read(st("0.0", at=EVE), st("15", at=EVE), MID)
== (None, solarfc.STALE))
fcm, _ = solarfc.read(st("15", at=MID - timedelta(minutes=2)),
st("18", at=EVE), MID)
check("...and a tomorrow reading from before midnight is dropped",
fcm is not None and fcm.tomorrow is None)
# ============================================================================ #
print("3. the house profile")
def profile(day_w=None, days=1, evening=None, start=UTC0 - timedelta(days=1)):
hp = dayplan.HouseProfile()
t = start
end = start + timedelta(days=days)
while t < end:
h = t.astimezone(L).hour
w = (evening if evening is not None and 17 <= h < 22 else
day_w if day_w is not None else 600.0)
hp.add(t, w)
t += timedelta(seconds=5)
hp.add(t, None)
return hp
hp = profile(day_w=600.0, evening=3000.0)
check("a full day recorded is ready, with 24 hours",
hp.ready() and sum(len(r) for r in hp.days.values()) == 24)
check("an hour's figure is its mean", abs(hp.hour_w(18) - 3000.0) < 1e-6
and abs(hp.hour_w(3) - 600.0) < 1e-6)
hp_half = dayplan.HouseProfile()
for i in range(12 * 60 * 10):
hp_half.add(UTC0 + timedelta(seconds=5 * i), 500.0)
check("ten hours are not ready (MIN_HOURS)", not hp_half.ready())
neg = profile(day_w=-2000.0)
f, base = neg.estimator()
check("a sunny net use (negative) is lifted to the night floor for the "
"solar sum", f(12) == -2000.0 and base == -2000.0 or base >= 0.0)
hp9 = profile(days=9)
check("only the last 7 days are kept", len(hp9.days) <= 8)
hp_rt = dayplan.HouseProfile(hp.dump())
check("dump and load give the same figures", hp_rt.hour_w(18) == round(
hp.hour_w(18), 1) and hp_rt.ready())
bogus = dayplan.HouseProfile({"2099-01-01": {str(h): 9e5 for h in range(24)},
"zz": {str(h): 9e5 for h in range(24)}})
check("a stored day that is not a date is dropped on load",
"zz" not in bogus.days)
bogus.add(UTC0, 500.0)
bogus.add(UTC0 + timedelta(hours=1), 500.0)
check("a stored day after today (a wrong clock) is pruned, not kept forever",
"2099-01-01" not in bogus.days)
check("a bad stored profile is ignored, not fatal",
not dayplan.HouseProfile({"x": "y", "2026-09-20": {"99": 1,
"a": "b"}}).ready())
# ============================================================================ #
print("4. the day plan")
P = modetrade.Pack
def pk(name, mode, soc, cap=10.0, **kw):
return P(name, True, mode, False, 2500.0, soc, cap, **kw)
# Prices: cheap 01:00-03:00, dear 19:00-20:00 local (as in test_modes_p3).
DAYW = [0.20] * 96 * 2
for i in list(range(4, 12)) + list(range(100, 108)):
DAYW[i] = 0.02
for i in list(range(76, 80)) + list(range(172, 176)):
DAYW[i] = 0.40
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p) for i, p in enumerate(DAYW)]
from app.price import PricePoint # noqa: E402
def runs_at(now):
pts = [PricePoint(start=s, eur_per_kwh=rp.all_in(p), provisional=False)
for s, _n, p in WPTS]
return priceruns.plan(pts, now, required_eur_kwh=0.30, charge_kwh=6.0,
charge_kw=2.5, sell_kwh=0.0, sell_kw=0.0)
T0 = datetime(2026, 9, 21, 0, 30, tzinfo=L).astimezone(timezone.utc)
R0 = runs_at(T0)
check("the price plan has a cheap run at night and a dear one at 19:00",
R0.on and R0.cheap.holds(datetime(2026, 9, 21, 1, 30, tzinfo=L))
and any(g.start.astimezone(L).hour == 19 for g in R0.give))
def fc_of(kwh, now=T0):
wh = {(UTC0 + timedelta(hours=h)).isoformat():
(kwh * 1000.0 / 10 if 8 <= h < 18 else 0.0) for h in range(24)}
return solarfc.read(st(f"{kwh}", wh_period=wh, at=now), None, now)[0]
EST = profile(day_w=600.0, evening=3000.0).estimator()
PACKS = [pk(A, packmode.SOLAR_FIRST, 60.0), pk(B, packmode.LEFTOVER_SOLAR,
30.0)]
def dplan(kwh, packs=PACKS, trust=70, est=EST):
return dayplan.plan(fc_of(kwh), est, R0, T0, packs, trust_pct=trust,
floor_pct=20.0, ceiling_pct=95.0)
sunny, partly, dark = dplan(40.0), dplan(12.0), dplan(1.0)
print(f" sunny {sunny.verdict} {sunny.targets}; partly {partly.verdict} "
f"{partly.targets}; dark {dark.verdict} {dark.targets}")
check("40 kWh forecast: sunny, and B needs nothing from the grid",
sunny.verdict == dayplan.SUNNY and sunny.need_kwh[B] == 0.0)
check("1 kWh forecast: dark (not even A fills from the sun)",
dark.verdict == dayplan.DARK)
check("Solar first never gets a level (never charges from the grid)",
all(A not in p.targets for p in (sunny, partly, dark)))
_use = 3000.0 / 1000.0 # the 19:00-20:00 run at 3 kW
_want = 20.0 + _use / dayplan.EFF / 10.0 * 100.0
check("dark: B's level is what the house draws in the dear run (floor + "
f"use / eff = {_want:.1f} %), not the 95 % ceiling",
abs(dark.targets[B] - _want) < 0.1 and dark.targets[B] < 95.0)
check("...so it buys only the shortfall", abs(dark.need_kwh[B] - (
_want - 30.0) / 10.0) < 0.01)
check("the level falls as the forecast rises (partly between dark and "
"sunny)", sunny.targets[B] <= partly.targets[B] <= dark.targets[B])
ch = dplan(12.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
pk(B, packmode.CHEAP_HOURS, 30.0)])
ch_dark = dplan(1.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
pk(B, packmode.CHEAP_HOURS, 30.0)])
check("...and on a partly sunny day too: Cheap hours ignores the plan "
"(design section 1; review of PR #156)", B not in ch.targets
and B not in ch_dark.targets
and modetrade.charge_to(pk(B, packmode.CHEAP_HOURS, 30.0), 95.0,
{B: 40.0}) == 95.0)
# Review of PR #156: four mutants that survived.
_hi = {h: (3000.0 if 17 <= h < 22 else 2000.0 if 8 <= h < 17 else 600.0)
for h in range(24)}
check("a house that uses more by day leaves less sun: a higher level",
dplan(20.0, est=(lambda h: _hi[h], 600.0)).targets[B]
> dplan(20.0).targets[B] + 1.0)
_two = dplan(40.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
pk(B, packmode.LEFTOVER_SOLAR, 30.0),
pk("inverter-c", packmode.LEFTOVER_SOLAR, 80.0)])
check("two Leftover solar batteries share the sun by their room (65:15)",
abs(_two.share_kwh[B] / _two.share_kwh["inverter-c"] - 6.5 / 1.5)
< 0.01)
_h = lambda hh: datetime(2026, 9, 21, hh, tzinfo=L).astimezone(timezone.utc)
_rp = priceruns.RunPlan(priceruns.Run(_h(13), _h(14), 0.02),
(priceruns.Run(_h(8), _h(9), 0.4),
priceruns.Run(_h(19), _h(20), 0.4)),
None, 0.3, None, True)
_pl = dayplan.plan(fc_of(12.0), EST, _rp, T0, PACKS, trust_pct=70,
floor_pct=20.0, ceiling_pct=95.0)
check("the plan is for the dear run after the cheap run, not an earlier one",
_pl is not None and _pl.until == _h(19))
_hp = dayplan.HouseProfile()
for i in range(30):
_hp.add(_h(10) + timedelta(seconds=5 * i), 500.0)
_hp.add(_h(11), 500.0)
check("an hour with under 5 minutes of readings is not kept",
not any(10 in r for r in _hp.days.values()))
check("trust 100 % counts more sun than 70 %: a lower level",
dplan(12.0, trust=100).targets[B] < partly.targets[B])
check("...and the sun counted scales with the trust exactly",
abs(dplan(12.0, trust=100).sun_kwh * 0.7 - partly.sun_kwh) < 0.01)
_room_a = (95.0 - 60.0) / 10.0 * 10.0 / 10.0 # A: 60 % -> 95 % of 10 kWh
check("Solar first takes the sun first: B's share is what is left after "
f"A's {_room_a:.1f} kWh of room",
abs(partly.share_kwh[B] - max(0.0, partly.sun_kwh - _room_a)) < 0.01
and partly.share_kwh[B] < partly.sun_kwh)
_flat = (lambda h: 600.0, 600.0)
_sunny_net = (lambda h: -2000.0 if 9 <= h < 17 else 600.0, 600.0)
check("the house's net use in sunny hours (negative) is lifted to the night "
"floor: the same sun as a flat 600 W house",
abs(dplan(12.0, est=_flat).sun_kwh - dplan(12.0, est=_sunny_net)
.sun_kwh) < 1e-6)
check("no forecast, no house profile or no price plan: no plan",
dayplan.plan(None, EST, R0, T0, PACKS, trust_pct=70, floor_pct=20,
ceiling_pct=95) is None
and dplan(12.0, est=None) is None
and dayplan.plan(fc_of(12.0), EST, None, T0, PACKS, trust_pct=70,
floor_pct=20, ceiling_pct=95) is None)
# A plan for a run beyond today's forecast (after 20:00, cheap run tomorrow
# night, give run tomorrow evening) with no tomorrow sensor: no plan.
T_EVE = datetime(2026, 9, 21, 21, 0, tzinfo=L).astimezone(timezone.utc)
check("the sun before the dear run is unknown (no tomorrow sensor): no plan",
dayplan.plan(fc_of(12.0, T_EVE), EST, runs_at(T_EVE), T_EVE, PACKS,
trust_pct=70, floor_pct=20, ceiling_pct=95) is None)
off = [pk(A, packmode.SOLAR_FIRST, 60.0),
P(B, False, packmode.LEFTOVER_SOLAR, False, 2500.0, 30.0, 10.0)]
check("an Off battery gets no level", B not in dplan(1.0, packs=off).targets)
maint = [pk(A, packmode.SOLAR_FIRST, 60.0),
pk(B, packmode.LEFTOVER_SOLAR, 30.0, idle=False)]
check("a battery in maintenance gets no level",
B not in dplan(1.0, packs=maint).targets)
# Hysteresis.
cap = {B: 10.0}
check("hysteresis: a 0.3 kWh move on a 40 % level is ignored",
dayplan.hold({B: 40.0}, {B: 43.0}, cap) == {B: 40.0})
check("...a 1 kWh move is taken", dayplan.hold({B: 40.0}, {B: 50.0}, cap)
== {B: 50.0})
check("...10 % of a small level is enough (0.3 kWh on a 25 % level)",
dayplan.hold({B: 25.0}, {B: 28.0}, cap) == {B: 28.0})
check("...a battery new to the plan takes its level, one gone is dropped",
dayplan.hold({A: 50.0}, {B: 33.0}, cap) == {B: 33.0})
_flips = 0
_kept = None
rnd = random.Random(4)
for i in range(400):
lvl = 40.0 + rnd.uniform(-2.0, 2.0) # a noisy forecast
new = dayplan.hold(_kept, {B: lvl}, cap)
if _kept is not None and new != _kept:
_flips += 1
_kept = new
check(f"a forecast wobbling by +-0.2 kWh moves the level rarely "
f"({_flips} of 400)", _flips <= 8)
# ============================================================================ #
print("5. modetrade with a plan")
def p3_chargers(packs, ceiling, done=()):
"""P3 option A's rule, as 5a4cdc2 wrote it."""
return [p for p in packs if p.use and p.idle and p.live
and p.mode in packmode.GRID_CHARGE_MODES and p.soc is not None
and p.soc < ceiling and p.refused >= 0 and p.name not in done]
rnd = random.Random(11)
bad = []
same = 0
for i in range(20000):
packs = [P(n, rnd.random() > 0.1, rnd.choice(packmode.MODES), False,
2500.0, rnd.choice([None, rnd.uniform(0, 100)]),
rnd.choice([None, 10.0, 5.0]), live=rnd.random() > 0.05,
idle=rnd.random() > 0.1, refused=rnd.choice([0, 0, 0, 1, -1]))
for n in (A, B, "c")]
ceiling = rnd.choice([90.0, 95.0, 100.0])
if [p.name for p in modetrade.chargers(packs, ceiling)] == \
[p.name for p in p3_chargers(packs, ceiling)]:
same += 1
targets = {p.name: rnd.uniform(0, 120) for p in packs
if rnd.random() > 0.3}
ch = modetrade.chargers(packs, ceiling, targets=targets)
for p in ch:
to = modetrade.charge_to(p, ceiling, targets)
if p.mode == packmode.SOLAR_FIRST or to is None or to > ceiling \
or p.soc >= to or p.refused < 0 or not (p.use and p.idle
and p.live):
bad.append((p, targets, ceiling))
if p.mode == packmode.LEFTOVER_SOLAR and p.name not in targets:
bad.append(("LS without a level", p))
check("no plan: chargers() is P3 option A's rule on 20 000 random rosters",
same == 20000)
check("with a plan: never Solar first, never above the ceiling or the "
"level, never refusing, Off, not live or in maintenance, and Leftover "
f"solar only with a level ({len(bad)} bad)", not bad)
# decide() with a plan, in the cheap run: every range holds 0, only chargers
# charge, SF never charges.
now_c = R0.cheap.start + timedelta(minutes=5)
bad = []
for i in range(5000):
packs = [P(n, rnd.random() > 0.1, rnd.choice(packmode.MODES), False,
2500.0, rnd.uniform(0, 100), 10.0, live=rnd.random() > 0.05,
idle=rnd.random() > 0.1, refused=rnd.choice([0, 0, 1, -1]))
for n in (A, B)]
targets = {p.name: rnd.uniform(0, 100) for p in packs}
t = modetrade.decide(R0, now_c, packs, charge_ok=True, sell_ok=True,
trade_why=None, price_now=0.1, headroom_w=3000.0,
floor_pct=20.0, ceiling_pct=95.0, house_w=500.0,
targets=targets)
for p in packs:
lo, hi = t.ranges.get(p.name, (0.0, 0.0))
if not lo <= 0.0 <= hi:
bad.append(("range without 0", p, lo, hi))
if t.roles[p.name] == modetrade.CHARGE and (
p.mode == packmode.SOLAR_FIRST
or p.soc >= (95.0 if p.mode == packmode.CHEAP_HOURS
else min(95.0, targets[p.name]))):
bad.append(("charges past its level", p, targets))
check(f"decide() with a plan on 5000 random cases: ranges hold 0, nothing "
f"charges at or past its level, Solar first never ({len(bad)} bad)",
not bad)
t_ls = modetrade.decide(R0, now_c, [pk(A, packmode.SOLAR_FIRST, 50.0),
pk(B, packmode.LEFTOVER_SOLAR, 30.0)],
charge_ok=True, sell_ok=True, trade_why=None,
price_now=0.1, headroom_w=3000.0, floor_pct=20.0,
ceiling_pct=95.0, house_w=500.0,
targets={B: 50.0})
check("a Leftover solar battery below its level charges in the cheap run, "
"Solar first rests", t_ls.roles == {A: modetrade.REST,
B: modetrade.CHARGE})
e = modetrade.energy([pk(B, packmode.LEFTOVER_SOLAR, 30.0)], 20.0, 95.0,
targets={B: 45.0})
check("the cheap run is sized by the shortfall (1.5 kWh), not the room to "
"95 % (6.5 kWh)", abs(e[0] - 1.5) < 1e-9)
# ============================================================================ #
print("6. the real controller over a night and a day")
AIM = rp.PEAK_CAP - 50
FC_KWH = [None] # today's forecast in the fake sensor (None: down)
async def _sensor(self, e):
now = rp_start[0] + timedelta(seconds=self.t)
kwh = FC_KWH[0](now) if callable(FC_KWH[0]) else FC_KWH[0]
if kwh is None:
return {"state": "unavailable", "attributes": {},
"last_updated": now.isoformat()}
if e.endswith("tomorrow"):
return None
loc = now.astimezone(L)
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
wh = {(d0 + timedelta(hours=h)).astimezone(timezone.utc).isoformat():
(kwh * 100.0 if 8 <= h < 18 else 0.0) for h in range(24)}
left = sum(v for k, v in wh.items()
if datetime.fromisoformat(k) >= now) / 1000.0
return {"state": f"{left:.3f}",
"attributes": {"unit_of_measurement": "kWh", "wh_period": wh},
"last_updated": now.isoformat(), "last_reported": now.isoformat()}
rp.Hass.state = _sensor
rp_start = [UTC0]
FC_OPTS = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
"cycle_cost_threshold_eur": 0.20}
def seed_profile(c):
if c.house_profile is not None:
c.house_profile = profile(day_w=600.0, evening=3000.0)
def house_flat(w):
return [(UTC0.timestamp() - 10, float(w))]
def run_day(choice, *, fc, soc0, hours=21.0, start_h=0.75, house_w=800.0,
refuse=(), extra=None, patch=seed_profile):
FC_KWH[0] = fc
start = UTC0 + timedelta(hours=start_h)
rp_start[0] = start
rows = []
def cb(c, h, now, pr):
sh = dict(c.allocation.shares) if c.allocation is not None and \
c.allocation.shares else {}
rows.append(dict(
now=now, grid=h.grid(), shares=sh,
action=getattr(c.arb, "action", None),
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
soc={u: p.soc for u, p in h.plants.items()},
targets=c.plan_targets(), plan=c.day_plan, runs=c.mode_runs))
st_, c = rp.run("p3_ls", house_flat(house_w), WPTS, start, 3600 * hours,
check=cb, soc0=soc0, choice=choice, refuse=refuse,
extra=dict(FC_OPTS, **(extra or {})), patch=patch)
return rows, st_, c
SF_LS = {"a": (True, "solar_first", False), "b": (True, "leftover_solar",
False)}
SF_CH = {"a": (True, "solar_first", False), "b": (True, "cheap_hours",
False)}
def invariants(tag, rows, c):
charge = [r for r in rows if r["action"] == arbitrage.CHARGE]
mixed = [r for r in rows if any(v > 0 for v in r["shares"].values())
and any(v < 0 for v in r["shares"].values())]
check(f"{tag}: no opposite-direction split on any cycle", not mixed)
check(f"{tag}: Solar first never charges from the grid (A's share >= 0 "
"while the site charges)",
all(r["shares"].get(A, 0) >= 0 for r in charge))
q = {int(r["now"].timestamp() // 900) for r in charge}
banked = [b.offtake_avg_w for b in c.p1.blocks
if int(b.start.timestamp() // 900) in q]
check(f"{tag}: every banked quarter with a grid charge <= cap - margin "
f"(max {max(banked, default=0):.1f} W, aim {AIM})",
not banked or max(banked) <= AIM + 0.5)
return charge
# Sunny: 40 kWh forecast. B (Leftover solar) at 30 % needs nothing.
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
charge = invariants("sunny", rows, c)
_last = nth(rows, -1, "a cycle")
print(f" sunny: verdict {present(_last) and _last['plan'] and _last['plan'].verdict}, "
f"{len(charge)} charge cycles")
check("sunny: a day plan is made, and says sunny",
any(r["plan"] is not None and r["plan"].verdict == dayplan.SUNNY
for r in rows))
check("sunny: no grid charge at all", not charge)
# Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run.
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0})
charge = invariants("dark", rows, c)
tgt = nth([r["targets"][B] for r in rows if r["targets"]], 0, "a level")
top = max(r["soc"]["b"] for r in rows)
print(f" dark: B level {tgt:.1f} %, B reached {top:.1f} %, "
f"{len(charge)} charge cycles")
check("dark: Leftover solar charges from the grid in the cheap run",
charge and all(r["runs"].cheap.holds(r["now"]) for r in charge)
and all(r["roles"][B] == modetrade.CHARGE for r in charge))
check("dark: ...only up to its level (the shortfall), not 95 %",
present(tgt) and tgt < 60.0 and top <= tgt + 2.0)
check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST
for r in charge))
# Cheap hours on a partly sunny day: ignores the plan, fills as in P3.
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0})
charge = invariants("cheap hours, partly", rows, c)
top = max(r["soc"]["b"] for r in rows)
print(f" cheap hours partly: B reached {top:.1f} %")
check("Cheap hours gets no planned level",
not any(r["targets"] and B in r["targets"] for r in rows))
rows_nf, _s, c_nf = run_day(SF_CH, fc=None, soc0={"a": 60.0, "b": 30.0})
check("Cheap hours with a sun forecast charges as with none (P3)",
charge and abs(max(r["soc"]["b"] for r in rows_nf) - top) < 1.0
and top > 80.0)
# The forecast goes away mid-run: Leftover solar stops at once (P3 option A).
DROP = UTC0 + timedelta(hours=1, minutes=30)
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
soc0={"a": 60.0, "b": 10.0})
invariants("forecast lost mid-run", rows, c)
before = [r for r in rows if r["now"] < DROP and r["action"]
== arbitrage.CHARGE]
late = [r for r in rows if r["now"] > DROP + timedelta(
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE]
check("forecast lost mid-run: Leftover solar was charging, and stops within "
"a forecast read (60 s) and a cycle", before and not late)
# The forecast is far too high (40 kWh, and there is no sun at all): no grid
# charge, so B covers less of the evening - a money risk, never a safety one.
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0})
invariants("forecast too high", rows, c)
check("forecast too high: no grid charge, and B still keeps its power for "
"the dear run", not [r for r in rows if r["action"]
== arbitrage.CHARGE]
and all(r["roles"].get(B) != modetrade.GIVE for r in rows
if r["now"].astimezone(L).hour not in (19,)))
# A refusal wins over the plan.
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
refuse=("b",))
invariants("refusal", rows, c)
check("refusal: B refuses to charge, so the plan's charge ends and B is "
"never a charger after it", rows and not [
r for r in rows[len(rows) // 3:]
if r["roles"].get(B) == modetrade.CHARGE])
# Off wins over the plan.
rows, st_, c = run_day({"a": (True, "solar_first", False),
"b": (False, "leftover_solar", False)},
fc=1.0, soc0={"a": 60.0, "b": 30.0})
check("Off: an Off battery never charges from the grid, whatever the plan",
not [r for r in rows if r["action"] == arbitrage.CHARGE]
and all(abs(r["shares"].get(B, 0)) < 1e-6 for r in rows))
# No house profile yet (first day): no plan, P3 option A.
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
patch=None)
check("no house profile yet: no plan, and Leftover solar takes solar only",
all(r["plan"] is None for r in rows)
and not [r for r in rows if r["action"] == arbitrage.CHARGE])
check("...while the profile learns and the store keeps each closed hour",
c.house_profile is not None and len(
c.store.data.get("house_profile") or {}) >= 1)
check("selling stays off (option A)", packmode.SELLING_ENABLED is False)
# A forecast arriving mid-period makes the plan at once, not at the next
# 15-minute period.
COME = datetime(2026, 9, 21, 1, 3, tzinfo=L).astimezone(timezone.utc)
rows, st_, c = run_day(SF_LS, fc=lambda now: None if now < COME else 1.0,
soc0={"a": 60.0, "b": 30.0}, hours=0.5)
first = next((r["now"] for r in rows if r["targets"]), None)
check("a forecast that arrives at 01:03 makes the plan before 01:15",
first is not None and first < COME + timedelta(minutes=3))
# Battery modes off: the forecast is never read.
READS = [0]
_real_sensor = rp.Hass.state
async def _counted(self, e):
READS[0] += 1
return await _real_sensor(self, e)
rp.Hass.state = _counted
FC_KWH[0] = 1.0
rp.run("modes_off", house_flat(800.0), WPTS, UTC0 + timedelta(hours=1),
600.0, extra=dict(FC_OPTS))
rp.Hass.state = _real_sensor
check("battery modes off: the forecast sensor is never read", READS[0] == 0)
# ============================================================================ #
print("7. the Batteries screen and the Simple view")
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
hours=0.6)
rp.m.datetime = datetime
sb = homeview.solar_block(c, c.solar_fc.at)
check("the forecast line names the forecast, what is counted and when",
sb["line"].startswith("Solar still to come today:")
and "we count on" in sb["line"] and sb["state"] == "plan")
why = homeview.day_key(c, B)
check("B's Why key (plan, dark) is tops_up", why == "tops_up")
check("...and says so in plain words", homeview.why_words(why)
== "Too little sun is forecast, so it charges some from the grid.")
_d0, _d1 = homeview.local_day(c.solar_fc.at)
_blocks, _ = homeview.mode_runs(c, _d0, _d1, c.solar_fc.at, False)
_grid = [b for b in _blocks if b["kind"] == "grid" and b["battery"] == 1]
_need = homeview.plan_need(c, B)
check(f"Today: B's grid block is the planned shortfall ({_need:.2f} kWh), "
"not its room to 95 %", _grid and abs(_grid[0]["kwh"] - _need) < 0.01
and _need < 3.0)
_run = c.mode_runs.cheap
_packs = c._mode_packs()
_m1 = c._run_mark("charge", _run, _packs, lambda p: True, ((B, 40.0),))
_m2 = c._run_mark("charge", _run, _packs, lambda p: False, ((B, 50.0),))
check("a level the hysteresis moved opens the run to that battery again",
_m1 and not _m2)
c.solar_fc = None
check("the levels are dropped as soon as the forecast goes, even before "
"the plan is rebuilt", c._plan_targets and c.plan_targets() is None)
check("a lost forecast says so: no forecast, Leftover solar solar only",
homeview.solar_block(c, datetime.now(timezone.utc))["state"]
== "no_forecast" and homeview.day_key(c, B) == "no_forecast")
check("the Why line wins over 'saves its power' only while holding",
homeview.battery_why(modes_on=True, mode="leftover_solar", off=False,
reading=True, phase="idle", shadow=False,
running=True, held=False, refused=0, peak=False,
sf_ahead=True, role="hold", has_at=True,
day="sun_fills") == "sun_fills"
and homeview.battery_why(modes_on=True, mode="leftover_solar",
off=False, reading=True, phase="idle",
shadow=False, running=True, held=False,
refused=0, peak=False, sf_ahead=True,
role="charge", has_at=True, day="tops_up")
== "grid_charge")
for lang in ("en", "nl"):
homeview._LANG[0] = lang
t = [homeview.why_words(k) for k in ("sun_fills", "tops_up",
"no_forecast")]
t += [homeview._t("solar.no_forecast"), homeview._t("solar.learning"),
homeview.line_solar(9.2, 6.4, "10:15")]
check(f"{lang}: every P4 sentence is there and short (<= 2 x 12 words)",
all(t) and all(len(s.split()) <= 24 for s in t))
homeview._LANG[0] = "en"
from app import uilayout # noqa: E402
simple = [f.key for _g, _p, f in uilayout.fields() if f.simple]
check("the Simple view has the solar forecast card",
"pv_forecast_entity" in simple)
# ============================================================================ #
print("8. options")
BASE_OPTS = {"setpoint_entity": "number.x", "max_w": 2500}
for v, ok in ((70, True), (30, True), (100, True), (29, False), (101, False),
(70.5, False), ("x", False)):
probs = validate._forecast_problems(dict(solar_trust_pct=v))
check(f"solar_trust_pct {v!r}: {'accepted' if ok else 'refused'}",
(not probs) == ok)
check("a forecast entity that is not a sensor is refused, in plain words",
validate._forecast_problems({"pv_forecast_entity": "number.x"})
and "must be a sensor" in validate._forecast_problems(
{"pv_forecast_entity": "number.x"})[0])
check("an entity id with a path or odd characters is refused",
all(validate._forecast_problems({"pv_forecast_entity": e}) for e in
("sensor.x/../config", "sensor.x?y=1", "sensor.X", "sensor.",
" sensor.x", "sensor.a b"))
and not validate._forecast_problems(
{"pv_forecast_entity": "sensor.energy_production_today_remaining"}))
check("empty entities are fine", not validate._forecast_problems(
{"pv_forecast_entity": "", "pv_forecast_tomorrow_entity": ""}))
notes = []
validate._entity_notes(notes, {"pv_forecast_entity": "sensor.f",
"battery_modes_enabled": True},
{"sensor.f": {"unit": "W"}})
check("a forecast sensor not in kWh gets a warning",
any("kWh" in n.text for n in notes))
notes = []
validate._entity_notes(notes, {"pv_forecast_entity": "sensor.f"},
{"sensor.f": {"unit": "kWh"}})
check("a forecast with battery modes off gets a note that it is unused",
any("battery modes on" in n.text for n in notes))
print()
if fails:
print(f"FAILED: {len(fails)}")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")
+19 -11
View File
@@ -2681,9 +2681,15 @@ check("...and so does the persistent notification, which outlives the page and "
# cycles with the floor, and part 10 at N=1. The sweeps below never reach it:
# their fixtures leave battery modes off, so they still see {device,
# supervised, loop}. DECISIONS/OPT-11-battery-modes.md.
# ⚠️ SAFETY-78 ADDED `drain`, AND IT IS NOT THE DEFERRED RESERVE. It is a
# site `lo` at P_MAINTENANCE, filed only at N>=2 while a pack's maintenance
# drain is pinned, equal to what that pin places. It is not conditioned on a
# state of charge, it never closes discharge (a `lo`, not a `hi`), and it is
# never filed at N=1, so the sweeps below still see {device, supervised,
# loop}. DECISIONS/SAFETY-78-drain-at-two.md.
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
"arbitrage", "maintenance", "loop", "circulation",
"sign_site", "pack", "refused", "mode"}
"sign_site", "pack", "refused", "mode", "drain"}
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
# that a THIRD such indirection reads as an unrecognised append rather than as a
# silent gap in the scan.
@@ -2725,7 +2731,7 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
"- a claim filed through a shape it cannot parse has to fail here rather "
"than be counted as nothing, which is how a scan like this goes quietly "
"vacuous", len(_appends) >= 10 and None not in _filed)
check("...and the sources that cycle can file are EXACTLY these THIRTEEN. THIS "
check("...and the sources that cycle can file are EXACTLY these FOURTEEN. THIS "
"is what makes part 10 a tripwire: replay_loop() hand-builds the claim "
"list, so it cannot see a claim main.py adds - and a loop-side SoC "
"reserve is a new source. Filing one takes this line red on the line it "
@@ -2865,7 +2871,7 @@ print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
+ " | per-inverter: "
+ ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT)))
check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
"SAFETY-30's and OPT-09's additions: the six about the GRID CONNECTION "
"SAFETY-30's, OPT-09's and SAFETY-78's additions: the seven about the GRID CONNECTION "
"are site claims and the five about a pack, a device or one write are "
"per-inverter. `circulation` is a site claim because its quantity is "
"defined over the roster and is identically zero for any one unit; "
@@ -2874,7 +2880,8 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
"per-inverter because since SAFETY-30 it grades one unit's command "
"against that unit's OWN measured power",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"}
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site",
"drain"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance", "pack",
"refused", "mode"})
@@ -2978,7 +2985,8 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
"mode": "inverter-a",
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE})
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE,
"drain": ACTUATOR_SITE})
check("...and the roster it was restored to is the shipped one, so nothing "
"below inherits a two-inverter Controller",
c_src.units == (app_main.UNIT_A,) and len(c_src.inverters) == 1)
@@ -3057,13 +3065,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 FOURTEEN of `Controller.cycle()`'s DIRECT appends route through "
check("all FIFTEEN 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) == 14
len(_direct) == 15
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 "
@@ -3080,7 +3088,7 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
"site-scoped sources and is the whole defect for a per-inverter one: a "
"`device` limit built inside the roster loop and routed without `inv` "
"files inverter B's rating on inverter A, where it binds the wrong "
"share. Nine of the fourteen appends are SCOPE_UNIT - `device`, "
"share. Nine of the fifteen appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack`, "
"SAFETY-73's two `refused` and OPT-11's `mode` - and all nine pass one. "
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
@@ -3097,8 +3105,8 @@ check("...and the only appends this pin does NOT reach are the two that file a "
"arbitrage.claim() by test_arbitrage.py",
sorted(_indirect) == sorted(CLAIM_BUILDERS)
and len(_direct) + len(_indirect) == len(_appends))
check("...and between them the sixteen appends account for exactly the "
"thirteen "
check("...and between them the seventeen appends account for exactly the "
"fourteen "
"sources 10f pins, so the routing pin and the source set are one scan "
"and neither can drift from the other",
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
@@ -3159,7 +3167,7 @@ check("...and naming those three is not an accident of this fixture but the "
{c.source for c in _cl2}
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
"arbitrage", "circulation", "sign_site",
"refused", "mode"})
"refused", "mode", "drain"})
# ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three
# checks above grade WHICH ACTUATOR each claim lands on. They say nothing about
# WHOSE NUMBERS the bound is built from - and those are two different defects
+424
View File
@@ -0,0 +1,424 @@
"""Runnable check for SAFETY-78: a maintenance drain at N=2 no longer stalls.
`python3 test_safety78.py`
At two inverters the drain pins its battery at the drain rate and the site
target is split around the pin. Below the pin the other battery had to charge,
the allocator refused the opposite directions, the site sat at 0 W and the
drain never moved (OPT-11 P1 F1, UX-13 P3 check 5, OPT-11 P3 F4). Now a site
`lo` (`drain`, P_MAINTENANCE) keeps the site target at or above what the pin
places. Drives the REAL Controller (tools/opt11_identity.py's harness).
DECISIONS/SAFETY-78-drain-at-two.md has the record.
1. N=2, house below, at and above the drain rate, a solar surplus: the drain
runs at its rate, no opposite split, no refusal, no log spam, and it ends.
2. A drain that cannot give power waits with ONE log line.
3. Battery modes on, a Cheap hours battery in a cheap run during the drain:
no grid charge, and its card says why.
4. The peak guard binds during a drain, and the arbiter's order holds.
5. N=1 is byte-identical to release/1.0 5a4cdc2, and the harness bites.
6. A seeded sweep of the floor against the loop, trades, the peak floor, a
safety stop, refusals and an Off battery.
Mutations (MUTATION.md, purged copies, graded over this file and the named
tests) are listed in the pull request.
"""
import functools
import logging
import os
import random
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import opt11p3_identity as p3 # noqa: E402
import safety78_identity as s78 # noqa: E402
import safety79_identity as s79 # noqa: E402
from app import allocator, arbitrage, homeview, packmode # noqa: E402
from app.arbiter import (Claim, P_ARBITRAGE, P_LOOP, P_MAINTENANCE, # noqa
P_MODE, P_PEAK, P_SAFETY, pre_resolve, resolve)
from app.main import Controller # noqa: E402
from app.maintenance import CHARGE, DRAIN # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
M = {"maintenance_enabled": True, "maintenance_start_hour": 11}
A, B = "inverter-a", "inverter-b"
def cmd(r):
return dict(r[2])
def refused(rows):
return sum(1 for r in rows if r[7] and "REFUSED" in r[7])
def opposite(rows):
return sum(1 for r in rows if cmd(r)["a"] * cmd(r)["b"] < 0)
def drain_run(house, dur=600.0, soc=lambda u, t: 50.0, cut=None, **kw):
return rig.run(2, extra=dict(M, **kw.pop("extra", {})), dur=dur,
load=lambda t: house(t) if callable(house) else house,
soc=soc, cut=cut or (lambda u, t: False), **kw)
def with_peak_db(fn):
"""Run `fn()` with the Controller's peak store in a temporary file."""
db = os.path.join(tempfile.mkdtemp(prefix="s78t-"), "tariff.db")
real = rig.Controller
rig.Controller = functools.partial(real, peak_db_path=db)
try:
return fn()
finally:
rig.Controller = real
# ============================================================================ #
print("1. N=2: the drain runs whatever the house uses")
RUNS = {h: drain_run(h) for h in (1200.0, 2000.0, 3000.0, -1500.0)}
for h, (rows, c) in RUNS.items():
print(f" house {h:+.0f} W: last A {cmd(rows[-1])['a']:+.0f} W, B "
f"{cmd(rows[-1])['b']:+.0f} W, grid {h - sum(cmd(rows[-1]).values()):+.0f}"
f" W; {rows[-1][6]}; refusals {refused(rows)}")
check("⭐ below, at and above the drain rate and under a solar surplus, "
"battery A is commanded its 2000 W drain on EVERY cycle, so the drain "
"moves (it used to sit at 0 W from the first cycle)",
all(cmd(r)["a"] == 2000.0 for rows, _c in RUNS.values() for r in rows)
and all(c.maints[0].phase == DRAIN for _r, c in RUNS.values()))
check("⭐ no cycle splits the site both ways and the allocator refuses "
"nothing: battery B never charges while A drains",
all(opposite(rows) == 0 and refused(rows) == 0
and all(cmd(r)["b"] >= 0.0 for r in rows)
for rows, _c in RUNS.values()))
check("...and nothing is logged per cycle: no ARBITER line in the action log",
not any("ARBITER" in e for _r, c in RUNS.values() for e in c.events))
_r3 = RUNS[3000.0][0]
check("above the drain rate, B covers the rest of the house in the SAME "
"direction and the grid settles at the loop's aim",
990.0 <= cmd(_r3[-1])["b"] <= 1030.0
and abs(3000.0 - sum(cmd(_r3[-1]).values()) + 10.0) <= 30.0)
check("at or below the drain rate, B is held at 0 W and A exports the rest, "
"as the drain does at one inverter ('exports the surplus')",
all(cmd(RUNS[h][0][-1])["b"] == 0.0 for h in (1200.0, 2000.0, -1500.0))
and all(RUNS[h][0][-1][6].startswith("loop -> 2000 W")
for h in (1200.0, 2000.0, -1500.0)))
check("...and where the loop alone would ask less, the Why names the drain",
"limited by drain" in RUNS[1200.0][0][-1][6])
# The drain ends: A's level falls to the floor, and the cycle moves on.
_end_rows, _end = drain_run(1200.0, dur=500.0,
soc=lambda u, t: max(5.0, 50.0 - t / 10.0)
if u == "a" else 50.0)
check("⭐ and the drain ENDS: A reaches the 11 % floor and the cycle moves "
"to its charge phase, with no refusal on the way",
_end.maints[0].phase == CHARGE and refused(_end_rows) == 0)
# The check bites: without the floor, the same site stalls as it did.
_real = Controller._drain_floor
Controller._drain_floor = lambda self, claims, results: None
try:
_bite, _bc = drain_run(1200.0, dur=120.0)
finally:
Controller._drain_floor = _real
print(f" without the drain floor: refusals {refused(_bite)}/{len(_bite)}, "
f"last A {cmd(_bite[-1])['a']:+.0f} W")
check("non-vacuous: with the floor taken out, the same fixture refuses every "
"cycle and parks A at 0 W (the reported defect)",
refused(_bite) == len(_bite) and cmd(_bite[-1])["a"] == 0.0
and any("ARBITER" in e for e in _bc.events))
# ============================================================================ #
print("\n2. a drain that cannot give power waits, with one log line")
_w_rows, _w = drain_run(1500.0, cut=lambda u, t: u == "a")
_waits = [e for e in _w.events if "cannot give power now" in e]
print(f" A refuses to give: {_waits}; last {_w_rows[-1][2]}")
check("⭐ A refuses to give power (SAFETY-73): the cycle waits, the line is "
"logged ONCE over 120 cycles, and nothing is refused",
len(_waits) == 1 and _w.inverters[0].refused == 1
and refused(_w_rows) == 0 and _w.maints[0].phase == DRAIN)
check("...and B covers the house on its own meanwhile",
1400.0 <= cmd(_w_rows[-1])["b"] <= 1600.0)
# PR #154 review: one line per episode, across a probe and a second episode.
_pr_rows, _pr = drain_run(1500.0, dur=1300.0, cut=lambda u, t: u == "a")
check("the SAFETY-73 probe (A pinned at 200 W, not by maintenance) does not "
"end the wait: still ONE line over 1300 s with a probe in it",
any(cmd(r)["a"] == 200.0 for r in _pr_rows)
and sum("cannot give power now" in e for e in _pr.events) == 1)
_e2_rows, _e2 = drain_run(1500.0, dur=2600.0, cut=lambda u, t: u == "a" and (
t < 600.0 or t >= 1400.0))
check("...and a second episode, after the probe ends the first, logs again",
sum("cannot give power now" in e for e in _e2.events) == 2)
_cw_rows, _cw = drain_run(-1500.0, dur=300.0, cut=lambda u, t: u == "a")
_cw_cards = homeview.battery_rows(_cw, False, True)
check("while A waits, B may take the solar surplus, so its card does not "
"say 'This one does not charge'",
cmd(_cw_rows[-1])["b"] < 0.0 and _cw_cards[1]["why_key"] != "other_cycle")
def reject_a(c, t):
"""Every write to A is rejected, so A's link goes stale (not live)."""
if not getattr(c.hass, "_rej", False):
real = c.hass.set_number
async def set_number(e, v):
return False if e == "number.sp_a" else await real(e, v)
c.hass.set_number, c.hass._rej = set_number, True
_l_rows, _l = drain_run(1500.0, dur=400.0, before=reject_a)
print(f" A's writes rejected: {_l_rows[-1][2]}, {_l_rows[-1][7][-40:]}")
check("⭐ a draining battery that is dropped (stale link) adds no floor: B "
"covers the 1500 W house, not the 2000 W drain rate, and the wait is "
"logged once",
"dropped inverter-a" in _l_rows[-1][7]
and cmd(_l_rows[-1])["b"] == 1500.0
and sum("cannot give power now" in e for e in _l.events) == 1)
# ============================================================================ #
print("\n3. battery modes on: no grid charge during the drain")
def cheap_b():
s = rig._Store()
s.data[unit_key(A, packmode.MODE_KEY)] = packmode.SOLAR_FIRST
s.data[unit_key(B, packmode.MODE_KEY)] = packmode.CHEAP_HOURS
return s
def modes_run(maint):
extra = dict(s79.P1, **p3.ARB, battery_modes_enabled=True,
**(M if maint else {}))
ticks = []
def on_cycle(c, t):
ticks.append(c.mode_tick)
rows, c = with_peak_db(lambda: rig.run(
2, extra=extra, dur=1800.0, load=lambda t: 800.0,
soc=lambda u, t: 50.0, cut=lambda u, t: False, before=p3._feed,
on_cycle=on_cycle, store=cheap_b()))
cheap = [(r, k) for r, k in zip(rows, ticks) if 900.0 <= r[0] < 1800.0]
return rows, c, cheap
_base_rows, _base_c, _base_cheap = modes_run(False)
_m_rows, _m_c, _m_cheap = modes_run(True)
check("baseline: with no drain, B (Cheap hours only) charges from the grid "
"in the cheap run, so the fixture reaches the charge",
any(cmd(r)["b"] < 0.0 for r, _k in _base_cheap)
and all(k.roles[B] == "charge" for _r, k in _base_cheap))
check("⭐ with A draining, B never charges in that run: its role is not "
"'charge', no trade is filed, and the modes say why ('cycle')",
all(cmd(r)["b"] >= 0.0 and k.roles[B] != "charge" and k.action is None
and k.why == "cycle" for r, k in _m_cheap)
and _m_c.maints[0].phase == DRAIN)
check("...and the drain runs through the run: A at 2000 W, nothing refused",
all(cmd(r)["a"] == 2000.0 for r in _m_rows) and refused(_m_rows) == 0)
# Battery modes off: the site trade (`arbitrage.decide()`) buys in the cheap
# period. It is a site SET at P_ARBITRAGE, below the drain floor.
_t_trades = []
_t_rows, _t_c = with_peak_db(lambda: rig.run(
2, extra=dict(s79.P1, **p3.ARB, **M), dur=1800.0, load=lambda t: 800.0,
soc=lambda u, t: 50.0, cut=lambda u, t: False, before=p3._feed,
on_cycle=lambda c, t: _t_trades.append(c.arb and c.arb.action)))
print(f" modes off, trades seen: {sorted({str(a) for a in _t_trades})}")
check("⭐ battery modes off: the site trade still asks to buy in the cheap "
"period, and the drain floor keeps it from charging B against the drain: "
"A drains at 2000 W, B never charges, nothing is refused",
arbitrage.CHARGE in _t_trades
and all(cmd(r)["a"] == 2000.0 and cmd(r)["b"] >= 0.0 for r in _t_rows)
and refused(_t_rows) == 0)
_cards = homeview.battery_rows(_m_c, False, True)
print(f" cards: {[(b['name'], b['now'], b['why']) for b in _cards]}")
check("⭐ B's card tells the truth: it names the draining battery and says "
"it does not charge (it said 'charges from the grid' while resting)",
_cards[1]["why_key"] == "other_cycle" and A in _cards[1]["why"]
and _cards[0]["why_key"] == "cycle_mode")
# ============================================================================ #
print("\n4. the peak guard during a drain, and the order of the claims")
def off_b():
s = rig._Store()
s.data[unit_key(B, packmode.USE_KEY)] = False
return s
_p_rows, _p = with_peak_db(lambda: rig.run(
2, extra=dict(s79.P1, battery_modes_enabled=True, **M), dur=600.0,
load=lambda t: 6000.0, soc=lambda u, t: 60.0, cut=lambda u, t: False,
before=s79._feed, store=off_b()))
print(f" house 6000 W, limit {s79.CAP_W} W, B Off: {_p_rows[-1][2]}, "
f"{_p_rows[-1][6]}")
check("⭐ the peak guard binds during the drain: the site goes above the "
"drain to the guard's floor, A keeps draining at 2000 W and B (Off) "
"gives only the shortfall, in the same direction",
_p.peak_guard.engaged and _p_rows[-1][1] >= _p.peak_guard.floor_w > 2000.0
and all(cmd(r)["a"] == 2000.0 and cmd(r)["b"] >= 0.0 for r in _p_rows)
and "limited by peak" in _p_rows[-1][6] and refused(_p_rows) == 0)
def blind_at(t0):
"""The site meter stops answering at `t0`: a safety SET at P_SAFETY."""
def before(c, t):
if t >= t0 and not getattr(c.hass, "_blind", False):
real = c.hass.number
async def number(e, invert=False):
return None if e == "sensor.grid" else await real(e, invert)
c.hass.number, c.hass._blind = number, True
return before
_s_rows, _s = drain_run(3000.0, dur=300.0, before=blind_at(150.0))
print(f" meter lost at 150 s: {_s_rows[-1][2]}, {_s_rows[-1][6]}")
check("⭐ a safety stop outranks the drain at N=2: with the meter gone the "
"safety SET parks both batteries at 0 W, and the drain ran before",
cmd(_s_rows[20])["a"] == 2000.0
and all(cmd(r) == {"a": 0.0, "b": 0.0} for r in _s_rows if r[0] >= 175.0)
and _s_rows[-1][6].startswith("safety -> 0 W"))
UNITS = (A, B)
_drain = [Claim.set("maintenance", P_MAINTENANCE, 2000.0, "drain", actuator=A),
Claim.limit("drain", P_MAINTENANCE, lo=2000.0, reason="drain")]
_order = {
"loop under the drain": ([Claim.set("loop", P_LOOP, 500.0)], 2000.0),
"a grid charge (trade)": ([Claim.set("arbitrage", P_ARBITRAGE, -2500.0)],
2000.0),
"a sale bigger than the drain": ([Claim.set("arbitrage", P_ARBITRAGE,
3000.0)], 3000.0),
"a peak floor above the drain": ([Claim.set("loop", P_LOOP, 500.0),
Claim.limit("peak", P_PEAK, lo=3500.0)],
3500.0),
"a safety stop": ([Claim.set("loop", P_LOOP, 500.0),
Claim.set("safety", P_SAFETY, 0.0, "stopped")], 0.0),
}
_got = {}
for k, (extra, want) in _order.items():
r = resolve(_drain + extra, units=UNITS)
_got[k] = (r.target_w, dict(r.shares), r.allocated)
print(f" {k}: target {r.target_w:+.0f} W, shares {dict(r.shares)}")
check("⭐ the order holds: a safety stop parks everything; the peak floor "
"above the drain wins; a sale above it runs; a grid charge and the "
"loop below it are lifted to it; never an opposite split",
all(_got[k][0] == want and _got[k][2]
and not (min(_got[k][1].values()) < 0 < max(_got[k][1].values()))
for k, (_e, want) in _order.items())
and _got["a safety stop"][1] == {A: 0, B: 0}
and _got["a grid charge (trade)"][1] == {A: 2000, B: 0})
# ============================================================================ #
print("\n5. N=1: byte-identical to release/1.0 5a4cdc2")
# Printed by `python3 tools/safety78_identity.py` on release/1.0 5a4cdc2 (the
# same file copied in, nothing else changed), goodwe-addon-checks:1,
# 2026-09-29. 480 cycles each, draining throughout, every claim and the
# newest action-log line hashed.
AT_5A4CDC2 = {
"n1_drain":
"55fcfc4a0b9251746f315cfdea607a33640d3a81faf6604c4718a04bbc7dd776",
"n1_drain_modes":
"93867eb4ee4132b47c030668bd7f07587a353bced2461524e09421125c3c4edf",
}
_now = s78.digests()
for k in AT_5A4CDC2:
print(f" {k} {'same' if _now[k] == AT_5A4CDC2[k] else 'DIFFERS'} "
f"{_now[k][:16]}")
check("⭐ at one inverter a drain under a house below, at and above its "
"rate, a surplus and a load over the peak limit, with battery modes "
"off and on, is byte-identical to 5a4cdc2: commands, reasons, roles, "
"trades and every claim",
_now == AT_5A4CDC2)
Controller._drain_floor = lambda self, claims, results: (
2000.0 if any(r.phase == DRAIN for r in results) else None)
try:
_leak = s78.digest(s78.run(**s78.SCENARIOS["n1_drain"], dur=60.0)[0])
_same = s78.digest(s78.run(**s78.SCENARIOS["n1_drain"], dur=60.0)[0])
finally:
Controller._drain_floor = _real
_ref = s78.digest(s78.run(**s78.SCENARIOS["n1_drain"], dur=60.0)[0])
check("non-vacuous: filing the drain floor at N=1 (which moves no watt "
"there) moves the digest, because the harness hashes the claims",
_leak != _ref and _leak == _same)
# ============================================================================ #
print("\n6. the floor against every other claim (PR #154 review, seeded sweep)")
_rnd = random.Random(78)
_bad, _peak_above, _off_gave = [], 0, 0
for _i in range(20000):
ma, mb = _rnd.choice((2500., 3000., 5000.)), _rnd.choice((1500., 2500.))
ua = [Claim.limit("supervised", P_SAFETY, -ma, ma, actuator=A),
Claim.set("maintenance", P_MAINTENANCE,
_rnd.choice((500., 2000., 6000.)), "drain", actuator=A)]
if _rnd.random() < .2:
ua.append(Claim.limit("pack", P_SAFETY, -ma,
_rnd.choice((0., 800.)), actuator=A))
ub = [Claim.limit("supervised", P_SAFETY, -mb, mb, actuator=B)]
_offb = _rnd.random() < .4
if _offb:
ub.append(Claim.limit("mode", P_MODE, 0., 0., "off", actuator=B))
if _rnd.random() < .15:
ub.append(Claim.limit("refused", P_SAFETY, -mb, 0., actuator=B))
pin = allocator.pin_w(pre_resolve(ua))
site = [Claim.set("loop", P_LOOP, _rnd.uniform(-6000., 8000.))]
if pin is not None and pin > 0.0:
site.append(Claim.limit("drain", P_MAINTENANCE, lo=pin))
if _rnd.random() < .3:
site.append(Claim.set("arbitrage", P_ARBITRAGE,
_rnd.choice((-3000., 1000., 4000.))))
pk = _rnd.uniform(0., 8000.) if _rnd.random() < .4 else None
if pk is not None:
site.append(Claim.limit("peak", P_PEAK, lo=pk))
stop = _rnd.random() < .05
if stop:
site.append(Claim.set("safety", P_SAFETY, 0.0, "stopped"))
r = resolve(ua + ub + site, units=UNITS)
s = dict(r.shares)
why = []
if min(s.values()) < 0 < max(s.values()) or r.contradiction:
why.append("opposite or contradiction")
if stop and (r.target_w != 0.0 or any(s.values())):
why.append("stop not parked")
if not stop and pin is not None and pin > 0.0:
if r.target_w < pin or s[B] < 0:
why.append("below the floor, or B charges")
if pk is not None and pk > pin:
_peak_above += 1
if r.allocated and r.target_w < min(pk, pin + mb):
why.append("peak floor not honoured")
if _offb and s[B] > 0:
_off_gave += 1
if not (pk is not None and pk > pin):
why.append("Off B used for the drain")
if why:
_bad.append((_i, why, r.target_w, s, pin, pk))
print(f" {_bad[:3]}; peak above the drain {_peak_above}, Off B gave for "
f"the peak {_off_gave}")
check("⭐ over 20000 seeded claim lists: never an opposite split or a "
"contradiction; a safety stop parks both; the site never goes below "
"the pin and B never charges; a peak floor above the drain is met; an "
"Off battery gives only for the peak floor, never for the drain",
not _bad and _peak_above > 1000 and _off_gave > 100)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""OPT-11 P4: with no usable solar forecast, is every command byte-identical
to release/1.0 5a4cdc2 (P3, owner option A)?
python3 tools/opt11p4_identity.py # one digest per scenario
tools/opt11p3_identity.py's harness (the REAL Controller on a P1 meter, a
cheap slot, a dear slot, a house over the limit, `arbitrage_enabled`), now
with battery modes ON and each battery's mode set in the store, so P3's run
code runs on every cycle. Each row adds the trade and the roles. Scenarios:
* `ls`, `ch`, `ls_ls`, `ch_ls`: no `pv_forecast_entity` (the day plan never
runs); N=1 and N=2.
* `*_fc_down`: `pv_forecast_entity` set but the sensor reads `unavailable`,
with a ready house profile in the store (the plan falls back); 5a4cdc2
ignores both keys, so the digest must match the scenario without them.
Uses only the API 5a4cdc2 has; test_modes_p4.py part 1 holds this tree to the
digests printed on 5a4cdc2.
"""
import functools
import os
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
import opt11p3_identity as p3 # noqa: E402
import safety79_identity as s79 # noqa: E402
from app.store import unit_key # noqa: E402
FC = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
"pv_forecast_tomorrow_entity": "sensor.energy_production_tomorrow"}
async def _unavailable(self, e):
return {"state": "unavailable", "attributes": {},
"last_updated": "2026-09-20T11:00:00+00:00"}
rig._Hass.state = _unavailable
SCENARIOS = {
"n1_ls": dict(n=1, modes={"inverter-a": "leftover_solar"}),
"n1_ch": dict(n=1, modes={"inverter-a": "cheap_hours"}),
"n2_sf_ls": dict(n=2, modes={"inverter-a": "solar_first",
"inverter-b": "leftover_solar"}),
"n2_ch_ls": dict(n=2, modes={"inverter-a": "cheap_hours",
"inverter-b": "leftover_solar"}),
"n2_ls_ls": dict(n=2, modes={"inverter-a": "leftover_solar",
"inverter-b": "leftover_solar"}),
"n2_sf_ch": dict(n=2, modes={"inverter-a": "solar_first",
"inverter-b": "cheap_hours"}),
}
for _k, _v in list(SCENARIOS.items()):
SCENARIOS[_k + "_fc_down"] = dict(_v, extra=FC)
def run(n, *, modes, extra=None, dur=p3.DUR):
opts = dict(s79.P1, **p3.ARB, battery_modes_enabled=True,
**(extra or {}))
if n == 2:
# A capacity on each battery, so a day plan could size a level;
# small, so the cheap run is the one cheap slot.
opts["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": 2500, "bms_soc_floor": 10, "capacity_kwh": 1.0}
for u in "ab"]
store = rig._Store()
# At N=1 the one battery is named after the site; the key is per name.
for name, mode in modes.items():
store.data[unit_key(name, "pack_mode")] = mode
if extra:
# A ready house profile (a day, 600 W, 3 kW 17:00-22:00), so only
# the missing forecast stops a plan here. 5a4cdc2 ignores the key.
store.data["house_profile"] = {"2026-09-19": {
str(h): (3000.0 if 17 <= h < 22 else 600.0) for h in range(24)}}
trade = []
def on_cycle(c, t):
a, tick = c.arb, getattr(c, "mode_tick", None)
trade.append((None if a is None else (a.action, a.power_w, a.reason),
None if tick is None else
tuple(sorted(tick.roles.items()))))
db = os.path.join(tempfile.mkdtemp(prefix="p4id-"), "tariff.db")
real = rig.Controller
rig.Controller = functools.partial(real, peak_db_path=db)
try:
rows, c = rig.run(n, extra=opts, dur=dur, load=p3.load, soc=p3.soc,
cut=lambda u, t: False, before=p3._feed,
on_cycle=on_cycle, store=store)
finally:
rig.Controller = real
return [r + (x,) for r, x in zip(rows, trade)], c
def digests() -> dict:
return {k: p3.digest(run(**v)[0]) for k, v in SCENARIOS.items()}
if __name__ == "__main__":
if "--summary" in sys.argv:
for k, v in SCENARIOS.items():
rows = run(**v)[0]
acts = sorted({str(r[-1][0][0]) if r[-1][0] else "None"
for r in rows})
roles = sorted({x for r in rows if r[-1][1]
for x in (y for _n, y in r[-1][1])})
print(f"{k}: {len(rows)} cycles, trade {acts}, roles {roles}")
for k, v in digests().items():
print(f"{k} {v}")
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""OPT-11 P4: does the solar forecast pay? Replays recorded days through the
REAL Controller with tools/opt11p3_replay.py's engine (house, batteries,
prices, peak guard), plus a solar forecast sensor in Home Assistant.
python3 tools/opt11p4_replay.py --variant p4_ls --fc recorded
python3 tools/opt11p4_replay.py --variant p3_ls --prices real2026
Runs unchanged on release/1.0 7a00f12 (P2) and 5a4cdc2 (P3): an older tree
ignores `pv_forecast_entity` and never reads the sensor.
HOW THE FORECAST IS MADE (the site's own solar was not recorded for these
days; the 2026-09-24 PV recording is only in the live Home Assistant):
* The site's solar, hour by hour, is rebuilt from the recording: the house's
net use (grid + battery) is house - solar, so solar = max(0, H - net),
where H is the median net use of the night hours (22:00-06:00) over all
days (`--fc perfect` feeds exactly this).
* `recorded`: that solar times the day's error of the real Belgian day-ahead
solar forecast (Elia, via energy-charts): forecast / actual, per day.
* `national`: the Belgian day-ahead forecast itself, scaled to the site
(one factor: the site's total over the national total, all days). It
carries local weather differences too, so it is the harder test.
* `zero`: a forecast of no sun at all (is the gain the forecast's, or only
Leftover solar buying the evening's shortfall?).
* `high`: `national` x 1.5. `missing`: the sensor is configured but reads
`unavailable`. The sensor has Open-Meteo's shape (state = kWh left today,
`wh_period` = Wh per hour); `--shape state` drops `wh_period`
(Forecast.Solar's shape).
MONEY: as opt11p3_replay.py (all-in import (wholesale + 0.12) x 1.06, export
at wholesale), plus `stored_eur`: the change in usable energy (above the
10 % cut-out) from start to end, x 0.95, at the median all-in import price
of the run. `net_after_eur` = net - stored_eur.
"""
import argparse
import bisect
import json
import os
import statistics
import sys
import time
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
import opt11p3_replay as rp # noqa: E402
EC = os.path.join(HERE, "scenarios",
"energy-charts-be-solar-price-2026-09-01_2026-09-17.json")
FC_ENTITY = "sensor.energy_production_today_remaining"
FC_TOMORROW = "sensor.energy_production_tomorrow"
rp.VARIANTS.update({
"p4_ls": (True, False, {"a": (True, "solar_first", False),
"b": (True, "leftover_solar", False)}),
"p4_ch": (True, False, {"a": (True, "solar_first", False),
"b": (True, "cheap_hours", False)}),
})
LOCAL = timezone(timedelta(hours=2)) # CEST for every replayed day
def ec():
return json.load(open(EC))
def real2026_points():
"""The real day-ahead prices of the replayed days (15-minute)."""
d = ec()["price"]
return [(datetime.fromtimestamp(t, timezone.utc), 900, p / 1000.0)
for t, p in zip(d["unix_seconds"], d["eur_mwh"]) if p is not None]
def site_solar(house):
"""{hour start (UTC epoch): site solar kWh}, and H (W)."""
by_h = {}
for t, w in house:
by_h.setdefault(int(t // 3600) * 3600, []).append(w)
net = {h: statistics.fmean(v) for h, v in by_h.items() if len(v) > 20}
night = [w for h, w in net.items()
if datetime.fromtimestamp(h, LOCAL).hour in
(22, 23, 0, 1, 2, 3, 4, 5)]
base = statistics.median(night)
return {h: max(0.0, base - w) / 1000.0 for h, w in net.items()}, base
def national():
s = ec()["solar"]
act = dict(zip(s["unix_seconds"], s["actual_mw"]))
fc = dict(zip(s["dayahead_unix_seconds"], s["dayahead_mw"]))
return act, fc
def forecast_table(kind, house, days):
"""{hour start epoch: forecast kWh}, and a report of its accuracy."""
pv, base = site_solar(house)
act, fc = national()
day_of = {h: datetime.fromtimestamp(h, LOCAL).date() for h in pv}
if kind in ("perfect", "missing"):
out = dict(pv)
elif kind == "zero":
out = {h: 0.0 for h in pv}
elif kind == "recorded":
ratio = {}
for d in set(day_of.values()):
hs = [h for h in pv if day_of[h] == d]
a = sum(act.get(h) or 0.0 for h in hs)
f = sum(fc.get(h) or 0.0 for h in hs)
ratio[d] = f / a if a > 0 else 1.0
out = {h: pv[h] * ratio[day_of[h]] for h in pv}
else:
k = sum(pv.values()) / max(1e-9, sum(act.get(h) or 0.0 for h in pv))
out = {h: (fc.get(h) or 0.0) * k * (1.5 if kind == "high" else 1.0)
for h in pv}
rep = []
for d in sorted(set(day_of.values())):
a = sum(pv[h] for h in pv if day_of[h] == d)
f = sum(out[h] for h in out if day_of[h] == d)
rep.append((d.isoformat(), round(a, 1), round(f, 1)))
return out, base, rep
def install_sensor(table, kind, shape):
"""Give opt11p3_replay's Hass a `state()` for the forecast sensors."""
hours = sorted(table)
t0 = rp_start[0]
def day_bounds(now):
loc = now.astimezone(LOCAL)
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
return d0, d0 + timedelta(days=1)
def sensor(now, tomorrow):
d0, d1 = day_bounds(now)
if tomorrow:
d0, d1 = d1, d1 + timedelta(days=1)
a, b = d0.timestamp(), d1.timestamp()
i, j = bisect.bisect_left(hours, a), bisect.bisect_left(hours, b)
rows = [(h, table[h]) for h in hours[i:j]]
if not rows:
return None
n = now.timestamp()
left = 0.0
for h, kwh in rows:
if tomorrow or h >= n:
left += kwh
elif h + 3600 > n:
left += kwh * (h + 3600 - n) / 3600.0
attrs = {"unit_of_measurement": "kWh"}
if shape == "periods":
attrs["wh_period"] = {
datetime.fromtimestamp(h, timezone.utc).isoformat():
round(kwh * 1000.0, 1) for h, kwh in rows}
# The forecast refreshes every hour.
rep = datetime.fromtimestamp(int(n // 3600) * 3600, timezone.utc)
return {"state": f"{left:.3f}", "attributes": attrs,
"last_updated": rep.isoformat(),
"last_reported": rep.isoformat()}
async def state(self, e):
now = t0 + timedelta(seconds=self.t)
if kind == "missing":
return {"state": "unavailable", "attributes": {},
"last_updated": now.isoformat()}
if e == FC_ENTITY:
return sensor(now, False)
if e == FC_TOMORROW:
return sensor(now, True)
return None
rp.Hass.state = state
rp_start = [None]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--variant", default="p4_ls")
ap.add_argument("--fc", default="recorded",
choices=("none", "recorded", "national", "perfect",
"high", "missing", "zero"))
ap.add_argument("--prices", default="epex2025",
choices=("epex2025", "real2026"))
ap.add_argument("--shape", default="periods",
choices=("periods", "state"))
ap.add_argument("--first", type=int, default=2)
ap.add_argument("--days", type=int, default=16)
ap.add_argument("--trust", type=int, default=None)
ap.add_argument("--report", action="store_true",
help="print the forecast's accuracy and exit")
a = ap.parse_args()
days = [f"2026-09-{d:02d}" for d in range(a.first, a.first + a.days)]
house = rp.load_house(days)
pts = rp.epex_points() if a.prices == "epex2025" else real2026_points()
start = datetime.fromisoformat(days[0] + "T00:00:00+02:00") \
.astimezone(timezone.utc)
rp_start[0] = start
extra = {}
if a.fc != "none":
table, base, rep = forecast_table(a.fc, house, days)
if a.report:
print(json.dumps({"fc": a.fc, "night_base_w": round(base),
"days": rep}))
return
install_sensor(table, a.fc, a.shape)
extra = {"pv_forecast_entity": FC_ENTITY,
"pv_forecast_tomorrow_entity": FC_TOMORROW}
if a.trust is not None:
extra["solar_trust_pct"] = a.trust
socs = {}
def check(c, hass, now, pr):
if "start" not in socs:
socs["start"] = {u: p.soc for u, p in hass.plants.items()}
socs["end"] = {u: p.soc for u, p in hass.plants.items()}
dp = getattr(c, "day_plan", None)
if dp is not None:
socs.setdefault("verdicts", {})[
now.astimezone(LOCAL).date().isoformat()] = dp.verdict
t0 = time.time()
stats, _c = rp.run(a.variant, house, pts, start, 86400.0 * len(days),
extra=extra, check=check)
out = rp.summary(stats)
prices = sorted(rp.all_in(p) for _s, _n, p in pts
if start <= _s < start + timedelta(days=len(days)))
med = prices[len(prices) // 2]
def usable(soc):
return sum(max(0.0, v - 10.0) / 100.0 * rp.CAP_KWH
for v in soc.values())
d_kwh = usable(socs["end"]) - usable(socs["start"])
out["stored_change_kwh"] = round(d_kwh, 2)
out["stored_eur"] = round(d_kwh * 0.95 * med, 2)
out["net_after_eur"] = round(out["net_eur"] - out["stored_eur"], 2)
out["end_soc"] = {u: round(v, 1) for u, v in socs["end"].items()}
out.update(variant=a.variant, fc=a.fc, prices=a.prices, shape=a.shape,
first=days[0], days=len(days),
verdicts=socs.get("verdicts", {}),
wall_s=round(time.time() - t0, 1))
print(json.dumps(out))
if __name__ == "__main__":
main()
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""SAFETY-78: at one inverter, is a maintenance drain byte-identical to before?
python3 tools/safety78_identity.py # prints one digest per scenario
tools/safety79_identity.py's harness (the REAL Controller on a P1 meter, so
the peak guard runs) with the battery draining for its monthly cycle from the
first cycle, under a house below, at and above the drain rate, a solar
surplus and a load over the peak limit. Each row hashes safety79's fields,
the modes' roles and the trade, the action log's newest line, and every claim
main.py hands `resolve()`, so a claim or a log line added at N=1 that changes
no watt still moves the digest. The `_modes` scenarios run battery modes on,
Cheap hours only, over opt11p3_identity's prices. Uses only the API release/1.0 5a4cdc2 has, so the same file runs on
both trees; test_safety78.py part 5 holds this tree to the digests printed
there.
"""
import functools
import hashlib
import os
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
import opt11p3_identity as p3 # noqa: E402
import safety79_identity as s79 # noqa: E402
import app.main as m # noqa: E402
from app import packmode # noqa: E402
from app.store import unit_key # noqa: E402
DUR = 2400.0
DRAIN = {"maintenance_enabled": True, "maintenance_start_hour": 11}
def load(t):
"""Below the 2000 W drain, at it, above it, a surplus, over the limit."""
k = int(t // 480)
return (1200.0, 2000.0, 3000.0, -1500.0, 6000.0)[min(k, 4)]
def store_cheap(n):
s = rig._Store()
for u in ("inverter-a", "inverter-b")[:n]:
s.data[unit_key(u, packmode.MODE_KEY)] = packmode.CHEAP_HOURS
return s
SCENARIOS = {
"n1_drain": dict(n=1),
"n1_drain_modes": dict(n=1, modes=True),
}
def run(n, *, modes=False, dur=DUR):
extra = dict(s79.P1, **DRAIN)
if modes:
extra.update(p3.ARB, battery_modes_enabled=True)
seen, extra_rows = [], []
real_resolve = m.resolve
def spy(claims, **kw):
seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi,
c.reason, c.actuator) for c in claims))
return real_resolve(claims, **kw)
def on_cycle(c, t):
g, tick, a = c.peak_guard, c.mode_tick, c.arb
extra_rows.append((
seen[-1] if seen else None,
None if g is None else (g.engaged, g.floor_w, g.reason),
None if tick is None else (tuple(sorted(tick.roles.items())),
tick.action, tick.why),
None if a is None else (a.action, a.power_w, a.reason),
[mm.phase for mm in c.maints],
len(c.events), c.events[0] if c.events else None))
db = os.path.join(tempfile.mkdtemp(prefix="s78-"), "tariff.db")
real = rig.Controller
rig.Controller = functools.partial(real, peak_db_path=db)
m.resolve = spy
try:
rows, c = rig.run(n, extra=extra, dur=dur, load=load, soc=s79.soc,
cut=lambda u, t: False,
before=p3._feed if modes else s79._feed,
on_cycle=on_cycle,
store=store_cheap(n) if modes else None)
finally:
rig.Controller = real
m.resolve = real_resolve
return [r + (x,) for r, x in zip(rows, extra_rows)], c
def digest(rows) -> str:
return hashlib.sha256(repr(rows).encode()).hexdigest()
def digests() -> dict:
return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
if __name__ == "__main__":
if "--summary" in sys.argv:
for k, v in SCENARIOS.items():
rows = run(**v)[0]
print(f"{k}: {len(rows)} cycles, phases "
f"{sorted({str(r[-1][-1]) for r in rows})}, targets "
f"{sorted({r[1] for r in rows})[:6]}")
for k, v in digests().items():
print(f"{k} {v}")
+9
View File
@@ -85,6 +85,15 @@ configuration:
battery_modes_enabled:
name: "Hardware · Battery modes"
description: "Give each battery an on and off switch and a mode, set on the Batteries screen."
pv_forecast_entity:
name: "Hardware · Solar forecast, today (kWh)"
description: "Optional. A forecast sensor with today's remaining solar energy, such as Forecast.Solar or Solcast. A battery then buys only what the sun will not give."
pv_forecast_tomorrow_entity:
name: "Hardware · Solar forecast, tomorrow (kWh)"
description: "Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight."
solar_trust_pct:
name: "Hardware · Trust in the forecast (%)"
description: "Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast."
pv_entity:
name: "Hardware · Solar production entity (W)"
description: "Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control."
+9
View File
@@ -66,6 +66,15 @@ configuration:
battery_modes_enabled:
name: "Hardware · Batterijmodi"
description: "Geef elke batterij een aan- en uitschakelaar en een modus, in te stellen op het scherm Batterijen."
pv_forecast_entity:
name: "Hardware · Zonneprognose, vandaag (kWh)"
description: "Optioneel. Een prognosesensor met de zonne-energie die vandaag nog komt, zoals Forecast.Solar of Solcast. Een batterij koopt dan alleen wat de zon niet geeft."
pv_forecast_tomorrow_entity:
name: "Hardware · Zonneprognose, morgen (kWh)"
description: "Optioneel. Dezelfde prognose voor morgen. Ze helpt een goedkoop uur voor middernacht te plannen."
solar_trust_pct:
name: "Hardware · Vertrouwen in de prognose (%)"
description: "Prognoses zijn vaak te zonnig. Bij 70 % plant de add-on met 7 van elke 10 kWh uit de prognose."
pv_entity:
name: "Hardware · Entiteit zonneproductie (W)"
description: "Optioneel. De vermogenssensor van uw zonne-omvormer, positief tijdens productie. De add-on houdt ze bij en regelt er niet mee."