Merge release/1.0 (0.15.0 + SAFETY-90) into maint-10-local-start-hour

Conflicts: main.py's maintenance import (base added CHARGE, HOLD; branch added
site_zone) - both kept. DOCS maintenance table: base's longer drain-rate row
kept, start hour row at 19 / local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-05 21:51:04 +02:00
co-authored by Claude Opus 5.5
70 changed files with 9513 additions and 294 deletions
+5
View File
@@ -354,6 +354,11 @@ def pin_w(b):
return float(pv) if lo > hi else float(_clamp_int(round(float(pv)), lo, hi))
def hi_w(b):
"""A unit's upper hard limit as `plan()` narrows it to whole W (SAFETY-84)."""
return float(_int_envelope(b)[1])
def loop_bound(bounds, live=None):
"""OPT-11. `(lo, hi)`: what the units can place when the LOOP decides.
+24 -1
View File
@@ -76,6 +76,12 @@ from . import allocator
P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits
P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold)
P_PEAK = 50 # capacity-tariff peak shaving - costs real money
# The order in effect: safety limits and the SAFETY-73 refusal > the peak
# guard > maintenance > modes > trade > loop. A peak floor above a drain still
# binds: the `drain` site floor at N>=2 (SAFETY-78), refiled at P_MAINTENANCE
# at N=1 (SAFETY-83); over a HOLD, `_peak_over_hold()` (SAFETY-86).
# A cheap-window charge is suspended while the peak guard holds and, at N>=2,
# sized to the quarter's headroom (SAFETY-84).
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
# OPT-04. §10.1 priority 3 - price arbitrage. ⚠️ ITS POSITION IS THE SPEC, NOT A
# preference: P_PEAK (50) is ABOVE it so clause 2 lets the capacity-tariff floor
@@ -190,6 +196,16 @@ CLAIM_SCOPE = {
# 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,
# main.py: `Claim.limit("charge", P_MAINT_SHAPE, hi=...)`, SAFETY-84. The
# mirror of `drain` for a cheap-window maintenance charge at N>=2: the site
# target may not go above what the charging pack's own `hi` asks, or a
# sibling would have to give against it. Never filed at N=1.
# DECISIONS/SAFETY-84-charge-at-two.md.
"charge": SCOPE_SITE,
# main.py: `Claim.limit("hold", P_MAINTENANCE, hi=...)`, SAFETY-86. While a
# holding pack gives the peak shortfall at N>=2, the site target stays at
# the peak floor, what the pin and the others place. Never filed at N=1.
"hold": SCOPE_SITE,
# --- UNIT: about one pack, one inverter, one device -------------------
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
@@ -619,7 +635,7 @@ def site_resolve(claims: list) -> Resolution:
def resolve(claims: list, *, units=None, weights=None, live=None,
tick=None) -> Resolution:
tick=None, reserve=()) -> Resolution:
"""The three stages, in order, over one cycle's claims.
`units` is the site's roster of inverters. Left None it is derived from the
@@ -721,6 +737,13 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
gate, held = _unit_modes(out, site_claims, bounds, live)
if callable(weights):
weights = weights(out.target_w)
# SAFETY-87/91: units under their peak reserve weigh 0 while the site
# gives, so the others carry the target first. An Off battery gives the
# peak's shortfall, so it goes before them when no other unit is left.
reserve = set(reserve) if out.target_w > 0.0 else set()
if held and reserve and not set(bounds) - held - reserve:
held = set()
held = held | reserve
if held:
weights = {u: (0.0 if u in held else
(1.0 if weights is None else weights.get(u, 1.0)))
+24
View File
@@ -260,6 +260,22 @@ def loop_clamp_note(d) -> str:
return ""
def shortfall_note(named) -> str:
"""SAFETY-85: the alarm text while another owner sets the batteries.
`named` is `[(inverter name or None, asked W, given W)]`, one per battery
that gave less than asked."""
return f"{REASON_CODE}: " + ", ".join(
f"{name or 'the battery'} gives {given:+.0f} W of the {asked:+.0f} W "
f"asked" for name, asked, given in named)
def delivery_short(asked_w, given_w, margin_w) -> bool:
"""SAFETY-85: did a battery asked to discharge give more than `margin_w`
less? A charge is never short: a full pack tapers (SAFETY-42 section 7)."""
asked, given = float(asked_w), float(given_w)
return asked > 0.0 and asked - given > abs(float(margin_w))
def write_clamp_note(named) -> str:
"""The write-side alarm text: `named` is `[(inverter name or None,
ClampResult)]` for every inverter whose write was cut back this cycle.
@@ -382,6 +398,10 @@ class ClampWatchdog:
self.over_rating = 0 # bug=True: "Power request too high"
self.bound = 0 # bug=False: "Battery is not keeping up"
self.episodes = 0 # rising edges
# SAFETY-85: episodes with a bug=False engagement. The status row
# counts these; counting cycles read 861 on one rig run.
self.bound_episodes = 0
self._bound_counted = False
self.since: datetime | None = None
self.last_alarm: datetime | None = None
self.reason = ""
@@ -436,6 +456,10 @@ class ClampWatchdog:
self.spoken = False
self.bug = False
self.bug_spoken = False
self._bound_counted = False
if not bug and not self._bound_counted:
self._bound_counted = True
self.bound_episodes += 1
if bug or not self.bug:
self.reason = reason
self.facts = tuple(facts)
+23 -3
View File
@@ -382,6 +382,9 @@ class Decision:
# SAFETY-52. The refused direction to carry back in (+1 discharge, -1
# charge, 0 none), or None when the caller does not carry it.
refused: int | None = None
# SAFETY-85. The `bound` held the integrator or the command this cycle,
# so the batteries' limits, not the loop, decided it.
held: bool = False
def slew_limit_w(tuning: Tuning) -> float:
@@ -481,6 +484,8 @@ def compute(
refused: int | None = None,
probe: bool = False,
bound: tuple | None = None,
gave_nothing: bool | None = None,
idle_w: float = 0.0,
) -> Decision:
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
@@ -494,6 +499,13 @@ def compute(
`bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
their modes applied (`allocator.loop_bound()`), or None. See
MODE_BOUND below. None is the law as it was.
`gave_nothing` SAFETY-94: the caller's span check - the reading stayed
under REFUSED_BELOW_W, one way, for SETTLE_CYCLES spanning
REFUSED_SPAN_S. A refusal engages only with it. None leaves it
out (compute() alone, as before).
`idle_w` SAFETY-97: what `actual_w` reads while the batteries give
nothing (app/idle.py). The refusal's band and its end are
centred on it. 0 is the law as it was.
"""
reason = "tracking"
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
@@ -614,11 +626,13 @@ def compute(
# OPT-11: the integrator does not wind past what the modes let the
# batteries place. Only a side tighter than the rail is left in `bound`
# (above), so this never takes SAFETY-03's `i_clamped` away from the rail.
held_by_bound = False
if bound is not None:
held = max(float(bound[0]), min(float(bound[1]), i_w))
if held != i_w:
i_w = held
reason = MODE_BOUND
held_by_bound = True
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
# bound is not. It is what makes the worst-case unwind time finite and
@@ -812,13 +826,18 @@ def compute(
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
# limit because it only removes an ask the battery is not meeting.
if refused is not None:
# SAFETY-94: and only when the reading has given nothing for the
# whole span, not just now. `sat_count` also counts the cycles a
# healthy battery spends reversing through its lag (-2000 W towards
# +800 W): at a 1 s meter its 12 cycles end as the reading passes 0.
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
and abs(prev_w) > tuning.saturation_w
and abs(actual_w) < REFUSED_BELOW_W):
and abs(actual_w - idle_w) < REFUSED_BELOW_W
and gave_nothing is not False):
refused = 1 if prev_w > 0 else -1
if refused and refused * i_w < 0:
refused = 0 # the loop wants the other way
elif refused and refused * actual_w >= PROBE_ANSWER_W:
elif refused and refused * (actual_w - idle_w) >= PROBE_ANSWER_W:
refused = 0 # it delivers: a probe answered,
# or a slow reading arriving
elif refused:
@@ -839,6 +858,7 @@ def compute(
if inside != target:
target = inside
reason = MODE_BOUND
held_by_bound = True
# SAFETY-48's lead cap above may pull the integrator back toward the
# slewed command; what is CARRIED stays inside the bound too.
i_w = max(float(bound[0]), min(float(bound[1]), i_w))
@@ -860,7 +880,7 @@ def compute(
return Decision(float(target), sat_count, frozen, reason, float(i_w),
i_clamped, clamped, float(i_bound_w), float(want),
float(clamped_w), refused)
float(clamped_w), refused, held_by_bound)
def maintenance_charge_floor(
+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
+53 -7
View File
@@ -87,6 +87,10 @@ when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
AND |standing command now - standing command when the reading
last changed| >= MOVE_W
SAFETY-94: "unchanged for" counts only the reads at which both command terms
held, so a register that rested is not judged before it could answer.
DECISIONS/SAFETY-94-lagging-battery.md.
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
@@ -216,6 +220,8 @@ change - which was the ONLY exit - has a median of 143 s, 17 episodes at or
over the live one's 710 s, and a worst case of 21440 s. That distribution is a
property of this battery's firmware. It is not a property of this code, and the
add-on must not depend on it.
SAFETY-94: many were the gate judging a rested register on its first read
(SAFETY-58). Counted from the ask, the same days give 59 trips.
THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
exit, not how long the block lasts":
@@ -372,7 +378,9 @@ BOUNDS = {
"SAFETY-57: the verdict is withdrawn after 17.0 s of the "
"standing command below 250 W, because at 0 W the register "
"is obliged to nothing and the verdict cannot be tested "
"again from inside the state it creates"),
"again from inside the state it creates. SAFETY-94: the "
"35 s counts only reads at which the command asks the "
"register to move"),
"soc": Bound(
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
unchanged_s=float("inf"),
@@ -416,11 +424,9 @@ class Freshness:
new grading window at that instant. The 1 Hz oscillation the latch exists to
prevent is still prevented: the new window is `bound.unchanged_s` wide, so
no re-trip can land sooner than 35 s after a release, rather than on the
next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if
the site rests for longer than the window before it asks for anything, the
window is already old and the re-trip lands on the first read at which the
command travels 250 W again. That is survivor 5's shape, it predates this
card, and its cost is one `stale_input_s` hold.
next read. SAFETY-94: the 35 s counts only while the command asks the
register to move, so a site that rests after a release is not re-tripped
on its first ask.
⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY.
`changed_mono` / `held` are facts about the REGISTER: when it last carried a
@@ -451,6 +457,11 @@ class Freshness:
# the change-stamps; `_release()` is where they diverge.
self.graded_from = None
self.standing_at_grade = None
# SAFETY-94. Seconds of this window during which the register was
# obliged to move (the command at least `move_w` and `move_w` away
# from `standing_at_grade`), and the instant of the last read.
self.obliged_acc = 0.0
self.last_read = None
# The instant the standing command last FELL below `move_w`, or None
# while an obligation is standing. The release timer runs off this.
self.rested_since = None
@@ -499,6 +510,7 @@ class Freshness:
# it reads in the order the state machine moves - withdraw, then
# re-grade - not because the code depends on it.
self._release(standing_w, now_mono)
self._oblige(standing_w, now_mono)
self._grade(standing_w, now_mono)
def _open_window(self, standing_w, now_mono) -> None:
@@ -510,6 +522,35 @@ class Freshness:
self.graded_from = float(now_mono)
self.standing_at_grade = (None if standing_w is None
else float(standing_w))
# The read that opens a window is never an ask (no travel yet).
self.obliged_acc = 0.0
def _obliged(self, standing_w) -> bool:
"""Is the register asked to move: the command is at least `move_w` and
has travelled `move_w` since the window opened?"""
if standing_w is None or self.standing_at_grade is None:
return False
standing_w = float(standing_w)
return (abs(standing_w) >= self.bound.move_w
and abs(standing_w - self.standing_at_grade)
>= self.bound.move_w)
def _oblige(self, standing_w, now_mono) -> None:
"""SAFETY-94. Count only the time the register was asked to move.
A register that held still while nothing asked it to move has not
failed, so time before the command moved is not evidence. The time
since the previous read counts when the obligation stands at this
read; a command flapping in and out of the obligation still adds up.
"""
now_mono = float(now_mono)
if self.last_read is not None and self._obliged(standing_w):
self.obliged_acc += max(0.0, now_mono - self.last_read)
self.last_read = now_mono
def obliged_s(self) -> float:
"""Seconds of the current window the register was obliged to move."""
return self.obliged_acc
def _release(self, standing_w, now_mono) -> None:
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing.
@@ -543,7 +584,12 @@ class Freshness:
if self.stale or self.graded_from is None:
return
held_s = self.graded_s(now_mono)
if held_s < self.bound.unchanged_s:
# SAFETY-94 (closes SAFETY-58): the bound counts only the time the
# command asked the register to move, not the time since it last
# changed. A register that rested for minutes was otherwise judged on
# the first read after a new command, before it could answer (live,
# 2026-10-03: 1 s after the command).
if self.obliged_s() < self.bound.unchanged_s:
return
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
+98 -10
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 in the
@@ -663,7 +718,9 @@ def battery_rows(c, shadow, running, held=False):
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),
other_cycle=cycling)
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),
@@ -698,10 +755,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, other_cycle=None) -> 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:
@@ -728,6 +800,8 @@ def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
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"
@@ -829,7 +903,9 @@ def month_peak(c, now):
if pg is not None:
out["period_w"] = pg.projected_w
ceiling = pg.ceiling_w or limit_w
out["line"] = line_period(out["period_w"], ceiling, c.shadow)
out["line"] = line_period(out["period_w"], ceiling, c.shadow,
unmeasured=getattr(c, "peak_unmeasured_s", 0.0)
> 0.0)
return out
@@ -848,24 +924,25 @@ def state_of(c, level, shadow):
def build(c, *, level: str, checks: list, now: datetime | None = None,
lang: str = "en") -> dict:
lang: str = "en", held: bool | None = None) -> dict:
"""The structured half of `/status`, in `lang` (UX-13 P5). Pure reads of
the controller."""
_LANG[0] = uitext.lang_of(lang)
try:
return _build(c, level=level, checks=checks, now=now)
return _build(c, level=level, checks=checks, now=now, held=held)
finally:
_LANG[0] = "en"
def _build(c, *, level, checks, now):
def _build(c, *, level, checks, now, held=None):
now = now or datetime.now(timezone.utc)
shadow = bool(c.shadow)
kind = state_of(c, level, shadow)
# A red banner means every inverter is at 0 W (the status cascade), so
# nothing is planned until it clears. Shadow mode still plans: it is
# what the add-on would do.
held = level == "bad" and not shadow
# what the add-on would do. `held` False: an alarm while control runs
# (SAFETY-87, the peak alarms).
held = level == "bad" and not shadow and held is not False
running = (bool(c.auto) or shadow) and not held
settled = all(at_zero(inv) for inv in c.inverters)
ctl = next((r for r in checks if r.get("id") == "controller"), None)
@@ -875,6 +952,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)},
@@ -1171,6 +1249,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)
@@ -1213,8 +1295,11 @@ def line_peak_not_covered() -> str:
return _t("peak_not_covered")
def line_period(period_w, limit_w, shadow) -> str:
def line_period(period_w, limit_w, shadow, unmeasured=False) -> str:
step = _w(shadow, "step_in", kw=_kw(limit_w))
if unmeasured:
# SAFETY-90: the add-on started during this period.
return _t("period_unmeasured") + " " + step
if period_w is None:
return step
return _t("period", kw=_kw(period_w)) + " " + step
@@ -1288,6 +1373,7 @@ def _samples() -> list:
("plan", line_peak_limit(3500, sh)),
("plan", line_period(1240, 3500, sh)),
("plan", line_period(None, 3500, sh)),
("plan", line_period(1240, 3500, sh, unmeasured=True)),
("plan", control_text(True, sh)),
("plan", control_text(False, sh))]
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
@@ -1313,6 +1399,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")]
+87
View File
@@ -0,0 +1,87 @@
"""SAFETY-97: what a battery's power reading shows while it gives nothing.
B's reading (0x0518) shows -95 to -118 W at idle (standby draw, live
2026-10-03); A's shows -2 to -4 W. "The battery gives nothing" is graded
against this idle reading, not against 0 W, or a refusing B never latches.
DECISIONS/SAFETY-97-idle-offset.md.
Learned per unit, never configured:
- only while control runs, the unit is not refused, stale or tripped, and
its command has been within IDLE_ASK_W for IDLE_QUIET_S (a refusing battery
is asked more than that, so it cannot teach its refusal as idle);
- a reading beyond IDLE_SAMPLE_W is not a sample;
- first value: the mean of IDLE_FIRST_S of samples; then a slow follow
(IDLE_TAU_S);
- persisted per unit and restored, clamped to IDLE_SAMPLE_W, at start.
The band is centred on it clamped to IDLE_MAX_W (owner, 2026-10-04). Until
learned the idle reading is 0 W, the rule as it was.
"""
import math
IDLE_MAX_W = 75.0 # |idle| used: under the 100 W band, so it keeps 0 W +-25 W
IDLE_SAMPLE_W = 250.0 # a reading beyond this is not a sample
IDLE_ASK_W = 50.0 # a command this small counts as "asked ~0"
IDLE_QUIET_S = 120.0 # 6 lags of 20 s: a 2500 W step has decayed to 6 W
IDLE_FIRST_S = 120.0 # seconds of samples averaged for the first value
IDLE_TAU_S = 1800.0 # then follow drift this slowly
IDLE_SAVE_W = 5.0 # persist when the value moved this far
IDLE_GAP_S = 10.0 # a longer gap between reads counts as this
def _num(v):
try:
v = float(v)
except (TypeError, ValueError):
return None
return v if math.isfinite(v) else None
class IdleReading:
"""One unit's idle reading. `w` is what "gives nothing" is centred on."""
def __init__(self, saved=None):
v = _num(saved)
self.value = (None if v is None
else max(-IDLE_SAMPLE_W, min(IDLE_SAMPLE_W, v)))
self.saved = self.value
self.quiet_since = None
self.last = None
self.acc_w = 0.0
self.acc_s = 0.0
@property
def w(self) -> float:
"""The centre used: the learned value clamped to IDLE_MAX_W."""
if self.value is None:
return 0.0
return max(-IDLE_MAX_W, min(IDLE_MAX_W, self.value))
def observe(self, reading, standing_w, now_mono, may_learn) -> bool:
"""One read. Returns True when the value should be persisted."""
now = float(now_mono)
dt = (0.0 if self.last is None
else max(0.0, min(IDLE_GAP_S, now - self.last)))
self.last = now
standing = _num(standing_w)
if not may_learn or standing is None or abs(standing) > IDLE_ASK_W:
self.quiet_since = None
return False
if self.quiet_since is None:
self.quiet_since = now
b = _num(reading)
if (now - self.quiet_since < IDLE_QUIET_S or b is None
or abs(b) > IDLE_SAMPLE_W or dt <= 0.0):
return False
if self.value is None:
self.acc_w += b * dt
self.acc_s += dt
if self.acc_s < IDLE_FIRST_S:
return False
self.value = self.acc_w / self.acc_s
else:
self.value += (b - self.value) * min(1.0, dt / IDLE_TAU_S)
if self.saved is None or abs(self.value - self.saved) >= IDLE_SAVE_W:
self.saved = self.value
return True
return False
+10 -1
View File
@@ -127,6 +127,7 @@ from . import messages
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
# freshness.py imports nothing from this package, so the direction is one-way.
from .freshness import BOUNDS, Freshness
from .idle import IdleReading
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
# shipped default and a literal here would be the fourth defaults site that
@@ -321,13 +322,21 @@ class Inverter:
# take, 0 none), when that refusal or its last probe started, the
# current probe's start, and the cycles of evidence so far and when
# they began. Memory only, like SAFETY-52's: stop and start end it.
# Used at N>1 only.
# The evidence count is also used at N=1 (SAFETY-94); the rest at N>1.
self.refused = 0
self.refused_at = None
self.probe_from = None
self.ref_count = 0
self.ref_since = None
self.ref_sign = 0
self.ref_from = None
# SAFETY-97: what this unit's reading shows at idle; "gives nothing"
# is centred on it. Learned, persisted, restored (app/idle.py).
self.idle = IdleReading(data.get(self.key("idle_w")))
@property
def idle_w(self) -> float:
return self.idle.w
# -- store ---------------------------------------------------------------
def key(self, name: str) -> str:
File diff suppressed because it is too large Load Diff
+36 -4
View File
@@ -217,7 +217,7 @@ def peak_saturated(ceiling_w, *, shadow: bool = False) -> Notice:
def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
packs=()) -> Notice:
packs=(), others: bool = False) -> Notice:
"""SAFETY-24/25/32: the peak guard may not draw the battery any lower.
The last sentence is SAFETY-25 and must stay: the reserve binds the peak
@@ -227,7 +227,8 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
roster means some inverter's cut-out level is missing (the peak reserve
itself always has a value) - so the action names the cut-out level.
Both are named by their setting labels, "Peak reserve (%)" and "Battery
cut-out level (%)".
cut-out level (%)". `others`: the other batteries give all they can and
it is not enough (SAFETY-87), so the limit MAY be passed.
"""
if soc_unread:
why = "The battery level cannot be read"
@@ -241,6 +242,12 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
why = (f"The battery of {names(packs)} is at its peak reserve" if packs
else "The battery is at its peak reserve")
act = "Switch off large loads, or lower the peak reserve."
if others:
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
f"{why}, and the other batteries cannot keep this "
f"15-minute period under your {watts(ceiling_w)} peak "
f"limit alone. {act} Home use can still draw a battery "
f"below its reserve, down to its cut-out level.")
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
f"{why}, so this 15-minute period will go over your "
f"{watts(ceiling_w)} peak limit and raise this month's peak. "
@@ -470,8 +477,9 @@ def row_peak_saturated(ceiling_w) -> str:
def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
packs=()) -> str:
"""SAFETY-24/25/32. The second sentence is SAFETY-25 and must stay."""
packs=(), others: bool = False) -> str:
"""SAFETY-24/25/32. The last sentence is SAFETY-25 and must stay.
`others`: as in `peak_reserve()` (SAFETY-87)."""
if soc_unread:
why = "Battery level cannot be read"
elif floor_unknown:
@@ -479,6 +487,10 @@ def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
else:
why = (f"{names(packs)} battery at its peak reserve" if packs
else "Battery at its peak reserve")
if others:
return (f"{why}. The others cannot hold the {watts(ceiling_w)} peak "
f"limit alone. Home use still draws it lower, to the cut-out "
f"level.")
return (f"{why}, so the {watts(ceiling_w)} peak limit will be passed. Home "
f"use still draws the battery below it, down to the cut-out level.")
@@ -781,6 +793,12 @@ def row_peak_guard_blind(why: str) -> str:
f"meanwhile")
def row_peak_guard_unmeasured() -> str:
"""SAFETY-90. Before the first reading; that part counts as high use."""
return ("Peak guard: part of this 15-minute period was not measured, "
"counted as high use")
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
if shadow:
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
@@ -835,6 +853,8 @@ WHY_LIMIT = {
"pack": "the battery level",
"reserve": "the battery reserve",
"maintenance": "battery maintenance",
"charge": "battery maintenance",
"hold": "battery maintenance",
"refused": "a battery that refuses power",
}
@@ -898,6 +918,7 @@ HINTS = {
"peak_on": "Peak guard started",
"peak_off": "Peak guard idle",
"peak_blind": "Peak guard cannot read the meter well",
"peak_gap": "Meter is back, the peak guard counts the missed time as high use",
"peak_saturated": T_PEAK_SATURATED,
"peak_reserve": T_PEAK_RESERVE,
"meter_frozen": T_METER_FROZEN,
@@ -906,6 +927,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",
@@ -1010,6 +1033,9 @@ def status_samples() -> list:
("row_peak_guard_off", AMBER, row_peak_guard_off()),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"])),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"],
others=True)),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=True, floor_unknown=False)),
("row_peak_reserve", RED, row_peak_reserve(
@@ -1078,6 +1104,7 @@ def status_samples() -> list:
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
for k in BLIND_WORDS),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
@@ -1112,6 +1139,8 @@ def samples() -> list:
peak_reserve(5000, soc_unread=False, floor_unknown=False),
peak_reserve(5000, soc_unread=False, floor_unknown=False,
packs=["inverter-b"]),
peak_reserve(5000, soc_unread=False, floor_unknown=False,
packs=["inverter-b"], others=True),
peak_reserve(5000, soc_unread=True, floor_unknown=False),
peak_reserve(5000, soc_unread=False, floor_unknown=True),
circulating(["inverter-a"], ["inverter-b"]),
@@ -1173,6 +1202,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",
}
+49 -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
@@ -47,7 +51,7 @@ Rules that keep this safe (tests: test_modes_p3.py):
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")
@@ -90,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:
@@ -108,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)
@@ -121,7 +151,7 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=()):
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
blocked=False, done_charge=(), done_sell=(),
draining=False) -> Tick:
targets=None, draining=False) -> Tick:
"""This tick's ranges, roles and site ask.
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
@@ -132,8 +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. `draining` says a battery drains for its
monthly cycle, so none may charge from the grid (why "cycle").
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()
@@ -141,7 +172,8 @@ 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
+7
View File
@@ -312,6 +312,13 @@ class QuarterAverager:
"""
return self._last_net
@property
def held_since(self) -> datetime | None:
"""SAFETY-90: when the held sample arrived (its `ingest_ts`), or None."""
if self._last_t is None:
return None
return datetime.fromtimestamp(self._last_t, timezone.utc)
@property
def offtake_avg_w(self) -> float:
"""Average offtake over the part of the open block seen so far."""
+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:
+99 -1
View File
@@ -183,6 +183,8 @@ guard passes its own tests while being switched off:
record). Those seconds make the projection read LOW, which is the
under-guarding direction. The threshold is `max_hold_s` itself - the point
p1.py already stops trusting a held sample - not a new number.
SAFETY-90: once readings are back, main.py counts those seconds
(`fill_gaps()`), so this holds only while the gap lasts.
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
rather than re-written here. A frozen meter is the worst case of all: the
@@ -397,7 +399,7 @@ function, so whichever way the owner answers, this file does not change and does
not need re-testing. Nothing here indexes a phase.
"""
from dataclasses import dataclass
from dataclasses import dataclass, replace
from . import messages
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
@@ -490,11 +492,18 @@ class PeakDecision:
# floor refused to size it. Mutually exclusive with `saturated` by
# construction: this branch asks for nothing, so it cannot have run out of
# inverter. `soc_floor_reason` says WHICH of the two ways it bound.
# SAFETY-87: at N>=2 it may ask the batteries above their reserve for
# `able_w`, all they have, when that is not enough (`place_on_able()`).
floor_limited: bool = False
soc: float | None = None
soc_floor: float | None = None
soc_floor_reason: str | None = None
reason: str = ""
# SAFETY-87, N>=2 only: the batteries at or under their own reserve,
# which give nothing for the peak, and what the others can give (None
# when they are not asked). See `place_on_able()`.
held: tuple = ()
able_w: float | None = None
def explain(self) -> str:
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
@@ -592,6 +601,57 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
return None
# SAFETY-90. Seconds of uncounted time below which nothing is filled: float
# noise between the averager's sums and the wall clock, never a real gap.
GAP_TOL_S = 1e-3
def gap_w(ceiling_w, *seen_w) -> float:
"""SAFETY-90: the watts a meter gap is counted at. The highest of the
limit and `seen_w`, the import with the batteries at 0 W (grid + their
discharge) just before and just after the gap; None entries are skipped.
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None])
def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w,
held_ws=0.0):
"""SAFETY-90: `projection` with the seconds the averager did not count
(a meter gap past `max_hold_s`) counted at `gap_w`, and those from before
this run's first reading (`unseen_s`) at `unseen_w`. `held_ws` is added
too: what the gap's first `max_hold_s`, credited at the reading before
it, lacks to reach `gap_w`. Returns
`(projection, gap seconds filled, unseen seconds filled)`.
`tail_s` is the age of the held reading: up to `max_hold_s` of it is left
as before (the averager credits it once the next reading lands). Past
that the gap is still open, nothing is filled and `blindness()` answers
as before. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
if projection.block_start is None or max_hold_s is None:
return projection, 0.0, 0.0
wall = float(projection.elapsed_s)
tail = min(max(0.0, float(tail_s)), wall)
if tail > float(max_hold_s):
return projection, 0.0, 0.0
covered = wall - uncovered_s(projection)
missing = wall - covered - tail
if missing <= GAP_TOL_S:
missing = 0.0
if held_ws <= 0.0:
return projection, 0.0, 0.0
unseen = min(missing, max(0.0, float(unseen_s)))
gap = missing - unseen
add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws)
done = covered + missing
return (replace(
projection,
projected_w=float(projection.projected_w) + add_ws / QUARTER_S,
elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done
if done > 0.0 else float(projection.elapsed_w)),
covered_frac=(done / wall if wall > 0.0
else float(projection.covered_frac))), gap, unseen)
def discharge_permitted(soc, soc_floor) -> str | None:
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
@@ -733,6 +793,43 @@ def binding_soc_floor(packs, soc_floor):
return max(floors)
def reserve_split(packs, soc_floor) -> tuple[list, tuple]:
"""SAFETY-87: `(packs the guard may spend, names of those it may not)`,
each pack graded on its own floor by `pack_permissions()`."""
perms = pack_permissions(packs, soc_floor)
return ([p for p, (_n, why) in zip(packs, perms) if why is None],
tuple(n for n, why in perms if why is not None))
def place_on_able(decision, held, *, able_w, soc, soc_floor) -> PeakDecision:
"""SAFETY-87: `decision` with the batteries in `held` (at or under their
own reserve) giving nothing for the peak. `able_w`, `soc` and `soc_floor`
describe the others, as `decide()`'s arguments do for the roster. A
floor-limited decision is re-taken on them: they are asked for the need
up to `able_w`, and it stays floor-limited only if that is not enough.
`soc` and `soc_floor` keep the roster's verdict, as `decide()` echoed it.
DECISIONS/SAFETY-87-guard-uses-able-batteries.md."""
held = tuple(held)
if not decision.floor_limited or decision.wanted_w is None \
or discharge_permitted(soc, soc_floor) is not None:
return replace(decision, held=held)
wanted = float(decision.wanted_w)
floor = min(wanted, float(able_w))
head = (f"this 15-minute period heads for {decision.projected_w:.0f} W "
f"against a {decision.ceiling_w:.0f} W limit "
f"({decision.ceiling_from}), aiming at {decision.target_w:.0f} W. "
f"At the peak reserve, so giving nothing for the peak: "
f"{messages.names(held)}. The others are asked for {floor:+.0f} W")
if floor >= wanted:
return replace(decision, floor_limited=False, floor_w=floor,
soc_floor_reason=None, held=held, able_w=float(able_w),
reason=f"PEAK_GUARD: {head}")
return replace(decision, floor_w=floor, held=held, able_w=float(able_w),
reason=(f"PEAK_GUARD_FLOOR_LIMITED: {head}, all they can "
f"give, but {wanted:+.0f} W was needed, so this "
f"period may go over the limit"))
def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc,
soc_floor, max_hold_s=None, frozen: bool = False) -> PeakDecision:
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
@@ -1097,5 +1194,6 @@ def claim(decision: PeakDecision) -> Claim | None:
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
reason=("blind" if decision.blind
else "SoC floor" if decision.floor_limited
and not decision.floor_w
else "quarter peak"),
actuator=ACTUATOR_SITE)
+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() {
+16
View File
@@ -822,6 +822,22 @@ class PeakTracker:
held_w=averager.held_net_w)
def next_quarter(p: Projection, max_hold_s: float) -> Projection:
"""SAFETY-91: `p` past its block's end, before the next block's first
telegram: the next quarter, projected from the held reading (the averager
credits it there). `p` itself otherwise, or when that reading is too old
to be credited. DECISIONS/SAFETY-91-held-battery-last.md."""
if p.block_start is None or p.elapsed_s < QUARTER_S \
or p.elapsed_s * (1.0 - p.covered_frac) > float(max_hold_s):
return p
held = max(p.held_w, 0.0)
return Projection(projected_w=held, elapsed_w=held,
elapsed_s=p.elapsed_s - QUARTER_S, covered_frac=1.0,
complete=True,
block_start=p.block_start + timedelta(seconds=QUARTER_S),
held_w=p.held_w)
@dataclass(frozen=True)
class _ClosedBlock:
"""A block reconstructed from a checkpoint. Shaped like `p1.QuarterBlock` so
+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"},
@@ -335,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.",
@@ -406,6 +410,7 @@
"step_in": "The batteries step in above {kw}.",
"step_in_would": "The batteries would step in above {kw}.",
"period": "This 15-minute period is heading for {kw}.",
"period_unmeasured": "Part of this 15-minute period was not measured.",
"prices": {
"now": "Price now {eur} per kWh, all in.",
"today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.",
+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"},
@@ -277,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.",
@@ -343,6 +347,7 @@
"step_in": "De batterijen springen bij boven {kw}.",
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
"period": "Dit kwartier gaat richting {kw}.",
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
+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 == "":