Files
goodwe-addon/goodwe_controller/app/ctl_cycle.py
T
glenn schrooyenandClaude 3939211176 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 <noreply@anthropic.com>
2026-11-09 00:29:52 +01:00

1113 lines
65 KiB
Python

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