"""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