MAINT-21 round 2: keep the move pure - revert the added DECISIONS text, restore cut comments
Router T241: the comments commit had put new rationale into MAIN-orchestration.md and cut comment lines that the note at56fc41fdid not hold. - DECISIONS/MAIN-orchestration.md is the56fc41ftext plus the one sentence that the Controller is split into app/main.py and app/ctl_*.py. - Comments whose content is not in a DECISIONS note at56fc41fare back in the code verbatim: MAINT-05 one config, the SAFETY-02, SAFETY-42/34, SAFETY-30 site half, SAFETY-31 and SAFETY-18 latch blocks in __init__, the _grade_store_migration docstring and comment, the trade_power_w docstring, the rows.py docstrings. - Kept: cuts of text already in the note, the SMA-10 / SAFETY-137 / SMA-2 pointers, the SAFETY-32 line fix. No code change: maint21_same_ast OK, maint20_same_code -OO same. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
902da3f723
commit
58b6fa35f5
@@ -13,18 +13,17 @@ comments that were shortened but not rewritten here are kept word for word in
|
||||
## Controller construction
|
||||
|
||||
- **Per-write budget (DEV-02).** Each write is dispatched concurrently and bounded, so one hung `hass.call()` cannot eat a sibling's layer-1 budget (the ESP32 zeroes after about 30 s, and a fault on A could zero a healthy B). The number is derived: `heartbeat_s` max 25 s, layer-1 veto `validate.FIRMWARE_INGEST_VETO_S` 30 s, so 25 + 4 < 30. test_inverter.py grades the inequality, so raising the heartbeat ceiling goes red instead of taking an inverter to zero. Below hass.py's 10 s HTTP timeout on purpose: abandon the write and re-issue it next tick, since `last_write` moves only on an accepted write.
|
||||
- **Tariff refresh (OPT-04).** `resolve_tariff()` may do an HTTPS fetch. The tick feeds the ESP32 heartbeat, so a supplier's TLS timeout on the tick would push the add-on into layer 1. The tick never awaits a fetch. The 30 min cadence is not about price change (day-ahead publishes once at 14-16 h CET and 8.3's carried hours go firm then); it picks the switch up within the hour at 48 fetches a day. The age bound answers another question: a failed refresh still stores a plan (the 11.4 static fallback), the bound covers the refresh task dying while a plan from hours ago stands as current. Three missed refreshes (5 400 s), for `p1.max_age_s`'s reason.
|
||||
- **Status rows (TARIFF-11).** The Tariff row used to be `static_status()`'s sentence alone and never saw the provider plan (read 'no static profile' at all 802 samples of a 40 h soak with a live feed). `tick_words` and `plan` are two clocks and the row says so; the plan is stamped with its own age from `tariff_at` (TARIFF-12 does not move it on a kept plan), and `age_s` is the caller's measurement. "No plan" is said aloud on a site that configured a provider. The Arbitrage row used to be `"off (arbitrage_enabled)" if self.arb is None`, under a comment saying `arb is None` iff trading was off; false, since `cycle()` returns at the input hold before `self.arb = None`: `arbitrage_enabled: true` plus a sensor answering None rendered 'off (arbitrage_enabled)', naming an option that is on, and past the first cycle the previous decision stood as current. Three states: off, armed with no decision this cycle, a decision with its age (always printed: a row that reports staleness only once stale cannot be told from one that never does).
|
||||
- **Tariff refresh (OPT-04).** `resolve_tariff()` may do an HTTPS fetch. The tick feeds the ESP32 heartbeat, so a supplier's TLS timeout on the tick would push the add-on into layer 1. The tick never awaits a fetch. The 30 min cadence is not about price change (day-ahead publishes once at 14-16 h CET and 8.3's carried hours go firm then); it picks the switch up within the hour at 48 fetches a day. The age bound answers another question: a failed refresh still stores a plan (the 11.4 static fallback), the bound covers the refresh task dying while a plan from hours ago stands as current. Three missed refreshes, for `p1.max_age_s`'s reason.
|
||||
- **Status rows (TARIFF-11).** The Tariff row used to be `static_status()`'s sentence alone and never saw the provider plan (read 'no static profile' at all 802 samples of a 40 h soak with a live feed). `tick_words` and `plan` are two clocks and the row says so; the plan is stamped with its own age from `tariff_at` (TARIFF-12 does not move it on a kept plan), and `age_s` is the caller's measurement. The Arbitrage row used to be `"off (arbitrage_enabled)" if self.arb is None`, under a comment saying `arb is None` iff trading was off; false, since `cycle()` returns at the input hold before `self.arb = None`: `arbitrage_enabled: true` plus a sensor answering None rendered 'off (arbitrage_enabled)', naming an option that is on, and past the first cycle the previous decision stood as current. Three states: off, armed with no decision this cycle, a decision with its age (always printed: a row that reports staleness only once stale cannot be told from one that never does).
|
||||
- **`stop` is required (review catch).** `stop=None` made a Controller that cannot stop itself the default: on the late-refusal path in `run_control()` it logged the banner, returned and left the process alive forever with no loop and no output.
|
||||
- **Action log first.** The store migration can log to it (15: how an operator finds a latch was re-armed); an AttributeError on the way to saying so would stop the add-on booting.
|
||||
- **Roster and migrations (DEV-02, SAFETY-44, MAINT-05).** Every per-device value lives on the roster objects (app/inverter.py); a `Tuning` and an integrator are deliberately not per device (6.2: one integrator on P1 above the split; a second regulating the same meter is the two-writers failure). The SAFETY-02 key migration runs before the first `Inverter` reads its latch, else a tripped install comes up armed with a green banner. SAFETY-44: it is handed `roster_names(opts)[0]`, never `UNIT_A` (on a declared roster the flat latch landed under `unit/inverter-a/`, read back under the name it was handed, stamping the marker); `roster_names()` returns `[UNIT_A]` on the flat shape so today's path is byte-identical. `misdirected` is unreachable from that call site (`unit` is a roster member by construction; dropping the `roster` argument survives 28/28), so the claim that the call site buys the refusal is gone; the argument stays for new callers and is graded in test_maintenance.py. The maintenance migration targets `self.inverters[0].name`, not `UNIT_A`: with `UNIT_A` the history would sit under a name on no roster (a nameless entry falls back to a positional `inverter-1`). Every two-pack fixture once named its first entry `inverter-a`, so swapping them left 19 files green; `migrate_maint_keys()` now grades the namespace it wrote against the names the machines read, and test_maintenance.py sections 9 and 11 use `["garage", "shed"]`.
|
||||
- **One maintenance config (MAINT-05).** The `maintenance_*` options describe the procedure, the same on every pack; the schedule and history are per pack, in the store. A per-entry config block would be a second shape for CFG-04 and a fifth place for `maintenance_charge_w` to disagree with config.yaml; two procedures would be a roster-schema card.
|
||||
- **CFG-04 disclosure.** A declared `inverters:` roster ignores `soc_entity`, `batt_entity`, `batt_invert` and `max_w`, which the UI still shows. Rule 9(e) refuses the dangerous one (a flat `setpoint_entity`); the rest cannot be refused (the schema has no blank value) so one action-log line at every start names the roster and the ignored keys.
|
||||
- **Site bound.** Summed from the roster, not `opts["max_w"]`: it is the integrator bound and rail of the site loop, then split. Identical at N=1; a per-unit `max_w` binds each share through its `supervised` claim.
|
||||
- **SAFETY-39 slew fallback** is 0 ("follow the rail"); it was 1000, a stale third copy. `control.slew_limit_w()` is the one reader.
|
||||
- **CFG-03 in main.py.** Eight `opts.get(key, <literal>)` calls used to live in the maintenance setup and two disagreed with config.yaml (2500 against 2000). `MaintConfig.from_options()` reads them. A ninth key goes in config.yaml `options:`/`schema:` and `maintenance.MAINT_OPTION_DEFAULTS` only.
|
||||
- **Watchdog thresholds (SAFETY-30).** The per-unit pair is re-derived at two inverters, not carried (it grades a command against one unit's own power). The site pair is used only at N>=2: the 300 W threshold carries (meter noise, deadband), the window does not (110 s at one inverter, 215 s at two, against a 180 s hold).
|
||||
- **Latches (SAFETY-02, -31, -34, -42, -18).** SAFETY-02: one per actuator, restored from namespaced keys (a latch held only in memory is cleared by the restart an operator tries first); graded on every start (a latch that failed to move namespace, or was taken by an older add-on in a rollback, is a disarmed guard, so a disagreement re-arms by hand and shouts). SAFETY-31: site-level, flat keys (circulating power is defined over the roster), restored because it acts: a cleared latch would restart straight into the loop it stopped. SAFETY-42's delivery latch does not act, so it is restored only so the ALARM survives (the 2026-09-02 outage went unseen 6 h 25 m with the detector correct). SAFETY-18 is not restored (does not act; a restart re-derives it within one `meter_freeze_s`). SAFETY-30's site check is armed only at N>=2: arming it at one inverter would duplicate `inverters[0].sign_wd` (two latches, two banners, a false-positive record spent twice), so it is constructed there and never evaluated; `cycle()` enforces it.
|
||||
- **Latches (SAFETY-02, -31, -34, -42, -18).** SAFETY-02: one per actuator, restored from namespaced keys; graded on every start (a latch that failed to move namespace, or was taken by an older add-on in a rollback, is a disarmed guard, so a disagreement re-arms by hand and shouts). SAFETY-31: site-level, flat keys (circulating power is defined over the roster), restored because it acts. SAFETY-42's delivery latch does not act, so it is restored only so the ALARM survives (the 2026-09-02 outage went unseen 6 h 25 m with the detector correct). SAFETY-18 is not restored (does not act). SAFETY-30's site check is armed only at N>=2.
|
||||
- **PV (TEL-11).** Instrumentation only; wiring it in would be an unrequested behaviour change and would destroy the measurement.
|
||||
- **Peak guard setup (SAFETY-07, TARIFF-01, FCST-04).** See SAFETY-07-peak-guard.md and FCST-04. The windowing caveat for FCST-04: hours already elapsed leave the spread, so a cheap night that has gone no longer sets the day's cheapest hour.
|
||||
|
||||
@@ -34,7 +33,7 @@ comments that were shortened but not rewritten here are kept word for word in
|
||||
- **`self.arb` (TARIFF-11).** See "Status rows" above; `arb_at < cycle_at` is the derived "the latest cycle did not reach the arbitrage step".
|
||||
- **Shadow (SAFETY-10, SAFETY-47).** `shadow_would_w` is what a live controller would have put on the wire; `self.target` is what is standing on the wire (held at `shadow.WIRE_W`, re-seeded each shadow cycle). Folding them shows an operator a number nothing is doing. The "sized from the billed-quarter meter" flag is recorded where the decision was taken (the page runs on its own task); False, not None, before the first shadow cycle.
|
||||
- **ARB-03.** `units` is a property derived from the roster so a site cannot claim two actuators while holding one writer. At N=1 (6.2) the grid connection and the only inverter are one actuator; a second entry makes per-inverter claims stop being site claims (ALLOC-01).
|
||||
- **DEV-02 latch guard (`_check_sign_latch...`).** Runs on every start: gating on `rep.ran` swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip in the window, upgrade; the controller came up untripped while state.json said `sign_tripped: true`; a firmware rollback path was performed on this site on 2026-09-02). The gate could go because `handle_action()` clears the flat keys with the namespaced ones and the trip mirrors them. SAFETY-44 second half: the question is whether a namespaced latch THAT A MACHINE READS is set. An earlier version accepted any `unit/*/sign_tripped`, which blinded the guard on exactly the installs the broken migration had put the latch under `unit/inverter-a/` (a key nothing reads). The first blunt fix (every off-roster latch is litter) re-armed a healthy A off B's mirrored flat key (test_inverter.py DEV-02 restart case). `UNIT_A` is the discriminator: the only name the broken migration wrote. A single flag written for two intents was defeated by one removed unit's key (roster ['garage','shed'] with `unit/old-shed/sign_tripped` drove on a state file reading `sign_tripped: true`; `tools/rehearse_roster.py` exit 1); the fixtures now hold both arrangements. Residual: a first unit really named `inverter-a`, latched and removed from the roster is re-armed on `inverters[0]` (conservative). This guard is the only repair for sites upgraded before the fix. The printed reason is the flat mirror's (whatever tripped last), so on a multi-unit site one inverter's evidence may appear under another's name; test_maintenance.py's check that the reason is not the removed unit's depends on that fixture's flat string, not on this code.
|
||||
- **DEV-02 latch guard (`_check_sign_latch...`).** Runs on every start: gating on `rep.ran` swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip in the window, upgrade; the controller came up untripped while state.json said `sign_tripped: true`; a firmware rollback path was performed on this site on 2026-09-02). The gate could go because `handle_action()` clears the flat keys with the namespaced ones and the trip mirrors them. SAFETY-44 second half: the question is whether a namespaced latch THAT A MACHINE READS is set. An earlier version accepted any `unit/*/sign_tripped`, which blinded the guard on exactly the installs the broken migration had put the latch under `unit/inverter-a/` (a key nothing reads). The first blunt fix (every off-roster latch is litter) re-armed a healthy A off B's mirrored flat key (test_inverter.py DEV-02 restart case). `UNIT_A` is the discriminator: the only name the broken migration wrote. A single flag written for two intents was defeated by one removed unit's key (roster ['garage','shed'] with `unit/old-shed/sign_tripped` drove on a state file reading `sign_tripped: true`; `tools/rehearse_roster.py` exit 1); the fixtures now hold both arrangements. Residual: a first unit really named `inverter-a`, latched and removed from the roster is re-armed on `inverters[0]` (conservative). The printed reason is the flat mirror's (whatever tripped last), so on a multi-unit site one inverter's evidence may appear under another's name; test_maintenance.py's check that the reason is not the removed unit's depends on that fixture's flat string, not on this code.
|
||||
- **Maintenance views (MAINT-05).** `maints` is one FSM per roster entry; `maint`, `maint_unit` and the button target are views of the one that holds the slot (`maintenance.arbitrate()` allows at most one out of IDLE). validate.py rule 6 grades `maintenance_charge_w` against the weakest cap on the roster.
|
||||
- **N=1 views.** Properties, not fields, and they refuse at N>=2 (6.2's identity is false there). Nothing inside main.py may read them (test_inverter.py AST scan).
|
||||
- **`_arm_sign_watchdogs` (SAFETY-30).** Called every cycle because fixtures append an `Inverter` after construction; a detector frozen at construction would be graded against the meter at a roster of two. `arm()` touches no timer or latch.
|
||||
@@ -51,4 +50,4 @@ comments that were shortened but not rewritten here are kept word for word in
|
||||
- **Cycle ordering (SAFETY-02/31/42).** The watchdog evaluations run every cycle, before the input hold, so a run of held cycles cannot leave their timers resuming from a start that predates the gap. Reason first, flag second (each `set` writes the file): a crash between persists a reason nothing reads, not a latch with no explanation. The per-unit trip mirrors the legacy flat keys (store.py rule 2 covered only the latch present at migration time). The site check is flat-keyed, armed only at N>=2. SAFETY-31's input is MEASURED power (`inv.batt`), never the allocation record, which has sign-coherence by construction.
|
||||
- **Maintenance FSMs (MAINT-05).** Each pack is ticked with its own SoC (a minimum would abort a cycle on a sibling's state, an average would declare a pack full that is not); `arbitrate()` runs first. A deferral is carried to the status page because the edge-logged line scrolls out of the 40-row log.
|
||||
- **Aggregates.** SUM for the site bound, achievable rating and the battery power the site loop regulates against (`compute()`'s saturation test compares against the site target; one unit's power trips the freeze permanently, up to 510 W of split-dependence at gain 0.2, test_alloc.py part 8). MINIMUM for the discharge permission (the emptiest pack). MEASURED power for circulating power. Unknown bounds stay unknown when any unit is silent.
|
||||
- **Trade sizing (OPT-09).** `trade_sizing` uses arbitrage's own SoC bounds (`arbitrage_soc_floor` / `_ceiling`), not the BMS cut-out (SAFETY-25): sizing is the trade's business, the pack envelope is the pack's. It is what the site can push and still have somewhere to put; it sizes the ask and does not route it. Neither side may be zero on a trading tick: `arbitrage.decide()` refuses `IDLE_NO_SIZE`, since a 0 W claim is a park order. Weights are a preference, so a pack between its arbitrage floor and its cut-out can still take part of a sell; fixing it needs a per-pack envelope depending on which claim won the tick.
|
||||
- **Trade sizing (OPT-09).** `trade_sizing` uses arbitrage's own SoC bounds, not the BMS cut-out (SAFETY-25): sizing is the trade's business, the pack envelope is the pack's. Weights are a preference, so a pack between its arbitrage floor and its cut-out can still take part of a sell; fixing it needs a per-pack envelope depending on which claim won the tick.
|
||||
|
||||
@@ -21,8 +21,13 @@ _LOG = logging.getLogger("goodwe")
|
||||
class TasksMixin:
|
||||
def _grade_store_migration(self, store) -> None:
|
||||
"""DEV-02. Did SAFETY-02's latch actually survive the key rename?
|
||||
Graded on EVERY start: a latch that silently fails to move is a
|
||||
disarmed safety guard, so a disagreement re-arms it by hand.
|
||||
Graded, not performed: a latch that silently fails to move is a
|
||||
DISARMED SAFETY GUARD on an install that believes it is protected. The
|
||||
pre-DEV-02 flat value is compared with what the roster came up with; a
|
||||
disagreement re-arms the latch by hand (the fail-safe direction is ON).
|
||||
It runs on EVERY start, not only the migrating one: gating on `rep.ran`
|
||||
swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip
|
||||
in the window, upgrade; performed on this site 2026-09-02).
|
||||
DECISIONS/MAIN-orchestration.md."""
|
||||
rep = self.store_migration
|
||||
if rep.ran and not rep.ok:
|
||||
@@ -33,10 +38,16 @@ class TasksMixin:
|
||||
legacy_tripped = bool(data.get("sign_tripped", False))
|
||||
if not legacy_tripped or not self.inverters:
|
||||
return
|
||||
# Is any namespaced latch set THAT A MACHINE READS (SAFETY-44)? Asked
|
||||
# of the STORE's keys, not the roster's objects. On the roster: stop;
|
||||
# off-roster `UNIT_A`: repair onto `inverters[0]`; any other
|
||||
# off-roster name: stop. DECISIONS/MAIN-orchestration.md.
|
||||
# The question is "is any namespaced latch set THAT A MACHINE READS"
|
||||
# (SAFETY-44), asked of the STORE's keys, not the roster's objects:
|
||||
# re-arming A off a flat key that B's trip mirrored would latch a
|
||||
# healthy inverter on every restart. Three cases: on the roster, stop;
|
||||
# off-roster named `UNIT_A` (the broken migration's fingerprint),
|
||||
# repair onto `inverters[0]`; off-roster, other name: stop unless a
|
||||
# stranded `UNIT_A` latch is present. This guard is the ONLY repair for
|
||||
# sites upgraded before the fix. Residual: a first unit really named
|
||||
# `inverter-a`, latched and removed, is re-armed on `inverters[0]`
|
||||
# (conservative). DECISIONS/MAIN-orchestration.md.
|
||||
on_roster = {inv.name for inv in self.inverters}
|
||||
stranded, on_roster_latched, other_off_roster = [], False, False
|
||||
for k, v in data.items():
|
||||
|
||||
@@ -82,10 +82,14 @@ MAINT_CHARGING_W = 50.0
|
||||
|
||||
class TradeMixin:
|
||||
def trade_power_w(self, floor_pct, ceiling_pct) -> tuple:
|
||||
"""OPT-09. `(charge_w, discharge_w)`: how hard a trade may be SIZED,
|
||||
on arbitrage's own SoC bounds, not the BMS cut-out. A pack whose SoC
|
||||
did not read counts on neither side; neither side may be zero on a
|
||||
trading tick (`IDLE_NO_SIZE`). DECISIONS/MAIN-orchestration.md."""
|
||||
"""OPT-09. `(charge_w, discharge_w)`: how hard a trade may be SIZED:
|
||||
what the site can push AND STILL HAVE SOMEWHERE TO PUT. The bounds are
|
||||
arbitrage's own (`arbitrage_soc_floor` / `_ceiling`), NOT the BMS
|
||||
cut-out that `packshare.envelope()` uses (SAFETY-25): different
|
||||
questions. It sizes the ask, it does not route it. A pack whose SoC did
|
||||
not read counts on neither side. Neither side may be zero on a trading
|
||||
tick (`arbitrage.decide()` refuses `IDLE_NO_SIZE`: a 0 W claim is a
|
||||
park order). DECISIONS/MAIN-orchestration.md."""
|
||||
# The reduction lives in `packshare`; `test_packshare.py` part 9 calls
|
||||
# the SAME function (a transcription in a check is a copy that drifts).
|
||||
return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct)
|
||||
|
||||
@@ -288,8 +288,12 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||
self.log_event("maintenance history did not move over cleanly: "
|
||||
+ self.maint_migration.explain(),
|
||||
hint=messages.hint("maint_history"))
|
||||
# MAINT-05: one config, N machines: the options describe the
|
||||
# PROCEDURE; the SCHEDULE and HISTORY are per pack, in the store.
|
||||
# MAINT-05: one config, N machines: the `maintenance_*` options
|
||||
# describe the PROCEDURE (same on every pack); the SCHEDULE and HISTORY
|
||||
# are per pack, in the store. A per-entry config block would be a
|
||||
# second shape for CFG-04 and a fifth place for `maintenance_charge_w`
|
||||
# to disagree with config.yaml; two procedures would be a roster-schema
|
||||
# card.
|
||||
_maint_cfg = MaintConfig.from_options(opts)
|
||||
_one_pack = len(self.inverters) <= 1
|
||||
self.maints = [
|
||||
@@ -307,9 +311,12 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||
tz=site_tz) # MAINT-10: the start hour's zone, from amain()
|
||||
for i, inv in enumerate(self.inverters)]
|
||||
|
||||
# SAFETY-02: one watchdog per actuator, restored from ITS OWN
|
||||
# namespaced keys and graded on every start.
|
||||
# DECISIONS/MAIN-orchestration.md.
|
||||
# SAFETY-02: one watchdog per actuator, each restored from ITS OWN
|
||||
# namespaced store keys (a trip on B latches B, A keeps commanding).
|
||||
# Restored, because a latch held only in memory is cleared by the
|
||||
# restart an operator tries first. Graded on every start: a
|
||||
# disagreement with the flat value re-arms by hand and shouts (the
|
||||
# fail-safe direction is ON). DECISIONS/MAIN-orchestration.md.
|
||||
self._grade_store_migration(store)
|
||||
for inv in self.inverters:
|
||||
if inv.sign_wd.tripped:
|
||||
@@ -329,9 +336,12 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||
hint=messages.hint("stop_restored",
|
||||
unit=inv.name))
|
||||
|
||||
# SAFETY-42 + SAFETY-34: a latch that does NOT act, restored so the
|
||||
# ALARM survives; `_delivery_announced` is per process, so a restart
|
||||
# clears the flag and keeps the latch. DECISIONS/MAIN-orchestration.md.
|
||||
# SAFETY-42 + SAFETY-34: a latch that does NOT act, unlike SAFETY-02's
|
||||
# and SAFETY-31's (which command 0 W). It is restored so the ALARM
|
||||
# survives (2026-09-02: an outage unseen for 6 h 25 m with the detector
|
||||
# correct); `delivery.py` argues reporting rather than acting.
|
||||
# `_delivery_announced` is per process (as `_circ_announced`): a
|
||||
# restart clears the flag and keeps the latch.
|
||||
self._delivery_announced = set()
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
@@ -348,9 +358,12 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||
hint=messages.hint("not_delivering",
|
||||
unit=inv.name))
|
||||
|
||||
# SAFETY-30: the SITE half of the sign check: armed only at N>=2 (at
|
||||
# one inverter it is constructed and never evaluated), restored with
|
||||
# FLAT keys. DECISIONS/SAFETY-30-per-unit-sign.md.
|
||||
# SAFETY-30: the SITE half of the sign check, one per site, armed only
|
||||
# at N>=2: the SUM of the standing commands against P1. At a roster of
|
||||
# one it is constructed and never evaluated (arming it would duplicate
|
||||
# `inverters[0].sign_wd`: two latches, two banners, a false-positive
|
||||
# record spent twice); `cycle()` enforces it. Restored from the store
|
||||
# with FLAT keys (store.py). DECISIONS/SAFETY-30-per-unit-sign.md.
|
||||
self.site_sign_wd = SignWatchdog(
|
||||
self.site_sign_cfg,
|
||||
tripped=bool(store.data.get("site_sign_tripped", False)),
|
||||
@@ -372,12 +385,15 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||
self._arm_sign_watchdogs()
|
||||
|
||||
# SAFETY-18: the frozen-but-fresh meter detector (freeze.py). NOT
|
||||
# restored: it does not act. DECISIONS/MAIN-orchestration.md.
|
||||
# restored from the store: it does not act, so it does not latch; a
|
||||
# restart re-derives it within one `meter_freeze_s`.
|
||||
self.freeze_wd = FreezeWatchdog(
|
||||
FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300))))
|
||||
|
||||
# SAFETY-31: one per site, RESTORED because it acts, with SITE-level
|
||||
# keys. DECISIONS/MAIN-orchestration.md.
|
||||
# SAFETY-31: one per site, RESTORED (unlike SAFETY-18): a trip files a
|
||||
# P_SAFETY SET that stops every inverter, and a cleared latch would
|
||||
# restart straight into the loop it stopped. SITE-level keys, not
|
||||
# namespaced: circulating power is defined over the roster (store.py).
|
||||
self.circ_wd = CirculationWatchdog(
|
||||
CirculationConfig(
|
||||
min_w=float(opts.get("circulation_w",
|
||||
|
||||
@@ -40,8 +40,11 @@ def why_key(winner: str, claims) -> str:
|
||||
def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
|
||||
provider: bool, max_age_s: float) -> str:
|
||||
"""The *Tariff* status row: the 30 s tick's sentence AND the provider plan
|
||||
(TARIFF-11), each with its own clock. "No plan" is said aloud on a site
|
||||
that configured a provider. DECISIONS/MAIN-orchestration.md."""
|
||||
(TARIFF-11): two sources, two clocks, and the row says so. The plan is
|
||||
stamped with its own age from `tariff_at` (not moved on a kept plan,
|
||||
TARIFF-12), up to TARIFF_MAX_AGE_S (5400 s); `age_s` is the caller's
|
||||
measurement. "No plan" is said aloud on a site that configured a provider.
|
||||
DECISIONS/MAIN-orchestration.md."""
|
||||
# UX-08: plain words, every figure kept. The tick's sentence is "now"; the
|
||||
# plan carries its own age.
|
||||
if plan is None:
|
||||
@@ -55,9 +58,12 @@ def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
|
||||
|
||||
def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
|
||||
why: str) -> str:
|
||||
"""The *Arbitrage* status row: off, armed with no decision this cycle
|
||||
(`why` is the loop's reason), or a decision with its age (TARIFF-11).
|
||||
`on` is read from the options, never inferred from `decision is None`.
|
||||
"""The *Arbitrage* status row: three states (TARIFF-11): off (not armed),
|
||||
armed with no decision this cycle (`why` is the loop's reason), or a
|
||||
decision with its age.
|
||||
`on` is read from the options, never inferred from `decision is None`:
|
||||
`cycle()` returns at the input hold before `self.arb = None`, so the old
|
||||
row named an option that was ON. The age is always printed, even 0.
|
||||
DECISIONS/MAIN-orchestration.md."""
|
||||
# UX-08: plain words; the setting is named by its label.
|
||||
if not on:
|
||||
|
||||
Reference in New Issue
Block a user