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>
1113 lines
65 KiB
Python
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
|