From 39392111765d63a703ae3d8af0a10c8db5e02e3c Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 9 Nov 2026 00:29:52 +0100 Subject: [PATCH] MAINT-21: app/ctl_cycle.py - Controller.cycle, whole Moves the methods named in the card and the constants only they read out of app/main.py unchanged. CycleMixin: cycle() and _shadow_settle(). cycle() is not cut up. tools/maint21_same_ast.py: OK. Co-Authored-By: Claude --- goodwe_controller/app/ctl_cycle.py | 1112 ++++++++++++++++++++++++++++ goodwe_controller/app/main.py | 1078 +-------------------------- 2 files changed, 1114 insertions(+), 1076 deletions(-) create mode 100644 goodwe_controller/app/ctl_cycle.py diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py new file mode 100644 index 0000000..c242179 --- /dev/null +++ b/goodwe_controller/app/ctl_cycle.py @@ -0,0 +1,1112 @@ +"""Controller.cycle(): one control cycle, whole. + +Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md.""" + +import asyncio +import logging +import math +from datetime import datetime, timedelta, timezone + +from . import arbitrage +from .arbiter import ( + Claim, P_LOOP, P_MAINTENANCE, P_MAINT_SHAPE, P_MODE, P_SAFETY, Resolution, + resolve, +) +from . import packmode +from . import control as _ctl +from .control import compute, maintenance_charge_floor +from .delivery import UnitDelivery, account as delivery_account, delinquent +from .clamp import loop_clamp_note +from . import messages +from .freeze import looks_frozen +from .maintenance import ( + FAIL_BACKOFF_DAYS, IDLE, MaintResult, arbitrate as maint_arbitrate, +) +from .mqtt import unit_slugs +from . import peakguard +from . import packshare +from . import shadow +from .price import Uncertainty +from .tariff import next_quarter +from .watchdog import loop_is_following +from .ctl_trade import pack_energy_of +from .rows import why_key, why_raised + +_LOG = logging.getLogger("goodwe") + + +def _refused_interval(sign: int) -> tuple: + """SAFETY-73: the (lo, hi) a refusing unit may be asked: 0 W that way.""" + return (-math.inf, 0.0) if sign > 0 else (0.0, math.inf) + + +class CycleMixin: + 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 + states what each strategy wants. + (DECISIONS/MAIN-orchestration-archive.md)""" + now = datetime.now(timezone.utc) + # BEFORE the first `return` of this method on purpose (TARIFF-11): it + # stamps that a cycle RAN, a different fact from the decisions it may + # reach. + self.cycle_at = now + + # Each pack on ITS OWN reading, not "the SoC": 12's recalibration + # drains and refills ONE pack and grades ONE coulomb counter; a minimum + # would abort on a sibling's state and an average would declare a pack + # full that is not. `arbitrate()` runs first, once, over all of them. + _off = ({inv.name for inv in self.inverters + if not self.pack_mode(inv).use} if self.modes_on else set()) + for inv, machine in zip(self.inverters, self.maints): + if inv.name in _off: + self._maint_stop_off(machine, now) + _on = [m for inv, m in zip(self.inverters, self.maints) + if inv.name not in _off] + _plan_on = iter(maint_arbitrate(_on, now) if _off + else maint_arbitrate(self.maints, now)) + maint_plan = [None if inv.name in _off else next(_plan_on) + for inv, _m in zip(self.inverters, self.maints)] + maint_results = [] + for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan): + if blocked is None: + maint_results.append(MaintResult(IDLE, False, None, False, [])) + continue + # MAINT-13: the charge timeout counts only the minutes the pack was + # allowed to charge (see `_charge_gate_open`). + res = machine.tick(now, inv.soc, blocked_by=blocked, + gate_open=self._charge_gate_open(inv)) + for msg in res.events: + self.log_event(msg, hint=messages.hint( + "maint_failed" if res.failed else "maintenance", + phase=machine.phase)) + if res.failed: + # Said once: only the tick that failed carries `res.failed`. + multi = len(self.inverters) > 1 + slug = (unit_slugs(self.units)[self.inverters.index(inv)] + if multi else "battery") + await self.hass.call( + "persistent_notification", "create", + messages.maint_failed( + res.failed, FAIL_BACKOFF_DAYS, + unit=inv.name if multi else "", slug=slug).data()) + maint_results.append(res) + # Carried to the status page, not left in the 40-line action log: the + # deferral is edge-logged, and a pack that is due, idle and WAITING + # must be able to say so when looked at (12's failure mode is a + # schedule that silently stops firing). + self._maint_deferred = [(m.name, r.deferred) + for m, r in zip(self.maints, maint_results) + if r.deferred] + + # SAFETY-02, and it runs BEFORE the early return below: evaluated every + # cycle, else a run of held cycles leaves `disagree_since` untouched + # and the timer resumes from a start time that predates the gap. One + # watchdog per inverter on that inverter's own reading; what it is + # compared against is decided by the roster (SAFETY-30). + self._arm_sign_watchdogs() + n_units = len(self.inverters) + standing = {} + for inv in self.inverters: + was_tripped = inv.sign_wd.tripped + # `Inverter.standing_w()`, not the expression it replaces: + # SAFETY-37's freshness gate arms off the same method. + stood = inv.standing_w() + standing[inv.name] = stood + against = self.grid if n_units <= 1 else inv.batt + if inv.sign_wd.evaluate(stood, against, self.following, now) \ + and not was_tripped: + # REASON first, FLAG second (each `set` writes the file): a + # crash between the two persists a reason nothing reads, not a + # latch with no explanation. Both keys are namespaced to this + # unit. + inv.store_set("sign_reason", inv.sign_wd.reason) + inv.store_set("sign_tripped", True) + # The legacy flat keys are MIRRORED, the other half of store.py + # rule 2: that rule only covered the latch existing at + # migration time, so a trip after it wrote nothing flat and a + # rollback would find no latch. + self.store.set("sign_reason", inv.sign_wd.reason) + self.store.set("sign_tripped", True) + self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason, + hint=messages.hint("direction", unit=inv.name)) + _LOG.error("%s is set to 0 W until control is started, " + "because its power went the wrong way: %s.", + inv.name, messages.reason_code(inv.sign_wd.reason)) + _LOG.debug("Evidence for %s: %s.", inv.name, + inv.sign_wd.reason) + # The site's own standing command is the SUM of the units': SAFETY-07 + # and SAFETY-18 grade a SITE quantity (the quarter average at the grid + # connection; a meter not moving while the site pushes near its rail). + # Same number at a roster of one. + site_standing = float(sum(standing.values())) + + # SAFETY-30: the site half of the sign check: the TOTAL standing on the + # wire against the meter, catching a fault no per-unit check sees + # (every unit faithfully executing the wrong total). Only at N>=2 (at + # one inverter it would duplicate `inverters[0].sign_wd`; AC 8). Flat + # keys, not namespaced. + if n_units > 1: + was_site = self.site_sign_wd.tripped + if self.site_sign_wd.evaluate(site_standing, self.grid, + self.following, now) and not was_site: + # REASON first, FLAG second, as the per-unit trip and + # SAFETY-31. The keys are flat, not namespaced, and not the + # per-unit pair (store.py). + self.store.set("site_sign_reason", self.site_sign_wd.reason) + self.store.set("site_sign_tripped", True) + self.log_event("FAILSAFE site: " + self.site_sign_wd.reason, + hint=messages.hint("site_direction")) + _LOG.error("Every inverter is set to 0 W until control is " + "started, because the total power went the wrong " + "way: %s.", + messages.reason_code(self.site_sign_wd.reason)) + _LOG.debug("Evidence for the site: %s.", + self.site_sign_wd.reason) + + # SAFETY-31, beside SAFETY-02 for the same reason: evaluated EVERY + # cycle, unconditionally, above the input hold (else `over_since` + # resumes from a start that predates the gap). The input is MEASURED + # power, never commanded: a unit ignoring or lagging its share, a stale + # share still executing. + circ_w = self.site_circulating_w + was_circ = self.circ_wd.tripped + if self.circ_wd.evaluate(circ_w, now) and not was_circ: + # REASON first, FLAG second (each `set` writes the file): a crash + # between must not persist a latch with no explanation. + self.store.set("circ_reason", self.circ_wd.reason) + self.store.set("circ_tripped", True) + self._circ_announced = False + self.log_event("CIRCULATING POWER - " + self.circ_wd.reason, + hint=messages.hint("circulating")) + _LOG.error( + "Every inverter is set to 0 W until control is started, " + "because the inverters worked against each other: %s.", + messages.reason_code(self.circ_wd.reason)) + _LOG.debug("Evidence for the site: %s.", self.circ_wd.reason) + + # SAFETY-42, for SAFETY-02's and SAFETY-31's reason: evaluated every + # cycle above the input hold. The operands are `standing` and + # `inv.batt`, the pair SAFETY-30 uses. `delinquent()` is called once + # over the whole roster, and every number is the configured one, never + # a literal that could drift (`self.delivery_cfg`, not + # `DeliveryConfig()`: part 4b grades a hold that is not the shipped 185 + # s). + for inv in self.inverters: + # `self.delivery_cfg`, not `DeliveryConfig()`: the window is + # `delivery_hold_s`, and part 4b grades a hold that is not the + # shipped 185 s. + inv.delivery_wd.arm(self.delivery_cfg) + _delinquent = delinquent( + [UnitDelivery( + inv.name, standing[inv.name], inv.batt, + delivery_account( + # ⚠️ `is_live()` AT ITS OWN DEFAULT WINDOW, which is + # `LINK_STALE_S` - the SAME call `live_units()` makes for + # the allocator. (DECISIONS/MAIN-orchestration-archive.md) + live=inv.is_live(now)[0], + # SAFETY-73: a refusing unit is asked 0 W on purpose, and + # one asked under PROBE_W that gives under 100 W is a + # refusal too small to call (its sibling covers the meter). + parked=inv.sign_wd.tripped or bool(inv.refused) or ( + n_units > 1 and inv.batt is not None + and abs(float(standing[inv.name])) <= _ctl.PROBE_W + and abs(float(inv.batt) - inv.idle_w) + < _ctl.REFUSED_BELOW_W), + # ⚠️ THIS UNIT'S OWN CUT-OUT (`bms_floor`): not the shipped + # 10 % and not the site's `peak_soc_floor`, which bounds a + # money-saving discharge only. + # (DECISIONS/MAIN-orchestration-archive.md) + soc=inv.soc, bms_floor=inv.bms_soc_floor)) + for inv in self.inverters], + self.tuning.saturation_w) + for inv in self.inverters: + was_short = inv.delivery_wd.tripped + if inv.delivery_wd.evaluate(_delinquent.get(inv.name), now) \ + and not was_short: + # REASON FIRST, FLAG SECOND, and each `set` writes the file (as + # the three trips above): a crash between must not persist a + # latch with no explanation. ⚠️ NO FLAT MIRROR OF THIS PAIR, + # unlike SAFETY-02: store.py's rule 2 keeps flat keys so a + # rollback finds its latch, but the version rolled back TO + # drives one actuator, where this check is inert; a mirror + # would hand it a latch about a roster it cannot have. + # DECISIONS/MAIN-orchestration.md. + inv.store_set("delivery_reason", inv.delivery_wd.reason) + inv.store_set("delivery_tripped", True) + self._delivery_announced.discard(inv.name) + self.log_event(f"NOT DELIVERING {inv.name}: " + + inv.delivery_wd.reason, + hint=messages.hint("not_delivering", + unit=inv.name)) + _LOG.error( + "%s is not delivering while control keeps running, and " + "only stopping and starting control clears this: %s.", + inv.name, messages.reason_code(inv.delivery_wd.reason)) + _LOG.debug("Evidence for %s: %s.", inv.name, + inv.delivery_wd.reason) + _LOG.debug( + "SAFETY-42 reports and does not act: commanding a unit " + "that is not listening achieves nothing (HW-01), and the " + "only re-allocation the add-on can express would throw " + "away a partly delivering unit's output.") + + stale_after = float(self.o.get("stale_input_s", 15)) + late_s = self._readings_late(site_standing) # SAFETY-128 + self._late_zero = late_s is not None and late_s >= stale_after + # ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed + # poll does not become a fault; a LATCHED inverter must still reach the + # claim list below, because its `failsafe` SET is what parks it. + # SAFETY-30's site latch takes the roster past the hold too (its + # `sign_site` SET parks every inverter): the trip would stop acting the + # moment a poll was missed. (DECISIONS/MAIN-orchestration-archive.md) + if not self.inputs_ok and not any(i.sign_wd.tripped + for i in self.inverters) \ + and not (n_units > 1 and self.site_sign_wd.tripped): + bad_for = (now - self.inputs_bad_since).total_seconds() if self.inputs_bad_since else 0.0 + if bad_for < stale_after: + # A single missed poll is not a fault. Hold, keep the heartbeat + # going, and give it a few seconds to come back. + self.reason = f"inputs missing {bad_for:.0f}s" + # UX-10: the age is rendered when the page asks, not frozen at + # this cycle's. (DECISIONS/MAIN-orchestration-archive.md) + self.why_words = messages.why_waiting(bad_for) + self._why_waiting_since = now - timedelta(seconds=bad_for) + return + if late_s is not None and late_s < stale_after and self.inputs_ok \ + and (self.auto or self.shadow) \ + and not any(i.sign_wd.tripped for i in self.inverters) \ + and not (n_units > 1 and self.site_sign_wd.tripped): + self.reason = f"readings late {late_s:.0f}s" # SAFETY-128: hold + self.why_words = messages.why_late() + return + + claims = [] + + # safety limits: these bind EVERY strategy, always. Defence in depth: + # write_setpoint() clamps to the device range too, and the duplication + # is deliberate. ⚠️ PER-INVERTER, ONE PAIR PER UNIT from THAT unit's + # own min/max and supervised cap: there is no site rating + # (arbiter.CLAIM_SCOPE). (DECISIONS/MAIN-orchestration-archive.md) + for inv in self.inverters: + if inv.dev_min is not None and inv.dev_max is not None: + claims.append(Claim.limit("device", P_SAFETY, inv.dev_min, inv.dev_max, "rating", + actuator=self.actuator_of("device", inv))) + # `max_w` is the supervised cap on what may be WRITTEN to one + # setpoint entity (§11.3), not a bound on the grid connection. + claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w", + actuator=self.actuator_of("supervised", inv))) + # OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS. ⚠️ AN ENVELOPE, + # NOT A WEIGHT: a zero weight does not stop `allocator._fill()` + # commanding a pack (at or under its BMS cut-out `hi = 0`, at 100 % + # `lo = 0`). ⚠️ NEVER FILED AT N=1 (it would bound the site target: + # SAFETY-25 Option A), so there nothing here stops the loop + # discharging into the BMS cut-out; the BMS does. + # (DECISIONS/MAIN-orchestration-archive.md) + if n_units > 1: + _pe = packshare.envelope(pack_energy_of(inv)) + if _pe.binding: + claims.append(Claim.limit("pack", P_SAFETY, _pe.lo, _pe.hi, + _pe.why, + actuator=self.actuator_of("pack", inv))) + # The SITE bound, for the site-level consumers below. It is the roster + # sum: what this site can deliver, not what one of its inverters can. + max_w = self.site_max_w + + # safety stops. ⚠️ SAFETY-02 is claimed FIRST and unconditionally while + # latched, a SET at P_SAFETY, and re-filed EVERY cycle (claims are not + # sticky). ⚠️ AND IT IS PER-INVERTER, THOUGH ITS EVIDENCE IS SITE-WIDE: + # at N>=2 the evidence cannot be pinned on a sibling this controller + # never commanded (arbiter.CLAIM_SCOPE). + # (DECISIONS/MAIN-orchestration-archive.md) + for inv in self.inverters: + if inv.sign_wd.tripped: + claims.append(Claim.set("failsafe", P_SAFETY, 0.0, + "sign disagreement", + actuator=self.actuator_of("failsafe", inv))) + + # SAFETY-30: THE SITE SIGN TRIP, A SITE SET: the SUM of every standing + # command against the meter, which no single inverter owns; parking one + # unit on it would leave the rest pushing the same way. ⚠️ The `n_units + # > 1` guard is not redundant: the latch is restored from the store, + # and an install reconfigured back to one inverter must come up + # driving. (DECISIONS/MAIN-orchestration-archive.md) + if n_units > 1 and self.site_sign_wd.tripped: + claims.append(Claim.set("sign_site", P_SAFETY, 0.0, + "site sign disagreement", + actuator=self.actuator_of("sign_site"))) + + # SAFETY-31: ⚠️ A SITE SET AT P_SAFETY: STOP COMMANDING, EVERYWHERE. A + # circulating loop needs two participants and one is this controller: + # stopping it halves the loop and makes the residue visible at P1. It + # does NOT stop a wedged inverter (HW-01); the remaining actuator is a + # human at the isolator. A site claim: circulating power is zero for + # any one unit (arbiter.CLAIM_SCOPE). + # (DECISIONS/MAIN-orchestration-archive.md) + if self.circ_wd.tripped: + claims.append(Claim.set("circulation", P_SAFETY, 0.0, + "circulating power", + actuator=self.actuator_of("circulation"))) + + # SITE, both of them: "stopped" (the operator switching control off) + # and "inputs missing" (the meter gone) are not about a pack. ⚠️ + # SAFETY-10: `and not self.shadow` is the one failsafe this card + # changes (a stop claim would record "stopped" on every shadow cycle); + # the wire gate in `_write_unit()` is stronger. + # (DECISIONS/MAIN-orchestration-archive.md) + if not self.auto and not self.shadow: + claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped", + actuator=self.actuator_of("safety"))) + elif not self.inputs_ok: + if self.target != 0.0: + self.log_event("inputs missing - commanding 0 W", + hint=messages.hint("inputs_missing")) + claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing", + actuator=self.actuator_of("safety"))) + elif late_s is not None: + claims.append(Claim.set("safety", P_SAFETY, 0.0, "readings late", + actuator=self.actuator_of("safety"))) + + # SAFETY-07: the quarter-peak projection guard: ONE `lo` limit at + # P_PEAK; peakguard.py carries the argument. ⚠️ `frozen` is evaluated + # on THIS cycle's standing command, not `self.freeze_wd.active` (a + # cycle behind). ⚠️ `achievable_w` keeps the floor from filing `lo > + # hi` (0 W). ⚠️ Filed EVERY cycle (claims are not sticky). ⚠️ NOT RUN + # WITHOUT P1: it would be blind, and blind means "no charging"; the + # report is NOT COVERED. (DECISIONS/MAIN-orchestration-archive.md) + self._peak_proj = (None if not self.p1_enabled + else self.peak.project(self.p1.averager, now, + roll=self.modes_on)) + # OPT-15 x SAFETY-81: see `check_peak_saturated()`. + self._peak_rolled = (self._peak_proj is not None + and self._peak_proj.block_start is not None + and self._peak_proj.block_start + != self.p1.averager.block_start) + _reading = self.peak.peak(now) if self.p1_enabled else None + # SAFETY-90: seconds a meter gap left uncounted are counted, so the + # guard goes on after the readings return. + self._peak_proj = self._fill_peak_gaps(self._peak_proj, now, _reading) + # SAFETY-91: at N>=2 the quarter's edge is graded on the next quarter, + # so a HOLD's ceiling does not lapse for a cycle onto a held battery. + if self._peak_proj is not None and len(self.inverters) > 1: + self._peak_proj = next_quarter(self._peak_proj, + self.p1.averager.max_hold_s) + self.peak_guard = None if not self.p1_enabled else peakguard.decide( + self._peak_proj, + # ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is + # ALREADY removing from the meter, and the meter sees every + # inverter. (DECISIONS/MAIN-orchestration-archive.md) + batt_w=self.site_batt_w, + cap_w=float(self.o.get("peak_cap_w", 3500)), + margin_w=self.peak_margin_w, + reading=_reading, + # ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each unit + # contributes `min(its max_w, |its own device bounds|)`, so + # inverter B's rating bounds inverter B. + # (DECISIONS/MAIN-orchestration-archive.md) + achievable_w=self.site_rating_w, + # ⚠️ SAFETY-24. `pack_floor_soc` IS A MEASURED READING AND MAY BE + # None, and it is handed over as None rather than defaulted (a + # defaulted 100 % is a full battery, the value that disarms this + # floor); `decide()` treats None as floor-limited. + # (DECISIONS/MAIN-orchestration-archive.md) + soc=self.pack_floor_soc, + # ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN + # CUT-OUTS: `binding_soc_floor()` takes the MAXIMUM with each + # pack's `bms_soc_floor`, pairing with the MINIMUM in + # `pack_floor_soc` (both move towards refusing a shave). + # "Unreachable" is a claim about rule ordering, and sim/inject.py + # does not run rule 8: asserted over the whole schema range in + # test_peakguard.py part 12. + # (DECISIONS/MAIN-orchestration-archive.md) + soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get( + "peak_soc_floor", + peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))), + max_hold_s=self.p1.averager.max_hold_s, + # ⚠️ SAFETY-20. The device range goes in TOO: `standing` is + # `last_write`, the value AFTER `clamp_to_rating()`, so a rail from + # bare `max_w` is a ceiling the device cannot reach. + # (DECISIONS/MAIN-orchestration-archive.md) + frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None), + site_standing, max_w, self.freeze_wd.cfg, + dev_min=self.site_dev_bound("dev_min"), + dev_max=self.site_dev_bound("dev_max")), + ) + # SAFETY-87: at N>=2 a battery at or under its own reserve gives + # nothing for the peak, and the others carry the guard's need. + if self.peak_guard is not None: + self.peak_guard = self._peak_on_able(self.peak_guard, + maint_results) + self.peak_guard = self._peak_landing(self.peak_guard) + self.peak_guard = peakguard.cap_floor( # SAFETY-128 + self.peak_guard, self.answer.peak_cap(site_standing)) + # SITE, and peakguard.claim() says so on the object it builds: the + # quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION, + # which the capacity tariff bills (one quarter-hour peak per site, not + # per pack). ⚠️ It is also the `lo` half of SAFETY-25 §0c's table: a + # site `lo` that the deferred loop reserve's per-inverter `hi` must + # never cross. (DECISIONS/MAIN-orchestration-archive.md) + peak_claim = (peakguard.claim(self.peak_guard) + if self.peak_guard is not None else None) + if peak_claim is not None: + claims.append(peak_claim) + # ⚠️ EDGE-LOGGED, not level-logged: the action log holds 40 lines, and + # a line every 15 s for a quarter would push the cause off the end. + # (DECISIONS/MAIN-orchestration-archive.md) + engaged_now = self.peak_guard is not None and self.peak_guard.engaged + if engaged_now != self._peak_engaged: + self._peak_engaged = engaged_now + if self.peak_guard is not None: + _pg = self.peak_guard + self.log_event(_pg.explain(), hint=messages.hint( + "peak_off" if not _pg.engaged else + "peak_blind" if _pg.blind else + "peak_on" if _pg.within_aim else + "peak_reserve" if _pg.floor_limited else + "peak_saturated" if _pg.saturated else "peak_on")) + + # OPT-04: price arbitrage (§10.1 priority 3): ONE `set` claim at + # P_ARBITRAGE; arbitrage.py carries the argument. ⚠️ AFTER THE PEAK + # BLOCK BECAUSE IT READS ITS CONCLUSION (`engaged_now`). ⚠️ The + # three-state collapse lives in arbitrage.guard_state(), NOT here (None + # = the guard never ran, without P1). ⚠️ NO FETCH ON THIS PATH. ⚠️ The + # uncertainty set is NOT empty (no weather forecast is built). + # TARIFF-11: `arb_at` keeps the status row honest. + # (DECISIONS/MAIN-orchestration-archive.md) + self.arb, self.arb_at = None, now + self.arb_headroom_w = None + self.room_charge = False + # OPT-11 P3: with battery modes on, each battery's mode decides who + # trades (`_mode_roles()`, `_mode_trade()`, below, which read the + # same headroom), and this site-wide trade is not run. + site_trade = arbitrage.enabled(self.o) and not self.modes_on + if site_trade or self.modes_on: + # SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED: one evaluation + # feeds both the sizing below and `arbitrage.sized_from_meter()`, + # so the caveat on the recorded watts is never answered from a + # different cycle's guard. + # (DECISIONS/MAIN-orchestration-archive.md) + self.arb_headroom_w = peakguard.charge_room_w(self.peak_guard, + self._peak_proj) + if site_trade: + floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) + charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct) + self.arb = arbitrage.decide( + self.fresh_tariff_plan(now), now, + peak_guard_active=arbitrage.guard_state(self.peak_guard), + # SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS + # ONE: the watts the billed quarter can still afford clamp the + # buy leg (a switch alone gave 28 charge onsets an hour on + # TEST-23). ⚠️ It reads the peak block's conclusion, so it must + # stay after it. (DECISIONS/MAIN-orchestration-archive.md) + charge_headroom_w=self.arb_headroom_w, + # The emptiest pack again, and for the sell side that is the + # right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there + # is one `soc` argument - `pack_floor_soc` names that residual + # rather than hiding it; a per-pack charge ceiling is CFG-04's, + # and at the shipped roster of one the two reductions are the + # same number. + soc=self.pack_floor_soc, + min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), + uncertainty=frozenset({Uncertainty.NO_WEATHER}), + # OPT-09. PER-PACK SIZING, not `max_w`: the trade is sized to + # the packs whose own state of charge permits that direction + # (`trade_power_w()`). ⚠️ At N=1 both sides are `max_w` + # exactly. ⚠️ On the sell side this is a no-op today (`soc=` + # below is the EMPTIEST pack); test_packshare.py part 9 grades + # it. (DECISIONS/MAIN-orchestration-archive.md) + charge_w=charge_w, discharge_w=discharge_w, + soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct) + # SITE, and arbitrage.claim() says so on the object it builds. It + # buys and sells at the grid against a day-ahead price: the price is + # a property of the connection and the energy crosses the meter. + arb_claim = arbitrage.claim(self.arb) + if arb_claim is not None: + claims.append(arb_claim) + self.room_charge = self.arb.action == arbitrage.CHARGE + # Edge-logged, for the peak guard's reason: the action log holds 40 + # lines and a trade lasts a whole hour, so a line per cycle would + # flush the context that explains it. + # (DECISIONS/MAIN-orchestration-archive.md) + 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")) + + # maintenance ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST + # STAYS SHORT. (DECISIONS/MAIN-orchestration-archive.md) + for inv, result in zip(self.inverters, maint_results): + if result.owns_setpoint: + # PER-INVERTER: §12's recalibration is a PACK procedure, filed + # on the pack whose state machine produced it (`inv`, + # MAINT-05). ⚠️ IT STAYS `SCOPE_UNIT`, never a site claim + # (ARCH-01: per-inverter limits filed site-wide are the + # cross-inverter `lo > hi`). + # (DECISIONS/MAIN-orchestration-archive.md) + claims.append(Claim.set("maintenance", P_MAINTENANCE, + float(result.setpoint_w or 0.0), result.phase, + actuator=self.actuator_of("maintenance", inv))) + self.sat_count = 0 + elif result.charge_only: + # Money outranks the maintenance schedule: while the projection + # is over the cap the charge-only shaping is not claimed, so + # the loop can shave. ⚠️ Shaping is `hi <= 0` and the peak + # claim `lo >= 0`; both filed give `lo > hi`, which resolves to + # 0 W in the quarter that sets the bill, so the higher claimant + # SUPPRESSES the lower. ⚠️ THE CONJUNCTION LIVES IN + # peakguard.shaping_suspended(), NOT HERE. One suspension flag + # per SITE (`arbitrate()` allows one pack in CHARGE). + # (DECISIONS/MAIN-orchestration-archive.md) + if peakguard.shaping_suspended( + self.peak_guard, self.peak_fc, self.peak_aim_w()): + # Edge-logged: level-logging here would write one line per + # cycle for as long as the event lasts and flush the 40-line + # action log of the very context that explains it. + if not self._maint_shape_suspended: + self._maint_shape_suspended = True + self.log_event("peak at risk - maintenance charge shaping " + "suspended so the peak claim is not " + "contradicted", + hint=messages.hint("maint_paused")) + else: + self._maint_shape_suspended = False + hi = 0.0 + reason = "charge-only" + if self.cheap: + # ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND + # READ OF `self.o`: a literal fallback here let the + # shaping and the state machine use different rates + # from the same install. + # (DECISIONS/MAIN-orchestration-archive.md) + floor = maintenance_charge_floor( + self.maint.cfg.charge_w, + self.peak_fc, self.peak_aim_w()) + floor = self._charge_headroom(floor, claims) + if floor > 0: + hi, reason = -floor, "cheap-window charge" + # PER-INVERTER, like the SET above and for the same reason - + # the shaping is about the pack being charged, which is `inv`. + claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, + reason=reason, + actuator=self.actuator_of("maintenance", + inv))) + + # OPT-11: each battery's mode, a soft unit limit at P_MODE. Filed only + # with battery modes on, and never on a battery in any maintenance + # phase (maintenance outranks the mode). The range always holds 0 W + # (packmode.mode_range()) and gives way to the peak floor only as far + # as needed, so the two never meet in `lo > hi` + # (arbiter._unit_modes()). (DECISIONS/MAIN-orchestration-archive.md) + if self.modes_on: + tick = self._mode_roles(now, maint_results) + for inv, result in zip(self.inverters, maint_results): + if result.phase != IDLE: + continue + pm = self.pack_mode(inv) + lo, hi = packmode.mode_range(pm, inv.max_w) + rlo, rhi = tick.ranges.get(inv.name, (lo, hi)) + lo, hi = max(lo, rlo), min(hi, rhi) + claims.append(Claim.limit("mode", P_MODE, lo, hi, pm.key, + actuator=self.actuator_of("mode", + inv))) + + # the grid-following loop. ⚠️ SAFETY-10: `or self.shadow`: the loop is + # the leg a shadow run most needs; it is safe because the loop's + # `prev_w` is `self.target`, re-seeded to what is on the wire. + # SAFETY-52: a refusal learned while control ran does not survive a + # stop or a start. (DECISIONS/MAIN-orchestration-archive.md) + if bool(self.auto) != self._refusal_auto: + self._refusal_auto = bool(self.auto) + self._refusal_end("control started" if self.auto + else "control stopped") + for inv in (self.inverters if len(self.inverters) > 1 else ()): + self._unit_refusal_end(inv, "control started" if self.auto + else "control stopped") + # SAFETY-73: each unit's own refusal, engaged and ended on its own + # reading, before the loop reads `self.target` (an end reseeds it). + _probing = {} + if len(self.inverters) > 1 and self.inputs_ok: + self._unit_refusals() + _probing = {inv.name: self._unit_probe(inv) + for inv in self.inverters if inv.refused} + # SAFETY-73: each refusing unit's own P_SAFETY claim: a hard unit limit + # that always intersects (OPT-11's P_MODE sits inside it), closed in + # the refused direction, so the allocator hands the share to the others + # within their own limits; a pin at PROBE_W during its probe. Filed + # before the loop so OPT-11's loop bound sees it. + # (DECISIONS/MAIN-orchestration-archive.md) + for inv in self.inverters: + if not inv.refused: + continue + if _probing.get(inv.name): + claims.append(Claim.set("refused", P_SAFETY, + inv.refused * _ctl.PROBE_W, + _ctl.PROBING, + actuator=self.actuator_of("refused", + inv))) + else: + claims.append(Claim.limit("refused", P_SAFETY, + *_refused_interval(inv.refused), + _ctl.REFUSED, + actuator=self.actuator_of("refused", + inv))) + # --- SAFETY-78: a pinned drain is a floor under the site target ----- + # After every unit claim, so the floor is what the pin really places. + drain = self._drain_floor(claims, maint_results) + if drain is not None: + claims.append(Claim.limit("drain", P_MAINTENANCE, lo=drain, + reason="maintenance drain", + actuator=self.actuator_of("drain"))) + self._peak_over_drain(claims, maint_results) + # --- SAFETY-86: while a HOLD gives, the site stays at the peak floor -- + # So the loop never asks for watts the hold keeps back. + hold_hi = self._peak_over_hold(claims, maint_results) + if hold_hi is not None: + claims.append(Claim.limit("hold", P_MAINTENANCE, hi=hold_hi, + reason="maintenance hold", + actuator=self.actuator_of("hold"))) + # --- SAFETY-91: a drain rises before a held battery gives ------------ + self._drain_rise(claims, maint_results) + # --- SAFETY-84: a cheap-window charge is a ceiling over the site ---- + charge = self._charge_ceiling(claims) + if charge is not None: + claims.append(Claim.limit("charge", P_MAINT_SHAPE, hi=charge, + reason="maintenance charge", + actuator=self.actuator_of("charge"))) + # --- OPT-11 P3: the site trade, after every unit claim -------------- + # Sized from what the batteries may place with their modes and hard + # limits (the loop bound), so none is asked for watts it may not give. + mode_trade, trade_was = False, self._mode_trade_was + if self.modes_on: + mode_trade = self._mode_trade(claims, now) + self.room_charge = self.room_charge or ( + mode_trade and self.arb.action == arbitrage.CHARGE) + self._mode_trade_was = mode_trade + self._charge_track(claims, maint_results) + _live96 = set(self.live_units()) + self._loop_start = {} + if (self.auto or self.shadow) and self.inputs_ok: + # ⚠️ prev is the ARBITER's last output, not the loop's own wish: if + # something outranked the loop, that is what the hardware did. The + # integrator re-seeds from the same (entering failsafe re-seeds it + # to zero). (DECISIONS/MAIN-orchestration-archive.md) + moved = self.loop_w is None or self.target != self.loop_w + if moved: + self.i_w = self.target + # OPT-11 P3: a run edge (a mode trade ended) reseeds the loop, and + # OPT-15 holds it there until the meter shows the step. + if trade_was and not mode_trade: + self._reseed_run_edge(claims, now) + moved = False + loop_grid = self._edge_grid(mode_trade or moved, now) + # SAFETY-52: another writer overrode the loop, so a refusal learned + # against the loop's own ask may no longer describe the wire. + # (DECISIONS/MAIN-orchestration-archive.md) + rejudged = 0 + if self.loop_w is not None and self.target != self.loop_w: + rejudged = self.batt_refused + # SAFETY-96: a battery that dropped out of the split leaves the + # loop's start point (SAFETY-93's own if its charge ended too). + self._reseed_drop(_live96) + # SAFETY-93: a charge that ended leaves the loop's start point. + self._reseed_charge_end(maint_results) + # SAFETY-95: then a drain that ended, last (it reads the start + # the others left). + self._reseed_drain_end(maint_results, _live96) + # No probe while it is judged afresh (the loop does not hold the wire). + probe = False if rejudged else self._refusal_probe() + if len(self.inverters) > 1: + probe = any(_probing.values()) + # ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF. + # The anti-windup test in compute() is `abs(prev_w - actual_w) > + # saturation_w` and `prev_w` is the SITE target; one inverter's + # power at N=2 would trip the saturation freeze permanently. + # (DECISIONS/MAIN-orchestration-archive.md) + site_batt = self.site_batt_w + # SAFETY-103: what each battery can be doing (its reading may be + # older than the meter). + self._track_reads() + # SAFETY-96: a battery the loop cannot move counts at its command, + # so its own power does not freeze the loop (the meter has it). + site_batt += self._held_out_w(_live96) + # TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED + # AGAINST. (DECISIONS/MAIN-orchestration-archive.md) + loop_prev = self.target + decision = compute( + prev_w=loop_prev, + # SAFETY-95: a drain's tail is not the loop's to chase. + # OPT-15: `loop_grid` is the grid, or the target while a run + # edge is held. + grid_w=(self._drain_grid_w(_live96) if self._drain_tail + else loop_grid), + actual_w=site_batt, + tuning=self.tuning, + sat_count=self.sat_count, + i_w=self.i_w, + # SAFETY-52 at one inverter only: with several, OPT-09's pack + # limit already closes an empty battery's discharge end. + # SAFETY-73: at N>1 the site law holds 0 W only while every + # unit refuses the same way. Otherwise None, the old law. + refused=(0 if rejudged else self.batt_refused) + if len(self.inverters) == 1 + else self._site_refused(), + probe=probe, + # SAFETY-94: at N=1 the engage also needs the reading to have + # given nothing for the whole span, as at N>1. + gave_nothing=(bool(self._gave_nothing(self.inverters[0])) + if len(self.inverters) == 1 else None), + # SAFETY-97: "gives nothing" is centred on the idle reading. + idle_w=self._site_idle_w(), + hold=self.answer.hold, hold_w=self.answer.hold_w, # SAFETY-128 + # OPT-11: None unless battery modes are on. SAFETY-85: or while + # maintenance holds a battery at N>=2, so the loop does not + # wind past what the others can give and jump when it ends. + bound=(self.loop_bound(claims) if self.modes_on or ( + len(self.inverters) > 1 + and any(r.phase != IDLE for r in maint_results)) + else None), + ) + if decision.frozen and self.sat_count < self.tuning.saturation_cycles: + self.log_event(f"saturation freeze ({self.target:.0f} W vs " + f"{site_batt:.0f} W)", + hint=messages.hint("not_keeping_up")) + self.sat_count = decision.sat_count + self.i_w = decision.i_w + self.loop_w = decision.target_w + self._loop_decision = decision + if len(self.inverters) == 1: + self._refusal_update(decision, loop_prev, site_batt, probe, + rejudged) + # TEL-10. ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS: `loop_i` + # is `Decision.i_w`, the value BEFORE the clamp, slew and + # quantisation, so it is NOT `setpoint` and must not be published + # from it; `loop_lead` is how far it stands ahead of the command; + # `loop_reason` is `compute()`'s own word and NOT `self.reason`. + # (DECISIONS/MAIN-orchestration-archive.md) + self.loop_i_w = decision.i_w + self.loop_i_lead = decision.i_w - loop_prev + self.loop_reason = decision.reason + # SAFETY-03. ⚠️ READ THE BOOLEANS, NOT `decision.reason`: `reason` + # is overwritten by slew-limiting or the saturation freeze after + # the clamp, so a string test is silent on the cases a winding loop + # most often presents as. SAFETY-40: every number comes from THIS + # decision, at clamp time. + # (DECISIONS/MAIN-orchestration-archive.md) + self.loop_clamped = ( + decision.i_clamped or decision.clamped, + loop_clamp_note(decision), + decision.clamped, + ) + self.loop_clamp_facts = ( + (messages.Clamp(None, decision.requested_w, decision.clamped_w, + "rating"),) if decision.clamped else ()) + # SITE. compute() regulates `grid_w` - the P1 measurement - to + # zero. Its subject is the meter, and §6.2's architecture is ONE + # integrator on P1 whose output is then SPLIT. + # (DECISIONS/MAIN-orchestration-archive.md) + claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason, + actuator=self.actuator_of("loop"))) + else: + # Stopped or blind: no accumulation may survive the outage. + self.i_w = 0.0 + self.loop_w = None + self._live_was = frozenset(_live96) + # TEL-10, and it is the same zero the line above writes rather than + # a second opinion about it. ⚠️ `not-running` rather than a stale + # `tracking`: a reason row that keeps repeating the last word the + # loop said while the loop is not running is a row that reads as + # health, which is the failure `publish()`'s own None-dropping note + # is about one field over. + self.loop_i_w = 0.0 + self.loop_i_lead = 0.0 + self.loop_reason = "not-running" + # A loop that is not running cannot be winding. Clearing this here + # rather than letting the last value stand is what makes the alarm + # de-assert when control is stopped. + self.loop_clamped = (False, "", False) + self.loop_clamp_facts = () + self._loop_decision = None + + # SAFETY-28. A new resolution is a new tick, and the previous record is + # stale from this line onwards. Incremented BEFORE resolve() so that the + # record resolve() builds is stamped with the tick it is for. + self.alloc_tick += 1 + # ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. A METHOD, NOT + # A DICT: the weighting is by ENERGY HEADROOM, so it depends on the + # direction (the sign of the site target, chosen inside `resolve()`; + # see `pack_weights()`). ⚠️ A PREFERENCE, NOT A PERMISSION: a weight + # cannot stop a unit being commanded. + # (DECISIONS/MAIN-orchestration-archive.md) + _live = self.live_units() + while True: + resolution = resolve(claims, units=self.units, live=_live, + weights=self.pack_weights, tick=self.alloc_tick, + reserve=self._held_back()) + # SAFETY-73: a probe pin stands only while the resolved site target + # asks at least PROBE_W its way; below that a sibling would carry + # the opposite direction (circulation), so the pin goes back to the + # closed limit. (DECISIONS/MAIN-orchestration-archive.md) + _void = [k for k, c in enumerate(claims) + if c.source == "refused" and c.kind == "set" + and c.value * resolution.target_w < _ctl.PROBE_W ** 2] + if not _void: + break + for k in _void: + claims[k] = Claim.limit( + "refused", P_SAFETY, + *_refused_interval(1 if claims[k].value > 0 else -1), + _ctl.REFUSED, actuator=claims[k].actuator) + self.allocation = resolution.allocation + if self.modes_on: + self._mode_fault_log(resolution) + # THE TWO REFUSALS ARE NOT THE SAME REFUSAL: both REPLACE the + # resolution with a 0 W failsafe, written like any other cycle; they + # differ in WHO refused, hence two branches and two messages. ⚠️ + # NEITHER MAY BE LEFT UNGRADED (test_peakguard.py part 10h). + # (DECISIONS/MAIN-orchestration-archive.md) + if not resolution.allocated: + self.log_event("ARBITER: " + resolution.allocation.explain(), + hint=messages.hint("split")) + resolution = Resolution(0.0, "failsafe", None, False, + resolution.considered, + unit_bounds=resolution.unit_bounds, + shares={}, allocated=False, + scope_errors=resolution.scope_errors, + allocation=resolution.allocation, + winner_priority=resolution.winner_priority) + # 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT. ⚠️ + # RE-AIMED BY DEV-02, NOT DELETED: a roster entry with no writer would + # have the site target written to the others (over-command by its + # share); "nothing" here means 0 W. Not folded into `live_units()`: a + # dead link is dropped and announced, an undeliverable roster stops the + # site. (DECISIONS/MAIN-orchestration-archive.md) + elif {u for u in resolution.shares if not self._can_write(u)}: + undeliverable = ", ".join( + sorted(u for u in resolution.shares if not self._can_write(u))) + self.log_event( + f"ARBITER: the site target was allocated across " + f"{len(resolution.shares)} inverters and this controller has no " + f"setpoint entity for {undeliverable} - refusing to command a " + f"partial site", hint=messages.hint("no_setpoint")) + # ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE + # DEV-02 THAT IS LOAD-BEARING: the write loop commands + # `shares.get(name, 0.0)`, so carrying the split through would + # write the numbers this branch declined, or nothing at all when + # they equal what stands (an unchanged value is not re-sent). + # (DECISIONS/MAIN-orchestration-archive.md) + resolution = Resolution(0.0, "failsafe", None, False, + resolution.considered, + unit_bounds=resolution.unit_bounds, + shares={}, allocated=True, + scope_errors=resolution.scope_errors, + allocation=resolution.allocation, + winner_priority=resolution.winner_priority) + # 3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its + # share, a real change in what the site can deliver, and an operator + # who is not told reads a working site. Edge-triggered on the SET of + # dropped units (the action log is a log of events, §15). + # (DECISIONS/MAIN-orchestration-archive.md) + _dropped = tuple(u for u, _why in (self.allocation.dropped + if self.allocation else ())) + if _dropped != self._dropped_last: + for _u, _why in (self.allocation.dropped if self.allocation else ()): + self.log_event(f"ALLOCATOR: {_u} dropped from the split " + f"({_why}) - the remaining inverter(s) now carry " + f"its share of the site target", + hint=messages.hint("unit_dropped", unit=_u)) + if self._dropped_last and not _dropped: + self.log_event("ALLOCATOR: every inverter is back in the split", + hint=messages.hint("units_back")) + self._dropped_last = _dropped + # 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER. + # `unmet_w` is the site target the envelopes could not absorb; not a + # broken allocation (fusing it with `ok` would let one saturated + # inverter park the site), but the site failing to deliver what the + # loop asked. Edge-triggered on the VALUE. + # (DECISIONS/MAIN-orchestration-archive.md) + _unmet = int(self.allocation.unmet_w if self.allocation else 0) + if _unmet != self._unmet_last: + if _unmet: + self.log_event( + f"ALLOCATOR: {_unmet:+d} W of the site target could not be " + f"placed on any inverter - {self.allocation.explain()}", + hint=messages.hint("unmet", w=_unmet)) + else: + self.log_event("ALLOCATOR: the whole site target fits again", + hint=messages.hint("unmet_gone")) + self._unmet_last = _unmet + if resolution.scope_errors: + # A claim reached the arbiter on an actuator its source's scope + # does not permit: a BUG in the filing site, not a state to survive + # (a per-inverter constraint in the site list is the `lo > hi` this + # card removes). Loud, every cycle; the resolution is unchanged. + # (DECISIONS/MAIN-orchestration-archive.md) + _LOG.error("A limit was filed on the wrong inverter, which is a " + "software fault. %s.", " / ".join(resolution.scope_errors)) + if resolution.contradiction: + self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log", + hint=messages.hint("clash")) + # SAFETY-02's arming gate: was the number chosen by the grid-following + # loop, or by a strategy that commands against the meter on purpose? + # Only the former is expected to agree in sign with P1. ⚠️ THE + # EXPRESSION LIVES IN watchdog.py, NOT HERE: read loop_is_following() + # before touching the gate. (DECISIONS/MAIN-orchestration-archive.md) + self.following = loop_is_following(resolution, self.loop_w) + self.target = resolution.target_w + self.reason = resolution.explain() + # UX-08: the same answer in plain words, for the page's "Why" line; + # `reason` stays the technical record the log and the checks read. + # UX-10: every key `why_key()` can return has words in + # messages.WHY_WINNER. (DECISIONS/MAIN-orchestration-archive.md) + _why = why_key(resolution.winner, claims) + if _why == "inputs missing" and not resolution.contradiction: + # UX-11: past the hold, say which reading is missing. + self.why_words = messages.why_missing(self.missing_reading(), + stale_after) + else: + self.why_words = messages.why( + _why, resolution.target_w, resolution.bound_by, + contradiction=resolution.contradiction, + raised=why_raised(resolution, claims)) + # DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE, NEVER THE SITE TARGET + # (handing each unit `self.target` is the ARCH-01 fault: each supplies + # it in full and the gain at the meter becomes N times the per-unit + # gain). At N=1 this is byte-identical. ⚠️ A unit not in `shares` is + # commanded 0 W, not left standing: an inverter holds its last command + # for ever. (DECISIONS/MAIN-orchestration-archive.md) + self._loop_alarm_input(resolution) + writes = [] + for inv in self.inverters: + inv.target = float(resolution.shares.get(inv.name, 0.0)) + writes.append(self.write_setpoint(inv.target, unit=inv)) + await asyncio.gather(*writes) + + # SAFETY-03, and its boundary is the WRITE. ⚠️ NOT THE SAME BOUNDARY AS + # SAFETY-18's BELOW, THOUGH THE CALLS ABUT: this one reads + # `self.clamp_last`, None until write_setpoint() has run, so it must + # follow the write (test_clamp.py part 6). Two guards, two orderings: + # do not "tidy" them into one rule. + # (DECISIONS/MAIN-orchestration-archive.md) + await self.check_output_clamp() + + # SAFETY-18, and its boundary is the ASSIGNMENT. ⚠️ THE BOUNDARY IS + # `self.target = resolution.target_w` ABOVE, NOT write_setpoint(): both + # terms of the conjunction must describe the SAME moment, so moving it + # above the assignment grades the previous cycle's command against this + # cycle's meter (test_freeze.py part 5). + # (DECISIONS/MAIN-orchestration-archive.md) + await self.check_meter_frozen() + + # --- PRICE-01: no price for now, said once and taken down once -------- + # Not a safety check, so it is one of the cheap ones: an attribute test + # and, on a change only, one Home Assistant call. + await self.check_price_gap() + await self.check_fixed_note() + + # SAFETY-31's alarm, and its boundary is the LATCH, not a number: it + # grades `self.circ_wd.tripped`, advanced near the top of this method. + # Called on EVERY cycle, not only the one that trips, because its + # second job is re-raising the alarm after a restart. + # (DECISIONS/MAIN-orchestration-archive.md) + await self.check_circulating() + + # SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's: + # it grades `inv.delivery_wd.tripped`, and is called on EVERY cycle + # because its second job is re-raising the alarm after a restart. + # (DECISIONS/MAIN-orchestration-archive.md) + await self.check_not_delivering() + + # --- SAFETY-07's alarm, and it is NOT the engagement ------------------ + # Its boundary is unconstrained: it grades `self.peak_guard`, which this + # method assigned above and nothing since has touched. + await self.check_peak_saturated() + + # SAFETY-24's alarm, and it is the OTHER way the money gets spent. Same + # boundary argument as check_peak_saturated(); kept as its own method + # because the two states are mutually exclusive and want OPPOSITE + # operator actions (saturated: shed load; this: the pack is at its + # floor and the quarter is not being shaved). + # (DECISIONS/MAIN-orchestration-archive.md) + await self.check_peak_floor_limited() + + # TARIFF-01, and it is LAST on purpose: banking a quarter is + # bookkeeping about a moment that has passed, and a slow SD card must + # never sit between the meter and the setpoint. It grades neither + # `target` nor `standing`. (DECISIONS/MAIN-orchestration-archive.md) + self.peak.sync(self.p1) + + # SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS: every + # guard above has read `self.target`, the resolved intent (SAFETY-18 + # grades exactly that); re-seeding before them would hand each a 0 W no + # decision produced. See `_shadow_settle()`. + # (DECISIONS/MAIN-orchestration-archive.md) + self._shadow_settle(resolution) + + def _shadow_settle(self, resolution) -> None: + """SAFETY-10. Record the decision, then re-seed the carried command + (`self.target`, each `inv.target`) to `shadow.WIRE_W`, in this order + because the second destroys the first's evidence. ⚠️ THE RE-SEED IS A + SAFETY PROPERTY: compute's slew base, the sign watchdogs + (`standing_w()`) and SAFETY-42 read the carried command as a statement + about the HARDWARE; a shadow decision there would latch a trip that + survives into live operation. One log line per CHANGE of decision. + (DECISIONS/MAIN-orchestration-archive.md)""" + engaged = self.shadow + if engaged != self._shadow_was: + # ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT (a + # "shadow mode off" line at every start pushed a clamp episode out + # of the 40-row log); coming up IN shadow mode always gets a line. + # (DECISIONS/MAIN-orchestration-archive.md) + first = self._shadow_was is None + self._shadow_was = engaged + if engaged: + self.log_event( + "SHADOW MODE ON - " + shadow.why( + self.o, control_started=self.auto) + + f"; inverter held at {shadow.WIRE_W:.0f} W", + hint=messages.hint("shadow_on")) + # UX-10: INFO. It follows a setting or a button press, and + # no person has to act on it. + _LOG.info( + "Shadow mode is on, so every inverter stays at %.0f W and " + "nothing the add-on decides is sent. %s.", shadow.WIRE_W, + shadow.why(self.o, control_started=self.auto)) + elif not first: + self.log_event( + "Shadow mode off: the add-on controls the battery again, " + f"starting from {shadow.WIRE_W:.0f} W", + hint=messages.hint("shadow_off")) + _LOG.info( + "Shadow mode is off, so the add-on controls the battery " + "again, starting from %.0f W.", shadow.WIRE_W) + if not engaged: + self.shadow_would_w = None + self.shadow_sized_from_meter = False + self._shadow_winner = None + return + self.shadow_would_w = float(resolution.target_w) + # SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY: all three rungs live in + # `arbitrage.sized_from_meter()`; a rung written at a call site is one + # no check can reach with the other two clear (TEST-16). + # (DECISIONS/MAIN-orchestration-archive.md) + self.shadow_sized_from_meter = arbitrage.sized_from_meter( + self.arb, self.arb_headroom_w, winner=resolution.winner) + # ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE (a loop + # target that that wanders by a watt would be the whole log). ⚠️ + # SAFETY-47 ADDS THE CAVEAT FLAG TO THE EDGE: the same winner at the + # same watts with and without the caveat are two different claims. + # (DECISIONS/MAIN-orchestration-archive.md) + edge = (resolution.winner, round(float(resolution.target_w)), + self.shadow_sized_from_meter) + if edge != self._shadow_winner: + self._shadow_winner = edge + self.log_event(shadow.log_line( + would_w=resolution.target_w, reason=resolution.explain(), + sized_from_meter=self.shadow_sized_from_meter), + hint=messages.hint("shadow_decision", + sw=resolution.target_w)) + self.target = shadow.WIRE_W + for inv in self.inverters: + inv.target = shadow.WIRE_W diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 505c52c..6918c2e 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -103,6 +103,7 @@ from .watchdog import ( ) from . import setuponly from . import web +from .ctl_cycle import CycleMixin, _refused_interval 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, @@ -185,12 +186,7 @@ ROLLING_S = 900.0 ROLLING_BOUND_CYCLES = 11 -def _refused_interval(sign: int) -> tuple: - """SAFETY-73: the (lo, hi) a refusing unit may be asked: 0 W that way.""" - return (-math.inf, 0.0) if sign > 0 else (0.0, math.inf) - - -class Controller(InputsMixin, WritesMixin, TradeMixin): +class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin): 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", @@ -1179,1076 +1175,6 @@ class Controller(InputsMixin, WritesMixin, TradeMixin): self._why_words = text self._why_waiting_since = None - 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 - states what each strategy wants. - (DECISIONS/MAIN-orchestration-archive.md)""" - now = datetime.now(timezone.utc) - # BEFORE the first `return` of this method on purpose (TARIFF-11): it - # stamps that a cycle RAN, a different fact from the decisions it may - # reach. - self.cycle_at = now - - # Each pack on ITS OWN reading, not "the SoC": 12's recalibration - # drains and refills ONE pack and grades ONE coulomb counter; a minimum - # would abort on a sibling's state and an average would declare a pack - # full that is not. `arbitrate()` runs first, once, over all of them. - _off = ({inv.name for inv in self.inverters - if not self.pack_mode(inv).use} if self.modes_on else set()) - for inv, machine in zip(self.inverters, self.maints): - if inv.name in _off: - self._maint_stop_off(machine, now) - _on = [m for inv, m in zip(self.inverters, self.maints) - if inv.name not in _off] - _plan_on = iter(maint_arbitrate(_on, now) if _off - else maint_arbitrate(self.maints, now)) - maint_plan = [None if inv.name in _off else next(_plan_on) - for inv, _m in zip(self.inverters, self.maints)] - maint_results = [] - for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan): - if blocked is None: - maint_results.append(MaintResult(IDLE, False, None, False, [])) - continue - # MAINT-13: the charge timeout counts only the minutes the pack was - # allowed to charge (see `_charge_gate_open`). - res = machine.tick(now, inv.soc, blocked_by=blocked, - gate_open=self._charge_gate_open(inv)) - for msg in res.events: - self.log_event(msg, hint=messages.hint( - "maint_failed" if res.failed else "maintenance", - phase=machine.phase)) - if res.failed: - # Said once: only the tick that failed carries `res.failed`. - multi = len(self.inverters) > 1 - slug = (unit_slugs(self.units)[self.inverters.index(inv)] - if multi else "battery") - await self.hass.call( - "persistent_notification", "create", - messages.maint_failed( - res.failed, FAIL_BACKOFF_DAYS, - unit=inv.name if multi else "", slug=slug).data()) - maint_results.append(res) - # Carried to the status page, not left in the 40-line action log: the - # deferral is edge-logged, and a pack that is due, idle and WAITING - # must be able to say so when looked at (12's failure mode is a - # schedule that silently stops firing). - self._maint_deferred = [(m.name, r.deferred) - for m, r in zip(self.maints, maint_results) - if r.deferred] - - # SAFETY-02, and it runs BEFORE the early return below: evaluated every - # cycle, else a run of held cycles leaves `disagree_since` untouched - # and the timer resumes from a start time that predates the gap. One - # watchdog per inverter on that inverter's own reading; what it is - # compared against is decided by the roster (SAFETY-30). - self._arm_sign_watchdogs() - n_units = len(self.inverters) - standing = {} - for inv in self.inverters: - was_tripped = inv.sign_wd.tripped - # `Inverter.standing_w()`, not the expression it replaces: - # SAFETY-37's freshness gate arms off the same method. - stood = inv.standing_w() - standing[inv.name] = stood - against = self.grid if n_units <= 1 else inv.batt - if inv.sign_wd.evaluate(stood, against, self.following, now) \ - and not was_tripped: - # REASON first, FLAG second (each `set` writes the file): a - # crash between the two persists a reason nothing reads, not a - # latch with no explanation. Both keys are namespaced to this - # unit. - inv.store_set("sign_reason", inv.sign_wd.reason) - inv.store_set("sign_tripped", True) - # The legacy flat keys are MIRRORED, the other half of store.py - # rule 2: that rule only covered the latch existing at - # migration time, so a trip after it wrote nothing flat and a - # rollback would find no latch. - self.store.set("sign_reason", inv.sign_wd.reason) - self.store.set("sign_tripped", True) - self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason, - hint=messages.hint("direction", unit=inv.name)) - _LOG.error("%s is set to 0 W until control is started, " - "because its power went the wrong way: %s.", - inv.name, messages.reason_code(inv.sign_wd.reason)) - _LOG.debug("Evidence for %s: %s.", inv.name, - inv.sign_wd.reason) - # The site's own standing command is the SUM of the units': SAFETY-07 - # and SAFETY-18 grade a SITE quantity (the quarter average at the grid - # connection; a meter not moving while the site pushes near its rail). - # Same number at a roster of one. - site_standing = float(sum(standing.values())) - - # SAFETY-30: the site half of the sign check: the TOTAL standing on the - # wire against the meter, catching a fault no per-unit check sees - # (every unit faithfully executing the wrong total). Only at N>=2 (at - # one inverter it would duplicate `inverters[0].sign_wd`; AC 8). Flat - # keys, not namespaced. - if n_units > 1: - was_site = self.site_sign_wd.tripped - if self.site_sign_wd.evaluate(site_standing, self.grid, - self.following, now) and not was_site: - # REASON first, FLAG second, as the per-unit trip and - # SAFETY-31. The keys are flat, not namespaced, and not the - # per-unit pair (store.py). - self.store.set("site_sign_reason", self.site_sign_wd.reason) - self.store.set("site_sign_tripped", True) - self.log_event("FAILSAFE site: " + self.site_sign_wd.reason, - hint=messages.hint("site_direction")) - _LOG.error("Every inverter is set to 0 W until control is " - "started, because the total power went the wrong " - "way: %s.", - messages.reason_code(self.site_sign_wd.reason)) - _LOG.debug("Evidence for the site: %s.", - self.site_sign_wd.reason) - - # SAFETY-31, beside SAFETY-02 for the same reason: evaluated EVERY - # cycle, unconditionally, above the input hold (else `over_since` - # resumes from a start that predates the gap). The input is MEASURED - # power, never commanded: a unit ignoring or lagging its share, a stale - # share still executing. - circ_w = self.site_circulating_w - was_circ = self.circ_wd.tripped - if self.circ_wd.evaluate(circ_w, now) and not was_circ: - # REASON first, FLAG second (each `set` writes the file): a crash - # between must not persist a latch with no explanation. - self.store.set("circ_reason", self.circ_wd.reason) - self.store.set("circ_tripped", True) - self._circ_announced = False - self.log_event("CIRCULATING POWER - " + self.circ_wd.reason, - hint=messages.hint("circulating")) - _LOG.error( - "Every inverter is set to 0 W until control is started, " - "because the inverters worked against each other: %s.", - messages.reason_code(self.circ_wd.reason)) - _LOG.debug("Evidence for the site: %s.", self.circ_wd.reason) - - # SAFETY-42, for SAFETY-02's and SAFETY-31's reason: evaluated every - # cycle above the input hold. The operands are `standing` and - # `inv.batt`, the pair SAFETY-30 uses. `delinquent()` is called once - # over the whole roster, and every number is the configured one, never - # a literal that could drift (`self.delivery_cfg`, not - # `DeliveryConfig()`: part 4b grades a hold that is not the shipped 185 - # s). - for inv in self.inverters: - # `self.delivery_cfg`, not `DeliveryConfig()`: the window is - # `delivery_hold_s`, and part 4b grades a hold that is not the - # shipped 185 s. - inv.delivery_wd.arm(self.delivery_cfg) - _delinquent = delinquent( - [UnitDelivery( - inv.name, standing[inv.name], inv.batt, - delivery_account( - # ⚠️ `is_live()` AT ITS OWN DEFAULT WINDOW, which is - # `LINK_STALE_S` - the SAME call `live_units()` makes for - # the allocator. (DECISIONS/MAIN-orchestration-archive.md) - live=inv.is_live(now)[0], - # SAFETY-73: a refusing unit is asked 0 W on purpose, and - # one asked under PROBE_W that gives under 100 W is a - # refusal too small to call (its sibling covers the meter). - parked=inv.sign_wd.tripped or bool(inv.refused) or ( - n_units > 1 and inv.batt is not None - and abs(float(standing[inv.name])) <= _ctl.PROBE_W - and abs(float(inv.batt) - inv.idle_w) - < _ctl.REFUSED_BELOW_W), - # ⚠️ THIS UNIT'S OWN CUT-OUT (`bms_floor`): not the shipped - # 10 % and not the site's `peak_soc_floor`, which bounds a - # money-saving discharge only. - # (DECISIONS/MAIN-orchestration-archive.md) - soc=inv.soc, bms_floor=inv.bms_soc_floor)) - for inv in self.inverters], - self.tuning.saturation_w) - for inv in self.inverters: - was_short = inv.delivery_wd.tripped - if inv.delivery_wd.evaluate(_delinquent.get(inv.name), now) \ - and not was_short: - # REASON FIRST, FLAG SECOND, and each `set` writes the file (as - # the three trips above): a crash between must not persist a - # latch with no explanation. ⚠️ NO FLAT MIRROR OF THIS PAIR, - # unlike SAFETY-02: store.py's rule 2 keeps flat keys so a - # rollback finds its latch, but the version rolled back TO - # drives one actuator, where this check is inert; a mirror - # would hand it a latch about a roster it cannot have. - # DECISIONS/MAIN-orchestration.md. - inv.store_set("delivery_reason", inv.delivery_wd.reason) - inv.store_set("delivery_tripped", True) - self._delivery_announced.discard(inv.name) - self.log_event(f"NOT DELIVERING {inv.name}: " - + inv.delivery_wd.reason, - hint=messages.hint("not_delivering", - unit=inv.name)) - _LOG.error( - "%s is not delivering while control keeps running, and " - "only stopping and starting control clears this: %s.", - inv.name, messages.reason_code(inv.delivery_wd.reason)) - _LOG.debug("Evidence for %s: %s.", inv.name, - inv.delivery_wd.reason) - _LOG.debug( - "SAFETY-42 reports and does not act: commanding a unit " - "that is not listening achieves nothing (HW-01), and the " - "only re-allocation the add-on can express would throw " - "away a partly delivering unit's output.") - - stale_after = float(self.o.get("stale_input_s", 15)) - late_s = self._readings_late(site_standing) # SAFETY-128 - self._late_zero = late_s is not None and late_s >= stale_after - # ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed - # poll does not become a fault; a LATCHED inverter must still reach the - # claim list below, because its `failsafe` SET is what parks it. - # SAFETY-30's site latch takes the roster past the hold too (its - # `sign_site` SET parks every inverter): the trip would stop acting the - # moment a poll was missed. (DECISIONS/MAIN-orchestration-archive.md) - if not self.inputs_ok and not any(i.sign_wd.tripped - for i in self.inverters) \ - and not (n_units > 1 and self.site_sign_wd.tripped): - bad_for = (now - self.inputs_bad_since).total_seconds() if self.inputs_bad_since else 0.0 - if bad_for < stale_after: - # A single missed poll is not a fault. Hold, keep the heartbeat - # going, and give it a few seconds to come back. - self.reason = f"inputs missing {bad_for:.0f}s" - # UX-10: the age is rendered when the page asks, not frozen at - # this cycle's. (DECISIONS/MAIN-orchestration-archive.md) - self.why_words = messages.why_waiting(bad_for) - self._why_waiting_since = now - timedelta(seconds=bad_for) - return - if late_s is not None and late_s < stale_after and self.inputs_ok \ - and (self.auto or self.shadow) \ - and not any(i.sign_wd.tripped for i in self.inverters) \ - and not (n_units > 1 and self.site_sign_wd.tripped): - self.reason = f"readings late {late_s:.0f}s" # SAFETY-128: hold - self.why_words = messages.why_late() - return - - claims = [] - - # safety limits: these bind EVERY strategy, always. Defence in depth: - # write_setpoint() clamps to the device range too, and the duplication - # is deliberate. ⚠️ PER-INVERTER, ONE PAIR PER UNIT from THAT unit's - # own min/max and supervised cap: there is no site rating - # (arbiter.CLAIM_SCOPE). (DECISIONS/MAIN-orchestration-archive.md) - for inv in self.inverters: - if inv.dev_min is not None and inv.dev_max is not None: - claims.append(Claim.limit("device", P_SAFETY, inv.dev_min, inv.dev_max, "rating", - actuator=self.actuator_of("device", inv))) - # `max_w` is the supervised cap on what may be WRITTEN to one - # setpoint entity (§11.3), not a bound on the grid connection. - claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w", - actuator=self.actuator_of("supervised", inv))) - # OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS. ⚠️ AN ENVELOPE, - # NOT A WEIGHT: a zero weight does not stop `allocator._fill()` - # commanding a pack (at or under its BMS cut-out `hi = 0`, at 100 % - # `lo = 0`). ⚠️ NEVER FILED AT N=1 (it would bound the site target: - # SAFETY-25 Option A), so there nothing here stops the loop - # discharging into the BMS cut-out; the BMS does. - # (DECISIONS/MAIN-orchestration-archive.md) - if n_units > 1: - _pe = packshare.envelope(pack_energy_of(inv)) - if _pe.binding: - claims.append(Claim.limit("pack", P_SAFETY, _pe.lo, _pe.hi, - _pe.why, - actuator=self.actuator_of("pack", inv))) - # The SITE bound, for the site-level consumers below. It is the roster - # sum: what this site can deliver, not what one of its inverters can. - max_w = self.site_max_w - - # safety stops. ⚠️ SAFETY-02 is claimed FIRST and unconditionally while - # latched, a SET at P_SAFETY, and re-filed EVERY cycle (claims are not - # sticky). ⚠️ AND IT IS PER-INVERTER, THOUGH ITS EVIDENCE IS SITE-WIDE: - # at N>=2 the evidence cannot be pinned on a sibling this controller - # never commanded (arbiter.CLAIM_SCOPE). - # (DECISIONS/MAIN-orchestration-archive.md) - for inv in self.inverters: - if inv.sign_wd.tripped: - claims.append(Claim.set("failsafe", P_SAFETY, 0.0, - "sign disagreement", - actuator=self.actuator_of("failsafe", inv))) - - # SAFETY-30: THE SITE SIGN TRIP, A SITE SET: the SUM of every standing - # command against the meter, which no single inverter owns; parking one - # unit on it would leave the rest pushing the same way. ⚠️ The `n_units - # > 1` guard is not redundant: the latch is restored from the store, - # and an install reconfigured back to one inverter must come up - # driving. (DECISIONS/MAIN-orchestration-archive.md) - if n_units > 1 and self.site_sign_wd.tripped: - claims.append(Claim.set("sign_site", P_SAFETY, 0.0, - "site sign disagreement", - actuator=self.actuator_of("sign_site"))) - - # SAFETY-31: ⚠️ A SITE SET AT P_SAFETY: STOP COMMANDING, EVERYWHERE. A - # circulating loop needs two participants and one is this controller: - # stopping it halves the loop and makes the residue visible at P1. It - # does NOT stop a wedged inverter (HW-01); the remaining actuator is a - # human at the isolator. A site claim: circulating power is zero for - # any one unit (arbiter.CLAIM_SCOPE). - # (DECISIONS/MAIN-orchestration-archive.md) - if self.circ_wd.tripped: - claims.append(Claim.set("circulation", P_SAFETY, 0.0, - "circulating power", - actuator=self.actuator_of("circulation"))) - - # SITE, both of them: "stopped" (the operator switching control off) - # and "inputs missing" (the meter gone) are not about a pack. ⚠️ - # SAFETY-10: `and not self.shadow` is the one failsafe this card - # changes (a stop claim would record "stopped" on every shadow cycle); - # the wire gate in `_write_unit()` is stronger. - # (DECISIONS/MAIN-orchestration-archive.md) - if not self.auto and not self.shadow: - claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped", - actuator=self.actuator_of("safety"))) - elif not self.inputs_ok: - if self.target != 0.0: - self.log_event("inputs missing - commanding 0 W", - hint=messages.hint("inputs_missing")) - claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing", - actuator=self.actuator_of("safety"))) - elif late_s is not None: - claims.append(Claim.set("safety", P_SAFETY, 0.0, "readings late", - actuator=self.actuator_of("safety"))) - - # SAFETY-07: the quarter-peak projection guard: ONE `lo` limit at - # P_PEAK; peakguard.py carries the argument. ⚠️ `frozen` is evaluated - # on THIS cycle's standing command, not `self.freeze_wd.active` (a - # cycle behind). ⚠️ `achievable_w` keeps the floor from filing `lo > - # hi` (0 W). ⚠️ Filed EVERY cycle (claims are not sticky). ⚠️ NOT RUN - # WITHOUT P1: it would be blind, and blind means "no charging"; the - # report is NOT COVERED. (DECISIONS/MAIN-orchestration-archive.md) - self._peak_proj = (None if not self.p1_enabled - else self.peak.project(self.p1.averager, now, - roll=self.modes_on)) - # OPT-15 x SAFETY-81: see `check_peak_saturated()`. - self._peak_rolled = (self._peak_proj is not None - and self._peak_proj.block_start is not None - and self._peak_proj.block_start - != self.p1.averager.block_start) - _reading = self.peak.peak(now) if self.p1_enabled else None - # SAFETY-90: seconds a meter gap left uncounted are counted, so the - # guard goes on after the readings return. - self._peak_proj = self._fill_peak_gaps(self._peak_proj, now, _reading) - # SAFETY-91: at N>=2 the quarter's edge is graded on the next quarter, - # so a HOLD's ceiling does not lapse for a cycle onto a held battery. - if self._peak_proj is not None and len(self.inverters) > 1: - self._peak_proj = next_quarter(self._peak_proj, - self.p1.averager.max_hold_s) - self.peak_guard = None if not self.p1_enabled else peakguard.decide( - self._peak_proj, - # ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is - # ALREADY removing from the meter, and the meter sees every - # inverter. (DECISIONS/MAIN-orchestration-archive.md) - batt_w=self.site_batt_w, - cap_w=float(self.o.get("peak_cap_w", 3500)), - margin_w=self.peak_margin_w, - reading=_reading, - # ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each unit - # contributes `min(its max_w, |its own device bounds|)`, so - # inverter B's rating bounds inverter B. - # (DECISIONS/MAIN-orchestration-archive.md) - achievable_w=self.site_rating_w, - # ⚠️ SAFETY-24. `pack_floor_soc` IS A MEASURED READING AND MAY BE - # None, and it is handed over as None rather than defaulted (a - # defaulted 100 % is a full battery, the value that disarms this - # floor); `decide()` treats None as floor-limited. - # (DECISIONS/MAIN-orchestration-archive.md) - soc=self.pack_floor_soc, - # ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN - # CUT-OUTS: `binding_soc_floor()` takes the MAXIMUM with each - # pack's `bms_soc_floor`, pairing with the MINIMUM in - # `pack_floor_soc` (both move towards refusing a shave). - # "Unreachable" is a claim about rule ordering, and sim/inject.py - # does not run rule 8: asserted over the whole schema range in - # test_peakguard.py part 12. - # (DECISIONS/MAIN-orchestration-archive.md) - soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get( - "peak_soc_floor", - peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))), - max_hold_s=self.p1.averager.max_hold_s, - # ⚠️ SAFETY-20. The device range goes in TOO: `standing` is - # `last_write`, the value AFTER `clamp_to_rating()`, so a rail from - # bare `max_w` is a ceiling the device cannot reach. - # (DECISIONS/MAIN-orchestration-archive.md) - frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None), - site_standing, max_w, self.freeze_wd.cfg, - dev_min=self.site_dev_bound("dev_min"), - dev_max=self.site_dev_bound("dev_max")), - ) - # SAFETY-87: at N>=2 a battery at or under its own reserve gives - # nothing for the peak, and the others carry the guard's need. - if self.peak_guard is not None: - self.peak_guard = self._peak_on_able(self.peak_guard, - maint_results) - self.peak_guard = self._peak_landing(self.peak_guard) - self.peak_guard = peakguard.cap_floor( # SAFETY-128 - self.peak_guard, self.answer.peak_cap(site_standing)) - # SITE, and peakguard.claim() says so on the object it builds: the - # quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION, - # which the capacity tariff bills (one quarter-hour peak per site, not - # per pack). ⚠️ It is also the `lo` half of SAFETY-25 §0c's table: a - # site `lo` that the deferred loop reserve's per-inverter `hi` must - # never cross. (DECISIONS/MAIN-orchestration-archive.md) - peak_claim = (peakguard.claim(self.peak_guard) - if self.peak_guard is not None else None) - if peak_claim is not None: - claims.append(peak_claim) - # ⚠️ EDGE-LOGGED, not level-logged: the action log holds 40 lines, and - # a line every 15 s for a quarter would push the cause off the end. - # (DECISIONS/MAIN-orchestration-archive.md) - engaged_now = self.peak_guard is not None and self.peak_guard.engaged - if engaged_now != self._peak_engaged: - self._peak_engaged = engaged_now - if self.peak_guard is not None: - _pg = self.peak_guard - self.log_event(_pg.explain(), hint=messages.hint( - "peak_off" if not _pg.engaged else - "peak_blind" if _pg.blind else - "peak_on" if _pg.within_aim else - "peak_reserve" if _pg.floor_limited else - "peak_saturated" if _pg.saturated else "peak_on")) - - # OPT-04: price arbitrage (§10.1 priority 3): ONE `set` claim at - # P_ARBITRAGE; arbitrage.py carries the argument. ⚠️ AFTER THE PEAK - # BLOCK BECAUSE IT READS ITS CONCLUSION (`engaged_now`). ⚠️ The - # three-state collapse lives in arbitrage.guard_state(), NOT here (None - # = the guard never ran, without P1). ⚠️ NO FETCH ON THIS PATH. ⚠️ The - # uncertainty set is NOT empty (no weather forecast is built). - # TARIFF-11: `arb_at` keeps the status row honest. - # (DECISIONS/MAIN-orchestration-archive.md) - self.arb, self.arb_at = None, now - self.arb_headroom_w = None - self.room_charge = False - # OPT-11 P3: with battery modes on, each battery's mode decides who - # trades (`_mode_roles()`, `_mode_trade()`, below, which read the - # same headroom), and this site-wide trade is not run. - site_trade = arbitrage.enabled(self.o) and not self.modes_on - if site_trade or self.modes_on: - # SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED: one evaluation - # feeds both the sizing below and `arbitrage.sized_from_meter()`, - # so the caveat on the recorded watts is never answered from a - # different cycle's guard. - # (DECISIONS/MAIN-orchestration-archive.md) - self.arb_headroom_w = peakguard.charge_room_w(self.peak_guard, - self._peak_proj) - if site_trade: - floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) - charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct) - self.arb = arbitrage.decide( - self.fresh_tariff_plan(now), now, - peak_guard_active=arbitrage.guard_state(self.peak_guard), - # SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS - # ONE: the watts the billed quarter can still afford clamp the - # buy leg (a switch alone gave 28 charge onsets an hour on - # TEST-23). ⚠️ It reads the peak block's conclusion, so it must - # stay after it. (DECISIONS/MAIN-orchestration-archive.md) - charge_headroom_w=self.arb_headroom_w, - # The emptiest pack again, and for the sell side that is the - # right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there - # is one `soc` argument - `pack_floor_soc` names that residual - # rather than hiding it; a per-pack charge ceiling is CFG-04's, - # and at the shipped roster of one the two reductions are the - # same number. - soc=self.pack_floor_soc, - min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), - uncertainty=frozenset({Uncertainty.NO_WEATHER}), - # OPT-09. PER-PACK SIZING, not `max_w`: the trade is sized to - # the packs whose own state of charge permits that direction - # (`trade_power_w()`). ⚠️ At N=1 both sides are `max_w` - # exactly. ⚠️ On the sell side this is a no-op today (`soc=` - # below is the EMPTIEST pack); test_packshare.py part 9 grades - # it. (DECISIONS/MAIN-orchestration-archive.md) - charge_w=charge_w, discharge_w=discharge_w, - soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct) - # SITE, and arbitrage.claim() says so on the object it builds. It - # buys and sells at the grid against a day-ahead price: the price is - # a property of the connection and the energy crosses the meter. - arb_claim = arbitrage.claim(self.arb) - if arb_claim is not None: - claims.append(arb_claim) - self.room_charge = self.arb.action == arbitrage.CHARGE - # Edge-logged, for the peak guard's reason: the action log holds 40 - # lines and a trade lasts a whole hour, so a line per cycle would - # flush the context that explains it. - # (DECISIONS/MAIN-orchestration-archive.md) - 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")) - - # maintenance ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST - # STAYS SHORT. (DECISIONS/MAIN-orchestration-archive.md) - for inv, result in zip(self.inverters, maint_results): - if result.owns_setpoint: - # PER-INVERTER: §12's recalibration is a PACK procedure, filed - # on the pack whose state machine produced it (`inv`, - # MAINT-05). ⚠️ IT STAYS `SCOPE_UNIT`, never a site claim - # (ARCH-01: per-inverter limits filed site-wide are the - # cross-inverter `lo > hi`). - # (DECISIONS/MAIN-orchestration-archive.md) - claims.append(Claim.set("maintenance", P_MAINTENANCE, - float(result.setpoint_w or 0.0), result.phase, - actuator=self.actuator_of("maintenance", inv))) - self.sat_count = 0 - elif result.charge_only: - # Money outranks the maintenance schedule: while the projection - # is over the cap the charge-only shaping is not claimed, so - # the loop can shave. ⚠️ Shaping is `hi <= 0` and the peak - # claim `lo >= 0`; both filed give `lo > hi`, which resolves to - # 0 W in the quarter that sets the bill, so the higher claimant - # SUPPRESSES the lower. ⚠️ THE CONJUNCTION LIVES IN - # peakguard.shaping_suspended(), NOT HERE. One suspension flag - # per SITE (`arbitrate()` allows one pack in CHARGE). - # (DECISIONS/MAIN-orchestration-archive.md) - if peakguard.shaping_suspended( - self.peak_guard, self.peak_fc, self.peak_aim_w()): - # Edge-logged: level-logging here would write one line per - # cycle for as long as the event lasts and flush the 40-line - # action log of the very context that explains it. - if not self._maint_shape_suspended: - self._maint_shape_suspended = True - self.log_event("peak at risk - maintenance charge shaping " - "suspended so the peak claim is not " - "contradicted", - hint=messages.hint("maint_paused")) - else: - self._maint_shape_suspended = False - hi = 0.0 - reason = "charge-only" - if self.cheap: - # ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND - # READ OF `self.o`: a literal fallback here let the - # shaping and the state machine use different rates - # from the same install. - # (DECISIONS/MAIN-orchestration-archive.md) - floor = maintenance_charge_floor( - self.maint.cfg.charge_w, - self.peak_fc, self.peak_aim_w()) - floor = self._charge_headroom(floor, claims) - if floor > 0: - hi, reason = -floor, "cheap-window charge" - # PER-INVERTER, like the SET above and for the same reason - - # the shaping is about the pack being charged, which is `inv`. - claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, - reason=reason, - actuator=self.actuator_of("maintenance", - inv))) - - # OPT-11: each battery's mode, a soft unit limit at P_MODE. Filed only - # with battery modes on, and never on a battery in any maintenance - # phase (maintenance outranks the mode). The range always holds 0 W - # (packmode.mode_range()) and gives way to the peak floor only as far - # as needed, so the two never meet in `lo > hi` - # (arbiter._unit_modes()). (DECISIONS/MAIN-orchestration-archive.md) - if self.modes_on: - tick = self._mode_roles(now, maint_results) - for inv, result in zip(self.inverters, maint_results): - if result.phase != IDLE: - continue - pm = self.pack_mode(inv) - lo, hi = packmode.mode_range(pm, inv.max_w) - rlo, rhi = tick.ranges.get(inv.name, (lo, hi)) - lo, hi = max(lo, rlo), min(hi, rhi) - claims.append(Claim.limit("mode", P_MODE, lo, hi, pm.key, - actuator=self.actuator_of("mode", - inv))) - - # the grid-following loop. ⚠️ SAFETY-10: `or self.shadow`: the loop is - # the leg a shadow run most needs; it is safe because the loop's - # `prev_w` is `self.target`, re-seeded to what is on the wire. - # SAFETY-52: a refusal learned while control ran does not survive a - # stop or a start. (DECISIONS/MAIN-orchestration-archive.md) - if bool(self.auto) != self._refusal_auto: - self._refusal_auto = bool(self.auto) - self._refusal_end("control started" if self.auto - else "control stopped") - for inv in (self.inverters if len(self.inverters) > 1 else ()): - self._unit_refusal_end(inv, "control started" if self.auto - else "control stopped") - # SAFETY-73: each unit's own refusal, engaged and ended on its own - # reading, before the loop reads `self.target` (an end reseeds it). - _probing = {} - if len(self.inverters) > 1 and self.inputs_ok: - self._unit_refusals() - _probing = {inv.name: self._unit_probe(inv) - for inv in self.inverters if inv.refused} - # SAFETY-73: each refusing unit's own P_SAFETY claim: a hard unit limit - # that always intersects (OPT-11's P_MODE sits inside it), closed in - # the refused direction, so the allocator hands the share to the others - # within their own limits; a pin at PROBE_W during its probe. Filed - # before the loop so OPT-11's loop bound sees it. - # (DECISIONS/MAIN-orchestration-archive.md) - for inv in self.inverters: - if not inv.refused: - continue - if _probing.get(inv.name): - claims.append(Claim.set("refused", P_SAFETY, - inv.refused * _ctl.PROBE_W, - _ctl.PROBING, - actuator=self.actuator_of("refused", - inv))) - else: - claims.append(Claim.limit("refused", P_SAFETY, - *_refused_interval(inv.refused), - _ctl.REFUSED, - actuator=self.actuator_of("refused", - inv))) - # --- SAFETY-78: a pinned drain is a floor under the site target ----- - # After every unit claim, so the floor is what the pin really places. - drain = self._drain_floor(claims, maint_results) - if drain is not None: - claims.append(Claim.limit("drain", P_MAINTENANCE, lo=drain, - reason="maintenance drain", - actuator=self.actuator_of("drain"))) - self._peak_over_drain(claims, maint_results) - # --- SAFETY-86: while a HOLD gives, the site stays at the peak floor -- - # So the loop never asks for watts the hold keeps back. - hold_hi = self._peak_over_hold(claims, maint_results) - if hold_hi is not None: - claims.append(Claim.limit("hold", P_MAINTENANCE, hi=hold_hi, - reason="maintenance hold", - actuator=self.actuator_of("hold"))) - # --- SAFETY-91: a drain rises before a held battery gives ------------ - self._drain_rise(claims, maint_results) - # --- SAFETY-84: a cheap-window charge is a ceiling over the site ---- - charge = self._charge_ceiling(claims) - if charge is not None: - claims.append(Claim.limit("charge", P_MAINT_SHAPE, hi=charge, - reason="maintenance charge", - actuator=self.actuator_of("charge"))) - # --- OPT-11 P3: the site trade, after every unit claim -------------- - # Sized from what the batteries may place with their modes and hard - # limits (the loop bound), so none is asked for watts it may not give. - mode_trade, trade_was = False, self._mode_trade_was - if self.modes_on: - mode_trade = self._mode_trade(claims, now) - self.room_charge = self.room_charge or ( - mode_trade and self.arb.action == arbitrage.CHARGE) - self._mode_trade_was = mode_trade - self._charge_track(claims, maint_results) - _live96 = set(self.live_units()) - self._loop_start = {} - if (self.auto or self.shadow) and self.inputs_ok: - # ⚠️ prev is the ARBITER's last output, not the loop's own wish: if - # something outranked the loop, that is what the hardware did. The - # integrator re-seeds from the same (entering failsafe re-seeds it - # to zero). (DECISIONS/MAIN-orchestration-archive.md) - moved = self.loop_w is None or self.target != self.loop_w - if moved: - self.i_w = self.target - # OPT-11 P3: a run edge (a mode trade ended) reseeds the loop, and - # OPT-15 holds it there until the meter shows the step. - if trade_was and not mode_trade: - self._reseed_run_edge(claims, now) - moved = False - loop_grid = self._edge_grid(mode_trade or moved, now) - # SAFETY-52: another writer overrode the loop, so a refusal learned - # against the loop's own ask may no longer describe the wire. - # (DECISIONS/MAIN-orchestration-archive.md) - rejudged = 0 - if self.loop_w is not None and self.target != self.loop_w: - rejudged = self.batt_refused - # SAFETY-96: a battery that dropped out of the split leaves the - # loop's start point (SAFETY-93's own if its charge ended too). - self._reseed_drop(_live96) - # SAFETY-93: a charge that ended leaves the loop's start point. - self._reseed_charge_end(maint_results) - # SAFETY-95: then a drain that ended, last (it reads the start - # the others left). - self._reseed_drain_end(maint_results, _live96) - # No probe while it is judged afresh (the loop does not hold the wire). - probe = False if rejudged else self._refusal_probe() - if len(self.inverters) > 1: - probe = any(_probing.values()) - # ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF. - # The anti-windup test in compute() is `abs(prev_w - actual_w) > - # saturation_w` and `prev_w` is the SITE target; one inverter's - # power at N=2 would trip the saturation freeze permanently. - # (DECISIONS/MAIN-orchestration-archive.md) - site_batt = self.site_batt_w - # SAFETY-103: what each battery can be doing (its reading may be - # older than the meter). - self._track_reads() - # SAFETY-96: a battery the loop cannot move counts at its command, - # so its own power does not freeze the loop (the meter has it). - site_batt += self._held_out_w(_live96) - # TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED - # AGAINST. (DECISIONS/MAIN-orchestration-archive.md) - loop_prev = self.target - decision = compute( - prev_w=loop_prev, - # SAFETY-95: a drain's tail is not the loop's to chase. - # OPT-15: `loop_grid` is the grid, or the target while a run - # edge is held. - grid_w=(self._drain_grid_w(_live96) if self._drain_tail - else loop_grid), - actual_w=site_batt, - tuning=self.tuning, - sat_count=self.sat_count, - i_w=self.i_w, - # SAFETY-52 at one inverter only: with several, OPT-09's pack - # limit already closes an empty battery's discharge end. - # SAFETY-73: at N>1 the site law holds 0 W only while every - # unit refuses the same way. Otherwise None, the old law. - refused=(0 if rejudged else self.batt_refused) - if len(self.inverters) == 1 - else self._site_refused(), - probe=probe, - # SAFETY-94: at N=1 the engage also needs the reading to have - # given nothing for the whole span, as at N>1. - gave_nothing=(bool(self._gave_nothing(self.inverters[0])) - if len(self.inverters) == 1 else None), - # SAFETY-97: "gives nothing" is centred on the idle reading. - idle_w=self._site_idle_w(), - hold=self.answer.hold, hold_w=self.answer.hold_w, # SAFETY-128 - # OPT-11: None unless battery modes are on. SAFETY-85: or while - # maintenance holds a battery at N>=2, so the loop does not - # wind past what the others can give and jump when it ends. - bound=(self.loop_bound(claims) if self.modes_on or ( - len(self.inverters) > 1 - and any(r.phase != IDLE for r in maint_results)) - else None), - ) - if decision.frozen and self.sat_count < self.tuning.saturation_cycles: - self.log_event(f"saturation freeze ({self.target:.0f} W vs " - f"{site_batt:.0f} W)", - hint=messages.hint("not_keeping_up")) - self.sat_count = decision.sat_count - self.i_w = decision.i_w - self.loop_w = decision.target_w - self._loop_decision = decision - if len(self.inverters) == 1: - self._refusal_update(decision, loop_prev, site_batt, probe, - rejudged) - # TEL-10. ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS: `loop_i` - # is `Decision.i_w`, the value BEFORE the clamp, slew and - # quantisation, so it is NOT `setpoint` and must not be published - # from it; `loop_lead` is how far it stands ahead of the command; - # `loop_reason` is `compute()`'s own word and NOT `self.reason`. - # (DECISIONS/MAIN-orchestration-archive.md) - self.loop_i_w = decision.i_w - self.loop_i_lead = decision.i_w - loop_prev - self.loop_reason = decision.reason - # SAFETY-03. ⚠️ READ THE BOOLEANS, NOT `decision.reason`: `reason` - # is overwritten by slew-limiting or the saturation freeze after - # the clamp, so a string test is silent on the cases a winding loop - # most often presents as. SAFETY-40: every number comes from THIS - # decision, at clamp time. - # (DECISIONS/MAIN-orchestration-archive.md) - self.loop_clamped = ( - decision.i_clamped or decision.clamped, - loop_clamp_note(decision), - decision.clamped, - ) - self.loop_clamp_facts = ( - (messages.Clamp(None, decision.requested_w, decision.clamped_w, - "rating"),) if decision.clamped else ()) - # SITE. compute() regulates `grid_w` - the P1 measurement - to - # zero. Its subject is the meter, and §6.2's architecture is ONE - # integrator on P1 whose output is then SPLIT. - # (DECISIONS/MAIN-orchestration-archive.md) - claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason, - actuator=self.actuator_of("loop"))) - else: - # Stopped or blind: no accumulation may survive the outage. - self.i_w = 0.0 - self.loop_w = None - self._live_was = frozenset(_live96) - # TEL-10, and it is the same zero the line above writes rather than - # a second opinion about it. ⚠️ `not-running` rather than a stale - # `tracking`: a reason row that keeps repeating the last word the - # loop said while the loop is not running is a row that reads as - # health, which is the failure `publish()`'s own None-dropping note - # is about one field over. - self.loop_i_w = 0.0 - self.loop_i_lead = 0.0 - self.loop_reason = "not-running" - # A loop that is not running cannot be winding. Clearing this here - # rather than letting the last value stand is what makes the alarm - # de-assert when control is stopped. - self.loop_clamped = (False, "", False) - self.loop_clamp_facts = () - self._loop_decision = None - - # SAFETY-28. A new resolution is a new tick, and the previous record is - # stale from this line onwards. Incremented BEFORE resolve() so that the - # record resolve() builds is stamped with the tick it is for. - self.alloc_tick += 1 - # ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. A METHOD, NOT - # A DICT: the weighting is by ENERGY HEADROOM, so it depends on the - # direction (the sign of the site target, chosen inside `resolve()`; - # see `pack_weights()`). ⚠️ A PREFERENCE, NOT A PERMISSION: a weight - # cannot stop a unit being commanded. - # (DECISIONS/MAIN-orchestration-archive.md) - _live = self.live_units() - while True: - resolution = resolve(claims, units=self.units, live=_live, - weights=self.pack_weights, tick=self.alloc_tick, - reserve=self._held_back()) - # SAFETY-73: a probe pin stands only while the resolved site target - # asks at least PROBE_W its way; below that a sibling would carry - # the opposite direction (circulation), so the pin goes back to the - # closed limit. (DECISIONS/MAIN-orchestration-archive.md) - _void = [k for k, c in enumerate(claims) - if c.source == "refused" and c.kind == "set" - and c.value * resolution.target_w < _ctl.PROBE_W ** 2] - if not _void: - break - for k in _void: - claims[k] = Claim.limit( - "refused", P_SAFETY, - *_refused_interval(1 if claims[k].value > 0 else -1), - _ctl.REFUSED, actuator=claims[k].actuator) - self.allocation = resolution.allocation - if self.modes_on: - self._mode_fault_log(resolution) - # THE TWO REFUSALS ARE NOT THE SAME REFUSAL: both REPLACE the - # resolution with a 0 W failsafe, written like any other cycle; they - # differ in WHO refused, hence two branches and two messages. ⚠️ - # NEITHER MAY BE LEFT UNGRADED (test_peakguard.py part 10h). - # (DECISIONS/MAIN-orchestration-archive.md) - if not resolution.allocated: - self.log_event("ARBITER: " + resolution.allocation.explain(), - hint=messages.hint("split")) - resolution = Resolution(0.0, "failsafe", None, False, - resolution.considered, - unit_bounds=resolution.unit_bounds, - shares={}, allocated=False, - scope_errors=resolution.scope_errors, - allocation=resolution.allocation, - winner_priority=resolution.winner_priority) - # 2. THE ALLOCATOR SUCCEEDED AND THIS CONTROLLER CANNOT DELIVER IT. ⚠️ - # RE-AIMED BY DEV-02, NOT DELETED: a roster entry with no writer would - # have the site target written to the others (over-command by its - # share); "nothing" here means 0 W. Not folded into `live_units()`: a - # dead link is dropped and announced, an undeliverable roster stops the - # site. (DECISIONS/MAIN-orchestration-archive.md) - elif {u for u in resolution.shares if not self._can_write(u)}: - undeliverable = ", ".join( - sorted(u for u in resolution.shares if not self._can_write(u))) - self.log_event( - f"ARBITER: the site target was allocated across " - f"{len(resolution.shares)} inverters and this controller has no " - f"setpoint entity for {undeliverable} - refusing to command a " - f"partial site", hint=messages.hint("no_setpoint")) - # ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE - # DEV-02 THAT IS LOAD-BEARING: the write loop commands - # `shares.get(name, 0.0)`, so carrying the split through would - # write the numbers this branch declined, or nothing at all when - # they equal what stands (an unchanged value is not re-sent). - # (DECISIONS/MAIN-orchestration-archive.md) - resolution = Resolution(0.0, "failsafe", None, False, - resolution.considered, - unit_bounds=resolution.unit_bounds, - shares={}, allocated=True, - scope_errors=resolution.scope_errors, - allocation=resolution.allocation, - winner_priority=resolution.winner_priority) - # 3. AC 6: A DROPPED INVERTER IS NEVER SILENT. The survivors carry its - # share, a real change in what the site can deliver, and an operator - # who is not told reads a working site. Edge-triggered on the SET of - # dropped units (the action log is a log of events, §15). - # (DECISIONS/MAIN-orchestration-archive.md) - _dropped = tuple(u for u, _why in (self.allocation.dropped - if self.allocation else ())) - if _dropped != self._dropped_last: - for _u, _why in (self.allocation.dropped if self.allocation else ()): - self.log_event(f"ALLOCATOR: {_u} dropped from the split " - f"({_why}) - the remaining inverter(s) now carry " - f"its share of the site target", - hint=messages.hint("unit_dropped", unit=_u)) - if self._dropped_last and not _dropped: - self.log_event("ALLOCATOR: every inverter is back in the split", - hint=messages.hint("units_back")) - self._dropped_last = _dropped - # 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER. - # `unmet_w` is the site target the envelopes could not absorb; not a - # broken allocation (fusing it with `ok` would let one saturated - # inverter park the site), but the site failing to deliver what the - # loop asked. Edge-triggered on the VALUE. - # (DECISIONS/MAIN-orchestration-archive.md) - _unmet = int(self.allocation.unmet_w if self.allocation else 0) - if _unmet != self._unmet_last: - if _unmet: - self.log_event( - f"ALLOCATOR: {_unmet:+d} W of the site target could not be " - f"placed on any inverter - {self.allocation.explain()}", - hint=messages.hint("unmet", w=_unmet)) - else: - self.log_event("ALLOCATOR: the whole site target fits again", - hint=messages.hint("unmet_gone")) - self._unmet_last = _unmet - if resolution.scope_errors: - # A claim reached the arbiter on an actuator its source's scope - # does not permit: a BUG in the filing site, not a state to survive - # (a per-inverter constraint in the site list is the `lo > hi` this - # card removes). Loud, every cycle; the resolution is unchanged. - # (DECISIONS/MAIN-orchestration-archive.md) - _LOG.error("A limit was filed on the wrong inverter, which is a " - "software fault. %s.", " / ".join(resolution.scope_errors)) - if resolution.contradiction: - self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log", - hint=messages.hint("clash")) - # SAFETY-02's arming gate: was the number chosen by the grid-following - # loop, or by a strategy that commands against the meter on purpose? - # Only the former is expected to agree in sign with P1. ⚠️ THE - # EXPRESSION LIVES IN watchdog.py, NOT HERE: read loop_is_following() - # before touching the gate. (DECISIONS/MAIN-orchestration-archive.md) - self.following = loop_is_following(resolution, self.loop_w) - self.target = resolution.target_w - self.reason = resolution.explain() - # UX-08: the same answer in plain words, for the page's "Why" line; - # `reason` stays the technical record the log and the checks read. - # UX-10: every key `why_key()` can return has words in - # messages.WHY_WINNER. (DECISIONS/MAIN-orchestration-archive.md) - _why = why_key(resolution.winner, claims) - if _why == "inputs missing" and not resolution.contradiction: - # UX-11: past the hold, say which reading is missing. - self.why_words = messages.why_missing(self.missing_reading(), - stale_after) - else: - self.why_words = messages.why( - _why, resolution.target_w, resolution.bound_by, - contradiction=resolution.contradiction, - raised=why_raised(resolution, claims)) - # DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE, NEVER THE SITE TARGET - # (handing each unit `self.target` is the ARCH-01 fault: each supplies - # it in full and the gain at the meter becomes N times the per-unit - # gain). At N=1 this is byte-identical. ⚠️ A unit not in `shares` is - # commanded 0 W, not left standing: an inverter holds its last command - # for ever. (DECISIONS/MAIN-orchestration-archive.md) - self._loop_alarm_input(resolution) - writes = [] - for inv in self.inverters: - inv.target = float(resolution.shares.get(inv.name, 0.0)) - writes.append(self.write_setpoint(inv.target, unit=inv)) - await asyncio.gather(*writes) - - # SAFETY-03, and its boundary is the WRITE. ⚠️ NOT THE SAME BOUNDARY AS - # SAFETY-18's BELOW, THOUGH THE CALLS ABUT: this one reads - # `self.clamp_last`, None until write_setpoint() has run, so it must - # follow the write (test_clamp.py part 6). Two guards, two orderings: - # do not "tidy" them into one rule. - # (DECISIONS/MAIN-orchestration-archive.md) - await self.check_output_clamp() - - # SAFETY-18, and its boundary is the ASSIGNMENT. ⚠️ THE BOUNDARY IS - # `self.target = resolution.target_w` ABOVE, NOT write_setpoint(): both - # terms of the conjunction must describe the SAME moment, so moving it - # above the assignment grades the previous cycle's command against this - # cycle's meter (test_freeze.py part 5). - # (DECISIONS/MAIN-orchestration-archive.md) - await self.check_meter_frozen() - - # --- PRICE-01: no price for now, said once and taken down once -------- - # Not a safety check, so it is one of the cheap ones: an attribute test - # and, on a change only, one Home Assistant call. - await self.check_price_gap() - await self.check_fixed_note() - - # SAFETY-31's alarm, and its boundary is the LATCH, not a number: it - # grades `self.circ_wd.tripped`, advanced near the top of this method. - # Called on EVERY cycle, not only the one that trips, because its - # second job is re-raising the alarm after a restart. - # (DECISIONS/MAIN-orchestration-archive.md) - await self.check_circulating() - - # SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's: - # it grades `inv.delivery_wd.tripped`, and is called on EVERY cycle - # because its second job is re-raising the alarm after a restart. - # (DECISIONS/MAIN-orchestration-archive.md) - await self.check_not_delivering() - - # --- SAFETY-07's alarm, and it is NOT the engagement ------------------ - # Its boundary is unconstrained: it grades `self.peak_guard`, which this - # method assigned above and nothing since has touched. - await self.check_peak_saturated() - - # SAFETY-24's alarm, and it is the OTHER way the money gets spent. Same - # boundary argument as check_peak_saturated(); kept as its own method - # because the two states are mutually exclusive and want OPPOSITE - # operator actions (saturated: shed load; this: the pack is at its - # floor and the quarter is not being shaved). - # (DECISIONS/MAIN-orchestration-archive.md) - await self.check_peak_floor_limited() - - # TARIFF-01, and it is LAST on purpose: banking a quarter is - # bookkeeping about a moment that has passed, and a slow SD card must - # never sit between the meter and the setpoint. It grades neither - # `target` nor `standing`. (DECISIONS/MAIN-orchestration-archive.md) - self.peak.sync(self.p1) - - # SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS: every - # guard above has read `self.target`, the resolved intent (SAFETY-18 - # grades exactly that); re-seeding before them would hand each a 0 W no - # decision produced. See `_shadow_settle()`. - # (DECISIONS/MAIN-orchestration-archive.md) - self._shadow_settle(resolution) - - def _shadow_settle(self, resolution) -> None: - """SAFETY-10. Record the decision, then re-seed the carried command - (`self.target`, each `inv.target`) to `shadow.WIRE_W`, in this order - because the second destroys the first's evidence. ⚠️ THE RE-SEED IS A - SAFETY PROPERTY: compute's slew base, the sign watchdogs - (`standing_w()`) and SAFETY-42 read the carried command as a statement - about the HARDWARE; a shadow decision there would latch a trip that - survives into live operation. One log line per CHANGE of decision. - (DECISIONS/MAIN-orchestration-archive.md)""" - engaged = self.shadow - if engaged != self._shadow_was: - # ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT (a - # "shadow mode off" line at every start pushed a clamp episode out - # of the 40-row log); coming up IN shadow mode always gets a line. - # (DECISIONS/MAIN-orchestration-archive.md) - first = self._shadow_was is None - self._shadow_was = engaged - if engaged: - self.log_event( - "SHADOW MODE ON - " + shadow.why( - self.o, control_started=self.auto) - + f"; inverter held at {shadow.WIRE_W:.0f} W", - hint=messages.hint("shadow_on")) - # UX-10: INFO. It follows a setting or a button press, and - # no person has to act on it. - _LOG.info( - "Shadow mode is on, so every inverter stays at %.0f W and " - "nothing the add-on decides is sent. %s.", shadow.WIRE_W, - shadow.why(self.o, control_started=self.auto)) - elif not first: - self.log_event( - "Shadow mode off: the add-on controls the battery again, " - f"starting from {shadow.WIRE_W:.0f} W", - hint=messages.hint("shadow_off")) - _LOG.info( - "Shadow mode is off, so the add-on controls the battery " - "again, starting from %.0f W.", shadow.WIRE_W) - if not engaged: - self.shadow_would_w = None - self.shadow_sized_from_meter = False - self._shadow_winner = None - return - self.shadow_would_w = float(resolution.target_w) - # SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY: all three rungs live in - # `arbitrage.sized_from_meter()`; a rung written at a call site is one - # no check can reach with the other two clear (TEST-16). - # (DECISIONS/MAIN-orchestration-archive.md) - self.shadow_sized_from_meter = arbitrage.sized_from_meter( - self.arb, self.arb_headroom_w, winner=resolution.winner) - # ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE (a loop - # target that that wanders by a watt would be the whole log). ⚠️ - # SAFETY-47 ADDS THE CAVEAT FLAG TO THE EDGE: the same winner at the - # same watts with and without the caveat are two different claims. - # (DECISIONS/MAIN-orchestration-archive.md) - edge = (resolution.winner, round(float(resolution.target_w)), - self.shadow_sized_from_meter) - if edge != self._shadow_winner: - self._shadow_winner = edge - self.log_event(shadow.log_line( - would_w=resolution.target_w, reason=resolution.explain(), - sized_from_meter=self.shadow_sized_from_meter), - hint=messages.hint("shadow_decision", - sw=resolution.target_w)) - self.target = shadow.WIRE_W - for inv in self.inverters: - inv.target = shadow.WIRE_W - def _loop_alarm_input(self, resolution) -> None: """SAFETY-85. The loop's clamp counts only on a cycle the loop decided (a rating may cut it). A battery another owner set to discharge counts