Merge release/1.0 (OPT-11 P4, #156) into safety-83-peak-over-drain

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-09-30 13:35:28 +02:00
co-authored by Claude Opus 5.5
24 changed files with 2123 additions and 36 deletions
+115 -6
View File
@@ -42,9 +42,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 (
@@ -235,6 +237,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
@@ -948,6 +955,25 @@ 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
@@ -2082,10 +2108,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]
@@ -2103,6 +2133,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
@@ -2117,6 +2150,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
@@ -2155,9 +2224,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)
@@ -2170,7 +2252,7 @@ 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 \
@@ -2178,10 +2260,13 @@ class Controller:
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()
@@ -2552,6 +2637,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.
@@ -2603,6 +2704,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"])