diff --git a/goodwe_controller/app/ctl_trade.py b/goodwe_controller/app/ctl_trade.py new file mode 100644 index 0000000..2d8e40a --- /dev/null +++ b/goodwe_controller/app/ctl_trade.py @@ -0,0 +1,1237 @@ +"""Controller methods for trading: mode runs, peak floors, pack actions. + +Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md.""" + +import dataclasses +import logging +from datetime import datetime, timedelta, timezone + +from . import arbitrage +from .arbiter import ( + ACTUATOR_SITE, Claim, P_MAINTENANCE, P_MAINT_SHAPE, P_PEAK, SET, + pre_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 . import messages +from .maintenance import CHARGE, DRAIN, HOLD, IDLE, MaintConfig, POWER_MOVING +from .mqtt import MODE_LABELS, USE_OFF, USE_ON, unit_slugs, unit_state_key +from . import peakguard +from . import packshare +from .price import Absence +from .price import required_spread as price_required_spread +from .price import uncertainty_of as price_uncertainty_of +from .price_chain import gate_for +from .static_tariff import static_gated +from .ctl_inputs import TARIFF_MAX_AGE_S + +_LOG = logging.getLogger("goodwe") + + +# OPT-11 P3: how long the last cheap run stays the price stored energy is +# valued at: the rest of that day. +REF_CHEAP_KEEP = timedelta(hours=12) + + +# OPT-15: the longest a run edge holds the loop at E waiting for the meter to +# show the step (control.PLANT_SETTLE_S). +EDGE_HOLD_S = _ctl.PLANT_SETTLE_S + + +# OPT-11 P4: store key of the house profile, and how often the forecast sensors +# are read (hourly at most). +HOUSE_PROFILE_KEY = "house_profile" + + +# OPT-34: the level of a night grid charge that has started, so it does not +# drop when the forecast goes (`Controller._latch_charge()`). +CHARGE_LATCH_KEY = "night_charge_latch" + + +# OPT-34: a latch outlives its last cheap run by one price slot, since the next +# run is planned at the slot edge. +CHARGE_LATCH_GRACE_S = 900.0 + + +def _night_limit(born): + """OPT-34: a run that starts after this cannot carry the latch on: the + first bell start (`solarfc.BELL_FROM_H`, local) after `born`.""" + d0, d1 = solarfc._bounds(born) + bell = solarfc._bell_start(d0) + return bell if bell > born else solarfc._bell_start(d1) + + +def pack_energy_of(inv): + """OPT-09. ONE roster entry as the view `packshare` consumes. + One builder for two callers (`pack_energy` needs the roster, `cycle()` one + entry): written twice they would drift. A module function so inverter.py + need not import `packshare`.""" + return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor, + inv.capacity_kwh, inv.max_w) + + +# MAINT-13: a battery power reading at or below minus this many W is a pack +# that is taking charge. +MAINT_CHARGING_W = 50.0 + + +class TradeMixin: + def trade_power_w(self, floor_pct, ceiling_pct) -> tuple: + """OPT-09. `(charge_w, discharge_w)`: how hard a trade may be SIZED: + what the site can push AND STILL HAVE SOMEWHERE TO PUT. The bounds are + arbitrage's own (`arbitrage_soc_floor` / `_ceiling`), NOT the BMS + cut-out that `packshare.envelope()` uses (SAFETY-25): different + questions. It sizes the ask, it does not route it. A pack whose SoC did + not read counts on neither side. Neither side may be zero on a trading + tick (`arbitrage.decide()` refuses `IDLE_NO_SIZE`: a 0 W claim is a + park order). DECISIONS/MAIN-orchestration.md.""" + # The reduction lives in `packshare`; `test_packshare.py` part 9 calls + # the SAME function (a transcription in a check is a copy that drifts). + return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct) + + def peak_aim_w(self) -> float: + """SAFETY-79. `peak_cap_w` less `peak_margin_w`: what the forecast + checks aim at. Reports and the bill use the real cap.""" + return float(self.o.get("peak_cap_w", 3500)) - self.peak_margin_w + + @property + def pack_energy(self) -> list: + """OPT-09. Each pack as the FOUR numbers a split needs; the fourth is + `capacity_kwh`, which is why this is not `packs`. EVERY unit, live + or not, for `packs`'s reason (the allocator drops dead ones itself).""" + return [pack_energy_of(inv) for inv in self.inverters] + + def pack_weights(self, target_w): + """OPT-09. The comfort weights for a site target of `target_w`. A + method handed to `resolve()`, not a dict computed before it (energy + headroom depends on the target), and read fresh on every call, never + cached.""" + base = packshare.weights(self.pack_energy, target_w) + if not self.modes_on: + return base + # OPT-11: the solar order. While the site charges, Leftover solar + # batteries weigh 0, so Solar first fill first (comfort, like the rest + # here; permission is the mode claim). + w = packmode.charge_weights(base, self.pack_modes(), target_w) + # P3: in a price run, the batteries whose run it is carry it first. + tick = self.mode_tick + return w if tick is None else modetrade.weights(tick.roles, w, + target_w) + + def pack_mode(self, inv) -> "packmode.PackMode": + """OPT-11. This battery's on/off switch and mode, read from the store.""" + return packmode.read(getattr(self.store, "data", None) or {}, inv.name) + + def pack_modes(self) -> dict: + return {inv.name: self.pack_mode(inv) for inv in self.inverters} + + def loop_bound(self, claims): + """OPT-11. `(lo, hi)`: what the batteries may place if the loop + decides: the sum over live units of each unit's hard limits with its + mode applied (`allocator.loop_bound()`), from THIS cycle's unit + claims. None when no mode is filed (maintenance) or the range leaves + out 0 W (a fault `resolve()` reports).""" + if len(self.inverters) <= 1: + modes = [c for c in claims if c.source == "mode"] + if not modes: + return None + lo = max(c.lo for c in modes) + hi = min(c.hi for c in modes) + return (lo, hi) if lo <= 0.0 <= hi else None + bounds = {u: pre_resolve([c for c in claims if c.actuator == u]) + for u in self.units} + return allocator.loop_bound(bounds, live=self.live_units()) + + # --- OPT-11 P3: price runs, per battery ------------------------------- + def _mode_packs(self, maint_results=None) -> list: + """Each battery as `modetrade.Pack`, in roster order.""" + live = set(self.live_units()) + out = [] + for i, inv in enumerate(self.inverters): + pm = self.pack_mode(inv) + idle = (maint_results is None + or maint_results[i].phase == IDLE) + refused = (inv.refused if len(self.inverters) > 1 + else self.batt_refused) or 0 + # OPT-15: a charge refusal's probe (SAFETY-73, N>1) as it stood + # when last decided; its pin is filed after this, in cycle(). + probing = (len(self.inverters) > 1 and refused < 0 + and inv.probe_from is not None) + out.append(modetrade.Pack( + inv.name, pm.use, pm.mode, pm.sell and pm.can_sell, + float(inv.max_w), inv.soc, inv.capacity_kwh, + live=inv.name in live, idle=idle, refused=int(refused), + probing=probing)) + return out + + def _wx_track(self, now) -> None: + """TARIFF-24, once per cycle, before the plan: with a forecast read, + remember when the run now under way (cheap, give or sell) ends.""" + if self.solar_fc is None: + return + rp = self.mode_runs + ends = [] + if rp is not None and rp.on: + ends = [r.end for r in (rp.cheap, rp.sell) + if r is not None and r.holds(now)] + g = rp.give_at(now) + if g is not None: + ends.append(g.end) + self._wx_free_to = max(ends) if ends else None + + def weather_held(self, now) -> bool: + """TARIFF-24: no forecast now, but a run planned with one is under way: + it keeps its gap to its end (OPT-34: a night charge that keeps its + level too); the wider gap applies from the next run. Pure; Home asks + the same (`homeview.trade_verdict()`). + (DECISIONS/MAIN-orchestration-archive.md)""" + return self.solar_fc is None and ( + (self._wx_free_to is not None and now < self._wx_free_to) + or (self._charge_latch is not None + and now < self._charge_latch[0])) + + def _price_runs(self, now, packs, floor_pct, ceiling_pct): + """The kept run plan, rebuilt at most once per price slot (15 min) or + when the prices or a battery's choice change; a run under way is kept + to its end (`priceruns.keep_started()`). None with no fresh firm + prices: then no battery holds or trades. + (DECISIONS/MAIN-orchestration-archive.md)""" + 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)) + held = self.weather_held(now) + if self.fc_entity is not None: + # P4: a forecast arriving or going rebuilds the plan at once (and + # so does the end of a run that held the gap, TARIFF-24). + key += (self.solar_fc is None, held) + 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] + e = modetrade.energy(packs, floor_pct, ceiling_pct, done_c, + done_s) + required = price_required_spread( + arbitrage.threshold_eur_kwh(self.o), + price_uncertainty_of(plan.read.product) + | arbitrage.weather_uncertainty(True, self.solar_fc, held)) + ref = self._ref_cheap + if ref is not None and now - ref.end > REF_CHEAP_KEEP: + ref = self._ref_cheap = None + new = priceruns.plan( + plan.read.points, now, required_eur_kwh=required, + 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 + # The cheapest cheap run under way lately is the price the stored + # energy was bought at; it stays the reference for REF_CHEAP_KEEP + # after it ends, unless a cheaper one comes. + ref = self._ref_cheap + if runs is not None and runs.cheap is not None \ + and runs.cheap.holds(now) and ( + ref is None or now - ref.end > REF_CHEAP_KEEP + or runs.cheap.mean_eur_kwh <= ref.mean_eur_kwh): + 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. With no plan + (no or stale forecast, no house profile yet) the levels are dropped: P3 + option A. (DECISIONS/MAIN-orchestration-archive.md)""" + 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, aim_w=self.peak_aim_w(), + slots=priceruns.firm_slots(plan.read.points, now), + spread=required) + self.day_plan = dp + if dp is None: + self._plan_targets = None + 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), except the level + of a night charge that has started (OPT-34): it keeps that level.""" + if self.solar_fc is None: + lt = self._charge_latch + if lt is None or datetime.now(timezone.utc) >= lt[0]: + return None + return dict(lt[1]) + return self._plan_targets + + def _latch_charge(self, now, runs, targets, packs=None) -> None: + """OPT-34, once per cycle after `modetrade.decide()`: a started night + grid charge keeps its planned level when the forecast goes mid-charge + (`plan_targets()` gives the latched levels, `weather_held()` the narrow + price gap). The latch lasts through every cheap run plus one price slot + (`CHARGE_LATCH_GRACE_S`), is dropped when `now` passes its end or the + battery leaves its mode, and a charge that never started has none. + DECISIONS/OPT-34-the-night-charge-keeps-its-level.md""" + old = lt = self._charge_latch + tick = self.mode_tick + run = None if runs is None else runs.cheap + in_run = run is not None and run.holds(now) + grace = timedelta(seconds=CHARGE_LATCH_GRACE_S) + if lt is not None and now >= lt[0]: + lt = None + if lt is not None and packs is not None: + ok = {p.name for p in packs + if p.use and p.mode in packmode.PLAN_CHARGE_MODES} + lv = {n: v for n, v in lt[1].items() if n in ok} + lt = (lt[0], lv, lt[2]) if lv else None + # A run may carry the latch on only if it starts by the night's bell. + carry = in_run and (lt is None or run.start <= _night_limit(lt[2])) + if in_run and self.solar_fc is not None: + # Rebuilt from the plan: a battery already latched follows its + # level up or down whatever its role this tick (a fresh figure may + # lower it below the SoC), one that left the plan is dropped, and + # a new one comes in only while it charges from the grid. + tg = targets or {} + lv = {n: float(tg[n]) for n in (lt[1] if lt else {}) if n in tg} + if tick is not None and tick.action == modetrade.CHARGE: + lv.update({n: float(tg[n]) for n, r in tick.roles.items() + if r == modetrade.CHARGE and n in tg}) + if not lv: + lt = None + elif lt is None: + lt = (run.end + grace, lv, now) + else: + lt = (max(run.end + grace, lt[0]) if carry else lt[0], lv, + lt[2]) + elif lt is not None and carry: + lt = (max(lt[0], run.end + grace), lt[1], lt[2]) + if lt != old: + self._charge_latch = lt + self.store.set(CHARGE_LATCH_KEY, None if lt is None else { + "until": lt[0].isoformat(), "levels": lt[1], + "born": lt[2].isoformat()}) + + 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 with + the MEASURED peak guard (None refuses: nothing measures the quarter), + exactly the gate `arbitrage.decide()` has. + (DECISIONS/MAIN-orchestration-archive.md)""" + floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) + packs = self._mode_packs(maint_results) + self._wx_track(now) + runs = self._price_runs(now, packs, floor_pct, ceiling_pct) + guard = arbitrage.guard_state(self.peak_guard) + plan = self.fresh_tariff_plan(now) + charge_ok = sell_ok = False + trade_why = "not_covered" + + def allowed(active): + return plan.arbitrage( + peak_guard_active=active, + min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), + uncertainty=arbitrage.weather_uncertainty( + True, self.solar_fc, self.weather_held(now)), + now=now).match( + allowed=lambda _v: True, refused=lambda _v: False) + if guard is not None and plan is not None and runs is not None: + # A charge asks the guard (section 9.1 interlock); a sell only + # needs it to have measured the quarter, since discharge is never + # capped by it (a big discharge alone engages it on export). + sell_ok = allowed(False) + charge_ok = sell_ok and allowed(guard) + trade_why = (None if charge_ok else "peak" if sell_ok + else "not_worth") + self.mode_gate = None if charge_ok else trade_why + price_now = None + if plan is not None: + try: + pt = plan.read.match(series=lambda r: r.at(now), + no_data=lambda _r: None) + price_now = (None if pt is None or pt.provisional + else float(pt.eur_per_kwh)) + except Absence: + 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)) + 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) + quarter = int(now.timestamp() // 900) + was = self.mode_tick is not None and \ + self.mode_tick.action == modetrade.CHARGE + self.mode_tick = modetrade.decide( + runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok, + trade_why=trade_why, price_now=price_now, + 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, targets=targets, + draining=any(r.phase == DRAIN for r in maint_results or ())) + self._latch_charge(now, runs, targets, packs) + # A charge the guard or the headroom stopped stays off this period. + # OPT-15: only a MEASURED stop latches; the first unmeasured headroom + # in a quarter stops one tick, a second latches. + # (DECISIONS/MAIN-orchestration-archive.md) + if was and self.mode_tick.action != modetrade.CHARGE: + why = self.mode_tick.why + gap = why == "headroom" and self.arb_headroom_w is None + if why == "peak" or (why == "headroom" and not gap) or ( + gap and self._charge_gap_q == quarter): + self._charge_blocked_q = quarter + elif gap: + self._charge_gap_q = quarter + return self.mode_tick + + 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. + `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() + if key is not None: + done |= {p.name for p in packs if out(p)} + self._run_done[kind] = (key, done) + return done + + def _mode_trade(self, claims, now) -> bool: + """OPT-11 P3: file the site trade the roles ask for, sized inside the + modes and the hard limits and, for a charge, the billed quarter's + headroom (SAFETY-45, aim cap - margin, SAFETY-79), so no battery that + its mode or a refusal holds is asked for watts it may not give. + (DECISIONS/MAIN-orchestration-archive.md)""" + tick = self.mode_tick + if tick is None: + return False + if tick.action == modetrade.CHARGE: + # OPT-20: the ask carries PROBE_W for exactly the probes whose pin + # is filed this tick (`decide()` ran before the pin, so they can + # differ by one cycle; left alone the allocator moves the pin's + # watts to another battery). + # (DECISIONS/MAIN-orchestration-archive.md) + pins = _ctl.PROBE_W * sum( + 1 for c in claims if c.source == "refused" + and c.kind == "set" and c.value < 0) + if pins != tick.probe_w: + tick = self.mode_tick = dataclasses.replace( + tick, ask_w=tick.ask_w + tick.probe_w - pins, + probe_w=pins) + size = modetrade.size(tick, self.loop_bound(claims) + if tick.action else None, self.arb_headroom_w) + runs = self.mode_runs + if tick.action and (size == 0.0 or runs is None): + # Nothing the batteries may place (a refusal, a hard limit): no + # trade, and the roles say so rather than "charging". + back = {modetrade.CHARGE: modetrade.HOLD, + modetrade.REST: modetrade.HOUSE, + modetrade.SELL: modetrade.GIVE} + tick = self.mode_tick = modetrade.Tick( + tick.ranges, {n: back.get(r, r) for n, r in tick.roles.items()}, + None, 0.0, "size") + if size == 0.0 or runs is None: + reason = ("battery modes: no price run now" if tick.why is None + else f"battery modes: no trade ({tick.why})") + self.arb = arbitrage.ArbitrageDecision( + action=None, power_w=None, reason=reason, + spread_eur_kwh=None, required_eur_kwh=None, + price_now_eur_kwh=None) + else: + charge = tick.action == modetrade.CHARGE + run = runs.cheap if charge else runs.sell + who = ", ".join(n for n, r in sorted(tick.roles.items()) + if r == tick.action) + act = arbitrage.CHARGE if charge else arbitrage.DISCHARGE + self.arb = arbitrage.ArbitrageDecision( + action=act, power_w=size, + reason=(f"{act}: battery modes, " + f"{'cheap' if charge else 'sell'} run " + f"{run.start.isoformat()} to {run.end.isoformat()} " + f"at {run.mean_eur_kwh:.4f} EUR/kWh; " + f"{abs(size):.0f} W on {who}"), + spread_eur_kwh=None, required_eur_kwh=runs.level_eur_kwh, + price_now_eur_kwh=None) + claims.append(arbitrage.claim(self.arb)) + if self.arb.action != self._arb_action: + self._arb_action = self.arb.action + self.log_event(self.arb.explain(), hint=messages.hint( + "trade_charge" if self.arb.action == arbitrage.CHARGE else + "trade_give" if self.arb.action == arbitrage.DISCHARGE + else "trade_off")) + return self.arb.action is not None + + def _reseed_run_edge(self, claims, now) -> None: + """OPT-11 P3: a mode trade just ended, so the loop takes over from a + command no loop chose: it starts at the house's own point E, not at the + trade's watts, and OPT-15 HOLDS it at E (`_edge_grid()`) until the + meter shows the step or `EDGE_HOLD_S` passes. + DECISIONS/OPT-11-battery-modes.md""" + self._edge = None + batt = self.site_batt_w + if batt is None or self.grid is None: + return + want = float(batt) + float(self.grid) - self.tuning.target_grid_w + bound = self.loop_bound(claims) + if bound is not None: + want = max(bound[0], min(bound[1], want)) + want = float(round(max(-self.site_max_w, min(self.site_max_w, want)))) + self.target = self.i_w = want + if want != float(batt): + self._edge = (want, 1.0 if want > float(batt) else -1.0, + now + timedelta(seconds=EDGE_HOLD_S), float(batt)) + + def _edge_grid(self, end, now): + """OPT-15: the grid reading the loop acts on this tick. While a run + edge is held, the target itself (no error, the command stays at E); the + hold ends when the meter reaches the target, on `end`, or after + `EDGE_HOLD_S`. (DECISIONS/MAIN-orchestration-archive.md)""" + e = self._edge + if e is None: + return self.grid + held_w, sign, until, last_b = e + tg, db = self.tuning.target_grid_w, self.tuning.deadband_w + b = self.site_batt_w + moving = (b is not None and sign * (float(b) - last_b) >= db + and sign * (held_w - float(b)) >= db + and now < until + timedelta(seconds=EDGE_HOLD_S)) + if (end or (now >= until and not moving) + or sign * (float(self.grid) - tg) < db): + self._edge = None + return self.grid + self._edge = (held_w, sign, until, last_b if b is None else float(b)) + self.i_w = held_w + return tg + + def _charge_track(self, claims, maint_results) -> None: + """SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can + no longer be placed (stale link, refusal). Kept until the loop runs. + SAFETY-95: a drain likewise; its refusal (SAFETY-73) is read from the + battery (+1 = will not give). (DECISIONS/MAIN-orchestration-archive.md)""" + if len(self.inverters) < 2: + return + live = set(self.live_units()) + now = {CHARGE: set(), DRAIN: set()} + for inv, res in zip(self.inverters, maint_results): + u = self.actuator_of("maintenance", inv) + if res.phase in now and u in live and not pre_resolve( + [c for c in claims if c.actuator == u]).contradiction \ + and not (res.phase == DRAIN and (inv.refused or 0) > 0): + now[res.phase].add(inv.name) + self._charge_ended |= self._charging_was - now[CHARGE] + self._charging_was = frozenset(now[CHARGE]) + self._drain_ended |= self._draining_was - now[DRAIN] + self._draining_was = frozenset(now[DRAIN]) + + def _reseed_charge_end(self, maint_results) -> None: + """SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop + starts from what the others stood at plus that battery's new command + (its HOLD pin, or its last discharge if it is free; a charge stops); + else the charge's watts land on the others (B 0 -> -1600 W in one + cycle). DECISIONS/SAFETY-93-charge-end.md""" + ended, self._charge_ended = self._charge_ended, frozenset() + if len(self.inverters) < 2 or not ended: + return + for inv, res in zip(self.inverters, maint_results): + if inv.name not in ended: + continue + now_w = (float(res.setpoint_w or 0.0) if res.owns_setpoint + else max(float(inv.target), 0.0)) + if not inv.is_live(datetime.now(timezone.utc))[0]: + now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W + self.target += now_w - float(inv.target) + # SAFETY-96: it settles from its charge to that command. + self._loop_start[inv.name] = now_w + self._settle[inv.name] = self._now_mono + self.i_w = self.target + + def _reseed_drain_end(self, maint_results, live) -> None: + """SAFETY-95, N>=2: when a battery's maintenance drain ends, its drain + leaves the loop's start point, as a charge does (SAFETY-93): the start + is what the others stood at plus its new command (its pin, else 0 W), + never above last cycle's start; else the drain's floor lands on the + others (B 0 -> +1600 W, exporting). DECISIONS/SAFETY-95-drain-end.md""" + ended, self._drain_ended = self._drain_ended, frozenset() + if len(self.inverters) < 2 or not ended: + return + hi = self.target + for inv, res in zip(self.inverters, maint_results): + if inv.name not in ended: + continue + now_w = (float(res.setpoint_w or 0.0) + if res.owns_setpoint and res.phase != DRAIN + else min(float(inv.target), 0.0)) + if inv.name not in live: + now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W + else: + self._drain_tail.add(inv.name) + self.target += now_w - float(inv.target) + self._loop_start[inv.name] = now_w + self._settle[inv.name] = self._now_mono + lo, hi = sorted((self.target, hi)) + # A dropped battery's power is the meter's, like the house's. + here = float(self.grid) - self.tuning.target_grid_w + sum( + float(inv.batt) for inv in self.inverters if inv.name in live) + self.target = self.i_w = min(hi, max(lo, here)) + + def _drain_grid_w(self, live) -> float: + """SAFETY-95: the grid the loop regulates. While a battery settles from + its ended drain (`_held_out_w`, at most PLANT_SETTLE_DOWN_S) the + batteries count at the site command, so the loop does not chase the + drain's tail. SAFETY-103: a reading can be up to a register period (10 + s) older than the meter, so the house point is held and moved only as + far as the meter proves. + DECISIONS/SAFETY-103-old-readings-after-a-drain.md""" + grid = float(self.grid) + self._drain_tail &= set(self._settle) & set(live) + if not self._drain_tail: + self._drain_house = None + return grid + aim = self.tuning.target_grid_w + lo = hi = grid - aim + for inv in self.inverters: + if inv.name not in live: + continue + r = float(inv.batt) + span = self._read_span.get(inv.name) + if (span is None or span[2] != inv.batt_fresh.changed_mono + or span[2] == self._now_mono): + span = (r, r) + # A battery that refuses one way (SAFETY-73) is not heading + # that way, whatever it was asked: that side is its reading. + if (inv.refused or 0) > 0: + span = (min(span[0], r), r) + elif (inv.refused or 0) < 0: + span = (r, max(span[1], r)) + lo, hi = lo + span[0], hi + span[1] + held = (float(self.target) if self._drain_house is None + else self._drain_house) + self._drain_house = min(hi, max(lo, held)) + return self._drain_house - float(self.target) + aim + + def _track_reads(self) -> None: + """SAFETY-103, N>=2: per battery, the range its power can be in now: + its last changed reading and every command on its wire since (a + lagging battery moves from one toward the others, never beyond).""" + if len(self.inverters) < 2: + return + for inv in self.inverters: + if inv.batt is None: + continue + cmd, since = inv.standing_w(), inv.batt_fresh.changed_mono + lo, hi, was = self._read_span.get(inv.name, (0.0, 0.0, None)) + if was is None or since != was: + lo = hi = float(inv.batt) + self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since) + + def _reseed_drop(self, live) -> None: + """SAFETY-96, N>=2: a battery that drops out of the split (stale link) + leaves the loop's start point, so the others take over at one slew step + a cycle instead of taking its share at once. + DECISIONS/SAFETY-96-dropped-battery.md""" + was, self._live_was = self._live_was, frozenset(live) + if len(self.inverters) < 2 or was is None or was == live: + return + for inv in self.inverters: + if inv.name in self._charge_ended | self._drain_ended: + continue # SAFETY-93/95's edge: `_reseed_*_end()` + if inv.name in was and inv.name not in live: + now_w = 0.0 + elif inv.name in live and inv.name not in was: + now_w = float(inv.batt) + # The split is made again, so every battery's command steps. + self._settle.update( + {u: self._now_mono for u in live}) + else: + continue + self.target += now_w - float(inv.target) + self._loop_start[inv.name] = now_w + self.i_w = self.target + + def _held_out_w(self, live) -> float: + """SAFETY-96, N>=2: what to add to the site sum so that a battery the + loop cannot move right now counts at its command, not at its reading. + Its real power still reaches the loop through the meter: neither lost + nor counted twice. DECISIONS/SAFETY-96-dropped-battery.md""" + if len(self.inverters) < 2: + return 0.0 + out = 0.0 + for inv in self.inverters: + cmd = self._loop_start.get(inv.name, float(inv.target)) + since = self._settle.get(inv.name) + age = (None if since is None or self._now_mono is None + else self._now_mono - since) + if since is not None and ( + age is None or age >= _ctl.PLANT_SETTLE_DOWN_S + or (age > 0 and abs(float(inv.batt) - cmd) + < self.tuning.deadband_w)): + del self._settle[inv.name] + if (inv.name not in live or inv.rejected_since is not None + or inv.name in self._settle): + out += cmd - float(inv.batt) + return out + + def _mode_fault_log(self, resolution) -> None: + """OPT-11: say once, per battery, that its mode was dropped as a fault. + A mode range without 0 W would force a direction, which no mode may do, + so it is never applied (allocator.unit_interval()); main.py never + builds one. (DECISIONS/MAIN-orchestration-archive.md)""" + alloc = resolution.allocation + now = {u for u, _ in getattr(alloc, "mode_faults", ())} + if resolution.mode_fault is not None and self.inverters: + now.add(self.inverters[0].name) + for inv in self.inverters: + if inv.name in now and inv.name not in self._mode_faults: + _LOG.error("Battery mode ignored for %s. Software fault " + "MODE_RANGE.", inv.name) + self.log_event(f"battery mode ignored on {inv.name}, a " + f"software fault", hint=messages.hint( + "mode_fault", unit=inv.name)) + self._mode_faults = now + + def _pack_action(self, what, unit, value, now) -> dict: + """OPT-11: switch one battery on or off, set its mode, or (P3) turn its + selling on or off. Store state, not an option: it applies next cycle + with no restart. Switching a battery off also stops its maintenance + cycle. (DECISIONS/MAIN-orchestration-archive.md)""" + if not self.modes_on: + return {"error": messages.pack_refused("off")} + inv = next((i for i in self.inverters if i.name == unit), None) + if inv is None: + return {"error": messages.pack_refused("unit")} + if what == "pack_use": + if not isinstance(value, bool): + return {"error": messages.pack_refused("value")} + inv.store_set(packmode.USE_KEY, value) + self.log_event(f"{inv.name} switched {'on' if value else 'off'} " + f"by operator", + hint=messages.hint("pack_on" if value + else "pack_off", unit=inv.name)) + if not value: + self._maint_stop_off(self.maints[self.inverters.index(inv)], + now) + elif what == "pack_sell": + if not packmode.SELLING_ENABLED: + return {"error": messages.pack_refused("sell_off")} + if not isinstance(value, bool): + return {"error": messages.pack_refused("value")} + inv.store_set(packmode.SELL_KEY, value) + self.log_event(f"{inv.name} selling switched " + f"{'on' if value else 'off'} by operator", + hint=messages.hint("pack_sell", unit=inv.name)) + else: + if value not in packmode.MODES: + return {"error": messages.pack_refused("value")} + inv.store_set(packmode.MODE_KEY, value) + self.log_event(f"{inv.name} mode set to {value.replace('_', ' ')} " + f"by operator", + hint=messages.hint("pack_mode", unit=inv.name)) + pm = self.pack_mode(inv) + out = {"unit": inv.name, "use": pm.use, "mode": pm.mode} + if what == "pack_sell": + out["sell"] = pm.sell + return out + + async def _maint_unit_action(self, what, unit, now) -> dict: + """MAINT-11: start or abort the maintenance cycle of ONE named battery. + The site keeps ONE maintenance slot: a start while any battery holds it + is refused (no queue). (DECISIONS/MAIN-orchestration-archive.md)""" + inv = next((i for i in self.inverters if i.name == unit), None) + if inv is None: + return self._maint_refusal("unit") + machine = self.maints[self.inverters.index(inv)] + if what == "maint_start": + holder = next((i for i, m in zip(self.inverters, self.maints) + if m.phase in POWER_MOVING), None) + if holder is not None: + return self._maint_refusal("busy", holder.name) + if self.modes_on and not self.pack_mode(inv).use: + return self._maint_refusal("off", inv.name) + for msg in machine.force_start(now): + self.log_event(msg, hint=messages.hint( + "maintenance", phase=machine.phase)) + return {"unit": inv.name, "phase": machine.phase} + if machine.phase not in POWER_MOVING: + return self._maint_refusal("idle", inv.name) + for msg in machine.abort(now, "operator"): + self.log_event(msg, hint=messages.hint("maintenance", + phase=machine.phase)) + await self.command_site_zero() + return {"unit": inv.name, "phase": machine.phase} + + @staticmethod + def _maint_refusal(code, unit="") -> dict: + return {"error": messages.maint_refused(code, unit), "code": code, + "unit": unit} + + def mode_values(self) -> dict: + """OPT-11 P2: each battery's mode and switch, for their HA state + topics. Read from the store, so they are right after a restart. Empty + while battery modes are off.""" + if not self.modes_on: + return {} + out = {} + names = [inv.name for inv in self.inverters] + for inv, slug in zip(self.inverters, unit_slugs(names)): + pm = self.pack_mode(inv) + out[unit_state_key(slug, "mode")] = MODE_LABELS.get(pm.mode, + pm.mode) + out[unit_state_key(slug, "use")] = USE_ON if pm.use else USE_OFF + return out + + 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, the drain never moves), so the site target + may not go below what the pin places. At N=1 the drain is the site + SET itself: nothing filed. A draining pack with nothing to give adds + no floor (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 _peak_on_able(self, decision, maint_results): + """SAFETY-87: at N>=2, `decision` placed on the batteries that can + still give: live, not refusing, above their own reserve. `able_w` is + their rating plus the drain a held battery gives anyway. + DECISIONS/SAFETY-87-guard-uses-able-batteries.md""" + if len(self.inverters) < 2: + return decision + floor = float(self.o.get( + "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) + ok, held = peakguard.reserve_split(self.packs, floor) + if not held: + return decision + live = set(self.live_units()) + able = [p for p, inv in zip(self.packs, self.inverters) + if p in ok and inv.name in live and not (inv.refused or 0) > 0] + if not able: + return dataclasses.replace(decision, held=held) + drain = sum(min(float(r.setpoint_w), i.rating_w()) + for i, r in zip(self.inverters, maint_results) + if i.name in held and r.phase == DRAIN + and r.owns_setpoint and (r.setpoint_w or 0.0) > 0.0) + names = {p.name for p in able} + return peakguard.place_on_able( + decision, held, + able_w=drain + sum(i.rating_w() for i in self.inverters + if i.name in names), + soc=min(float(p.soc) for p in able), + soc_floor=peakguard.binding_soc_floor(able, floor)) + + def _peak_landing(self, decision): + """SAFETY-126: `decision` marked `within_aim` while the guard wants + power but the batteries at 0 W would end the quarter within + `ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). The claim and the + commands are unchanged; only the alarms and the "Why" read it. + DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md""" + batt, prev = self.site_batt_w, self._land_batt_prev + self._land_batt_prev = batt + if not (decision.engaged and decision.blind is None + and (decision.wanted_w or 0.0) > 0.0): + self._peak_needed = False + return decision + if batt is not None and prev is not None: + batt = min(float(batt), float(prev)) + self._peak_needed = self._peak_needed or ( + self._peak_proj is not None and peakguard.misses_aim( + self._peak_proj, batt_w=batt, target_w=decision.target_w)) + return (decision if self._peak_needed + else dataclasses.replace(decision, within_aim=True)) + + def _peak_reserve(self) -> frozenset: + """SAFETY-87: the batteries that give nothing for the peak this + cycle: under their reserve while the others carry a peak floor.""" + g = self.peak_guard + if g is None or g.able_w is None or not (g.floor_w or 0.0) > 0.0: + return frozenset() + return frozenset(g.held) + + def _held_back(self) -> frozenset: + """SAFETY-91, N>=2: the batteries at or under their own peak reserve + while another can still give (live, not refusing). One held back stays + so until its SoC reads more than 1 point above its reserve. + DECISIONS/SAFETY-91-held-battery-last.md""" + if len(self.inverters) < 2: + return frozenset() + floor = float(self.o.get( + "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) + ok, held = peakguard.reserve_split(self.packs, floor) + held = set(held) | { + p.name for p in ok if p.name in self._held_was + and float(p.soc) <= peakguard.pack_floor_pct(p, floor) + 1.0} + live = set(self.live_units()) + if not held or not any( + p.name not in held and inv.name in live + and not (inv.refused or 0) > 0 + for p, inv in zip(self.packs, self.inverters)): + self._held_was = frozenset() + else: + self._held_was = frozenset(held) + return self._held_was + + def _drain_rise(self, claims, maint_results) -> None: + """SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a + draining battery's pin becomes a floor at its drain rate, so it rises + towards its rating before the held one gives. A site stop still decides + alone. DECISIONS/SAFETY-91-held-battery-last.md""" + held = self._held_back() + if not held or any(c.kind == SET and c.actuator == ACTUATOR_SITE + and c.priority > P_PEAK for c in claims): + return + live = set(self.live_units()) + for inv, res in zip(self.inverters, maint_results): + u = self.actuator_of("maintenance", inv) + if res.phase != DRAIN or not res.owns_setpoint \ + or inv.name in held or u not in live: + continue + b = pre_resolve([c for c in claims if c.actuator == u]) + p = (None if b.contradiction or b.pinned_by != "maintenance" + else allocator.pin_w(b)) + if p is None or not p > 0.0: + continue + for i, c in enumerate(claims): + if c.source == "maintenance" and c.kind == SET \ + and c.actuator == u: + claims[i] = Claim.limit("maintenance", P_MAINTENANCE, + lo=p, reason=c.reason, + actuator=u) + + def _fill_peak_gaps(self, proj, now, reading): + """SAFETY-90: `proj` with a meter gap's uncounted seconds counted at + `peakguard.gap_w()` of the imports seen just before and just after it, + and those before this run's first reading likewise at the import seen + first (shown as not measured). + DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.""" + self.peak_gap_s = self.peak_unmeasured_s = 0.0 + avg = self.p1.averager + since = avg.held_since + if proj is None or proj.block_start is None or since is None: + return proj + if self._unseen is None and self.p1.last is not None: + self._unseen = [proj.block_start, max(0.0, ( + since - proj.block_start).total_seconds() - avg.elapsed_s), + since, None] + seen = (None if self.grid is None or self.site_batt_w is None + else float(self.grid) + max(0.0, float(self.site_batt_w))) + if proj.block_start != self._gap_block: + self._gap_block, self._gap_w = proj.block_start, None + self._gap_filled_s = self._gap_held_ws = 0.0 + cap = float(self.o.get("peak_cap_w", 3500)) + ceiling = peakguard.quarter_ceiling_w(cap, reading)[0] + unseen_on = (self._unseen is not None + and self._unseen[0] == proj.block_start) + if unseen_on and self._unseen[3] is None and seen is not None: + # While the add-on was down the batteries were at 0 W, so the + # seconds before its first reading count like a gap, at the + # import seen first; a restored reading's held part is topped up. + self._unseen[3] = peakguard.gap_w(ceiling, seen) + if self._run_pre is not None: + s0, g0 = self._run_pre + h = (min(s0 + timedelta(seconds=avg.max_hold_s), + self._unseen[2]) + - max(s0, proj.block_start)).total_seconds() + self._unseen_held_ws = max(0.0, h) * max( + 0.0, self._unseen[3] - max(0.0, g0)) + kw = dict(tail_s=(now - since).total_seconds(), + max_hold_s=avg.max_hold_s, + unseen_s=self._unseen[1] if unseen_on else 0.0, + unseen_w=(self._unseen[3] if unseen_on + and self._unseen[3] is not None else ceiling)) + _p, gap_s, _u = peakguard.fill_gaps(proj, gap_w=0.0, **kw) + if gap_s > self._gap_filled_s + peakguard.GAP_TOL_S: + self._gap_w = peakguard.gap_w(ceiling, self._gap_w, + self._gap_seen_w, seen) + if self._gap_pre is not None: + # The averager credited the gap's first max_hold_s at the + # reading before it; top that part up to gap_w as well. + s0, g0 = self._gap_pre + h = (min(s0 + timedelta(seconds=avg.max_hold_s), since) + - max(s0, proj.block_start)).total_seconds() + self._gap_held_ws += max(0.0, h) * max( + 0.0, self._gap_w - max(0.0, g0)) + self.log_event(f"PEAK_GUARD_GAP: the meter missed {gap_s:.0f} s " + f"of this 15-minute period, counted at " + f"{self._gap_w:.0f} W", + hint=messages.hint("peak_gap")) + self._gap_filled_s = max(self._gap_filled_s, gap_s) + if seen is not None: + self._gap_seen_w = seen + if self.grid is not None: + self._gap_pre = (since, float(self.grid)) + out, self.peak_gap_s, self.peak_unmeasured_s = peakguard.fill_gaps( + proj, gap_w=self._gap_w if self._gap_w is not None else ceiling, + held_ws=self._gap_held_ws + (self._unseen_held_ws if unseen_on + else 0.0), **kw) + return out + + def _peak_over_drain(self, claims, maint_results) -> None: + """SAFETY-83: at N=1 a peak floor above the drain rate outranks it: the + drain is the site SET at P_MAINTENANCE, above P_PEAK, so the floor + did not bind. Refiled at P_MAINTENANCE where it asks for more; + P_SAFETY still outranks. N>=2: the `drain` floor and + `_drain_over_reserve()`.""" + if len(self.inverters) != 1: + self._drain_over_reserve(claims, maint_results) + return + res = maint_results[0] + if res.phase != DRAIN or not res.owns_setpoint: + return + rate = float(res.setpoint_w or 0.0) + for i, c in enumerate(claims): + if c.source == peakguard.SOURCE and c.lo > rate: + claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE) + + def _held_pin(self, claims, u): + """SAFETY-87: what held unit `u` places anyway (its own pin), or None + when that pin charges, so the site is never split both ways.""" + b = pre_resolve([c for c in claims if c.actuator == u]) + p = None if b.contradiction else allocator.pin_w(b) + return None if p is not None and p < 0.0 else max(p or 0.0, 0.0) + + def _drain_over_reserve(self, claims, maint_results) -> None: + """SAFETY-87, N>=2: while a battery is held at its peak reserve, a + draining battery that may still give is raised from its drain rate + towards its rating for the peak floor, after the others. A site stop + still decides alone. DECISIONS/SAFETY-87-guard-uses-able-batteries.md""" + reserve = self._peak_reserve() + floor = max((c.lo for c in claims if c.source == peakguard.SOURCE), + default=0.0) + if not reserve or not floor > 0.0 or any( + c.kind == SET and c.actuator == ACTUATOR_SITE + and c.priority > P_PEAK for c in claims): + return + live = set(self.live_units()) + drains, others = [], 0.0 + for inv, res in zip(self.inverters, maint_results): + u = self.actuator_of("maintenance", inv) + if u not in live: + continue + if inv.name in reserve: + p = self._held_pin(claims, u) + if p is None: + return + others += p + continue + b = pre_resolve([c for c in claims if c.actuator == u]) + if b.contradiction: + continue + p = allocator.pin_w(b) + if p is not None and p < 0.0: + return # a pack pinned to charge: never split both ways + if res.phase == DRAIN and b.pinned_by == "maintenance" \ + and p is not None and p > 0.0: + drains.append((u, p, allocator.hi_w(b))) + others += allocator.hi_w(b) if p is None else p + give = floor - others + for u, p, hi in drains: + if give <= 0.0: + break + up = max(p, min(hi, p + give)) + give -= up - p + for i, c in enumerate(claims): + if c.source == "maintenance" and c.kind == SET \ + and c.actuator == u: + claims[i] = dataclasses.replace(c, value=up) + + def _peak_over_hold(self, claims, maint_results): + """SAFETY-86: a peak floor outranks a maintenance HOLD (0 W). N=1: + refiled at P_MAINTENANCE as for a drain. N>=2: the holding pack's + pin is raised to the floor minus what the others may place, and the + floor is returned as the site `hold` ceiling. A site stop still + decides alone. DECISIONS/SAFETY-86-peak-over-hold.md.""" + hold = {self.actuator_of("maintenance", inv) + for inv, res in zip(self.inverters, maint_results) + if res.phase == HOLD and res.owns_setpoint} + peak = [i for i, c in enumerate(claims) + if c.source == peakguard.SOURCE and c.lo > 0.0] + if not hold or not peak: + return + if len(self.inverters) == 1: + for i in peak: + claims[i] = dataclasses.replace(claims[i], + priority=P_MAINTENANCE) + return + if any(c.kind == SET and c.actuator == ACTUATOR_SITE + and c.priority > P_PEAK for c in claims): + return + live = set(self.live_units()) + reserve = self._peak_reserve() # SAFETY-87: they give nothing + others = 0.0 + for inv in self.inverters: + u = self.actuator_of("maintenance", inv) + if u in hold or u not in live: + continue + if inv.name in reserve: # only its own pin, never both ways + p = self._held_pin(claims, u) + if p is None: + return + others += p + continue + b = pre_resolve([c for c in claims if c.actuator == u]) + if b.contradiction: + continue + p = allocator.pin_w(b) + if p is not None and p < 0.0: + return # a pack pinned to charge: never split both ways + others += allocator.hi_w(b) if p is None else p + floor = max(claims[i].lo for i in peak) + give = floor - others + if give <= 0.0: + return + for i, c in enumerate(claims): + if c.source == "maintenance" and c.kind == SET \ + and c.actuator in hold: + claims[i] = dataclasses.replace(c, value=give) + return floor + + def _charge_headroom(self, floor: float, claims) -> float: + """SAFETY-84: a cheap-window charge pulls no more than the quarter's + room under the peak aim (`charge_room_w()`), so it yields just enough + to the peak guard; at N>=2 nothing under a site stop (its 0 W would + need the other pack to give). SAFETY-125: at N=1 too; the stop rule + stays N>=2 only. + DECISIONS/SAFETY-125-grid-charge-under-the-month-peak.md""" + if len(self.inverters) > 1 and any( + c.kind == SET and c.actuator == ACTUATOR_SITE + and c.priority > P_MAINT_SHAPE for c in claims): + return 0.0 + room = peakguard.charge_room_w(self.peak_guard, self._peak_proj) + floor = floor if room is None else min(floor, room) + self.room_charge = self.room_charge or floor > 0.0 + return floor + + def _charge_ceiling(self, claims): + """SAFETY-84: the site `hi` for a cheap-window charge at N>=2, or None: + the charging pack's own `hi` (P_MAINT_SHAPE) makes it charge; above + that the other pack would have to give (opposite directions, + refused, 0 W). Not filed at N=1.""" + if len(self.inverters) < 2: + return None + live = set(self.live_units()) + ceiling = 0.0 + for inv in self.inverters: + u = self.actuator_of("maintenance", inv) + b = pre_resolve([c for c in claims if c.actuator == u]) + if u in live and not b.contradiction and b.hi < 0.0: + ceiling += allocator.hi_w(b) + return ceiling if ceiling < 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: + return + for msg in machine.abort(now, "battery switched off"): + self.log_event(msg, hint=messages.hint("maint_off", + unit=machine.name)) + + def _provider_gate(self, now: datetime) -> None: + """PRICE-01: `cheap`, the gate and the gap from the standing plan. No + price for now is a closed gate, so the charge waits rather than run + unpriced; the gap flag stays False until the first refresh has + finished. (DECISIONS/MAIN-orchestration-archive.md)""" + plan = self.fresh_tariff_plan(now) + published = None if plan is None else plan.published + if published is not None: + self._last_published = published + gate = gate_for(published, now) + self.cheap, self._price_gated = gate.cheap, True + self.tariff_words = gate.words + # Owner, 2026-10-14: the words say 'maintenance waits', so they show + # only while the maintenance charge is on. + self.price_gap = ((not gate.covered) and self.tariff_at is not None + and MaintConfig.from_options(self.o).enabled) + last = self._last_published + if gate.covered or last is None: + self.price_gap_since = None + elif last.covers(now): + # The last series still covers now, but no refresh has made it + # current for TARIFF_MAX_AGE_S: the refresh task has stopped. The + # gap began when the plan was retired. + self.price_gap_since = ( + None if self.tariff_at is None + else self.tariff_at + timedelta(seconds=TARIFF_MAX_AGE_S)) + else: + self.price_gap_since = last.last_end_before(now) + + def _static_gated(self, o: dict, now: datetime) -> bool: + """`static_gated()`, worked out once an hour: it builds the same + month-long plan as `static_status()` (about 20 ms with a profile), and + whether a fixed tariff is set up does not change from tick to tick + (options change only with a restart).""" + key = (id(o), now.strftime("%Y-%m-%d %H")) + if key != self._gated_key: + self._gated_key, self._gated = key, static_gated(o, now) + return self._gated + + def _charge_gate_open(self, inv) -> bool: + """MAINT-13: may this pack charge now, for the maintenance timeout? + Closed only when a price gate exists, it is shut, and the pack is not + really taking charge. (DECISIONS/MAIN-orchestration-archive.md)""" + if not self._price_gated or self.cheap: + return True + batt = inv.batt + return batt is not None and float(batt) <= -MAINT_CHARGING_W diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index f7ceb7a..505c52c 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -103,6 +103,11 @@ from .watchdog import ( ) from . import setuponly from . import web +from .ctl_trade import ( + TradeMixin, CHARGE_LATCH_GRACE_S, CHARGE_LATCH_KEY, EDGE_HOLD_S, + HOUSE_PROFILE_KEY, MAINT_CHARGING_W, REF_CHEAP_KEEP, _night_limit, + pack_energy_of, +) from .ctl_writes import ( WritesMixin, FALLBACK_BUDGET_S, REFUSED_PROGRESS_W, REFUSED_SPAN_S, RELEASE_BUDGET_S, SETTLE_BUDGET_S, WRITE_BUDGET_S, _bounded, @@ -150,27 +155,6 @@ def load_options() -> dict: return {} - -# OPT-11 P3: how long the last cheap run stays the price stored energy is -# valued at: the rest of that day. -REF_CHEAP_KEEP = timedelta(hours=12) -# OPT-15: the longest a run edge holds the loop at E waiting for the meter to -# show the step (control.PLANT_SETTLE_S). -EDGE_HOLD_S = _ctl.PLANT_SETTLE_S - -# OPT-11 P4: store key of the house profile, and how often the forecast sensors -# are read (hourly at most). -HOUSE_PROFILE_KEY = "house_profile" - - -# OPT-34: the level of a night grid charge that has started, so it does not -# drop when the forecast goes (`Controller._latch_charge()`). -CHARGE_LATCH_KEY = "night_charge_latch" -# OPT-34: a latch outlives its last cheap run by one price slot, since the next -# run is planned at the slot edge. -CHARGE_LATCH_GRACE_S = 900.0 - - def _load_latch(data, now=None): """`(until, {name: level %}, born)` from the store, or None if it is not a well-formed latch or has ended (OPT-34).""" @@ -188,23 +172,6 @@ def _load_latch(data, now=None): return until, levels, born -def _night_limit(born): - """OPT-34: a run that starts after this cannot carry the latch on: the - first bell start (`solarfc.BELL_FROM_H`, local) after `born`.""" - d0, d1 = solarfc._bounds(born) - bell = solarfc._bell_start(d0) - return bell if bell > born else solarfc._bell_start(d1) - - -def pack_energy_of(inv): - """OPT-09. ONE roster entry as the view `packshare` consumes. - One builder for two callers (`pack_energy` needs the roster, `cycle()` one - entry): written twice they would drift. A module function so inverter.py - need not import `packshare`.""" - return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor, - inv.capacity_kwh, inv.max_w) - - # SAFETY-85: a battery another owner set is graded against the least it was # told over the plant's settle time, so a battery still settling is not short. SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S @@ -223,7 +190,7 @@ def _refused_interval(sign: int) -> tuple: return (-math.inf, 0.0) if sign > 0 else (0.0, math.inf) -class Controller(InputsMixin, WritesMixin): +class Controller(InputsMixin, WritesMixin, TradeMixin): def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop, peak_db_path: str = "/data/tariff.db", price_history_path: str = "/data/price_history.json", @@ -1091,19 +1058,6 @@ class Controller(InputsMixin, WritesMixin): the second inverter. `max_w` exactly at N=1.""" return float(sum(inv.max_w for inv in self.inverters)) - def trade_power_w(self, floor_pct, ceiling_pct) -> tuple: - """OPT-09. `(charge_w, discharge_w)`: how hard a trade may be SIZED: - what the site can push AND STILL HAVE SOMEWHERE TO PUT. The bounds are - arbitrage's own (`arbitrage_soc_floor` / `_ceiling`), NOT the BMS - cut-out that `packshare.envelope()` uses (SAFETY-25): different - questions. It sizes the ask, it does not route it. A pack whose SoC did - not read counts on neither side. Neither side may be zero on a trading - tick (`arbitrage.decide()` refuses `IDLE_NO_SIZE`: a 0 W claim is a - park order). DECISIONS/MAIN-orchestration.md.""" - # The reduction lives in `packshare`; `test_packshare.py` part 9 calls - # the SAME function (a transcription in a check is a copy that drifts). - return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct) - @property def site_rating_w(self) -> float: """The largest magnitude the SITE can be commanded, in W: SUM of each @@ -1113,11 +1067,6 @@ class Controller(InputsMixin, WritesMixin): contributes `max_w`.""" return float(sum(inv.rating_w() for inv in self.inverters)) - def peak_aim_w(self) -> float: - """SAFETY-79. `peak_cap_w` less `peak_margin_w`: what the forecast - checks aim at. Reports and the bill use the real cap.""" - return float(self.o.get("peak_cap_w", 3500)) - self.peak_margin_w - def site_dev_bound(self, which: str): """The roster's summed device floor / ceiling, or None if unknown. `freeze.looks_frozen()` and `rail_unreachable()` SKIP a bound handed @@ -1175,729 +1124,6 @@ class Controller(InputsMixin, WritesMixin): return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor) for inv in self.inverters] - @property - def pack_energy(self) -> list: - """OPT-09. Each pack as the FOUR numbers a split needs; the fourth is - `capacity_kwh`, which is why this is not `packs`. EVERY unit, live - or not, for `packs`'s reason (the allocator drops dead ones itself).""" - return [pack_energy_of(inv) for inv in self.inverters] - - def pack_weights(self, target_w): - """OPT-09. The comfort weights for a site target of `target_w`. A - method handed to `resolve()`, not a dict computed before it (energy - headroom depends on the target), and read fresh on every call, never - cached.""" - base = packshare.weights(self.pack_energy, target_w) - if not self.modes_on: - return base - # OPT-11: the solar order. While the site charges, Leftover solar - # batteries weigh 0, so Solar first fill first (comfort, like the rest - # here; permission is the mode claim). - w = packmode.charge_weights(base, self.pack_modes(), target_w) - # P3: in a price run, the batteries whose run it is carry it first. - tick = self.mode_tick - return w if tick is None else modetrade.weights(tick.roles, w, - target_w) - - def pack_mode(self, inv) -> "packmode.PackMode": - """OPT-11. This battery's on/off switch and mode, read from the store.""" - return packmode.read(getattr(self.store, "data", None) or {}, inv.name) - - def pack_modes(self) -> dict: - return {inv.name: self.pack_mode(inv) for inv in self.inverters} - - def loop_bound(self, claims): - """OPT-11. `(lo, hi)`: what the batteries may place if the loop - decides: the sum over live units of each unit's hard limits with its - mode applied (`allocator.loop_bound()`), from THIS cycle's unit - claims. None when no mode is filed (maintenance) or the range leaves - out 0 W (a fault `resolve()` reports).""" - if len(self.inverters) <= 1: - modes = [c for c in claims if c.source == "mode"] - if not modes: - return None - lo = max(c.lo for c in modes) - hi = min(c.hi for c in modes) - return (lo, hi) if lo <= 0.0 <= hi else None - bounds = {u: pre_resolve([c for c in claims if c.actuator == u]) - for u in self.units} - return allocator.loop_bound(bounds, live=self.live_units()) - - # --- OPT-11 P3: price runs, per battery ------------------------------- - def _mode_packs(self, maint_results=None) -> list: - """Each battery as `modetrade.Pack`, in roster order.""" - live = set(self.live_units()) - out = [] - for i, inv in enumerate(self.inverters): - pm = self.pack_mode(inv) - idle = (maint_results is None - or maint_results[i].phase == IDLE) - refused = (inv.refused if len(self.inverters) > 1 - else self.batt_refused) or 0 - # OPT-15: a charge refusal's probe (SAFETY-73, N>1) as it stood - # when last decided; its pin is filed after this, in cycle(). - probing = (len(self.inverters) > 1 and refused < 0 - and inv.probe_from is not None) - out.append(modetrade.Pack( - inv.name, pm.use, pm.mode, pm.sell and pm.can_sell, - float(inv.max_w), inv.soc, inv.capacity_kwh, - live=inv.name in live, idle=idle, refused=int(refused), - probing=probing)) - return out - - def _wx_track(self, now) -> None: - """TARIFF-24, once per cycle, before the plan: with a forecast read, - remember when the run now under way (cheap, give or sell) ends.""" - if self.solar_fc is None: - return - rp = self.mode_runs - ends = [] - if rp is not None and rp.on: - ends = [r.end for r in (rp.cheap, rp.sell) - if r is not None and r.holds(now)] - g = rp.give_at(now) - if g is not None: - ends.append(g.end) - self._wx_free_to = max(ends) if ends else None - - def weather_held(self, now) -> bool: - """TARIFF-24: no forecast now, but a run planned with one is under way: - it keeps its gap to its end (OPT-34: a night charge that keeps its - level too); the wider gap applies from the next run. Pure; Home asks - the same (`homeview.trade_verdict()`). - (DECISIONS/MAIN-orchestration-archive.md)""" - return self.solar_fc is None and ( - (self._wx_free_to is not None and now < self._wx_free_to) - or (self._charge_latch is not None - and now < self._charge_latch[0])) - - def _price_runs(self, now, packs, floor_pct, ceiling_pct): - """The kept run plan, rebuilt at most once per price slot (15 min) or - when the prices or a battery's choice change; a run under way is kept - to its end (`priceruns.keep_started()`). None with no fresh firm - prices: then no battery holds or trades. - (DECISIONS/MAIN-orchestration-archive.md)""" - 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)) - held = self.weather_held(now) - if self.fc_entity is not None: - # P4: a forecast arriving or going rebuilds the plan at once (and - # so does the end of a run that held the gap, TARIFF-24). - key += (self.solar_fc is None, held) - 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] - e = modetrade.energy(packs, floor_pct, ceiling_pct, done_c, - done_s) - required = price_required_spread( - arbitrage.threshold_eur_kwh(self.o), - price_uncertainty_of(plan.read.product) - | arbitrage.weather_uncertainty(True, self.solar_fc, held)) - ref = self._ref_cheap - if ref is not None and now - ref.end > REF_CHEAP_KEEP: - ref = self._ref_cheap = None - new = priceruns.plan( - plan.read.points, now, required_eur_kwh=required, - 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 - # The cheapest cheap run under way lately is the price the stored - # energy was bought at; it stays the reference for REF_CHEAP_KEEP - # after it ends, unless a cheaper one comes. - ref = self._ref_cheap - if runs is not None and runs.cheap is not None \ - and runs.cheap.holds(now) and ( - ref is None or now - ref.end > REF_CHEAP_KEEP - or runs.cheap.mean_eur_kwh <= ref.mean_eur_kwh): - 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. With no plan - (no or stale forecast, no house profile yet) the levels are dropped: P3 - option A. (DECISIONS/MAIN-orchestration-archive.md)""" - 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, aim_w=self.peak_aim_w(), - slots=priceruns.firm_slots(plan.read.points, now), - spread=required) - self.day_plan = dp - if dp is None: - self._plan_targets = None - 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), except the level - of a night charge that has started (OPT-34): it keeps that level.""" - if self.solar_fc is None: - lt = self._charge_latch - if lt is None or datetime.now(timezone.utc) >= lt[0]: - return None - return dict(lt[1]) - return self._plan_targets - - def _latch_charge(self, now, runs, targets, packs=None) -> None: - """OPT-34, once per cycle after `modetrade.decide()`: a started night - grid charge keeps its planned level when the forecast goes mid-charge - (`plan_targets()` gives the latched levels, `weather_held()` the narrow - price gap). The latch lasts through every cheap run plus one price slot - (`CHARGE_LATCH_GRACE_S`), is dropped when `now` passes its end or the - battery leaves its mode, and a charge that never started has none. - DECISIONS/OPT-34-the-night-charge-keeps-its-level.md""" - old = lt = self._charge_latch - tick = self.mode_tick - run = None if runs is None else runs.cheap - in_run = run is not None and run.holds(now) - grace = timedelta(seconds=CHARGE_LATCH_GRACE_S) - if lt is not None and now >= lt[0]: - lt = None - if lt is not None and packs is not None: - ok = {p.name for p in packs - if p.use and p.mode in packmode.PLAN_CHARGE_MODES} - lv = {n: v for n, v in lt[1].items() if n in ok} - lt = (lt[0], lv, lt[2]) if lv else None - # A run may carry the latch on only if it starts by the night's bell. - carry = in_run and (lt is None or run.start <= _night_limit(lt[2])) - if in_run and self.solar_fc is not None: - # Rebuilt from the plan: a battery already latched follows its - # level up or down whatever its role this tick (a fresh figure may - # lower it below the SoC), one that left the plan is dropped, and - # a new one comes in only while it charges from the grid. - tg = targets or {} - lv = {n: float(tg[n]) for n in (lt[1] if lt else {}) if n in tg} - if tick is not None and tick.action == modetrade.CHARGE: - lv.update({n: float(tg[n]) for n, r in tick.roles.items() - if r == modetrade.CHARGE and n in tg}) - if not lv: - lt = None - elif lt is None: - lt = (run.end + grace, lv, now) - else: - lt = (max(run.end + grace, lt[0]) if carry else lt[0], lv, - lt[2]) - elif lt is not None and carry: - lt = (max(lt[0], run.end + grace), lt[1], lt[2]) - if lt != old: - self._charge_latch = lt - self.store.set(CHARGE_LATCH_KEY, None if lt is None else { - "until": lt[0].isoformat(), "levels": lt[1], - "born": lt[2].isoformat()}) - - 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 with - the MEASURED peak guard (None refuses: nothing measures the quarter), - exactly the gate `arbitrage.decide()` has. - (DECISIONS/MAIN-orchestration-archive.md)""" - floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) - packs = self._mode_packs(maint_results) - self._wx_track(now) - runs = self._price_runs(now, packs, floor_pct, ceiling_pct) - guard = arbitrage.guard_state(self.peak_guard) - plan = self.fresh_tariff_plan(now) - charge_ok = sell_ok = False - trade_why = "not_covered" - - def allowed(active): - return plan.arbitrage( - peak_guard_active=active, - min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), - uncertainty=arbitrage.weather_uncertainty( - True, self.solar_fc, self.weather_held(now)), - now=now).match( - allowed=lambda _v: True, refused=lambda _v: False) - if guard is not None and plan is not None and runs is not None: - # A charge asks the guard (section 9.1 interlock); a sell only - # needs it to have measured the quarter, since discharge is never - # capped by it (a big discharge alone engages it on export). - sell_ok = allowed(False) - charge_ok = sell_ok and allowed(guard) - trade_why = (None if charge_ok else "peak" if sell_ok - else "not_worth") - self.mode_gate = None if charge_ok else trade_why - price_now = None - if plan is not None: - try: - pt = plan.read.match(series=lambda r: r.at(now), - no_data=lambda _r: None) - price_now = (None if pt is None or pt.provisional - else float(pt.eur_per_kwh)) - except Absence: - 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)) - 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) - quarter = int(now.timestamp() // 900) - was = self.mode_tick is not None and \ - self.mode_tick.action == modetrade.CHARGE - self.mode_tick = modetrade.decide( - runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok, - trade_why=trade_why, price_now=price_now, - 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, targets=targets, - draining=any(r.phase == DRAIN for r in maint_results or ())) - self._latch_charge(now, runs, targets, packs) - # A charge the guard or the headroom stopped stays off this period. - # OPT-15: only a MEASURED stop latches; the first unmeasured headroom - # in a quarter stops one tick, a second latches. - # (DECISIONS/MAIN-orchestration-archive.md) - if was and self.mode_tick.action != modetrade.CHARGE: - why = self.mode_tick.why - gap = why == "headroom" and self.arb_headroom_w is None - if why == "peak" or (why == "headroom" and not gap) or ( - gap and self._charge_gap_q == quarter): - self._charge_blocked_q = quarter - elif gap: - self._charge_gap_q = quarter - return self.mode_tick - - 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. - `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() - if key is not None: - done |= {p.name for p in packs if out(p)} - self._run_done[kind] = (key, done) - return done - - def _mode_trade(self, claims, now) -> bool: - """OPT-11 P3: file the site trade the roles ask for, sized inside the - modes and the hard limits and, for a charge, the billed quarter's - headroom (SAFETY-45, aim cap - margin, SAFETY-79), so no battery that - its mode or a refusal holds is asked for watts it may not give. - (DECISIONS/MAIN-orchestration-archive.md)""" - tick = self.mode_tick - if tick is None: - return False - if tick.action == modetrade.CHARGE: - # OPT-20: the ask carries PROBE_W for exactly the probes whose pin - # is filed this tick (`decide()` ran before the pin, so they can - # differ by one cycle; left alone the allocator moves the pin's - # watts to another battery). - # (DECISIONS/MAIN-orchestration-archive.md) - pins = _ctl.PROBE_W * sum( - 1 for c in claims if c.source == "refused" - and c.kind == "set" and c.value < 0) - if pins != tick.probe_w: - tick = self.mode_tick = dataclasses.replace( - tick, ask_w=tick.ask_w + tick.probe_w - pins, - probe_w=pins) - size = modetrade.size(tick, self.loop_bound(claims) - if tick.action else None, self.arb_headroom_w) - runs = self.mode_runs - if tick.action and (size == 0.0 or runs is None): - # Nothing the batteries may place (a refusal, a hard limit): no - # trade, and the roles say so rather than "charging". - back = {modetrade.CHARGE: modetrade.HOLD, - modetrade.REST: modetrade.HOUSE, - modetrade.SELL: modetrade.GIVE} - tick = self.mode_tick = modetrade.Tick( - tick.ranges, {n: back.get(r, r) for n, r in tick.roles.items()}, - None, 0.0, "size") - if size == 0.0 or runs is None: - reason = ("battery modes: no price run now" if tick.why is None - else f"battery modes: no trade ({tick.why})") - self.arb = arbitrage.ArbitrageDecision( - action=None, power_w=None, reason=reason, - spread_eur_kwh=None, required_eur_kwh=None, - price_now_eur_kwh=None) - else: - charge = tick.action == modetrade.CHARGE - run = runs.cheap if charge else runs.sell - who = ", ".join(n for n, r in sorted(tick.roles.items()) - if r == tick.action) - act = arbitrage.CHARGE if charge else arbitrage.DISCHARGE - self.arb = arbitrage.ArbitrageDecision( - action=act, power_w=size, - reason=(f"{act}: battery modes, " - f"{'cheap' if charge else 'sell'} run " - f"{run.start.isoformat()} to {run.end.isoformat()} " - f"at {run.mean_eur_kwh:.4f} EUR/kWh; " - f"{abs(size):.0f} W on {who}"), - spread_eur_kwh=None, required_eur_kwh=runs.level_eur_kwh, - price_now_eur_kwh=None) - claims.append(arbitrage.claim(self.arb)) - if self.arb.action != self._arb_action: - self._arb_action = self.arb.action - self.log_event(self.arb.explain(), hint=messages.hint( - "trade_charge" if self.arb.action == arbitrage.CHARGE else - "trade_give" if self.arb.action == arbitrage.DISCHARGE - else "trade_off")) - return self.arb.action is not None - - def _reseed_run_edge(self, claims, now) -> None: - """OPT-11 P3: a mode trade just ended, so the loop takes over from a - command no loop chose: it starts at the house's own point E, not at the - trade's watts, and OPT-15 HOLDS it at E (`_edge_grid()`) until the - meter shows the step or `EDGE_HOLD_S` passes. - DECISIONS/OPT-11-battery-modes.md""" - self._edge = None - batt = self.site_batt_w - if batt is None or self.grid is None: - return - want = float(batt) + float(self.grid) - self.tuning.target_grid_w - bound = self.loop_bound(claims) - if bound is not None: - want = max(bound[0], min(bound[1], want)) - want = float(round(max(-self.site_max_w, min(self.site_max_w, want)))) - self.target = self.i_w = want - if want != float(batt): - self._edge = (want, 1.0 if want > float(batt) else -1.0, - now + timedelta(seconds=EDGE_HOLD_S), float(batt)) - - def _edge_grid(self, end, now): - """OPT-15: the grid reading the loop acts on this tick. While a run - edge is held, the target itself (no error, the command stays at E); the - hold ends when the meter reaches the target, on `end`, or after - `EDGE_HOLD_S`. (DECISIONS/MAIN-orchestration-archive.md)""" - e = self._edge - if e is None: - return self.grid - held_w, sign, until, last_b = e - tg, db = self.tuning.target_grid_w, self.tuning.deadband_w - b = self.site_batt_w - moving = (b is not None and sign * (float(b) - last_b) >= db - and sign * (held_w - float(b)) >= db - and now < until + timedelta(seconds=EDGE_HOLD_S)) - if (end or (now >= until and not moving) - or sign * (float(self.grid) - tg) < db): - self._edge = None - return self.grid - self._edge = (held_w, sign, until, last_b if b is None else float(b)) - self.i_w = held_w - return tg - - def _charge_track(self, claims, maint_results) -> None: - """SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can - no longer be placed (stale link, refusal). Kept until the loop runs. - SAFETY-95: a drain likewise; its refusal (SAFETY-73) is read from the - battery (+1 = will not give). (DECISIONS/MAIN-orchestration-archive.md)""" - if len(self.inverters) < 2: - return - live = set(self.live_units()) - now = {CHARGE: set(), DRAIN: set()} - for inv, res in zip(self.inverters, maint_results): - u = self.actuator_of("maintenance", inv) - if res.phase in now and u in live and not pre_resolve( - [c for c in claims if c.actuator == u]).contradiction \ - and not (res.phase == DRAIN and (inv.refused or 0) > 0): - now[res.phase].add(inv.name) - self._charge_ended |= self._charging_was - now[CHARGE] - self._charging_was = frozenset(now[CHARGE]) - self._drain_ended |= self._draining_was - now[DRAIN] - self._draining_was = frozenset(now[DRAIN]) - - def _reseed_charge_end(self, maint_results) -> None: - """SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop - starts from what the others stood at plus that battery's new command - (its HOLD pin, or its last discharge if it is free; a charge stops); - else the charge's watts land on the others (B 0 -> -1600 W in one - cycle). DECISIONS/SAFETY-93-charge-end.md""" - ended, self._charge_ended = self._charge_ended, frozenset() - if len(self.inverters) < 2 or not ended: - return - for inv, res in zip(self.inverters, maint_results): - if inv.name not in ended: - continue - now_w = (float(res.setpoint_w or 0.0) if res.owns_setpoint - else max(float(inv.target), 0.0)) - if not inv.is_live(datetime.now(timezone.utc))[0]: - now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W - self.target += now_w - float(inv.target) - # SAFETY-96: it settles from its charge to that command. - self._loop_start[inv.name] = now_w - self._settle[inv.name] = self._now_mono - self.i_w = self.target - - def _reseed_drain_end(self, maint_results, live) -> None: - """SAFETY-95, N>=2: when a battery's maintenance drain ends, its drain - leaves the loop's start point, as a charge does (SAFETY-93): the start - is what the others stood at plus its new command (its pin, else 0 W), - never above last cycle's start; else the drain's floor lands on the - others (B 0 -> +1600 W, exporting). DECISIONS/SAFETY-95-drain-end.md""" - ended, self._drain_ended = self._drain_ended, frozenset() - if len(self.inverters) < 2 or not ended: - return - hi = self.target - for inv, res in zip(self.inverters, maint_results): - if inv.name not in ended: - continue - now_w = (float(res.setpoint_w or 0.0) - if res.owns_setpoint and res.phase != DRAIN - else min(float(inv.target), 0.0)) - if inv.name not in live: - now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W - else: - self._drain_tail.add(inv.name) - self.target += now_w - float(inv.target) - self._loop_start[inv.name] = now_w - self._settle[inv.name] = self._now_mono - lo, hi = sorted((self.target, hi)) - # A dropped battery's power is the meter's, like the house's. - here = float(self.grid) - self.tuning.target_grid_w + sum( - float(inv.batt) for inv in self.inverters if inv.name in live) - self.target = self.i_w = min(hi, max(lo, here)) - - def _drain_grid_w(self, live) -> float: - """SAFETY-95: the grid the loop regulates. While a battery settles from - its ended drain (`_held_out_w`, at most PLANT_SETTLE_DOWN_S) the - batteries count at the site command, so the loop does not chase the - drain's tail. SAFETY-103: a reading can be up to a register period (10 - s) older than the meter, so the house point is held and moved only as - far as the meter proves. - DECISIONS/SAFETY-103-old-readings-after-a-drain.md""" - grid = float(self.grid) - self._drain_tail &= set(self._settle) & set(live) - if not self._drain_tail: - self._drain_house = None - return grid - aim = self.tuning.target_grid_w - lo = hi = grid - aim - for inv in self.inverters: - if inv.name not in live: - continue - r = float(inv.batt) - span = self._read_span.get(inv.name) - if (span is None or span[2] != inv.batt_fresh.changed_mono - or span[2] == self._now_mono): - span = (r, r) - # A battery that refuses one way (SAFETY-73) is not heading - # that way, whatever it was asked: that side is its reading. - if (inv.refused or 0) > 0: - span = (min(span[0], r), r) - elif (inv.refused or 0) < 0: - span = (r, max(span[1], r)) - lo, hi = lo + span[0], hi + span[1] - held = (float(self.target) if self._drain_house is None - else self._drain_house) - self._drain_house = min(hi, max(lo, held)) - return self._drain_house - float(self.target) + aim - - def _track_reads(self) -> None: - """SAFETY-103, N>=2: per battery, the range its power can be in now: - its last changed reading and every command on its wire since (a - lagging battery moves from one toward the others, never beyond).""" - if len(self.inverters) < 2: - return - for inv in self.inverters: - if inv.batt is None: - continue - cmd, since = inv.standing_w(), inv.batt_fresh.changed_mono - lo, hi, was = self._read_span.get(inv.name, (0.0, 0.0, None)) - if was is None or since != was: - lo = hi = float(inv.batt) - self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since) - - def _reseed_drop(self, live) -> None: - """SAFETY-96, N>=2: a battery that drops out of the split (stale link) - leaves the loop's start point, so the others take over at one slew step - a cycle instead of taking its share at once. - DECISIONS/SAFETY-96-dropped-battery.md""" - was, self._live_was = self._live_was, frozenset(live) - if len(self.inverters) < 2 or was is None or was == live: - return - for inv in self.inverters: - if inv.name in self._charge_ended | self._drain_ended: - continue # SAFETY-93/95's edge: `_reseed_*_end()` - if inv.name in was and inv.name not in live: - now_w = 0.0 - elif inv.name in live and inv.name not in was: - now_w = float(inv.batt) - # The split is made again, so every battery's command steps. - self._settle.update( - {u: self._now_mono for u in live}) - else: - continue - self.target += now_w - float(inv.target) - self._loop_start[inv.name] = now_w - self.i_w = self.target - - def _held_out_w(self, live) -> float: - """SAFETY-96, N>=2: what to add to the site sum so that a battery the - loop cannot move right now counts at its command, not at its reading. - Its real power still reaches the loop through the meter: neither lost - nor counted twice. DECISIONS/SAFETY-96-dropped-battery.md""" - if len(self.inverters) < 2: - return 0.0 - out = 0.0 - for inv in self.inverters: - cmd = self._loop_start.get(inv.name, float(inv.target)) - since = self._settle.get(inv.name) - age = (None if since is None or self._now_mono is None - else self._now_mono - since) - if since is not None and ( - age is None or age >= _ctl.PLANT_SETTLE_DOWN_S - or (age > 0 and abs(float(inv.batt) - cmd) - < self.tuning.deadband_w)): - del self._settle[inv.name] - if (inv.name not in live or inv.rejected_since is not None - or inv.name in self._settle): - out += cmd - float(inv.batt) - return out - - def _mode_fault_log(self, resolution) -> None: - """OPT-11: say once, per battery, that its mode was dropped as a fault. - A mode range without 0 W would force a direction, which no mode may do, - so it is never applied (allocator.unit_interval()); main.py never - builds one. (DECISIONS/MAIN-orchestration-archive.md)""" - alloc = resolution.allocation - now = {u for u, _ in getattr(alloc, "mode_faults", ())} - if resolution.mode_fault is not None and self.inverters: - now.add(self.inverters[0].name) - for inv in self.inverters: - if inv.name in now and inv.name not in self._mode_faults: - _LOG.error("Battery mode ignored for %s. Software fault " - "MODE_RANGE.", inv.name) - self.log_event(f"battery mode ignored on {inv.name}, a " - f"software fault", hint=messages.hint( - "mode_fault", unit=inv.name)) - self._mode_faults = now - - def _pack_action(self, what, unit, value, now) -> dict: - """OPT-11: switch one battery on or off, set its mode, or (P3) turn its - selling on or off. Store state, not an option: it applies next cycle - with no restart. Switching a battery off also stops its maintenance - cycle. (DECISIONS/MAIN-orchestration-archive.md)""" - if not self.modes_on: - return {"error": messages.pack_refused("off")} - inv = next((i for i in self.inverters if i.name == unit), None) - if inv is None: - return {"error": messages.pack_refused("unit")} - if what == "pack_use": - if not isinstance(value, bool): - return {"error": messages.pack_refused("value")} - inv.store_set(packmode.USE_KEY, value) - self.log_event(f"{inv.name} switched {'on' if value else 'off'} " - f"by operator", - hint=messages.hint("pack_on" if value - else "pack_off", unit=inv.name)) - if not value: - self._maint_stop_off(self.maints[self.inverters.index(inv)], - now) - elif what == "pack_sell": - if not packmode.SELLING_ENABLED: - return {"error": messages.pack_refused("sell_off")} - if not isinstance(value, bool): - return {"error": messages.pack_refused("value")} - inv.store_set(packmode.SELL_KEY, value) - self.log_event(f"{inv.name} selling switched " - f"{'on' if value else 'off'} by operator", - hint=messages.hint("pack_sell", unit=inv.name)) - else: - if value not in packmode.MODES: - return {"error": messages.pack_refused("value")} - inv.store_set(packmode.MODE_KEY, value) - self.log_event(f"{inv.name} mode set to {value.replace('_', ' ')} " - f"by operator", - hint=messages.hint("pack_mode", unit=inv.name)) - pm = self.pack_mode(inv) - out = {"unit": inv.name, "use": pm.use, "mode": pm.mode} - if what == "pack_sell": - out["sell"] = pm.sell - return out - - async def _maint_unit_action(self, what, unit, now) -> dict: - """MAINT-11: start or abort the maintenance cycle of ONE named battery. - The site keeps ONE maintenance slot: a start while any battery holds it - is refused (no queue). (DECISIONS/MAIN-orchestration-archive.md)""" - inv = next((i for i in self.inverters if i.name == unit), None) - if inv is None: - return self._maint_refusal("unit") - machine = self.maints[self.inverters.index(inv)] - if what == "maint_start": - holder = next((i for i, m in zip(self.inverters, self.maints) - if m.phase in POWER_MOVING), None) - if holder is not None: - return self._maint_refusal("busy", holder.name) - if self.modes_on and not self.pack_mode(inv).use: - return self._maint_refusal("off", inv.name) - for msg in machine.force_start(now): - self.log_event(msg, hint=messages.hint( - "maintenance", phase=machine.phase)) - return {"unit": inv.name, "phase": machine.phase} - if machine.phase not in POWER_MOVING: - return self._maint_refusal("idle", inv.name) - for msg in machine.abort(now, "operator"): - self.log_event(msg, hint=messages.hint("maintenance", - phase=machine.phase)) - await self.command_site_zero() - return {"unit": inv.name, "phase": machine.phase} - - @staticmethod - def _maint_refusal(code, unit="") -> dict: - return {"error": messages.maint_refused(code, unit), "code": code, - "unit": unit} - - def mode_values(self) -> dict: - """OPT-11 P2: each battery's mode and switch, for their HA state - topics. Read from the store, so they are right after a restart. Empty - while battery modes are off.""" - if not self.modes_on: - return {} - out = {} - names = [inv.name for inv in self.inverters] - for inv, slug in zip(self.inverters, unit_slugs(names)): - pm = self.pack_mode(inv) - out[unit_state_key(slug, "mode")] = MODE_LABELS.get(pm.mode, - pm.mode) - out[unit_state_key(slug, "use")] = USE_ON if pm.use else USE_OFF - return out - async def mqtt_command(self, topic, payload, retained) -> None: """OPT-11 P2: one command from a battery's HA switch or mode list, through handle_action(). A retained command is never applied (the store @@ -1937,372 +1163,6 @@ class Controller(InputsMixin, WritesMixin): 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, the drain never moves), so the site target - may not go below what the pin places. At N=1 the drain is the site - SET itself: nothing filed. A draining pack with nothing to give adds - no floor (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 _peak_on_able(self, decision, maint_results): - """SAFETY-87: at N>=2, `decision` placed on the batteries that can - still give: live, not refusing, above their own reserve. `able_w` is - their rating plus the drain a held battery gives anyway. - DECISIONS/SAFETY-87-guard-uses-able-batteries.md""" - if len(self.inverters) < 2: - return decision - floor = float(self.o.get( - "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) - ok, held = peakguard.reserve_split(self.packs, floor) - if not held: - return decision - live = set(self.live_units()) - able = [p for p, inv in zip(self.packs, self.inverters) - if p in ok and inv.name in live and not (inv.refused or 0) > 0] - if not able: - return dataclasses.replace(decision, held=held) - drain = sum(min(float(r.setpoint_w), i.rating_w()) - for i, r in zip(self.inverters, maint_results) - if i.name in held and r.phase == DRAIN - and r.owns_setpoint and (r.setpoint_w or 0.0) > 0.0) - names = {p.name for p in able} - return peakguard.place_on_able( - decision, held, - able_w=drain + sum(i.rating_w() for i in self.inverters - if i.name in names), - soc=min(float(p.soc) for p in able), - soc_floor=peakguard.binding_soc_floor(able, floor)) - - def _peak_landing(self, decision): - """SAFETY-126: `decision` marked `within_aim` while the guard wants - power but the batteries at 0 W would end the quarter within - `ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). The claim and the - commands are unchanged; only the alarms and the "Why" read it. - DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md""" - batt, prev = self.site_batt_w, self._land_batt_prev - self._land_batt_prev = batt - if not (decision.engaged and decision.blind is None - and (decision.wanted_w or 0.0) > 0.0): - self._peak_needed = False - return decision - if batt is not None and prev is not None: - batt = min(float(batt), float(prev)) - self._peak_needed = self._peak_needed or ( - self._peak_proj is not None and peakguard.misses_aim( - self._peak_proj, batt_w=batt, target_w=decision.target_w)) - return (decision if self._peak_needed - else dataclasses.replace(decision, within_aim=True)) - - def _peak_reserve(self) -> frozenset: - """SAFETY-87: the batteries that give nothing for the peak this - cycle: under their reserve while the others carry a peak floor.""" - g = self.peak_guard - if g is None or g.able_w is None or not (g.floor_w or 0.0) > 0.0: - return frozenset() - return frozenset(g.held) - - def _held_back(self) -> frozenset: - """SAFETY-91, N>=2: the batteries at or under their own peak reserve - while another can still give (live, not refusing). One held back stays - so until its SoC reads more than 1 point above its reserve. - DECISIONS/SAFETY-91-held-battery-last.md""" - if len(self.inverters) < 2: - return frozenset() - floor = float(self.o.get( - "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) - ok, held = peakguard.reserve_split(self.packs, floor) - held = set(held) | { - p.name for p in ok if p.name in self._held_was - and float(p.soc) <= peakguard.pack_floor_pct(p, floor) + 1.0} - live = set(self.live_units()) - if not held or not any( - p.name not in held and inv.name in live - and not (inv.refused or 0) > 0 - for p, inv in zip(self.packs, self.inverters)): - self._held_was = frozenset() - else: - self._held_was = frozenset(held) - return self._held_was - - def _drain_rise(self, claims, maint_results) -> None: - """SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a - draining battery's pin becomes a floor at its drain rate, so it rises - towards its rating before the held one gives. A site stop still decides - alone. DECISIONS/SAFETY-91-held-battery-last.md""" - held = self._held_back() - if not held or any(c.kind == SET and c.actuator == ACTUATOR_SITE - and c.priority > P_PEAK for c in claims): - return - live = set(self.live_units()) - for inv, res in zip(self.inverters, maint_results): - u = self.actuator_of("maintenance", inv) - if res.phase != DRAIN or not res.owns_setpoint \ - or inv.name in held or u not in live: - continue - b = pre_resolve([c for c in claims if c.actuator == u]) - p = (None if b.contradiction or b.pinned_by != "maintenance" - else allocator.pin_w(b)) - if p is None or not p > 0.0: - continue - for i, c in enumerate(claims): - if c.source == "maintenance" and c.kind == SET \ - and c.actuator == u: - claims[i] = Claim.limit("maintenance", P_MAINTENANCE, - lo=p, reason=c.reason, - actuator=u) - - def _fill_peak_gaps(self, proj, now, reading): - """SAFETY-90: `proj` with a meter gap's uncounted seconds counted at - `peakguard.gap_w()` of the imports seen just before and just after it, - and those before this run's first reading likewise at the import seen - first (shown as not measured). - DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.""" - self.peak_gap_s = self.peak_unmeasured_s = 0.0 - avg = self.p1.averager - since = avg.held_since - if proj is None or proj.block_start is None or since is None: - return proj - if self._unseen is None and self.p1.last is not None: - self._unseen = [proj.block_start, max(0.0, ( - since - proj.block_start).total_seconds() - avg.elapsed_s), - since, None] - seen = (None if self.grid is None or self.site_batt_w is None - else float(self.grid) + max(0.0, float(self.site_batt_w))) - if proj.block_start != self._gap_block: - self._gap_block, self._gap_w = proj.block_start, None - self._gap_filled_s = self._gap_held_ws = 0.0 - cap = float(self.o.get("peak_cap_w", 3500)) - ceiling = peakguard.quarter_ceiling_w(cap, reading)[0] - unseen_on = (self._unseen is not None - and self._unseen[0] == proj.block_start) - if unseen_on and self._unseen[3] is None and seen is not None: - # While the add-on was down the batteries were at 0 W, so the - # seconds before its first reading count like a gap, at the - # import seen first; a restored reading's held part is topped up. - self._unseen[3] = peakguard.gap_w(ceiling, seen) - if self._run_pre is not None: - s0, g0 = self._run_pre - h = (min(s0 + timedelta(seconds=avg.max_hold_s), - self._unseen[2]) - - max(s0, proj.block_start)).total_seconds() - self._unseen_held_ws = max(0.0, h) * max( - 0.0, self._unseen[3] - max(0.0, g0)) - kw = dict(tail_s=(now - since).total_seconds(), - max_hold_s=avg.max_hold_s, - unseen_s=self._unseen[1] if unseen_on else 0.0, - unseen_w=(self._unseen[3] if unseen_on - and self._unseen[3] is not None else ceiling)) - _p, gap_s, _u = peakguard.fill_gaps(proj, gap_w=0.0, **kw) - if gap_s > self._gap_filled_s + peakguard.GAP_TOL_S: - self._gap_w = peakguard.gap_w(ceiling, self._gap_w, - self._gap_seen_w, seen) - if self._gap_pre is not None: - # The averager credited the gap's first max_hold_s at the - # reading before it; top that part up to gap_w as well. - s0, g0 = self._gap_pre - h = (min(s0 + timedelta(seconds=avg.max_hold_s), since) - - max(s0, proj.block_start)).total_seconds() - self._gap_held_ws += max(0.0, h) * max( - 0.0, self._gap_w - max(0.0, g0)) - self.log_event(f"PEAK_GUARD_GAP: the meter missed {gap_s:.0f} s " - f"of this 15-minute period, counted at " - f"{self._gap_w:.0f} W", - hint=messages.hint("peak_gap")) - self._gap_filled_s = max(self._gap_filled_s, gap_s) - if seen is not None: - self._gap_seen_w = seen - if self.grid is not None: - self._gap_pre = (since, float(self.grid)) - out, self.peak_gap_s, self.peak_unmeasured_s = peakguard.fill_gaps( - proj, gap_w=self._gap_w if self._gap_w is not None else ceiling, - held_ws=self._gap_held_ws + (self._unseen_held_ws if unseen_on - else 0.0), **kw) - return out - - def _peak_over_drain(self, claims, maint_results) -> None: - """SAFETY-83: at N=1 a peak floor above the drain rate outranks it: the - drain is the site SET at P_MAINTENANCE, above P_PEAK, so the floor - did not bind. Refiled at P_MAINTENANCE where it asks for more; - P_SAFETY still outranks. N>=2: the `drain` floor and - `_drain_over_reserve()`.""" - if len(self.inverters) != 1: - self._drain_over_reserve(claims, maint_results) - return - res = maint_results[0] - if res.phase != DRAIN or not res.owns_setpoint: - return - rate = float(res.setpoint_w or 0.0) - for i, c in enumerate(claims): - if c.source == peakguard.SOURCE and c.lo > rate: - claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE) - - def _held_pin(self, claims, u): - """SAFETY-87: what held unit `u` places anyway (its own pin), or None - when that pin charges, so the site is never split both ways.""" - b = pre_resolve([c for c in claims if c.actuator == u]) - p = None if b.contradiction else allocator.pin_w(b) - return None if p is not None and p < 0.0 else max(p or 0.0, 0.0) - - def _drain_over_reserve(self, claims, maint_results) -> None: - """SAFETY-87, N>=2: while a battery is held at its peak reserve, a - draining battery that may still give is raised from its drain rate - towards its rating for the peak floor, after the others. A site stop - still decides alone. DECISIONS/SAFETY-87-guard-uses-able-batteries.md""" - reserve = self._peak_reserve() - floor = max((c.lo for c in claims if c.source == peakguard.SOURCE), - default=0.0) - if not reserve or not floor > 0.0 or any( - c.kind == SET and c.actuator == ACTUATOR_SITE - and c.priority > P_PEAK for c in claims): - return - live = set(self.live_units()) - drains, others = [], 0.0 - for inv, res in zip(self.inverters, maint_results): - u = self.actuator_of("maintenance", inv) - if u not in live: - continue - if inv.name in reserve: - p = self._held_pin(claims, u) - if p is None: - return - others += p - continue - b = pre_resolve([c for c in claims if c.actuator == u]) - if b.contradiction: - continue - p = allocator.pin_w(b) - if p is not None and p < 0.0: - return # a pack pinned to charge: never split both ways - if res.phase == DRAIN and b.pinned_by == "maintenance" \ - and p is not None and p > 0.0: - drains.append((u, p, allocator.hi_w(b))) - others += allocator.hi_w(b) if p is None else p - give = floor - others - for u, p, hi in drains: - if give <= 0.0: - break - up = max(p, min(hi, p + give)) - give -= up - p - for i, c in enumerate(claims): - if c.source == "maintenance" and c.kind == SET \ - and c.actuator == u: - claims[i] = dataclasses.replace(c, value=up) - - def _peak_over_hold(self, claims, maint_results): - """SAFETY-86: a peak floor outranks a maintenance HOLD (0 W). N=1: - refiled at P_MAINTENANCE as for a drain. N>=2: the holding pack's - pin is raised to the floor minus what the others may place, and the - floor is returned as the site `hold` ceiling. A site stop still - decides alone. DECISIONS/SAFETY-86-peak-over-hold.md.""" - hold = {self.actuator_of("maintenance", inv) - for inv, res in zip(self.inverters, maint_results) - if res.phase == HOLD and res.owns_setpoint} - peak = [i for i, c in enumerate(claims) - if c.source == peakguard.SOURCE and c.lo > 0.0] - if not hold or not peak: - return - if len(self.inverters) == 1: - for i in peak: - claims[i] = dataclasses.replace(claims[i], - priority=P_MAINTENANCE) - return - if any(c.kind == SET and c.actuator == ACTUATOR_SITE - and c.priority > P_PEAK for c in claims): - return - live = set(self.live_units()) - reserve = self._peak_reserve() # SAFETY-87: they give nothing - others = 0.0 - for inv in self.inverters: - u = self.actuator_of("maintenance", inv) - if u in hold or u not in live: - continue - if inv.name in reserve: # only its own pin, never both ways - p = self._held_pin(claims, u) - if p is None: - return - others += p - continue - b = pre_resolve([c for c in claims if c.actuator == u]) - if b.contradiction: - continue - p = allocator.pin_w(b) - if p is not None and p < 0.0: - return # a pack pinned to charge: never split both ways - others += allocator.hi_w(b) if p is None else p - floor = max(claims[i].lo for i in peak) - give = floor - others - if give <= 0.0: - return - for i, c in enumerate(claims): - if c.source == "maintenance" and c.kind == SET \ - and c.actuator in hold: - claims[i] = dataclasses.replace(c, value=give) - return floor - - def _charge_headroom(self, floor: float, claims) -> float: - """SAFETY-84: a cheap-window charge pulls no more than the quarter's - room under the peak aim (`charge_room_w()`), so it yields just enough - to the peak guard; at N>=2 nothing under a site stop (its 0 W would - need the other pack to give). SAFETY-125: at N=1 too; the stop rule - stays N>=2 only. - DECISIONS/SAFETY-125-grid-charge-under-the-month-peak.md""" - if len(self.inverters) > 1 and any( - c.kind == SET and c.actuator == ACTUATOR_SITE - and c.priority > P_MAINT_SHAPE for c in claims): - return 0.0 - room = peakguard.charge_room_w(self.peak_guard, self._peak_proj) - floor = floor if room is None else min(floor, room) - self.room_charge = self.room_charge or floor > 0.0 - return floor - - def _charge_ceiling(self, claims): - """SAFETY-84: the site `hi` for a cheap-window charge at N>=2, or None: - the charging pack's own `hi` (P_MAINT_SHAPE) makes it charge; above - that the other pack would have to give (opposite directions, - refused, 0 W). Not filed at N=1.""" - if len(self.inverters) < 2: - return None - live = set(self.live_units()) - ceiling = 0.0 - for inv in self.inverters: - u = self.actuator_of("maintenance", inv) - b = pre_resolve([c for c in claims if c.actuator == u]) - if u in live and not b.contradiction and b.hi < 0.0: - ceiling += allocator.hi_w(b) - return ceiling if ceiling < 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: - return - for msg in machine.abort(now, "battery switched off"): - self.log_event(msg, hint=messages.hint("maint_off", - unit=machine.name)) - @property def why_words(self) -> str: """The page's "Why" line. While control waits for a missing reading @@ -2319,54 +1179,6 @@ class Controller(InputsMixin, WritesMixin): self._why_words = text self._why_waiting_since = None - def _provider_gate(self, now: datetime) -> None: - """PRICE-01: `cheap`, the gate and the gap from the standing plan. No - price for now is a closed gate, so the charge waits rather than run - unpriced; the gap flag stays False until the first refresh has - finished. (DECISIONS/MAIN-orchestration-archive.md)""" - plan = self.fresh_tariff_plan(now) - published = None if plan is None else plan.published - if published is not None: - self._last_published = published - gate = gate_for(published, now) - self.cheap, self._price_gated = gate.cheap, True - self.tariff_words = gate.words - # Owner, 2026-10-14: the words say 'maintenance waits', so they show - # only while the maintenance charge is on. - self.price_gap = ((not gate.covered) and self.tariff_at is not None - and MaintConfig.from_options(self.o).enabled) - last = self._last_published - if gate.covered or last is None: - self.price_gap_since = None - elif last.covers(now): - # The last series still covers now, but no refresh has made it - # current for TARIFF_MAX_AGE_S: the refresh task has stopped. The - # gap began when the plan was retired. - self.price_gap_since = ( - None if self.tariff_at is None - else self.tariff_at + timedelta(seconds=TARIFF_MAX_AGE_S)) - else: - self.price_gap_since = last.last_end_before(now) - - def _static_gated(self, o: dict, now: datetime) -> bool: - """`static_gated()`, worked out once an hour: it builds the same - month-long plan as `static_status()` (about 20 ms with a profile), and - whether a fixed tariff is set up does not change from tick to tick - (options change only with a restart).""" - key = (id(o), now.strftime("%Y-%m-%d %H")) - if key != self._gated_key: - self._gated_key, self._gated = key, static_gated(o, now) - return self._gated - - def _charge_gate_open(self, inv) -> bool: - """MAINT-13: may this pack charge now, for the maintenance timeout? - Closed only when a price gate exists, it is shut, and the pack is not - really taking charge. (DECISIONS/MAIN-orchestration-archive.md)""" - if not self._price_gated or self.cheap: - return True - batt = inv.batt - return batt is not None and float(batt) <= -MAINT_CHARGING_W - async def cycle(self) -> None: """Gather claims, resolve precedence, write the winner. Nothing here decides who wins (arbiter.py does, by one rule); this method only @@ -4841,10 +3653,6 @@ class Controller(InputsMixin, WritesMixin): return None -# MAINT-13: a battery power reading at or below minus this many W is a pack -# that is taking charge. -MAINT_CHARGING_W = 50.0 - # SAFETY-61. Exit status when the control task dies; 70 is sysexits(3) # EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md. EXIT_CONTROL_DIED = 70