Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b6fa35f5 | ||
|
|
902da3f723 | ||
|
|
27a867f34b | ||
|
|
66952beb53 | ||
|
|
d3504fbbe7 | ||
|
|
3939211176 | ||
|
|
f7c2d45957 | ||
|
|
058ecb7caa | ||
|
|
fb0730fe6f | ||
|
|
1b6bb05fb4 | ||
|
|
0d49027daa | ||
|
|
56fc41fffa | ||
|
|
bbbc89b235 | ||
|
|
f8df7cd860 | ||
|
|
f5714248f6 | ||
|
|
40ef27c367 | ||
|
|
b05ad2b273 | ||
|
|
31d13ee8bb | ||
|
|
47e99a37bc | ||
|
|
d298f8aa4f | ||
|
|
391c4b4411 | ||
|
|
6f94944e44 | ||
|
|
56d5532b83 | ||
|
|
9ec0a84129 | ||
|
|
6492d179d6 | ||
|
|
f114a19dcc | ||
|
|
a5fec377ad | ||
|
|
8fc5f15b03 | ||
|
|
c2b4302dcf | ||
|
|
2bbf88862a | ||
|
|
3d31b679eb | ||
|
|
6889667fcd | ||
|
|
0fb4c2b5e2 |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
# MAIN - why app/main.py is built the way it is
|
||||
|
||||
**Status: SHIPPED.** Written down by MAINT-20 part 2 from the long comments and
|
||||
docstrings of `app/main.py`, so the module can keep one line and a pointer. The
|
||||
comments that were shortened but not rewritten here are kept word for word in
|
||||
[[MAIN-orchestration-archive]], by their line in `app/main.py` at release/1.0
|
||||
`d4c01da`. Since MAINT-21 the Controller's methods are split by job into
|
||||
`app/main.py` and `app/ctl_*.py`; a comment keeps its text and moves with its code. Related: [[SAFETY-07-peak-guard]], [[FCST-04-the-day-that-is-carried]],
|
||||
[[SAFETY-30-per-unit-sign]], [[SAFETY-73-refusal-per-inverter]],
|
||||
[[SAFETY-121-bound-every-quarter]], [[TARIFF-10-fetching-is-not-trading]],
|
||||
[[TARIFF-24-modes-need-the-tariff-task]], [[OPT-34-the-night-charge-keeps-its-level]].
|
||||
|
||||
## 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, 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"]`.
|
||||
- **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; 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.
|
||||
|
||||
## Cycle state and latch repair
|
||||
|
||||
- **Allocation record (SAFETY-28).** The tick that mints a record increments once per resolution, not per loop iteration. A cycle with no resolution (the "inputs missing, not yet stale" hold) must not bump it: that hold returns before the allocator so one missed poll does not zero the inverter, and the heartbeat re-affirms `self.target` through the write path; bumping would make that heartbeat unauthorised and drop the site to 0 W. The record is superseded, never expired.
|
||||
- **`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). 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.
|
||||
- **Per-inverter refusal (SAFETY-73).** The probe ask is PROBE_W, not `saturation_w`: a unit's share is often small (a 400 W night load split two ways) and SAFETY-43 would name the empty battery as not delivering. The site target is restarted from the watts on the wire when a unit reopens (phase A: -1059 W).
|
||||
|
||||
## Write path and cycle
|
||||
|
||||
- **The write gate (SAFETY-28).** specs 1.5 makes two writers on one actuator a non-goal. `len(validate.ACTUATOR_WRITE_KEYS) == 1` is a fact about the options schema, not the writes, and goes false when CFG-04 adds per-inverter config. So the enforcement is on the write: a number reaches the wire only if the single allocator placed it, on this tick, for this unit (`Allocation.authorises()`); a second writer must file a Claim. 0 W is always authorised and the test for zero comes first and reads nothing: the failsafe branches write 0 W while the allocator has refused (`Allocation.ok` False, no shares), and a gate asking its permission would deadlock the failsafe behind the component it answers; at S = 0 broadcast and allocation coincide, so there is no over-command (the hazard is N-times loop gain from broadcast, TEST-19's `N=2 gain 1.0` limit cycle). The predicate is `value == 0.0`, not a `failsafe=True` flag, which a second writer would pass; -0.0 == 0.0 and a nan never gets here. DEV-02: the unit is an argument, not the N=1 view; the default refuses at N>=2. The refusal sits above the clamp and below the non-finite coercion.
|
||||
- **Clamp (SAFETY-03)** is on the only path to the wire, so no optimizer, maintenance or manual path routes around it. A non-finite value is loss of input (11.1), coerced to 0 W. Every engagement goes to the action log undeduplicated; the notification is deduplicated.
|
||||
- **Shadow (SAFETY-10)** suppresses in the same method, below the gate and the clamp, as a substitution not a return. It is not a second writer.
|
||||
- **Reads (DEV-02, TEL-12).** Each inverter's readings are gathered concurrently across units and sequentially within one; PV entities ride in the same gather (a slow sensor must not push a healthy battery toward layer 1's veto). P1 has no inverter-side fallback: the inverter's AC power correlates 0.998 with battery power and 0.09 with the real meter.
|
||||
- **Stale reading (SAFETY-37/56/57).** The power reading goes through the freshness gate, the SoC does not (the power feeds the sign and delivery checks, which a frozen register fools). SAFETY-57 rewrote the escape clause: "it clears itself when the register reports a different value" was false for the state announced, because the 0 W it announces is what stops the value changing. The release is reported, never silent (it withdraws a SITE STOP).
|
||||
- **TARIFF-03.** The one shiftable load is the maintenance cycle's cheap-window top-up; the static window is synchronous and I/O-free, and validate.py refuses `static_profile` together with the price entities.
|
||||
- **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, 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.
|
||||
@@ -709,7 +709,7 @@ rather than smuggled in.
|
||||
**Also still not in this card, and named by the shipped code already:** the
|
||||
charge *ceiling* is the mirror image — `pack_floor_soc` is the wrong reduction
|
||||
for it, because a ceiling is the **fullest** pack and `arbitrage`'s `soc_ceiling`
|
||||
takes one number. `main.py:932-936` says so, and it belongs to CFG-04/SPIKE-04's
|
||||
takes one number. The `Controller.pack_floor_soc` docstring in `app/main.py` says so, and it belongs to CFG-04/SPIKE-04's
|
||||
per-pack bound. Nothing here touched it.
|
||||
|
||||
## 6. What was changed, and one thing that was found on the way
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
"""Controller methods that raise and clear the alarms.
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import allocator
|
||||
from . import control as _ctl
|
||||
from .circulation import opposing
|
||||
from .clamp import (
|
||||
REASON_CODE as CLAMP_REASON, delivery_short, shortfall_note,
|
||||
write_clamp_note,
|
||||
)
|
||||
from . import messages
|
||||
from .mqtt import unit_slugs
|
||||
from . import peakguard
|
||||
from .price import brussels
|
||||
from .price_chain import fixed_tariff_beside_service
|
||||
from .ctl_writes import REFUSED_SPAN_S
|
||||
from .ctl_inputs import _mono
|
||||
|
||||
_LOG = logging.getLogger("goodwe")
|
||||
|
||||
|
||||
# SAFETY-34: a Core-only restart wipes persistent_notification without
|
||||
# restarting this add-on, so a latched alarm is re-asserted this often.
|
||||
REANNOUNCE_S = 300.0
|
||||
|
||||
|
||||
# SAFETY-85: a battery another owner set is graded against the least it was
|
||||
# told over the plant's settle time, so a battery still settling is not short.
|
||||
SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S
|
||||
|
||||
|
||||
# SAFETY-119/121: after a hand-back the loop's bound is hidden for
|
||||
# HANDBACK_CYCLES / SHORT_SPAN_S, and across hand-backs on at most
|
||||
# ROLLING_BOUND_CYCLES cycles per ROLLING_S
|
||||
# (DECISIONS/SAFETY-121-bound-every-quarter.md). Without a monotonic clock the
|
||||
# budget never refills.
|
||||
HANDBACK_CYCLES = 4
|
||||
|
||||
|
||||
ROLLING_S = 900.0
|
||||
|
||||
|
||||
ROLLING_BOUND_CYCLES = 11
|
||||
|
||||
|
||||
class AlarmsMixin:
|
||||
def _loop_alarm_input(self, resolution) -> None:
|
||||
"""SAFETY-85. The loop's clamp counts only on a cycle the loop decided
|
||||
(a rating may cut it). A battery another owner set to discharge counts
|
||||
when it gave less than it was told for `saturation_cycles` cycles in a
|
||||
row. DECISIONS/SAFETY-85-no-false-not-keeping-up.md"""
|
||||
d = self._loop_decision
|
||||
if d is None:
|
||||
self._short_run = 0
|
||||
self._asked_hist = {}
|
||||
self._wire_lost, self._handback = False, None
|
||||
return
|
||||
unmet = self.allocation.unmet_w if self.allocation is not None else 0
|
||||
cut = (resolution.bound_by or "").split("(")[0]
|
||||
owns = (resolution.winner == "loop" and not d.held and not unmet
|
||||
and (resolution.target_w == d.target_w
|
||||
or cut in ("device", "supervised")))
|
||||
# Graded against the least that stood on the wire (0 W in shadow
|
||||
# mode) over SHORT_CYCLES cycles and SHORT_SPAN_S seconds.
|
||||
now, asked = self._now_mono, {}
|
||||
for inv in self.inverters:
|
||||
h = self._asked_hist.setdefault(inv.name, [])
|
||||
h.append((now, inv.standing_w()))
|
||||
while len(h) > SHORT_CYCLES and (
|
||||
now is None or h[0][0] is None
|
||||
or now - h[0][0] > SHORT_SPAN_S):
|
||||
h.pop(0)
|
||||
asked[inv.name] = min(w for _t, w in h)
|
||||
others = [inv for inv in self.inverters if not owns
|
||||
or allocator.pin_w(resolution.unit_bounds.get(inv.name))
|
||||
is not None]
|
||||
short = [(inv, asked[inv.name], inv.batt) for inv in others
|
||||
if inv.batt is not None
|
||||
and delivery_short(asked[inv.name], inv.batt,
|
||||
self.tuning.saturation_w)]
|
||||
self._short_run = self._short_run + 1 if short else 0
|
||||
settling = self._settling(owns, now)
|
||||
if owns and self.loop_clamped[0] and not (
|
||||
settling and not self.loop_clamped[2]):
|
||||
return
|
||||
engaged = self._short_run >= self.tuning.saturation_cycles
|
||||
named = len(self.inverters) > 1
|
||||
self.loop_clamped = (engaged, shortfall_note(
|
||||
[(inv.name if named else None, a, g) for inv, a, g in short])
|
||||
if engaged else "", False)
|
||||
self.loop_clamp_facts = ()
|
||||
|
||||
def _settling(self, owns, now) -> bool:
|
||||
"""SAFETY-119: does the loop's bound on this cycle count? Not within a
|
||||
settle (HANDBACK_CYCLES cycles and SHORT_SPAN_S) after another owner
|
||||
handed the wire back; across all hand-backs the bound is hidden on at
|
||||
most ROLLING_BOUND_CYCLES cycles in any ROLLING_S.
|
||||
DECISIONS/SAFETY-121-bound-every-quarter.md"""
|
||||
def aged(t0, span):
|
||||
return now is None or t0 is None or now - t0 >= span
|
||||
|
||||
if not owns:
|
||||
self._wire_lost, self._handback = True, None
|
||||
return False
|
||||
if self._wire_lost:
|
||||
self._wire_lost, self._handback = False, (now, 0)
|
||||
if self._handback is None:
|
||||
return False
|
||||
tc, n = self._handback
|
||||
n += 1
|
||||
self._handback = (tc, n)
|
||||
if aged(tc, SHORT_SPAN_S) and n >= HANDBACK_CYCLES:
|
||||
self._handback = None
|
||||
return False
|
||||
if self.loop_clamped[0] and not self.loop_clamped[2]:
|
||||
kept = tuple(t for t in self._hidden if now is None or (
|
||||
t is not None and now - t < ROLLING_S))
|
||||
if len(kept) >= ROLLING_BOUND_CYCLES:
|
||||
self._hidden = kept
|
||||
return False
|
||||
self._hidden = kept + (now,)
|
||||
return True
|
||||
|
||||
async def check_output_clamp(self) -> None:
|
||||
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle. Runs
|
||||
AFTER write_setpoint() (the clamp is arithmetic done inside the write).
|
||||
⚠️ PUBLISHING IS NOT IN THE WRITE PATH: a stalled supervisor must never
|
||||
delay or drop a frame. ⚠️ THIS IS NOT A TRIP: no claim, no latch, 0 W
|
||||
is not commanded (§11.2: clamp and alarm); turning it into one is a
|
||||
deliberate change. (DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
# ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT: `clamp_last` is on
|
||||
# the Inverter, `ClampWatchdog` is one per controller (an anti-flood
|
||||
# counter), so any unit that clamped is an engagement and the reason
|
||||
# names it. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
clamped_units = [inv for inv in self.inverters
|
||||
if inv.clamp_last is not None and inv.clamp_last.engaged]
|
||||
engaged_write = bool(clamped_units)
|
||||
engaged_loop, loop_note, loop_bug = self.loop_clamped
|
||||
engaged = engaged_write or engaged_loop
|
||||
# SAFETY-40: only an over-rating is a bug; the integrator bound is not.
|
||||
bug = engaged_write or loop_bug
|
||||
if engaged_write:
|
||||
_named = len(self.inverters) > 1
|
||||
reason = write_clamp_note([(inv.name if _named else None,
|
||||
inv.clamp_last)
|
||||
for inv in clamped_units])
|
||||
facts = [messages.Clamp(inv.name if _named else None,
|
||||
inv.clamp_last.requested_w,
|
||||
inv.clamp_last.applied_w,
|
||||
inv.clamp_last.bound)
|
||||
for inv in clamped_units]
|
||||
else:
|
||||
reason = loop_note if engaged else ""
|
||||
facts = self.loop_clamp_facts if engaged else ()
|
||||
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc),
|
||||
bug=bug, facts=facts)
|
||||
|
||||
# THE ACTION LOG IS COALESCED PER EPISODE: consecutive engagements
|
||||
# update one row's count. ⚠️ NOT COSMETIC: the log keeps 40 rows and a
|
||||
# winding loop clamps every cycle, so a row each flushed SAFETY-18's
|
||||
# "METER FROZEN?" row. Here as well as in write_setpoint() because on a
|
||||
# default install the actuator-side clamp is unreachable.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if engaged and not engaged_write:
|
||||
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
|
||||
row = (f"{tag}: "
|
||||
f"{messages.count(self.clamp_wd.engagements, 'engagement')}"
|
||||
f" - {reason}")
|
||||
if self.events and tag in self.events[0]:
|
||||
stamp = self.events[0].split(" ", 1)[0]
|
||||
self.events[0] = f"{stamp} {row}"
|
||||
else:
|
||||
self.log_event(row, hint=messages.hint(
|
||||
"power_too_high" if bug else "not_keeping_up"))
|
||||
if not fired:
|
||||
return
|
||||
if not self.clamp_wd.bug:
|
||||
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
|
||||
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
|
||||
# it can never replace a BUG SIGNAL notice still on screen.
|
||||
_LOG.warning("The battery gives less than asked: %s. Held back %s "
|
||||
"so far.", messages.reason_code(self.clamp_wd.reason),
|
||||
messages.times(self.clamp_wd.engagements))
|
||||
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
|
||||
_LOG.debug("A short hold after a large load or solar step is "
|
||||
"normal. If it lasts, check that the battery follows "
|
||||
"its commands.")
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.loop_bound().data())
|
||||
return
|
||||
_LOG.error(
|
||||
"Power request too high, a software fault: %s. Cut back %s so "
|
||||
"far, control runs on.", messages.reason_code(self.clamp_wd.reason),
|
||||
messages.times(self.clamp_wd.engagements))
|
||||
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-03: silently clipping this is how a winding integrator "
|
||||
"reached 14 768 W on this project before. The clamp makes every "
|
||||
"dashboard read a legal number while the thing producing it goes "
|
||||
"on climbing. This is an alarm, not a trip.")
|
||||
# A fixed notification_id, for the reason check_meter_frozen() gives -
|
||||
# and a DIFFERENT one from every other alarm this add-on raises,
|
||||
# because a clamp is not a frozen meter and neither is a saturation
|
||||
# freeze. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.output_clamp(self.clamp_wd.facts,
|
||||
shadow=self.shadow).data())
|
||||
|
||||
async def check_peak_saturated(self) -> None:
|
||||
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter. ⚠️ An
|
||||
engagement is not an alarm: a peak floor above the rating is a house
|
||||
load bigger than the inverter, not a defect (unlike SAFETY-03's winding
|
||||
integrator). SAFETY-81: `g.saturated` must agree with
|
||||
`peakguard.load_too_high()` for `ALARM_HOLD_S`. Edge-triggered: one
|
||||
notification per episode, at most once per quarter.
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
g = self.peak_guard
|
||||
batt, prev = self.site_batt_w, self._peak_batt_prev
|
||||
if batt is None:
|
||||
# Unknown, not "fine": the hold below neither grows nor restarts.
|
||||
return
|
||||
self._peak_batt_prev = batt
|
||||
if prev is not None:
|
||||
batt = min(float(batt), float(prev))
|
||||
p = self._peak_proj
|
||||
# OPT-15 x SAFETY-81: until the new quarter's first sample the old
|
||||
# projection read "no seconds left" (not high), which restarted the
|
||||
# hold; a rolled one (battery modes on) is not graded then, so a
|
||||
# persistent overload is still notified once per quarter.
|
||||
high = bool(
|
||||
g is not None and g.saturated and p is not None
|
||||
and p.block_start is not None and not self._peak_rolled
|
||||
and peakguard.load_too_high(
|
||||
p, batt_w=batt, ceiling_w=g.ceiling_w,
|
||||
achievable_w=self.site_rating_w))
|
||||
# SAFETY-81: and for ALARM_HOLD_S, on the projection's clock.
|
||||
saturated = False
|
||||
if high:
|
||||
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
|
||||
if self._peak_high_since is None:
|
||||
self._peak_high_since = t
|
||||
saturated = ((t - self._peak_high_since).total_seconds()
|
||||
>= peakguard.ALARM_HOLD_S)
|
||||
else:
|
||||
self._peak_high_since = None
|
||||
was, self._peak_saturated = self._peak_saturated, saturated
|
||||
if not saturated or was:
|
||||
return
|
||||
_LOG.error(
|
||||
"Switch off large loads, the battery alone cannot keep this "
|
||||
"15-minute period under the peak limit: %s.",
|
||||
messages.reason_code(g.reason))
|
||||
_LOG.debug("Evidence: %s.", g.reason)
|
||||
# SAFETY-81: notified once per quarter; a later episode is logged only.
|
||||
if p.block_start == self._peak_alarm_block:
|
||||
return
|
||||
self._peak_alarm_block = p.block_start
|
||||
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
|
||||
hint=messages.hint("peak_saturated"))
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
||||
# is not a frozen meter and is not an out-of-range setpoint.
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.peak_saturated(
|
||||
g.ceiling_w, shadow=self.shadow).data())
|
||||
|
||||
async def check_peak_floor_limited(self) -> None:
|
||||
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving. ⚠️
|
||||
FAILING TO SHAVE MUST NOT BE SILENT (the capacity tariff bills the
|
||||
month's worst quarter). Not check_peak_saturated(): that is shed-load,
|
||||
this is nothing discharged; never both. ⚠️ SAFETY-25: the message says
|
||||
the floor binds this guard only; the loop has no SoC floor and goes on
|
||||
discharging. Edge-triggered. (DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
g = self.peak_guard
|
||||
limited = bool(g is not None and g.floor_limited)
|
||||
if limited and (g.floor_w or 0.0) > 0.0:
|
||||
limited = self._reserve_short(g)
|
||||
else:
|
||||
self._reserve_batt_prev = self._reserve_high_since = None
|
||||
# SAFETY-126: nothing can give, so it alarms at once, but only
|
||||
# when the quarter really misses the aim (`_peak_landing()`).
|
||||
limited = limited and not g.within_aim
|
||||
was, self._peak_floor_limited = self._peak_floor_limited, limited
|
||||
if not limited or was:
|
||||
return
|
||||
# No prefix: `reason` already opens with PEAK_GUARD_FLOOR_LIMITED, and
|
||||
# a second copy is the kind of stutter that makes a 40-line action log
|
||||
# read like boilerplate. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
|
||||
_LOG.error(
|
||||
"Switch off large loads, the peak guard may not draw the battery "
|
||||
"lower: %s.", messages.reason_code(g.reason))
|
||||
_LOG.debug("Evidence: %s.", g.reason)
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. ⚠️ SAFETY-25's
|
||||
# sentence (the reserve binds the peak guard only) is in
|
||||
# messages.peak_reserve() on every branch, unreadable SoC included.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.peak_reserve(
|
||||
g.ceiling_w, soc_unread=g.soc is None,
|
||||
floor_unknown=(g.soc is not None
|
||||
and g.soc_floor is None),
|
||||
packs=(g.held if len(self.inverters) > 1
|
||||
else ()),
|
||||
others=(g.floor_w or 0.0) > 0.0).data())
|
||||
|
||||
def _reserve_short(self, g) -> bool:
|
||||
"""SAFETY-87: the batteries that can still give, at full power, miss
|
||||
the limit (`load_too_high()` on `g.able_w`), for ALARM_HOLD_S, as
|
||||
SAFETY-81 grades 'Load too high'."""
|
||||
p, batt = self._peak_proj, self.site_batt_w
|
||||
if batt is None:
|
||||
return self._peak_floor_limited
|
||||
prev, self._reserve_batt_prev = self._reserve_batt_prev, batt
|
||||
if prev is not None:
|
||||
batt = min(float(batt), float(prev))
|
||||
if not (p is not None and p.block_start is not None
|
||||
and peakguard.load_too_high(p, batt_w=batt,
|
||||
ceiling_w=g.ceiling_w,
|
||||
achievable_w=g.able_w)):
|
||||
self._reserve_high_since = None
|
||||
return False
|
||||
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
|
||||
if self._reserve_high_since is None:
|
||||
self._reserve_high_since = t
|
||||
return ((t - self._reserve_high_since).total_seconds()
|
||||
>= peakguard.ALARM_HOLD_S)
|
||||
|
||||
async def check_circulating(self) -> None:
|
||||
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. ⚠️
|
||||
THE LATCH IS NOT THE ALARM: the notification is in memory, so
|
||||
`_circ_announced` (per-PROCESS, not persisted) makes a restart re-raise
|
||||
it, and REANNOUNCE_S re-asserts it after a Core-only restart. ⚠️ ONE
|
||||
CODE PATH for a fresh trip and a restart into one.
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
if not self.circ_wd.tripped:
|
||||
return
|
||||
if self._circ_announced and not self._reannounce_due("goodwe_circulating_power"):
|
||||
return
|
||||
# Set BEFORE the await. `hass.call()` can raise or hang, and a retry
|
||||
# every cycle for a broker that is down would be an alarm storm on the
|
||||
# event bus on top of a site that is already stopped.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
self._circ_announced = True
|
||||
self._announced_mono["goodwe_circulating_power"] = _mono()
|
||||
names = self.units
|
||||
pos, neg = opposing([inv.batt for inv in self.inverters],
|
||||
min_w=self.circ_wd.cfg.min_w / 2.0)
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# this add-on raises - check_meter_frozen() gives the reasoning.
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.circulating(
|
||||
[names[i] for i in pos] if pos and neg else (),
|
||||
[names[i] for i in neg] if pos and neg else (),
|
||||
).data())
|
||||
|
||||
def _reannounce_due(self, notification_id: str) -> bool:
|
||||
"""SAFETY-34: has this latched alarm gone REANNOUNCE_S without being re-asserted?"""
|
||||
t = self._announced_mono.get(notification_id)
|
||||
return t is None or _mono() - t >= REANNOUNCE_S
|
||||
|
||||
async def check_not_delivering(self) -> None:
|
||||
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart. ⚠️
|
||||
THE LATCH IS NOT THE ALARM: the notification is in memory, so
|
||||
`_delivery_announced` (a per-PROCESS set of unit names, not persisted)
|
||||
makes a restart re-raise it, and REANNOUNCE_S re-asserts it. ⚠️ ONE
|
||||
CODE PATH for a fresh trip and a restart into one. ⚠️ ONE ID PER UNIT
|
||||
(`unit_slugs()`): two wedged inverters give two cards.
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
names = self.units
|
||||
slugs = unit_slugs(names)
|
||||
for inv, slug in zip(self.inverters, slugs):
|
||||
nid = f"goodwe_not_delivering_{slug}"
|
||||
if not inv.delivery_wd.tripped:
|
||||
continue
|
||||
if inv.name in self._delivery_announced and not self._reannounce_due(nid):
|
||||
continue
|
||||
# Added BEFORE the await, for `check_circulating()`'s reason:
|
||||
# `hass.call()` can raise or hang, and a retry every cycle for a
|
||||
# broker that is down would be an alarm storm on the event bus.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
self._delivery_announced.add(inv.name)
|
||||
self._announced_mono[nid] = _mono()
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.not_delivering(inv.name, slug).data())
|
||||
|
||||
async def check_price_gap(self) -> None:
|
||||
"""PRICE-01. One notification when no price covers now, gone when one
|
||||
does. (DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
if self.price_gap and not self._price_gap_announced:
|
||||
self._price_gap_announced = True
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.no_prices(*self._gap_args()).data())
|
||||
elif not self.price_gap and self._price_gap_announced:
|
||||
self._price_gap_announced = False
|
||||
await self.hass.call("persistent_notification", "dismiss",
|
||||
messages.prices_back().data())
|
||||
|
||||
async def check_fixed_note(self) -> None:
|
||||
"""PRICE-01 G1. First cycle only: one INFO line when `static_profile`
|
||||
sits beside `price_service` (the plan's last fallback, not a fault, so
|
||||
no notification). (DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
if self._fixed_note_said:
|
||||
return
|
||||
self._fixed_note_said = True
|
||||
if fixed_tariff_beside_service(self.o):
|
||||
_LOG.info(messages.FIXED_TARIFF_LOG)
|
||||
try:
|
||||
await self.hass.call("persistent_notification", "dismiss",
|
||||
messages.fixed_tariff_card_gone().data())
|
||||
except Exception: # noqa: BLE001 - housekeeping never stops a cycle
|
||||
_LOG.debug("Could not take down the old fixed-tariff card.")
|
||||
|
||||
def _gap_args(self) -> tuple:
|
||||
"""`(since, now)` in Brussels time for the gap's words. `since` is None
|
||||
when no price was ever published."""
|
||||
now = brussels(datetime.now(timezone.utc))
|
||||
if self.price_gap_since is None:
|
||||
return None, now
|
||||
return brussels(self.price_gap_since), now
|
||||
|
||||
async def check_meter_frozen(self) -> None:
|
||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||
Notification only: no claim, no latch, no 0 W (freeze.py carries the
|
||||
reasoning). getattr, not an attribute access: `unchanged_s` exists only
|
||||
on `homewizard_local`, and `p1_source` is None until amain() builds the
|
||||
transport. (DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
unchanged = getattr(self.p1_source, "unchanged_s", None)
|
||||
# SITE, all three inputs: one meter, the SITE target the arbiter
|
||||
# resolved, and the rail the SITE could stand at - which is the summed
|
||||
# per-unit conjunction, so a small inverter B lowers the site rail rather
|
||||
# than being graded against inverter A's.
|
||||
fired = self.freeze_wd.evaluate(unchanged, self.target,
|
||||
self.site_max_w,
|
||||
datetime.now(timezone.utc),
|
||||
dev_min=self.site_dev_bound("dev_min"),
|
||||
dev_max=self.site_dev_bound("dev_max"))
|
||||
if not fired:
|
||||
return
|
||||
self.log_event("METER FROZEN? " + self.freeze_wd.reason,
|
||||
hint=messages.hint("meter_frozen"))
|
||||
_LOG.error(
|
||||
"The meter shows the same value while the battery is asked for "
|
||||
"full power, and control still follows it, so check the meter: "
|
||||
"%s.", messages.reason_code(self.freeze_wd.reason))
|
||||
_LOG.debug("Evidence: %s.", self.freeze_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-18: nothing else stops this. The meter still answers, so "
|
||||
"its age is fresh, the firmware watchdog is fed and the arbiter "
|
||||
"only bounds the magnitude. Kept up for a quarter of an hour, it "
|
||||
"sets the month's billed capacity peak.")
|
||||
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
|
||||
# rather than stacking. An operator who has to dismiss eleven identical
|
||||
# notifications learns to dismiss the twelfth without reading it.
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.meter_frozen(unchanged).data())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,424 @@
|
||||
"""Controller methods that read the inputs: meter, packs, PV, forecast.
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .arbiter import actuator_for
|
||||
from . import solarfc
|
||||
from . import freshness
|
||||
from . import lateness
|
||||
from . import messages
|
||||
from . import pv as pv_read
|
||||
from .price import brussels, price_option, provider_configured
|
||||
from .price_chain import uses_chain
|
||||
from .static_tariff import static_status
|
||||
from .rows import tariff_plan_row
|
||||
|
||||
_LOG = logging.getLogger("goodwe")
|
||||
|
||||
|
||||
# UX-11: a quiet meter logs at most one WARNING this often (on `ha_signed` a
|
||||
# healthy meter can go 42-97 s between changes).
|
||||
METER_QUIET_REPEAT_S = 600.0
|
||||
|
||||
|
||||
_mono = time.monotonic # patched by the checks; asyncio keeps the real one
|
||||
|
||||
|
||||
# OPT-04: the background tariff refresh. The tick NEVER awaits a fetch: it
|
||||
# reads `self.tariff_plan`, which the refresh task assigns.
|
||||
# DECISIONS/MAIN-orchestration.md.
|
||||
TARIFF_REFRESH_S = 1800.0
|
||||
|
||||
|
||||
# The age bound covers the refresh task dying: three missed refreshes.
|
||||
# DECISIONS/MAIN-orchestration.md.
|
||||
TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
|
||||
|
||||
|
||||
# OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an
|
||||
# evening restart keeps the night plan's figure.
|
||||
FC_HOLD_KEY = "solar_fc_hold"
|
||||
|
||||
|
||||
FORECAST_READ_S = 60.0
|
||||
|
||||
|
||||
class InputsMixin:
|
||||
def _readings_late(self, standing_w):
|
||||
"""SAFETY-128: update the answer gate; seconds the late verdict has
|
||||
stood, or None. DECISIONS/SAFETY-128-late-readings.md."""
|
||||
a = self.answer
|
||||
if self._now_mono is None:
|
||||
return None
|
||||
a.observe(self._now_mono, standing_w, self.site_batt_w,
|
||||
grid_w=self.grid, exempt=bool(self.batt_refused) or any(
|
||||
i.refused or (
|
||||
i.batt is not None
|
||||
and abs(i.standing_w()) >= lateness.ANSWER_W
|
||||
and abs(i.batt - i.idle_w) < lateness.ANSWER_W)
|
||||
for i in self.inverters),
|
||||
idle_w=self._site_idle_w())
|
||||
if a.late_seen != self._late_logged:
|
||||
self._late_logged = a.late_seen
|
||||
if a.late_seen:
|
||||
self.log_event("readings late - holding",
|
||||
hint=messages.hint("readings_late"))
|
||||
_LOG.warning("Home Assistant readings arrive late, so control "
|
||||
"holds the batteries: READINGS_LATE.")
|
||||
_LOG.debug("READINGS_LATE: the readings show a power request "
|
||||
"%.0f s after it is sent.", a.delay_s)
|
||||
else:
|
||||
self.log_event("readings on time", hint=messages.hint(
|
||||
"readings_back"))
|
||||
_LOG.info("Home Assistant readings are on time again.")
|
||||
return a.late_s(self._now_mono)
|
||||
|
||||
@property
|
||||
def inputs_ok(self) -> bool:
|
||||
"""The site meter, and EVERY pack's own two readings: every pack, not
|
||||
the live ones. A unit whose SoC did not answer cannot be graded for
|
||||
the maintenance FSM or the pack floor, and 11.1 rule 1 for a missing
|
||||
input is command zero and keep commanding."""
|
||||
if self.grid is None or not self.inverters:
|
||||
return False
|
||||
return all(inv.soc is not None and inv.batt is not None
|
||||
for inv in self.inverters)
|
||||
|
||||
def missing_reading(self) -> str:
|
||||
"""UX-11: the first reading `inputs_ok` finds missing, in words."""
|
||||
if self.grid is None or not self.inverters:
|
||||
return "meter"
|
||||
for inv in self.inverters:
|
||||
of = "" if len(self.inverters) == 1 else f" from {inv.name}"
|
||||
if inv.batt is None:
|
||||
return f"battery power{of}"
|
||||
if inv.soc is None:
|
||||
return f"battery level{of}"
|
||||
return "input"
|
||||
|
||||
# -- io ------------------------------------------------------------------
|
||||
@property
|
||||
def pv_roster(self) -> tuple:
|
||||
"""Every configured PV production entity, or `()` (TEL-11/TEL-12). ONE
|
||||
reading of the options, `pv.entities()`, the same call
|
||||
`mqtt.optional_keys()` makes, so what is ANNOUNCED, READ and CHECKED
|
||||
cannot disagree; it replaced a `pv_entity` property."""
|
||||
return pv_read.entities(self.o)
|
||||
|
||||
async def _read_pv(self) -> None:
|
||||
"""This cycle's SITE PV production, or an ABSENCE with a reason
|
||||
(TEL-11/12). `pv.total()` makes "one of three absent" an absence of
|
||||
the SITE figure. It assigns on EVERY call including failing ones (a
|
||||
stale value must not survive a failure), and uses `hass.state()` not
|
||||
`hass.number()` because the UNIT is half of the answer."""
|
||||
# TEL-12: every entity in ONE gather. Read serially, three slow sensors
|
||||
# would add three 10 s timeouts to a cycle that feeds the ESP32
|
||||
# heartbeat; telemetry must have no foot in the control path.
|
||||
roster = self.pv_roster
|
||||
states = await asyncio.gather(
|
||||
*(self.hass.state(entity) for entity in roster))
|
||||
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
|
||||
list(zip(roster, states)), pv_read.inverted(self.o))
|
||||
# UX-54: kept for the Forecast tab only. It must never cost a cycle.
|
||||
try:
|
||||
self.solar_today.add(datetime.now(timezone.utc), self.pv_w)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOG.debug("measured solar not kept", exc_info=True)
|
||||
|
||||
def _peak_fc_unit_log(self, fault) -> None:
|
||||
"""SAFETY-118. One WARNING when the peak forecast's unit cannot be read
|
||||
in watts, and again only if the unit changes; the unit is on DEBUG."""
|
||||
if fault == self._peak_fc_fault:
|
||||
return
|
||||
self._peak_fc_fault = fault
|
||||
if fault is None:
|
||||
_LOG.info("The 15-minute demand forecast is used again.")
|
||||
elif fault == "":
|
||||
_LOG.warning("The 15-minute demand forecast has no unit, so it "
|
||||
"is not used.")
|
||||
else:
|
||||
_LOG.warning("The 15-minute demand forecast is not in a power "
|
||||
"unit, so it is not used.")
|
||||
_LOG.debug("The 15-minute demand forecast reads in %r.", fault)
|
||||
|
||||
async def _read_forecast(self) -> None:
|
||||
"""OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A
|
||||
failed or stale read is no forecast, never a zero."""
|
||||
# OPT-17: every member of both days in one gather.
|
||||
ents = self.fc_entities + self.fc_tomorrow_entities
|
||||
states = await asyncio.gather(*(self.hass.state(e) for e in ents))
|
||||
n = len(self.fc_entities)
|
||||
now = datetime.now(timezone.utc)
|
||||
fc, why, gaps = solarfc.read_sum(
|
||||
list(zip(self.fc_entities, states[:n])),
|
||||
list(zip(self.fc_tomorrow_entities, states[n:])), now,
|
||||
hold=self.fc_hold)
|
||||
if self.fc_hold.changed:
|
||||
self.fc_hold.changed = False
|
||||
self.store.set(FC_HOLD_KEY, self.fc_hold.dump())
|
||||
# A member is named only in a sum of several; one entity reads as
|
||||
# before. Logged once per episode: on a change, never every read.
|
||||
gaps = tuple(g for g in gaps if len(
|
||||
self.fc_entities if g[0] == "today"
|
||||
else self.fc_tomorrow_entities) > 1)
|
||||
was_kept = bool(self.solar_fc is not None and self.solar_fc.kept)
|
||||
if (fc is None) != (self.solar_fc is None) \
|
||||
or why != self.solar_fc_why or gaps != self.solar_fc_gaps \
|
||||
or bool(fc is not None and fc.kept) != was_kept:
|
||||
self.log_event(
|
||||
"solar forecast: " + (
|
||||
"none (" + str(why) + ")" if fc is None else
|
||||
f"read, kept from {brussels(fc.at):%H:%M}" if fc.kept
|
||||
else "read")
|
||||
+ "".join(f", {d} {e} not usable ({w})"
|
||||
for d, _i, e, w in gaps),
|
||||
hint=messages.hint(
|
||||
"forecast_none" if fc is None else
|
||||
"forecast_no_tomorrow" if gaps else "forecast_read"))
|
||||
self.solar_fc, self.solar_fc_why = fc, why
|
||||
self.solar_fc_gaps = gaps
|
||||
|
||||
async def read_inputs(self, now_mono: float | None = None) -> None:
|
||||
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
|
||||
`now_mono` is defaulted rather than read inside `_read_unit()` so
|
||||
ONE instant covers every unit in a gather."""
|
||||
o = self.o
|
||||
now_mono = freshness.monotonic() if now_mono is None else float(now_mono)
|
||||
self._now_mono = now_mono
|
||||
if self.p1_enabled:
|
||||
# P1 is the only authoritative measurement of what the utility sees
|
||||
# (5.1); when stale this is None -> "inputs missing -> command 0
|
||||
# W". NO fallback to an inverter-side figure: its AC power
|
||||
# correlates 0.998 with battery power and 0.09 with the real meter
|
||||
# (a controller would regulate against its own output).
|
||||
self.grid = self.p1.net_w
|
||||
else:
|
||||
self.grid = await self.hass.number(o.get("meter_entity", ""),
|
||||
bool(o.get("meter_invert")))
|
||||
# DEV-02: each inverter's own two readings from its own entities,
|
||||
# CONCURRENTLY across units and sequentially within one (a slow entity
|
||||
# must not delay the heartbeat path). The PV reads ride in the same
|
||||
# gather and an empty PV roster appends nothing (no coroutine).
|
||||
# DECISIONS/MAIN-orchestration.md.
|
||||
_reads = [self._read_unit(inv, now_mono) for inv in self.inverters]
|
||||
if self.pv_roster:
|
||||
_reads.append(self._read_pv())
|
||||
# OPT-11 P4: the solar forecast, in the same gather (a slow sensor must
|
||||
# not delay control), at most once per FORECAST_READ_S.
|
||||
if self.fc_entity is not None and (
|
||||
self._fc_read_mono is None
|
||||
or now_mono - self._fc_read_mono >= FORECAST_READ_S
|
||||
or now_mono < self._fc_read_mono):
|
||||
self._fc_read_mono = now_mono
|
||||
_reads.append(self._read_forecast())
|
||||
await asyncio.gather(*_reads)
|
||||
if o.get("peak_forecast_entity"):
|
||||
# SAFETY-118: read in its own unit like the PV path, one state
|
||||
# read. No unit or a non-power unit: not used, as if the option
|
||||
# were empty.
|
||||
st = await self.hass.state(o["peak_forecast_entity"])
|
||||
self.peak_fc, _why = pv_read.watts(st)
|
||||
# A missing reading leaves the unit-fault state as it was.
|
||||
if pv_read.has_reading(st):
|
||||
self._peak_fc_unit_log(pv_read.unit_fault(st))
|
||||
else:
|
||||
self.peak_fc = None
|
||||
self._peak_fc_fault = None
|
||||
|
||||
now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity")
|
||||
if now_e and avg_e:
|
||||
now_p = await self.hass.number(now_e)
|
||||
avg_p = await self.hass.number(avg_e)
|
||||
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
|
||||
self._price_gated = True
|
||||
self.tariff_words = "Prices from Home Assistant entities"
|
||||
elif uses_chain(o):
|
||||
# PRICE-01. A provider with fallbacks gives the maintenance gate
|
||||
# its own prices: the current 15-minute price against today's
|
||||
# average, as the Home Assistant pair did. No I/O here: the series
|
||||
# is the one the background refresh stored.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
self._provider_gate(datetime.now(timezone.utc))
|
||||
else:
|
||||
# TARIFF-03: 9.2 makes a static day/night tariff a first-class
|
||||
# product and its EMS handling TIME-WINDOW SHIFTING; the one
|
||||
# shiftable load is the maintenance cycle's cheap-window grid
|
||||
# top-up. Synchronous and I/O-free on purpose (the provider-backed
|
||||
# plan is the background task's); validate.py refuses
|
||||
# `static_profile` with the price entities, so the two cannot both
|
||||
# be set.
|
||||
_now = datetime.now(timezone.utc)
|
||||
self.cheap, self.tariff_words = static_status(o, _now)
|
||||
self._price_gated = self._static_gated(o, _now)
|
||||
|
||||
# TARIFF-11: the provider plan reaches the *Tariff* row here, the only
|
||||
# place the two sources meet. `fresh_tariff_plan()` is an attribute
|
||||
# read and an age comparison against TARIFF_MAX_AGE_S: still no fetch
|
||||
# on this path.
|
||||
_tick = datetime.now(timezone.utc)
|
||||
_standing = self.fresh_tariff_plan(_tick)
|
||||
self.tariff_words = tariff_plan_row(
|
||||
_standing, self.tariff_words,
|
||||
age_s=(None if _standing is None
|
||||
else (_tick - self.tariff_at).total_seconds()),
|
||||
horizon_h=float(price_option(o, "price_horizon_h")),
|
||||
provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S)
|
||||
|
||||
# UX-11: the red meter row says "the add-on log shows why", so a quiet
|
||||
# meter gets one WARNING per episode (at most one per
|
||||
# METER_QUIET_REPEAT_S; the rest DEBUG). Log only: no notification,
|
||||
# decides nothing.
|
||||
if self.p1_enabled:
|
||||
if (self.p1.stale and not self._meter_quiet_logged
|
||||
and self.p1.published_age_s > self.p1.max_age_s):
|
||||
self._meter_quiet_logged = True
|
||||
now = _mono()
|
||||
self._meter_quiet_loud = (
|
||||
self._meter_quiet_warned is None
|
||||
or now - self._meter_quiet_warned >= METER_QUIET_REPEAT_S)
|
||||
if self._meter_quiet_loud:
|
||||
self._meter_quiet_warned = now
|
||||
_LOG.warning("No meter reading for over %s, so control "
|
||||
"waits and then sets the battery to 0 W.",
|
||||
messages.seconds(self.p1.max_age_s))
|
||||
else:
|
||||
_LOG.debug("No meter reading for over %s again.",
|
||||
messages.seconds(self.p1.max_age_s))
|
||||
elif not self.p1.stale and self._meter_quiet_logged:
|
||||
self._meter_quiet_logged = False
|
||||
if self._meter_quiet_loud:
|
||||
_LOG.info("Meter readings are back.")
|
||||
else:
|
||||
_LOG.debug("Meter readings are back.")
|
||||
|
||||
self.answer.watch(now_mono, [(inv.name, inv.standing_w(), inv.batt)
|
||||
for inv in self.inverters], # SAFETY-128
|
||||
sum(inv.standing_w() for inv in self.inverters),
|
||||
self.grid)
|
||||
if self.inputs_ok:
|
||||
if self.inputs_bad_since is not None:
|
||||
self.log_event("inputs recovered",
|
||||
hint=messages.hint("inputs_back"))
|
||||
self.inputs_bad_since = None
|
||||
elif self.inputs_bad_since is None:
|
||||
self.inputs_bad_since = datetime.now(timezone.utc)
|
||||
|
||||
def _driver(self, inv):
|
||||
"""`inv`'s driver, wired to this Controller's Home Assistant on first use
|
||||
(SMA-2: bound here, so a unit added later and a swapped `self.hass` are
|
||||
followed). DECISIONS/SMA-2-driver-seam.md."""
|
||||
drv = inv.driver
|
||||
if not drv.bound():
|
||||
drv.bind_getter(lambda: self.hass)
|
||||
return drv
|
||||
|
||||
async def _read_unit(self, inv, now_mono: float) -> None:
|
||||
"""One inverter's own SoC and battery power, from ITS OWN entities.
|
||||
SAFETY-37: the POWER reading goes through the freshness gate and the
|
||||
SoC does not, deliberately: `batt` feeds SAFETY-30's per-unit sign
|
||||
check and the delivery check, which a frozen register would fool.
|
||||
The standing command is asked of the unit, not recomputed."""
|
||||
# SMA-2: the driver reads SoC, then power, as this method always did.
|
||||
# DECISIONS/SMA-2-driver-seam.md.
|
||||
inv.soc, raw, _extra = await self._driver(inv).read()
|
||||
was_stale = inv.batt_fresh.stale
|
||||
was_releases = inv.batt_fresh.releases
|
||||
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
|
||||
# `inv.batt` is the GATED value: a stale register is a MISSING input,
|
||||
# so the site holds for `stale_input_s` then commands 0 W and keeps
|
||||
# commanding (11.1 rule 1).
|
||||
inv.batt = inv.batt_fresh.reading()
|
||||
# SAFETY-97: learn this unit's idle reading, only while control runs
|
||||
# and nothing says the unit is refusing, stale or faulty.
|
||||
if inv.idle.observe(
|
||||
inv.batt, inv.standing_w(), now_mono,
|
||||
bool(self.auto) and inv.batt is not None
|
||||
and not inv.batt_fresh.stale and not inv.refused
|
||||
and not self.batt_refused and not inv.sign_wd.tripped):
|
||||
inv.store_set("idle_w", round(inv.idle.value, 1))
|
||||
if inv.batt_fresh.stale and not was_stale:
|
||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason,
|
||||
hint=messages.hint("battery_stuck", unit=inv.name))
|
||||
self._refusal_from_stale(inv, now_mono)
|
||||
# SAFETY-56/57: the stale-reading line names two explanations once,
|
||||
# in `%s`. The escape clause "clears itself when the register
|
||||
# reports a different value" was false for the state this line
|
||||
# announces (the 0 W it announces is what stops the value
|
||||
# changing), so SAFETY-57 rewrote it and SAFETY-56 the diagnosis.
|
||||
# DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md.
|
||||
_LOG.error("%s: battery power reading stopped changing, set to "
|
||||
"0 W until it changes, or %.0f s after the command "
|
||||
"drops below %.0f W: %s.",
|
||||
inv.name, inv.batt_fresh.bound.release_s,
|
||||
inv.batt_fresh.bound.move_w,
|
||||
messages.reason_code(inv.batt_fresh.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason)
|
||||
_LOG.debug("%s: the limits are unchanged for %.0f s while asked "
|
||||
"to move: a command over %.0f W that moved over %.0f W.",
|
||||
inv.name,
|
||||
inv.batt_fresh.bound.unchanged_s,
|
||||
inv.batt_fresh.bound.move_w,
|
||||
inv.batt_fresh.bound.move_w)
|
||||
elif inv.batt_fresh.releases > was_releases:
|
||||
# SAFETY-57: reported, never silent: a latch that lets go on a
|
||||
# timer and says nothing cannot be audited, and this one withdraws
|
||||
# a SITE STOP. `release_note()` says nothing has been learned about
|
||||
# the register.
|
||||
self.log_event(f"STALE WITHDRAWN {inv.name}",
|
||||
hint=messages.hint("battery_back", unit=inv.name))
|
||||
# Not "recovered": nothing is known about the reading yet, so the
|
||||
# line gives the value it holds and the window it is rechecked over
|
||||
# (SAFETY-57).
|
||||
_LOG.warning("%s: the command stayed below %.0f W for %.0f s, so "
|
||||
"battery power reading %+.0f W is used again and "
|
||||
"rechecked over %.0f s.", inv.name,
|
||||
inv.batt_fresh.bound.move_w,
|
||||
inv.batt_fresh.bound.release_s, inv.batt_fresh.held,
|
||||
inv.batt_fresh.bound.unchanged_s)
|
||||
_LOG.debug("SAFETY-57: %s", inv.batt_fresh.release_note(inv.name))
|
||||
|
||||
# -- the cycle -----------------------------------------------------------
|
||||
def actuator_of(self, source: str, inv=None) -> str:
|
||||
"""Which actuator a claim from `source` is filed on this cycle. `inv`
|
||||
is the roster entry the claim is ABOUT; left out it is the first
|
||||
inverter (what every per-inverter claim meant at N=1). The
|
||||
classification is not here: `arbiter.CLAIM_SCOPE` decides."""
|
||||
return actuator_for(source, (inv or self.inverters[0]).name, self.units)
|
||||
|
||||
def live_units(self) -> tuple:
|
||||
"""Which inverters may be commanded THIS TICK, for the allocator. The
|
||||
allocator DROPS what this does not list and the survivors carry the
|
||||
whole site target, so it is reported (`Allocation.dropped`).
|
||||
Answered per tick, never cached (a master goes stale). It answers
|
||||
ONE question: can this controller put a number on this unit's wire.
|
||||
Deliberately not "its sensors are missing". At a roster of ONE,
|
||||
False is a 0 W site."""
|
||||
now = datetime.now(timezone.utc)
|
||||
live = []
|
||||
for inv in self.inverters:
|
||||
ok, why = inv.is_live(now)
|
||||
if ok:
|
||||
live.append(inv.name)
|
||||
else:
|
||||
# Level-logged at DEBUG only: `Allocation.dropped` is the
|
||||
# operator-facing surface (edge-logged in cycle()); a line
|
||||
# every second would flush the 40-row action log.
|
||||
_LOG.debug("%s is not commandable this tick: %s", inv.name, why)
|
||||
return tuple(live)
|
||||
|
||||
def liveness_notes(self) -> list:
|
||||
"""Why each unit is or is not commandable, for the status page.
|
||||
SAFETY-37's stale-reading line rides here too: a different fault, a
|
||||
different remedy (a dropped unit is carried by the survivors; a
|
||||
stale power register is a missing input)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
return [n for n in
|
||||
[x for inv in self.inverters
|
||||
for x in (inv.link_note(now), inv.stale_note(),
|
||||
inv.refusal_note())]
|
||||
if n]
|
||||
@@ -0,0 +1,927 @@
|
||||
"""Controller methods that build the status page, rows and MQTT output.
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import arbitrage
|
||||
from . import packmode
|
||||
from .clamp import is_commandable
|
||||
from . import dayview, homeview
|
||||
from . import messages
|
||||
from .freeze import rail_unreachable, rail_w
|
||||
from .hass import OUTAGE
|
||||
from .maintenance import IDLE
|
||||
from .mqtt import (
|
||||
announced_units, optional_keys, parse_command, unit_slugs, unit_state_key,
|
||||
)
|
||||
from . import peakguard
|
||||
from . import pv as pv_read
|
||||
from . import shadow
|
||||
from .price import brussels
|
||||
from .validate import FIRMWARE_INGEST_VETO_S
|
||||
from .rows import arbitrage_row
|
||||
|
||||
_LOG = logging.getLogger("goodwe")
|
||||
|
||||
|
||||
class StatusMixin:
|
||||
async def mqtt_command(self, topic, payload, retained) -> None:
|
||||
"""OPT-11 P2: one command from a battery's HA switch or mode list,
|
||||
through handle_action(). A retained command is never applied (the store
|
||||
already holds the last choice). Never raises: MQTT must not reach
|
||||
control. (DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
try:
|
||||
slug, action, value, why = parse_command(
|
||||
topic, payload, retained, packmode.MODES)
|
||||
if why is None:
|
||||
names = [inv.name for inv in self.inverters]
|
||||
unit = dict(zip(unit_slugs(names), names)).get(slug)
|
||||
result = await self.handle_action(action, unit=unit,
|
||||
value=value)
|
||||
if not (isinstance(result, dict) and "error" in result):
|
||||
self.mqtt.publish(self.mode_values())
|
||||
return
|
||||
why = result["error"]
|
||||
else:
|
||||
why = messages.pack_refused(why)
|
||||
# Printable only: a topic is sender-chosen, so no log injection.
|
||||
topic_s = "".join(c if c.isprintable() else "?"
|
||||
for c in str(topic)[:128])
|
||||
_LOG.debug("Home Assistant command %s %s refused: %s", topic_s,
|
||||
repr(payload)[:48], why)
|
||||
if (topic_s, why) in self._mqtt_refused \
|
||||
or len(self._mqtt_refused) >= 32:
|
||||
# Not logged again, but counted: 1, 10, 100... stay visible.
|
||||
self._mqtt_refused_more += 1
|
||||
more = self._mqtt_refused_more
|
||||
if str(more).rstrip("0") == "1":
|
||||
_LOG.warning("Home Assistant commands still ignored: %d "
|
||||
"more so far.", more)
|
||||
return
|
||||
self._mqtt_refused.add((topic_s, why))
|
||||
_LOG.warning("Home Assistant command on %s ignored: %s", topic_s,
|
||||
why)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOG.exception("Home Assistant command could not be handled")
|
||||
|
||||
@property
|
||||
def why_words(self) -> str:
|
||||
"""The page's "Why" line. While control waits for a missing reading
|
||||
the age is counted when the page asks (UX-10), not frozen at the age
|
||||
the last cycle saw."""
|
||||
if self._why_waiting_since is not None:
|
||||
return messages.why_waiting(max(0.0, (
|
||||
datetime.now(timezone.utc)
|
||||
- self._why_waiting_since).total_seconds()))
|
||||
return self._why_words
|
||||
|
||||
@why_words.setter
|
||||
def why_words(self, text: str) -> None:
|
||||
self._why_words = text
|
||||
self._why_waiting_since = None
|
||||
|
||||
def publish(self) -> None:
|
||||
# ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE: `battery` stays the SUM and
|
||||
# `soc` the EMPTIEST pack (their `unique_id`s are on live dashboards);
|
||||
# per-inverter entities are TEL-07's.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
values = {
|
||||
"setpoint": self.target,
|
||||
"grid": self.grid,
|
||||
"battery": self.site_batt_w,
|
||||
"soc": self.pack_floor_soc,
|
||||
"phase": self.maint.phase,
|
||||
# ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing
|
||||
# one: "failsafe" is additive to a sensor already on every
|
||||
# dashboard, and a P_SAFETY stop that did not reach it would be a
|
||||
# site at 0 W reporting "running".
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
"status": ("failsafe" if any(i.sign_wd.tripped
|
||||
for i in self.inverters)
|
||||
or (self.site_sign_wd.tripped
|
||||
and len(self.inverters) > 1)
|
||||
or self.circ_wd.tripped
|
||||
# SAFETY-10: "shadow" is its own value and not a flavour
|
||||
# of "stopped". ⚠️ BELOW `failsafe`: a latch that is up
|
||||
# is up, and shadow does not clear it.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
else "shadow" if self.shadow
|
||||
else "running" if self.auto else "stopped"),
|
||||
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
|
||||
# `site_circulating_w`. `publish()` drops None values, so a missing
|
||||
# per-unit reading leaves the last figure standing rather than
|
||||
# publishing a zero that would read as health.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
"circulation": (None if self.site_circulating_w is None
|
||||
else round(self.site_circulating_w)),
|
||||
# TEL-10. The loop's own three, written from the fields `cycle()`
|
||||
# recorded and not re-derived (the page and the publisher run on
|
||||
# their own tasks, and `self.target` has moved on).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
"loop_i": round(self.loop_i_w, 1),
|
||||
"loop_lead": round(self.loop_i_lead, 1),
|
||||
"loop_reason": self.loop_reason,
|
||||
}
|
||||
# TEL-11. ⚠️ THE KEY IS PRESENT WITH `None` WHEN THERE IS NO READING
|
||||
# AND ABSENT WHEN THERE IS NO SOURCE: two states
|
||||
# (`mqtt.ABSENT_AS_UNKNOWN` makes the first an `unknown`, a gap in the
|
||||
# recorder). TEL-12: still ONE number, the SITE total, None unless
|
||||
# EVERY entity answered. ⚠️ THE GATE IS `optional_keys()`, THE SAME
|
||||
# EXPRESSION THE ANNOUNCEMENT USES.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if "pv" in optional_keys(self.o):
|
||||
values["pv"] = (None if self.pv_w is None
|
||||
else round(self.pv_w, 1))
|
||||
# ⚠️ ONLY when P1 ingestion is running: the ESP32's stale-input
|
||||
# watchdog subscribes to sensor.goodwe_p1_sample_age and forces layer-1
|
||||
# failsafe at max_age_s, so with meter_source off a published age would
|
||||
# climb unbounded and pin every existing install at 0 W within 30 s.
|
||||
# Publishing nothing leaves the entity non-existent (firmware
|
||||
# has_state()). (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if self.p1_enabled:
|
||||
# Recomputed here, once a second, on purpose - see P1Ingest.
|
||||
values["p1_age"] = round(self.p1.published_age_s, 1)
|
||||
# TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE: ⚠️ no `if
|
||||
# len(self.inverters) > 1` here, deliberately: `announced_units()`
|
||||
# answers [] at N=1, so what is announced and what is published come
|
||||
# from one expression. ⚠️ The site rows do not move: `battery` stays
|
||||
# the SUM and `soc` the EMPTIEST pack (live dashboards use those ids).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
names = announced_units([inv.name for inv in self.inverters])
|
||||
for inv, slug in zip(self.inverters, unit_slugs(names)):
|
||||
values[unit_state_key(slug, "setpoint")] = inv.target
|
||||
values[unit_state_key(slug, "battery")] = inv.batt
|
||||
values[unit_state_key(slug, "soc")] = inv.soc
|
||||
values[unit_state_key(slug, "sign_latch")] = (
|
||||
"tripped" if inv.sign_wd.tripped else "clear")
|
||||
# OPT-11 P2: each battery's mode and switch; nothing while modes are off.
|
||||
values.update(self.mode_values())
|
||||
self.mqtt.publish(values)
|
||||
|
||||
# -- UI ------------------------------------------------------------------
|
||||
def checks(self, when: datetime | None = None) -> list:
|
||||
"""The status rows. `when` is the reference time for anything billed by
|
||||
the calendar - today's month when it is None, which is what the live
|
||||
page means. ⚠️ IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17): a
|
||||
suite that banks an August quarter went red at the September boundary.
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
o = self.o
|
||||
out = []
|
||||
# SAFETY-61. First, so a stalled loop is the headline: the heartbeat is
|
||||
# sent from this loop, so past layer 1's veto the inverter is at 0 W.
|
||||
if self.loop_pass_mono is None:
|
||||
out.append({"ok": True, "warn": False, "id": "controller",
|
||||
"text": messages.row_controller_starting()})
|
||||
else:
|
||||
_pass_age = time.monotonic() - self.loop_pass_mono
|
||||
if _pass_age > FIRMWARE_INGEST_VETO_S:
|
||||
out.append({"ok": False, "warn": False, "id": "controller",
|
||||
"text": messages.row_controller_stalled(
|
||||
_pass_age, FIRMWARE_INGEST_VETO_S)})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "controller",
|
||||
"text": messages.row_controller_ran(_pass_age)})
|
||||
# SAFETY-112. The headline unless control has stalled: while the
|
||||
# Supervisor gives 502 (Home Assistant not yet RUNNING) no reading or
|
||||
# write gets through, and the rows below that need it follow from it.
|
||||
if OUTAGE.waiting():
|
||||
out.insert(1 if out[0]["ok"] is False else 0,
|
||||
{"ok": False, "warn": False, "id": "home_assistant",
|
||||
"banner": messages.banner_ha_waiting(),
|
||||
"text": messages.row_ha_waiting()})
|
||||
if self.p1_enabled:
|
||||
age = self.p1.published_age_s
|
||||
if (self.p1.stale and self.p1.age_s() is None
|
||||
and age <= self.p1.max_age_s):
|
||||
# UX-10: no reading yet since the start, still inside the
|
||||
# limit. Amber, not red: it is not a fault yet, but control
|
||||
# waits for it. Red at 0 s contradicted the limit the row
|
||||
# prints. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
out.append({"ok": False, "warn": True, "id": "meter",
|
||||
"text": messages.row_meter_waiting(
|
||||
self.p1.max_age_s)})
|
||||
elif self.p1.stale:
|
||||
# The last error is on the add-on log's WARNING line (p1.py).
|
||||
out.append({"ok": False, "warn": False, "id": "meter",
|
||||
"text": messages.row_meter_stale(
|
||||
age, self.p1.max_age_s)})
|
||||
else:
|
||||
# ⚠️ unchanged_s is REPORTED, never thresholded and never
|
||||
# folded into the age - see HomeWizardLocalSource.unchanged_s
|
||||
# for why (at the converged -10 W this controller aims for, a 1
|
||||
# Wh register needs ~6 minutes to move, so any limit
|
||||
# false-trips at the exact operating point we target).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
unchanged = getattr(self.p1_source, "unchanged_s", None)
|
||||
out.append({"ok": True, "warn": False, "id": "meter",
|
||||
"text": messages.row_meter(
|
||||
self.p1.net_w, age, self.p1.parse_errors,
|
||||
unchanged)})
|
||||
# SAFETY-18. Reported in both states (a detector nobody can see is
|
||||
# armed is not trusted when it fires). ok=False/warn=False so the
|
||||
# banner goes red; it changes NOTHING about control (see
|
||||
# check_meter_frozen()). (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if self.freeze_wd.active:
|
||||
# The figures are in `freeze_wd.reason`, on the log and the
|
||||
# event list (check_meter_frozen()).
|
||||
out.append({"ok": False, "warn": False, "id": "meter_frozen",
|
||||
"text": messages.row_meter_frozen()})
|
||||
elif getattr(self.p1_source, "unchanged_s", None) is None:
|
||||
# Not "armed". Every transport but homewizard_local is invisible
|
||||
# to this check, and saying "armed" there would be a lie the
|
||||
# status page tells on behalf of a guard that cannot run.
|
||||
out.append({"ok": False, "warn": True, "id": "freeze_check",
|
||||
"text": messages.row_freeze_off("source")})
|
||||
elif rail_unreachable(self.site_max_w,
|
||||
dev_min=self.site_dev_bound("dev_min"),
|
||||
dev_max=self.site_dev_bound("dev_max")):
|
||||
# SAFETY-20: the same row as the transport case above (the
|
||||
# check cannot be evaluated here); only the missing term
|
||||
# differs. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_no_rating = (not self.site_max_w
|
||||
or not is_commandable(self.site_max_w))
|
||||
out.append({"ok": False, "warn": True, "id": "freeze_check",
|
||||
"text": messages.row_freeze_off(
|
||||
"rating" if _no_rating else "range")})
|
||||
else:
|
||||
# ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE
|
||||
# USES, device range included.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_rail = rail_w(self.site_max_w,
|
||||
dev_min=self.site_dev_bound("dev_min"),
|
||||
dev_max=self.site_dev_bound("dev_max"))
|
||||
# And when the device set that number, SAY SO: the operator
|
||||
# configured `max_w`. ⚠️ Either bound may be None on its own
|
||||
# (`hass.limits()` reads min and max independently), so render
|
||||
# what is there. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
out.append({"ok": True, "warn": False, "id": "freeze_check",
|
||||
"text": messages.row_freeze_on(
|
||||
self.freeze_wd.cfg.hold_s, _rail,
|
||||
own_limit=_rail < rail_w(self.site_max_w))})
|
||||
|
||||
# TARIFF-01. The month's billed peak, and a DIFFERENT row when the
|
||||
# store could not answer: "no peak yet" is normal on the 1st,
|
||||
# "unreadable" is a broken instrument.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_pk = self.peak.peak(when)
|
||||
if _pk.is_unreadable:
|
||||
out.append({"ok": False, "warn": True, "id": "month_peak",
|
||||
"text": messages.row_month_peak_unreadable()})
|
||||
else:
|
||||
# §9.1's "warning entity at 80 %", as a status row rather than
|
||||
# a second entity - see the constructor on not doubling up.
|
||||
_frac = self.peak.warning_frac(when) or 0.0
|
||||
_ok = _frac < 0.8
|
||||
out.append({"ok": _ok, "warn": not _ok, "id": "month_peak",
|
||||
"text": (messages.row_month_peak_none()
|
||||
if not _pk.is_observed else
|
||||
messages.row_month_peak(
|
||||
_pk.peak_w,
|
||||
brussels(_pk.quarter_start),
|
||||
_frac))})
|
||||
out.append({"ok": True, "warn": False, "id": "periods_measured",
|
||||
"text": messages.row_periods_measured(
|
||||
self.peak.recorded)})
|
||||
|
||||
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
|
||||
# are opposite states and a page that spelled them the same would
|
||||
# show a green line over a guard that is switched off. `None` is a
|
||||
# fourth state on purpose: before the first cycle nothing has been
|
||||
# concluded. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_pg = self.peak_guard
|
||||
if _pg is None:
|
||||
out.append({"ok": True, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_waiting()})
|
||||
elif _pg.saturated and not _pg.within_aim:
|
||||
out.append({"ok": False, "warn": False, "id": "peak_guard",
|
||||
"runs": True,
|
||||
"text": messages.row_peak_saturated(_pg.ceiling_w)})
|
||||
elif _pg.floor_limited and (
|
||||
not ((_pg.floor_w or 0.0) > 0.0 or _pg.within_aim)
|
||||
or self._peak_floor_limited):
|
||||
# SAFETY-24. NOT ok, like saturation (the money is being spent
|
||||
# and a human is the only actuator left); a separate row
|
||||
# because the remedy is opposite. ⚠️ SAFETY-25: the scope
|
||||
# sentence (the floor binds the guard, not the loop) is part of
|
||||
# the row, which carries its own short `banner`; shortening the
|
||||
# ROW would weaken AC 3.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
out.append({"ok": False, "warn": False, "id": "peak_guard",
|
||||
"runs": True,
|
||||
"banner": messages.banner_peak_reserve(),
|
||||
"text": messages.row_peak_reserve(
|
||||
_pg.ceiling_w, soc_unread=_pg.soc is None,
|
||||
floor_unknown=(_pg.soc is not None
|
||||
and _pg.soc_floor is None),
|
||||
packs=(_pg.held if len(self.inverters) > 1
|
||||
else ()),
|
||||
others=(_pg.floor_w or 0.0) > 0.0)})
|
||||
elif _pg.blind:
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_blind(
|
||||
peakguard.blind_kind(_pg.blind))})
|
||||
elif self.peak_unmeasured_s > 0.0:
|
||||
# SAFETY-90: the start of this quarter came before the first
|
||||
# reading, and the guard counts it as high use.
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_unmeasured()})
|
||||
elif _pg.engaged:
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_on(
|
||||
_pg.floor_w or 0.0, _pg.ceiling_w,
|
||||
shadow=self.shadow)})
|
||||
elif _pg.projected_w is not None:
|
||||
# SAFETY-125: "under" only when it is; over the limit at this
|
||||
# rate, a grid charge is cut back to the quarter's room.
|
||||
out.append({"ok": True, "warn": False, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_idle(
|
||||
_pg.projected_w, _pg.ceiling_w,
|
||||
month_peak=(_pg.ceiling_from
|
||||
== peakguard.CEILING_MONTH_PEAK),
|
||||
cut=self.room_charge and not self.shadow)})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "peak_guard",
|
||||
"text": _pg.explain()})
|
||||
else:
|
||||
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the
|
||||
# point: SAFETY-07 is NOT COVERED on the legacy single-entity path
|
||||
# (no telegrams), and a row that rendered only where the guard runs
|
||||
# would leave the unprotected install showing nothing.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_off()})
|
||||
|
||||
# ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row
|
||||
# on a site with two packs is a page that shows one pack and calls it
|
||||
# the site. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
rows = []
|
||||
for inv in self.inverters:
|
||||
unit = None if len(self.inverters) == 1 else inv.name
|
||||
rows.append((messages.LABEL_SOC, unit, inv.soc,
|
||||
inv.driver.soc_source()))
|
||||
rows.append((messages.LABEL_BATTERY, unit, inv.batt,
|
||||
inv.driver.batt_source()))
|
||||
if not self.p1_enabled:
|
||||
# In P1 mode the check above replaces this one; leaving both in
|
||||
# would report "no entity configured" for a meter_entity that is
|
||||
# correctly unused, i.e. a permanent false NOT READY.
|
||||
rows.insert(0, (messages.LABEL_GRID, None, self.grid,
|
||||
o.get("meter_entity")))
|
||||
# UX-08: the rows name the setting, never the entity id (STYLE.md).
|
||||
for label, unit, value, entity in rows:
|
||||
if not entity:
|
||||
out.append({"ok": False, "warn": False, "id": "sensor",
|
||||
"text": messages.row_sensor_unset(label, unit)})
|
||||
elif value is None:
|
||||
# ⚠️ SAFETY-56. THIS ROW ONCE ASSERTED A FALSE FACT ABOUT A
|
||||
# NAMED ENTITY: `Freshness.reading()` returns None BY DESIGN
|
||||
# while the gate withholds a numeric reading. ⚠️ FIXED BY
|
||||
# SAYING LESS: the gate has its own surface (`stale_note()` in
|
||||
# `liveness_notes()`).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
out.append({"ok": False, "warn": False, "id": "sensor",
|
||||
"text": messages.row_sensor_unusable(label, unit)})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "sensor",
|
||||
"text": messages.row_sensor(label, value, unit)})
|
||||
|
||||
for inv in self.inverters:
|
||||
unit = None if len(self.inverters) == 1 else inv.name
|
||||
if not inv.driver.configured():
|
||||
out.append({"ok": False, "warn": False, "id": "control",
|
||||
"text": messages.row_control_unset(unit)})
|
||||
elif inv.dev_max is None:
|
||||
out.append({"ok": False, "warn": False, "id": "control",
|
||||
"text": messages.row_control_missing(unit)})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "control",
|
||||
"text": messages.row_control(inv.dev_min,
|
||||
inv.dev_max, unit)})
|
||||
|
||||
# DEV-02. Whether this controller can still put a number on each
|
||||
# inverter: a dropped link that shows nothing on the page reads as a
|
||||
# healthy site. Red only for a unit no longer live; the rest amber. The
|
||||
# same notes, in the same order, as `liveness_notes()`.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_now = datetime.now(timezone.utc)
|
||||
for inv in self.inverters:
|
||||
for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]),
|
||||
(inv.stale_note(), False),
|
||||
(inv.refusal_note(), False)):
|
||||
if note:
|
||||
out.append({"ok": False, "warn": not red, "text": note})
|
||||
if self.answer.late_seen: # SAFETY-128
|
||||
out.append({"ok": False, "warn": True, "id": "late",
|
||||
"text": messages.row_readings_late(
|
||||
self.answer.shown_s, self._late_zero)})
|
||||
|
||||
# SAFETY-03. Red while an over-rating clamp is engaged (amber for the
|
||||
# integrator bound, SAFETY-40), and the engagement COUNT stays shown
|
||||
# after it clears. Each count is shown under ITS alarm's name, never
|
||||
# the two summed (UX-07/UX-08).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_cw = self.clamp_wd
|
||||
if _cw.active and _cw.bug:
|
||||
out.append({"ok": False, "warn": False, "id": "power_too_high",
|
||||
"banner": messages.banner_power_too_high(),
|
||||
"text": messages.row_power_too_high(
|
||||
_cw.facts, _cw.over_rating, shadow=self.shadow)})
|
||||
elif _cw.active:
|
||||
# SAFETY-40: the integrator bound alone is amber, not red.
|
||||
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
|
||||
"text": messages.row_not_keeping_up(
|
||||
_cw.bound_episodes)})
|
||||
# No over-rating in THIS episode (`bug` would be set), so any count
|
||||
# is from an earlier one: keep that software-fault evidence shown.
|
||||
if _cw.over_rating:
|
||||
out.append({"ok": False, "warn": True, "id": "power_too_high",
|
||||
"text": messages.row_before(
|
||||
messages.T_OUTPUT_CLAMP, _cw.over_rating)})
|
||||
else:
|
||||
if _cw.over_rating:
|
||||
out.append({"ok": False, "warn": True, "id": "power_too_high",
|
||||
"text": messages.row_before(
|
||||
messages.T_OUTPUT_CLAMP, _cw.over_rating)})
|
||||
if _cw.bound_episodes:
|
||||
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
|
||||
"text": messages.row_before(
|
||||
messages.T_LOOP_BOUND, _cw.bound_episodes)})
|
||||
|
||||
for inv in self.inverters:
|
||||
if inv.last_write_ok is False:
|
||||
out.append({"ok": False, "warn": False, "id": "write",
|
||||
"text": messages.row_write_refused(
|
||||
None if len(self.inverters) == 1
|
||||
else inv.name)})
|
||||
|
||||
# SAFETY-02. Reported in both states. ⚠️ ONE ROW PER INVERTER: a trip
|
||||
# on B leaves A commanding. Since SAFETY-30 the row says what the unit
|
||||
# was compared against (`sign_wd.against`, the string the trip sentence
|
||||
# uses); at N=1 the wording is unchanged.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
for inv in self.inverters:
|
||||
unit = None if len(self.inverters) == 1 else inv.name
|
||||
# ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE: at N=1 the
|
||||
# two sentences below are the ones that have always been on the
|
||||
# page, word for word (AC 8); at N>=2 the noun is load-bearing, so
|
||||
# the row does not send an operator to P1 for a fault visible only
|
||||
# on one inverter's own power.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if inv.sign_wd.tripped:
|
||||
out.append({"ok": False, "warn": False, "id": "direction",
|
||||
"text": messages.row_direction_trip(unit)})
|
||||
else:
|
||||
held = inv.sign_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False, "id": "direction",
|
||||
"text": messages.row_direction_check(
|
||||
held, inv.sign_wd.cfg.hold_s, unit)})
|
||||
|
||||
# SAFETY-30: the SITE row. ⚠️ ONLY AT N>=2, and the absence at N=1 is
|
||||
# the honest report: with one inverter the site comparison and the row
|
||||
# above are the same sentence about the same actuator.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if len(self.inverters) > 1:
|
||||
if self.site_sign_wd.tripped:
|
||||
out.append({"ok": False, "warn": False, "id": "site_direction",
|
||||
"text": messages.row_site_direction_trip()})
|
||||
else:
|
||||
_sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False, "id": "site_direction",
|
||||
"text": messages.row_site_direction_check(
|
||||
_sheld, self.site_sign_wd.cfg.hold_s)})
|
||||
|
||||
# SAFETY-31. ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and
|
||||
# the row says so: there is one circulating figure for the site, and
|
||||
# naming an inverter would invite clearing "the other one".
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_circ = self.site_circulating_w
|
||||
if self.circ_wd.tripped:
|
||||
out.append({"ok": False, "warn": False, "id": "circulating",
|
||||
"text": messages.row_circulating_trip()})
|
||||
elif len(self.inverters) <= 1:
|
||||
# AC 5. At the shipped roster the quantity is identically zero for
|
||||
# every value of every reading, so "armed" would be a claim to be
|
||||
# watching something that cannot happen. Say what is true instead.
|
||||
out.append({"ok": True, "warn": False, "id": "circulating",
|
||||
"text": messages.row_one_inverter(
|
||||
messages.CHECK_CIRCULATING)})
|
||||
else:
|
||||
held = self.circ_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False, "id": "circulating",
|
||||
"text": messages.row_circulating_check(
|
||||
_circ, held, self.circ_wd.cfg.hold_s)})
|
||||
|
||||
# SAFETY-42. ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE
|
||||
# ONE PLACE THE "reports, does not act" DECISION IS VISIBLE: every
|
||||
# other latched row means the site is at 0 W, this one means it is
|
||||
# STILL RUNNING and quietly short by one unit's share; `ok: False` with
|
||||
# `warn: True` is this list's caution.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if len(self.inverters) > 1:
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
out.append({"ok": False, "warn": True, "id": "delivery",
|
||||
"text": messages.row_not_delivering(inv.name)})
|
||||
else:
|
||||
_dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False, "id": "delivery",
|
||||
"text": messages.row_delivery_check(
|
||||
inv.name, _dheld,
|
||||
inv.delivery_wd.cfg.hold_s)})
|
||||
else:
|
||||
# AC 5: at one inverter this check has nothing to find, so it is
|
||||
# not called "armed" (as `circulating power: n/a` above).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
out.append({"ok": True, "warn": False, "id": "delivery",
|
||||
"text": messages.row_one_inverter("Delivery check")})
|
||||
|
||||
if not o.get("estop_fitted", False):
|
||||
out.append({"ok": False, "warn": True, "id": "estop",
|
||||
"text": messages.row_no_estop()})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "estop",
|
||||
"text": messages.row_estop_fitted()})
|
||||
|
||||
if not o.get("peak_forecast_entity"):
|
||||
# ok=False + warn=True renders as a caution, not a pass. A check that
|
||||
# is both is a check nobody reads.
|
||||
out.append({"ok": False, "warn": True, "id": "peak_forecast",
|
||||
"text": messages.row_no_peak_forecast()})
|
||||
elif getattr(self, "_peak_fc_fault", None) is not None:
|
||||
out.append({"ok": False, "warn": True, "id": "peak_forecast",
|
||||
"text": messages.row_peak_forecast_unit(
|
||||
self._peak_fc_fault)})
|
||||
# OPT-17: a forecast summed from several entities names, by position,
|
||||
# each member with no usable reading. A caution: control goes on.
|
||||
gaps = getattr(self, "solar_fc_gaps", ())
|
||||
for day, ents in (("today", getattr(self, "fc_entities", ())),
|
||||
("tomorrow",
|
||||
getattr(self, "fc_tomorrow_entities", ()))):
|
||||
gone = [i for d, i, _e, _w in gaps if d == day]
|
||||
if gone:
|
||||
out.append({"ok": False, "warn": True, "id": "solar_forecast",
|
||||
"text": messages.row_forecast_gap(
|
||||
day, gone, len(ents))})
|
||||
# TEL-11. ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY (pure
|
||||
# instrumentation: no warning spam). ⚠️ WHEN IT IS SET, A FAILED READ
|
||||
# IS `ok=False, warn=True`, a CAUTION never `bad`: telemetry must not
|
||||
# put a battery site into NOT READY. UX-08 replaced 0.11.0's sentences
|
||||
# on purpose. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
roster = self.pv_roster
|
||||
if roster:
|
||||
# UX-08: sources are numbered in the order of the settings list,
|
||||
# never named by entity id (STYLE.md). The per-source figures and
|
||||
# units are what `pv.total()` returns; they stay in `pv_parts`.
|
||||
flip = pv_read.inverted(self.o)
|
||||
n_src = len(roster)
|
||||
if self.pv_w is None and n_src == 1:
|
||||
out.append({"ok": False, "warn": True, "id": "pv",
|
||||
"text": messages.row_pv_none(self.pv_why)})
|
||||
elif self.pv_w is None:
|
||||
# ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN
|
||||
# THOSE WORDS - and which sources are missing, by position.
|
||||
out.append({"ok": False, "warn": True, "id": "pv",
|
||||
"text": messages.row_pv_partial(
|
||||
[i + 1 for i, (_e, watt, _y)
|
||||
in enumerate(self.pv_parts) if watt is None],
|
||||
n_src)})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "pv",
|
||||
"text": messages.row_pv(
|
||||
self.pv_w, n_src,
|
||||
n_src == 1 and roster[0] in flip)})
|
||||
# ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
|
||||
# `entities()` makes `pv_entities` REPLACE `pv_entity`, which is
|
||||
# the rule an operator upgrading from 0.11.0 is most likely to trip
|
||||
# over. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if pv_read.entity_is_ignored(self.o):
|
||||
out.append({"ok": False, "warn": True, "id": "pv",
|
||||
"text": messages.row_pv_single_ignored()})
|
||||
# TEL-14: a sign that looks reversed, per entity rather than on the
|
||||
# total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3.
|
||||
for i, (ent, watt, _y) in enumerate(self.pv_parts):
|
||||
if watt is not None and watt < pv_read.NEGATIVE_W:
|
||||
out.append({"ok": False, "warn": True, "id": "pv",
|
||||
"text": messages.row_pv_negative(
|
||||
i + 1, n_src, watt, ent in flip)})
|
||||
_inv_list = [str(x or "").strip()
|
||||
for x in (self.o.get("pv_invert_entities") or [])]
|
||||
for ent in pv_read.stray_inverts(self.o):
|
||||
out.append({"ok": False, "warn": True, "id": "pv",
|
||||
"text": messages.row_pv_stray(
|
||||
_inv_list.index(ent) + 1)})
|
||||
return out
|
||||
|
||||
def status_payload(self, lang: str = "en") -> dict:
|
||||
checks = self.checks()
|
||||
bad = [c for c in checks if not c["ok"] and not c["warn"]]
|
||||
# SAFETY-87: a red row with `runs` (the peak alarms) is an alarm while
|
||||
# control goes on, so it neither reads "Not ready" nor holds the page.
|
||||
runs = [c for c in bad if c.get("runs")]
|
||||
bad = [c for c in bad if not c.get("runs")]
|
||||
running_alarm = waiting = False
|
||||
# SAFETY-10, AND IT IS FIRST IN THE CASCADE: "The battery is NOT being
|
||||
# controlled" may never be displaced by another condition. ⚠️ AND IT
|
||||
# CARRIES A STANDING LATCH RATHER THAN HIDING ONE: `shadow.banner()`
|
||||
# names the latch and goes red for it, so nothing the cascade would
|
||||
# have said is lost. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_shadow = shadow.banner(self.o, control_started=self.auto,
|
||||
latched=self.shadow_latches())
|
||||
if _shadow is not None:
|
||||
level, banner = _shadow
|
||||
elif self.circ_wd.tripped:
|
||||
# ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision: both are
|
||||
# latched P_SAFETY stops and the banner carries one sentence; this
|
||||
# one goes first because it is the fault the operator has NO OTHER
|
||||
# WAY of learning about (a sign disagreement is visible at the
|
||||
# meter, circulating power nowhere).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
level, banner = "bad", messages.banner_circulating()
|
||||
elif self.site_sign_wd.tripped and len(self.inverters) > 1:
|
||||
# SAFETY-30's site half, ABOVE the per-unit rows below and below
|
||||
# circulating power above: the banner carries one sentence, so the
|
||||
# one that says the widest true thing. A site sign trip parks EVERY
|
||||
# inverter and the fault is in what the site was asking for; naming
|
||||
# a unit would send an operator to swap the wrong box.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
level, banner = "bad", messages.banner_site_direction()
|
||||
elif any(i.sign_wd.tripped for i in self.inverters):
|
||||
# Outranks "Not ready": nothing is missing, something went wrong.
|
||||
# At N>=2 the unit was graded against its own power, so the banner
|
||||
# says so (see checks()). (DECISIONS/MAIN-orchestration-archive.md)
|
||||
level, banner = "bad", messages.banner_direction(
|
||||
len(self.inverters) > 1)
|
||||
elif bad:
|
||||
# ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE: a row may
|
||||
# say more than fits the headline (SAFETY-25's floor row).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
level, banner = "bad", messages.banner_not_ready(bad[0])
|
||||
# SAFETY-112: nothing to do but wait, so Home says that instead
|
||||
# of "Needs you now". Only when it IS the headline: a safety stop
|
||||
# above outranks it and still needs a person.
|
||||
waiting = bad[0].get("id") == "home_assistant"
|
||||
elif not self.auto:
|
||||
level, banner = "warn", messages.banner_stopped()
|
||||
elif runs:
|
||||
level, running_alarm = "bad", True
|
||||
banner = runs[0].get("banner") or messages.headline_bi(
|
||||
runs[0]["text"])
|
||||
elif any(i.delivery_wd.tripped for i in self.inverters):
|
||||
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
|
||||
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE
|
||||
# (the site is STILL RUNNING, one unit short). ⚠️ BUT IT MUST
|
||||
# DISPLACE "Running - holding grid at zero": that sentence is a
|
||||
# claim the site cannot make while a unit is absent. It sits BELOW
|
||||
# "Stopped". (DECISIONS/MAIN-orchestration-archive.md)
|
||||
level, banner = "warn", messages.banner_not_delivering(
|
||||
[i.name for i in self.inverters if i.delivery_wd.tripped])
|
||||
elif self.maint.phase != IDLE:
|
||||
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE: at N=1 the
|
||||
# string is what it has always been, character for character.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
level, banner = "warn", messages.banner_maintenance(
|
||||
self.maint.phase,
|
||||
self.maint_unit.name if len(self.inverters) > 1 else "")
|
||||
else:
|
||||
level, banner = "ok", messages.banner_running()
|
||||
|
||||
def w(v):
|
||||
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
|
||||
|
||||
# ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. `None` means
|
||||
# "never run, due now" and has to win over any date, so it is tested
|
||||
# for rather than sorted with. At N=1 this is that pack's own date.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_now = datetime.now(timezone.utc)
|
||||
_dues = [m.next_due(_now) for m in self.maints]
|
||||
_waits = [u for u in (m.backoff_until(_now) for m in self.maints) if u]
|
||||
next_due = None if any(d is None for d in _dues) else min(_dues)
|
||||
rows = [
|
||||
("Grid", w(self.grid)),
|
||||
("Battery", w(self.site_batt_w)),
|
||||
# SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is
|
||||
# not derivable from ("Grid 0 W, Battery 0 W, Circulating 6000 W"
|
||||
# is the whole card in three rows). Only shown at N>=2: at one
|
||||
# inverter it is identically zero.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
*(() if len(self.inverters) <= 1
|
||||
else ((("Circulating", w(self.site_circulating_w)),))),
|
||||
# TEL-11, and ONLY when a source is configured - see `checks()`.
|
||||
# `w(None)` renders "-", which is how this row says "no reading"
|
||||
# rather than "0 W". (DECISIONS/MAIN-orchestration-archive.md)
|
||||
*(() if not self.pv_roster else ((("Solar production",
|
||||
w(self.pv_w)),))),
|
||||
("Battery level", "—" if self.pack_floor_soc is None
|
||||
else f"{self.pack_floor_soc:g} %"),
|
||||
("Power request", w(self.target)),
|
||||
# SAFETY-10, and it sits BETWEEN the two rows it reconciles.
|
||||
# "Commanded 0 W" above and a "Why" below that explains a trade is
|
||||
# the exact pair an operator would otherwise have to guess at; this
|
||||
# row is what makes the two of them consistent, and it is rendered
|
||||
# in EVERY state - including `off`, where it says the battery IS
|
||||
# being controlled - because a surface whose ABSENCE is how you
|
||||
# learn something is a surface you have to already know about.
|
||||
("Shadow mode", shadow.words(
|
||||
self.o, control_started=self.auto,
|
||||
would_w=self.shadow_would_w,
|
||||
# SAFETY-47. THE FLAG THE CYCLE RECORDED, NOT ONE DERIVED HERE
|
||||
# - the page runs on its own task and `self.peak_guard` has
|
||||
# moved on. See `Controller.__init__`.
|
||||
sized_from_meter=self.shadow_sized_from_meter,
|
||||
reason=self.why_words)),
|
||||
("Why", self.why_words),
|
||||
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
|
||||
else f"{self.maint.phase} ({self.maint_unit.name})"),
|
||||
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
|
||||
# page - where `_maint_deferred` is empty on every tick - carries
|
||||
# exactly the rows it carried before.
|
||||
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
|
||||
for n, why in self._maint_deferred)),)
|
||||
if self._maint_deferred else ()),
|
||||
# MAINT-13: only while a failed cycle waits, so a site with nothing
|
||||
# failed carries exactly the rows it carried before.
|
||||
*((("Maintenance paused", messages.row_maint_waits(
|
||||
min(_waits).strftime("%Y-%m-%d"))),) if _waits else ()),
|
||||
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
||||
("Cheap window", "yes" if self.cheap else "no"),
|
||||
("Tariff", self.tariff_words),
|
||||
# PRICE-01: only while no price covers now.
|
||||
*((("Prices", messages.row_no_prices(*self._gap_args())),)
|
||||
if self.price_gap else ()),
|
||||
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, the difference between a
|
||||
# 48-hour plan and a 12-hour one on a weekday morning. Its own row:
|
||||
# "Tariff" says what the plan IS, this says what it could still be
|
||||
# filled from if the feed is late again.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
("Price history", self.price_history.words),
|
||||
# OPT-04. The strategy's own sentence, not a re-derivation: every
|
||||
# refusal it can make carries a reason a human can act on. ⚠️
|
||||
# TARIFF-11: `self.arb is None` is NOT "trading is off" (cycle()
|
||||
# returns at the input hold first); `arbitrage_row()` reads the
|
||||
# option, and `cycle_at`/`arb_at` make "no decision this cycle" an
|
||||
# observation. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
("Arbitrage", arbitrage_row(
|
||||
on=arbitrage.enabled(self.o) or self.modes_on,
|
||||
decision=self.arb,
|
||||
decided_at=self.arb_at, cycle_at=self.cycle_at,
|
||||
now=datetime.now(timezone.utc), why=self.why_words)),
|
||||
]
|
||||
held = level == "bad" and not running_alarm
|
||||
# UX-46. The page's language is chosen once, here, for the Home
|
||||
# sentences, checks, rows that carry one and the banner; a plain str
|
||||
# (no Dutch twin) stays as it is.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks]
|
||||
rows = [(k, messages.pick(v, lang)) for k, v in rows]
|
||||
banner = messages.pick(banner, lang)
|
||||
out = {
|
||||
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
||||
"hint": self.hint,
|
||||
# The action log itself, newest first: not shown on the page (UX-08
|
||||
# 2d) but readable in the `status` JSON, since several events are
|
||||
# written nowhere else. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
"events": list(self.events),
|
||||
}
|
||||
# UX-13 P1: the Home screen's structured half. Read-only, and it may
|
||||
# never cost the page its rows: a fault here is logged and the old
|
||||
# fields are served alone (the page then shows the installer details).
|
||||
try:
|
||||
out.update(homeview.build(self, level=level, checks=checks,
|
||||
lang=lang, held=held, waiting=waiting))
|
||||
except Exception: # noqa: BLE001
|
||||
# Once per process at ERROR: the page polls every 3 s.
|
||||
if not getattr(self, "_home_failed", False):
|
||||
self._home_failed = True
|
||||
_LOG.exception("Home view could not be built, so the page "
|
||||
"shows the plain status rows only")
|
||||
else:
|
||||
_LOG.debug("Home view failed again", exc_info=True)
|
||||
out["home_error"] = True
|
||||
return out
|
||||
|
||||
def forecast_payload(self, lang: str = "en") -> dict:
|
||||
"""UX-54: the Forecast tab's data. Built on request, from what the
|
||||
control cycle already holds; no Home Assistant call. Today's blocks are
|
||||
Home's own, so `status_payload()` is built first and read."""
|
||||
try:
|
||||
return dayview.build(self, self.status_payload(lang=lang),
|
||||
datetime.now(timezone.utc), lang)
|
||||
except Exception: # noqa: BLE001
|
||||
if not getattr(self, "_forecast_failed", False):
|
||||
self._forecast_failed = True
|
||||
_LOG.exception("The Forecast tab could not be built")
|
||||
else:
|
||||
_LOG.debug("The Forecast tab failed again", exc_info=True)
|
||||
return {"ok": False}
|
||||
|
||||
async def handle_action(self, what: str, *, unit=None, value=None):
|
||||
now = datetime.now(timezone.utc)
|
||||
if what in ("pack_use", "pack_mode", "pack_sell"):
|
||||
return self._pack_action(what, unit, value, now)
|
||||
if what == "auto_toggle":
|
||||
self.auto = not self.auto
|
||||
self.store.set("auto", self.auto)
|
||||
self.log_event("control started" if self.auto else "control stopped",
|
||||
hint=messages.hint("control_started" if self.auto
|
||||
else "control_stopped"))
|
||||
if self.auto:
|
||||
# ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose:
|
||||
# it clears on a deliberate human start, not on the condition
|
||||
# going away (the condition ALWAYS goes away, because the trip
|
||||
# commands 0 W). ⚠️ IT CLEARS EVERY INVERTER'S LATCH, AND SAYS
|
||||
# SO PER INVERTER, each under its OWN key.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
for inv in self.inverters:
|
||||
if not inv.sign_wd.tripped:
|
||||
continue
|
||||
inv.sign_wd.reset()
|
||||
inv.store_set("sign_tripped", False)
|
||||
inv.store_set("sign_reason", "")
|
||||
self.log_event(f"sign-disagreement failsafe cleared by "
|
||||
f"operator on {inv.name}",
|
||||
hint=messages.hint("stop_cleared",
|
||||
unit=inv.name))
|
||||
# ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM,
|
||||
# UNCONDITIONALLY: this is what makes `flat True + namespaced
|
||||
# False` MEAN something, and why `_grade_store_migration()` can
|
||||
# run on every start. Left behind, a flat key is a fossil that
|
||||
# re-arms the failsafe on every restart.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
self.store.set("sign_tripped", False)
|
||||
self.store.set("sign_reason", "")
|
||||
# SAFETY-30's SITE latch, cleared by the SAME press for the
|
||||
# same reason (the trip commands 0 W, so the condition goes
|
||||
# away on its own). ⚠️ UNCONDITIONAL, NOT GUARDED ON THE
|
||||
# ROSTER: an install reconfigured back to one inverter would
|
||||
# otherwise keep `site_sign_tripped: true` for ever.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if self.site_sign_wd.tripped:
|
||||
self.log_event("site sign-disagreement failsafe cleared "
|
||||
"by operator",
|
||||
hint=messages.hint("site_stop_cleared"))
|
||||
self.site_sign_wd.reset()
|
||||
self.store.set("site_sign_tripped", False)
|
||||
self.store.set("site_sign_reason", "")
|
||||
# SAFETY-31, cleared by the SAME press and for the same reason.
|
||||
# ⚠️ AND `_circ_announced` GOES WITH IT: left True, the NEXT
|
||||
# trip in this process would latch, stop the site and raise no
|
||||
# notification. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if self.circ_wd.tripped:
|
||||
self.circ_wd.reset()
|
||||
self.store.set("circ_tripped", False)
|
||||
self.store.set("circ_reason", "")
|
||||
self.log_event("circulating-power trip cleared by operator",
|
||||
hint=messages.hint("site_stop_cleared"))
|
||||
self._circ_announced = False
|
||||
# SAFETY-42, cleared by the SAME press, for a reason that is
|
||||
# NOT the one above: this latch commands nothing, so its
|
||||
# evidence still stands and it could clear itself. It does not:
|
||||
# a unit that stopped delivering and resumed is an INTERMITTENT
|
||||
# fault. ⚠️ AND `_delivery_announced` GOES WITH IT, like
|
||||
# `_circ_announced`. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
self.log_event(f"not-delivering report on {inv.name} "
|
||||
f"cleared by operator",
|
||||
hint=messages.hint(
|
||||
"not_delivering_cleared",
|
||||
unit=inv.name))
|
||||
inv.delivery_wd.reset()
|
||||
inv.store_set("delivery_tripped", False)
|
||||
inv.store_set("delivery_reason", "")
|
||||
self._delivery_announced = set()
|
||||
if not self.auto:
|
||||
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see
|
||||
# command_site_zero(). SMA-10: Stop gives hand-back units back.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await self.command_site_release()
|
||||
return {"auto": self.auto}
|
||||
if what in ("maint_start", "maint_abort") and unit is not None:
|
||||
# MAINT-11: the operator named the battery. A request with no unit
|
||||
# falls through to the branches below, unchanged.
|
||||
return await self._maint_unit_action(what, unit, now)
|
||||
if what == "maint_start":
|
||||
# ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
|
||||
# ONE LABEL: `_maint_button()` picks the pack that holds the
|
||||
# maintenance slot, else the one longest without a cycle; at N=1
|
||||
# both are `maints[0]`. This is the bare request; a named battery
|
||||
# is `_maint_unit_action()` (MAINT-11).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_m = self._maint_button(now)
|
||||
for msg in _m.force_start(now):
|
||||
self.log_event(msg, hint=messages.hint("maintenance",
|
||||
phase=_m.phase))
|
||||
return {"phase": self.maint.phase}
|
||||
if what == "maint_abort":
|
||||
# Aborts the pack that is actually cycling - `_maint_button()` again
|
||||
# - and then zeroes the SITE, which is what it always did.
|
||||
_m = self._maint_button(now)
|
||||
for msg in _m.abort(now, "operator"):
|
||||
self.log_event(msg, hint=messages.hint("maintenance",
|
||||
phase=_m.phase))
|
||||
await self.command_site_zero()
|
||||
return {"phase": self.maint.phase}
|
||||
return None
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Controller methods that run as background tasks.
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import messages
|
||||
from .inverter import UNIT_A
|
||||
from .price import brussels, price_option
|
||||
from .static_tariff import resolve_tariff
|
||||
from .validate import refuse, validate_startup_config, validate_unit_ranges
|
||||
from .ctl_inputs import TARIFF_MAX_AGE_S, TARIFF_REFRESH_S
|
||||
|
||||
_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, 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:
|
||||
_LOG.error("The saved safety stops did not copy over cleanly, so "
|
||||
"they are read again from the older saved state: %s.",
|
||||
rep.explain())
|
||||
data = getattr(store, "data", {})
|
||||
legacy_tripped = bool(data.get("sign_tripped", False))
|
||||
if not legacy_tripped or not self.inverters:
|
||||
return
|
||||
# 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():
|
||||
if not (k.startswith("unit/") and k.endswith("/sign_tripped")):
|
||||
continue
|
||||
if not bool(v):
|
||||
continue
|
||||
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1:
|
||||
# split through the writer's own spelling.
|
||||
unit = k.split("/")[1]
|
||||
if unit in on_roster:
|
||||
on_roster_latched = True
|
||||
elif unit == UNIT_A:
|
||||
stranded.append(k)
|
||||
else:
|
||||
other_off_roster = True
|
||||
if on_roster_latched or any(inv.sign_wd.tripped
|
||||
for inv in self.inverters):
|
||||
return
|
||||
if other_off_roster and not stranded:
|
||||
# A removed unit's own latch and no misdirected one: the flat key
|
||||
# is that unit's mirror, so re-arming `inverters[0]` from it would
|
||||
# latch a healthy pack.
|
||||
return
|
||||
if stranded:
|
||||
_LOG.error(
|
||||
"A saved safety stop belongs to %s, which is not in the "
|
||||
"inverter list %s, so it is set again on the first inverter.",
|
||||
", ".join(sorted(stranded)), sorted(on_roster))
|
||||
first = self.inverters[0]
|
||||
# The one state this method exists for.
|
||||
reason = str(data.get("sign_reason", "") or "")
|
||||
first.sign_wd.tripped = True
|
||||
first.sign_wd.reason = reason or (
|
||||
"SIGN_DISAGREEMENT: latched before this version; the reason was "
|
||||
"not carried across the per-inverter store migration")
|
||||
first.store_set("sign_reason", first.sign_wd.reason)
|
||||
first.store_set("sign_tripped", True)
|
||||
_LOG.error(
|
||||
"%s is set to 0 W again because an older saved safety stop was "
|
||||
"found, so find out why before you start control: %s.",
|
||||
first.name, messages.reason_code(first.sign_wd.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", first.name, first.sign_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
|
||||
"sign-disagreement failsafe is latched and no inverter came up "
|
||||
"latched. Either the per-inverter migration lost it, or an older "
|
||||
"add-on wrote it during a rollback. Both would be a disarmed "
|
||||
"guard, so the latch is re-armed on %s.", first.name)
|
||||
self.log_event("FAILSAFE re-armed from the pre-DEV-02 store keys - "
|
||||
+ first.sign_wd.reason,
|
||||
hint=messages.hint("stop_restored", unit=first.name))
|
||||
|
||||
# -- tasks ---------------------------------------------------------------
|
||||
# -- OPT-04: the background tariff refresh -------------------------------
|
||||
def fresh_tariff_plan(self, now):
|
||||
"""The stored plan if it is still current, else None. NEVER a stale
|
||||
one. THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for
|
||||
`loop_is_following()`'s reason: a check has to be able to assert on the
|
||||
shipped expression rather than on a transcription that drifts.
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
if self.tariff_plan is None or self.tariff_at is None:
|
||||
return None
|
||||
if (now - self.tariff_at).total_seconds() > TARIFF_MAX_AGE_S:
|
||||
return None
|
||||
return self.tariff_plan
|
||||
|
||||
async def run_tariff_refresh(self) -> None:
|
||||
"""Resolve the tariff off the control path, forever. NEVER on the tick.
|
||||
⚠️ IT CATCHES EVERYTHING AND KEEPS GOING: raising out of here would
|
||||
freeze the plan, looking current for ever (`fresh_tariff_plan()`'s age
|
||||
bound is the backstop). ⚠️ A FAILURE DOES NOT CLEAR THE PLAN
|
||||
(TARIFF-12): a degraded plan replaces a standing observation only once
|
||||
it has aged out; a §11.4 fallback never replaces a fresh provider plan.
|
||||
⚠️ `tariff_at` DOES NOT MOVE ON A KEPT PLAN.
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
horizon = float(price_option(self.o, "price_horizon_h"))
|
||||
while not self.stopping:
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
plan = await resolve_tariff(
|
||||
self.o, now, now + timedelta(hours=horizon), now=now,
|
||||
history=self.price_history, watch=self.price_watch)
|
||||
standing = self.fresh_tariff_plan(now)
|
||||
# Three terms, and each of them is the only false one in some
|
||||
# check: the feed answered (replace), nothing current stands
|
||||
# (take the degradation - this is what makes the age bound
|
||||
# govern), or what stands is itself a degradation (replace).
|
||||
keep = (not plan.from_feed
|
||||
and standing is not None and standing.from_feed)
|
||||
if keep:
|
||||
_LOG.warning(
|
||||
"The price update was incomplete, so the plan from %s "
|
||||
"is kept until it is %s old. The update held %s.",
|
||||
messages.when(brussels(self.tariff_at)),
|
||||
messages.duration(TARIFF_MAX_AGE_S), plan.words)
|
||||
else:
|
||||
self.tariff_plan, self.tariff_at = plan, now
|
||||
_LOG.info("tariff refreshed: %s", plan.words)
|
||||
except Exception as err: # noqa: BLE001
|
||||
# ⚠️ NOT THE OUTAGE PATH - see the docstring: `load_prices()`
|
||||
# returns on every failure a site can have, so what reaches
|
||||
# here is unexpected, and the plan is kept on the same terms.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_LOG.warning(
|
||||
"The price update failed (%s), so the plan from %s is kept "
|
||||
"until it is %s old, and trading stops then.",
|
||||
err, messages.when(brussels(self.tariff_at)),
|
||||
messages.duration(TARIFF_MAX_AGE_S))
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(self.stop_event.wait(),
|
||||
timeout=TARIFF_REFRESH_S)
|
||||
|
||||
async def run_control(self) -> None:
|
||||
# ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4 (one
|
||||
# read of one `setpoint_entity` left a second inverter's clamp, freeze
|
||||
# rail and `achievable_w` on the first one's numbers). Concurrently: a
|
||||
# slow ESPHome on one unit must not delay the start on the others.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
async def _limits(inv):
|
||||
if inv.driver.configured():
|
||||
inv.dev_min, inv.dev_max = await self._driver(inv).limits()
|
||||
await asyncio.gather(*(_limits(inv) for inv in self.inverters))
|
||||
entity = self.o.get("setpoint_entity", "")
|
||||
# ⚠️ THIS USED TO WARN AND CARRY ON, the bug CFG-01 exists to remove: a
|
||||
# max_w above the device's rating was silently clamped. amain() ran
|
||||
# this gate already; it runs AGAIN because ESPHome is often not up at
|
||||
# boot (limits None) and this is the first moment the real rating is
|
||||
# known; a conflict refuses through the normal shutdown (0 W). ⚠️ THE
|
||||
# `if entity:` THAT WRAPPED THIS BLOCK FAILED PERMISSIVE (CFG-04's
|
||||
# hazard): a per-inverter config leaves the flat `setpoint_entity`
|
||||
# blank, so the re-check was skipped; the per-unit rule now runs
|
||||
# unconditionally. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
problems = []
|
||||
if entity:
|
||||
_first = self.inverters[0]
|
||||
problems += validate_startup_config(self.o, _first.dev_min,
|
||||
_first.dev_max)
|
||||
problems += validate_unit_ranges(self.inverters, skip_entity=entity)
|
||||
if problems:
|
||||
refuse(problems)
|
||||
self.refused = problems
|
||||
self.log_event("REFUSED: " + problems[0],
|
||||
hint=messages.hint("refused"))
|
||||
# Signal and return WITHOUT entering the loop: amain() runs the
|
||||
# wind-down (0 W, or a hand-back: SMA-10, see
|
||||
# DECISIONS/SMA-10-two-idle-states.md) and exits with code 78.
|
||||
self.stop_event.set()
|
||||
return
|
||||
|
||||
last_grid = object()
|
||||
heartbeat = float(self.o.get("heartbeat_s", 10))
|
||||
# ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not
|
||||
# a second scheduler: every cycle resets it, so on a healthy meter it
|
||||
# never elapses and the per-cycle gain is unchanged. It exists because
|
||||
# a meter that repeats a value would run NO cycles, and the `inputs
|
||||
# missing -> 0 W` claim lives inside cycle(). NOT a freeze detector. ⚠️
|
||||
# THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN: it sits under
|
||||
# validate.FIRMWARE_INGEST_VETO_S (30 s) and `meter_max_age_s` and
|
||||
# moves with the firmware's veto; the schema minimum of 5 s is the
|
||||
# reference meter's interval and deserves a second look.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
floor = float(self.o.get("cycle_floor_s", 15))
|
||||
last_cycle = None
|
||||
while not self.stopping:
|
||||
await self.read_inputs()
|
||||
now_mono = asyncio.get_running_loop().time()
|
||||
# A cycle is one meter update, exactly as on the reference install -
|
||||
# or the floor elapsing, whichever comes first.
|
||||
overdue = last_cycle is None or (now_mono - last_cycle) >= floor
|
||||
if self.grid != last_grid or overdue:
|
||||
last_grid = self.grid
|
||||
last_cycle = now_mono
|
||||
# ⚠️ The FULL cycle, not a degraded one: in P1 mode past
|
||||
# `meter_max_age_s` (30 s) p1.net_w is None ("inputs missing").
|
||||
# ⚠️ TRANSPORT-CONDITIONAL: with `meter_source: off`,
|
||||
# read_inputs() has NO age gate, so a quiet entity reads as
|
||||
# current; the floor does not make it fresh. The integrator
|
||||
# advances per CYCLE, so bounding the gap bounds the
|
||||
# accumulation. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await self.cycle()
|
||||
# ⚠️ The heartbeat is not an optimisation: the ESP32 zeroes the
|
||||
# inverter after ~30 s of silence, and refreshing the SAME value
|
||||
# proves we are alive. ⚠️ ONE DEADLINE PER INVERTER, refreshed
|
||||
# concurrently, each bounded by WRITE_BUDGET_S (budget plus the
|
||||
# largest `heartbeat_s` stays under layer 1's veto: graded); each
|
||||
# unit refreshes its own `inv.target`, never the site target.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await self.send_heartbeats(asyncio.get_running_loop().time(),
|
||||
heartbeat)
|
||||
self.publish()
|
||||
self.loop_pass_mono = time.monotonic()
|
||||
await asyncio.sleep(1)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,613 @@
|
||||
"""Controller methods that write to, refuse and hand back the inverters.
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import control as _ctl
|
||||
from .clamp import clamp_to_rating, is_commandable
|
||||
from . import messages
|
||||
from . import shadow
|
||||
|
||||
_LOG = logging.getLogger("goodwe")
|
||||
|
||||
|
||||
# DEV-02: the per-write budget, DERIVED: `heartbeat_s` max 25 s + 4 <
|
||||
# `validate.FIRMWARE_INGEST_VETO_S` (30 s), graded by test_inverter.py.
|
||||
# DECISIONS/MAIN-orchestration.md.
|
||||
WRITE_BUDGET_S = 4.0
|
||||
|
||||
|
||||
# SMA-10: the bound for handing a unit back (`driver.release()`); units are
|
||||
# released concurrently and one still running at the bound is abandoned.
|
||||
# DECISIONS/SMA-10-two-idle-states.md.
|
||||
RELEASE_BUDGET_S = WRITE_BUDGET_S
|
||||
|
||||
|
||||
# SMA-10: the one fallback write after a failed release (default 0 W). Worst
|
||||
# case RELEASE_BUDGET_S + FALLBACK_BUDGET_S = 6 s, inside the Supervisor's 10 s.
|
||||
# DECISIONS/SMA-10-two-idle-states.md.
|
||||
FALLBACK_BUDGET_S = 2.0
|
||||
|
||||
|
||||
# SAFETY-137: how long a LAST write waits for a write already on its way to the
|
||||
# same unit; half the release budget, so a GoodWe unit's worst case is
|
||||
# HAND_BACK_WORST_S. DECISIONS/SAFETY-137-last-write-is-zero.md.
|
||||
SETTLE_BUDGET_S = RELEASE_BUDGET_S / 2
|
||||
|
||||
|
||||
# SAFETY-73: the evidence must also span this long, not only SETTLE_CYCLES
|
||||
# cycles: a 1 s meter makes 12 cycles only 11 s, shorter than a healthy battery
|
||||
# reading's lag, and both inverters were refused after ordinary load steps.
|
||||
REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S
|
||||
|
||||
|
||||
# SAFETY-94: evidence restarts when the reading has moved this far toward the
|
||||
# ask. A refusing battery reads flat; live readings wander under 25 W at rest;
|
||||
# a first-order lag gains at most 0.4 tau x 100 W across the band, so the
|
||||
# evidence cannot span REFUSED_SPAN_S unless the lag is over 54 s.
|
||||
REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2
|
||||
|
||||
|
||||
async def _bounded(awaitable, budget_s: float):
|
||||
"""SMA-10. `await awaitable`, abandoning it after `budget_s` without waiting
|
||||
for it to wind down (`wait_for` would). Raises `asyncio.TimeoutError`."""
|
||||
task = asyncio.ensure_future(awaitable)
|
||||
done, _ = await asyncio.wait({task}, timeout=budget_s)
|
||||
if not done:
|
||||
task.cancel()
|
||||
# a result or error that arrives later has nobody to read it
|
||||
task.add_done_callback(lambda t: t.cancelled() or t.exception())
|
||||
raise asyncio.TimeoutError
|
||||
return task.result()
|
||||
|
||||
|
||||
class WritesMixin:
|
||||
# helpers; SAFETY-52: a battery that refuses is asked for 0 W
|
||||
def _refusal_probe(self) -> bool:
|
||||
"""True while a refused direction may try `PROBE_W` (every 15 min)."""
|
||||
if (not self.batt_refused or self.batt_refused_at is None
|
||||
or self._now_mono is None):
|
||||
return False
|
||||
held = self._now_mono - self.batt_refused_at
|
||||
if held >= _ctl.PROBE_EVERY_S + _ctl.PROBE_S:
|
||||
self.batt_refused_at = self._now_mono
|
||||
return False
|
||||
return held >= _ctl.PROBE_EVERY_S
|
||||
|
||||
def _refusal_start(self, sign: int, evidence: str) -> None:
|
||||
self.batt_refused = sign
|
||||
self.batt_refused_at = self._now_mono
|
||||
self.log_event(f"BATTERY REFUSES {evidence}",
|
||||
hint=messages.hint("battery_refuses"))
|
||||
_LOG.info("The battery refuses to give power, so the add-on asks 0 W "
|
||||
"and tries %.0f W every %.0f minutes: BATTERY_REFUSES.",
|
||||
_ctl.PROBE_W, _ctl.PROBE_EVERY_S / 60)
|
||||
_LOG.debug("Evidence: BATTERY_REFUSES: %s.", evidence)
|
||||
|
||||
def _refusal_end(self, why: str) -> None:
|
||||
if not self.batt_refused:
|
||||
return
|
||||
self.batt_refused, self.batt_refused_at = 0, None
|
||||
self.log_event(f"BATTERY REFUSES ENDED: {why}",
|
||||
hint=messages.hint("battery_gives"))
|
||||
_LOG.info("The battery refusal ends, control asks for power again: %s.",
|
||||
why)
|
||||
|
||||
def _refusal_update(self, decision, asked_w, batt_w, probe,
|
||||
rejudged: int = 0) -> None:
|
||||
"""Follow `compute()`'s carried refused direction, and say so.
|
||||
`rejudged`: the refusal held while another writer had the wire, so
|
||||
`compute()` judged it afresh against that writer's ask (SAFETY-107)."""
|
||||
now = decision.refused or 0
|
||||
if rejudged:
|
||||
if now == rejudged:
|
||||
# Still refused against the ask on the wire: the same episode.
|
||||
# The probe clock restarts: a probe is the loop's ask and is
|
||||
# not on the wire while another writer is.
|
||||
self.batt_refused_at = self._now_mono
|
||||
return
|
||||
gave = (rejudged * (batt_w - self._site_idle_w())
|
||||
>= _ctl.PROBE_ANSWER_W)
|
||||
self._refusal_end("the battery gives power" if gave
|
||||
else "another writer")
|
||||
if now and not self.batt_refused:
|
||||
self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery "
|
||||
f"gave {batt_w:+.0f} W for "
|
||||
f"{decision.sat_count} cycles")
|
||||
elif self.batt_refused and not now:
|
||||
gave = (self.batt_refused * (batt_w - self._site_idle_w())
|
||||
>= _ctl.PROBE_ANSWER_W)
|
||||
self._refusal_end("the probe was answered" if gave and probe
|
||||
else "the battery gives power" if gave
|
||||
else "the grid now asks the other direction")
|
||||
|
||||
def _refusal_from_stale(self, inv, now_mono) -> None:
|
||||
"""A stale-reading stop while the loop is frozen on ~0 W is a refusal.
|
||||
Only at one inverter: with several, one empty battery must not stop
|
||||
the site (OPT-09 moves its share)."""
|
||||
t = self.tuning
|
||||
standing = inv.standing_w()
|
||||
if len(self.inverters) > 1:
|
||||
# SAFETY-73: at N>1 the same trip refuses THIS unit only.
|
||||
if ((self.auto or self.shadow) and not inv.refused
|
||||
and inv.ref_count >= t.saturation_cycles
|
||||
and inv.batt_fresh.held is not None
|
||||
and abs(inv.batt_fresh.held - inv.idle_w)
|
||||
< _ctl.REFUSED_BELOW_W
|
||||
and abs(standing) > _ctl.PROBE_W):
|
||||
self._now_mono = now_mono
|
||||
self._unit_refusal_start(
|
||||
inv, 1 if standing > 0 else -1,
|
||||
f"stale reading {inv.batt_fresh.held:+.0f} W while asked "
|
||||
f"{standing:+.0f} W")
|
||||
return
|
||||
if (len(self.inverters) == 1 and (self.auto or self.shadow)
|
||||
and not self.batt_refused
|
||||
and self.sat_count >= t.saturation_cycles
|
||||
and inv.batt_fresh.held is not None
|
||||
and abs(inv.batt_fresh.held - inv.idle_w)
|
||||
< _ctl.REFUSED_BELOW_W
|
||||
and abs(standing) > t.saturation_w):
|
||||
self._now_mono = now_mono
|
||||
self._refusal_start(1 if standing > 0 else -1,
|
||||
f"stale reading {inv.batt_fresh.held:+.0f} W "
|
||||
f"while asked {standing:+.0f} W")
|
||||
|
||||
# SAFETY-73: SAFETY-52's decisions per inverter at N>1: a unit giving <
|
||||
# REFUSED_BELOW_W for SETTLE_CYCLES while asked > PROBE_W is closed in that
|
||||
# direction by a P_SAFETY unit limit, so the allocator hands its share on;
|
||||
# probed at PROBE_W every PROBE_EVERY_S, one unit at a time. The ask is
|
||||
# PROBE_W, not `saturation_w`: a unit's share is often small and SAFETY-43
|
||||
# would name the empty battery as not delivering.
|
||||
# DECISIONS/SAFETY-73-refusal-per-inverter.md.
|
||||
def _unit_refusal_start(self, inv, sign: int, evidence: str) -> None:
|
||||
inv.refused, inv.refused_at = sign, self._now_mono
|
||||
inv.probe_from, inv.ref_count = None, 0
|
||||
self.log_event(f"BATTERY REFUSES {inv.name}: {evidence}",
|
||||
hint=messages.hint("unit_refuses", unit=inv.name))
|
||||
_LOG.info("%s: the battery refuses power, so it is asked 0 W and the "
|
||||
"other inverters take over: BATTERY_REFUSES.", inv.name)
|
||||
_LOG.debug("Evidence for %s: BATTERY_REFUSES: %s.", inv.name, evidence)
|
||||
# Only a refusal to GIVE: a full battery that stops taking charge is
|
||||
# above its cut-out level by definition.
|
||||
if (sign > 0 and inv.soc is not None and inv.bms_soc_floor is not None
|
||||
and float(inv.soc) > float(inv.bms_soc_floor)):
|
||||
# Owner decision 3: a refusal above the configured cut-out level
|
||||
# may mean that level is set wrong. One line, no safety stop.
|
||||
_LOG.warning("%s refuses power above its battery cut-out level, "
|
||||
"check its cut-out level: REFUSES_ABOVE_CUT_OUT.",
|
||||
inv.name)
|
||||
_LOG.debug("Evidence for %s: REFUSES_ABOVE_CUT_OUT: battery level "
|
||||
"%.0f %%, cut-out level %.0f %%.", inv.name,
|
||||
float(inv.soc), float(inv.bms_soc_floor))
|
||||
|
||||
def _unit_refusal_end(self, inv, why: str) -> None:
|
||||
if not inv.refused:
|
||||
return
|
||||
inv.refused, inv.refused_at, inv.probe_from = 0, None, None
|
||||
inv.ref_count = 0
|
||||
self.log_event(f"BATTERY REFUSES ENDED {inv.name}: {why}",
|
||||
hint=messages.hint("unit_gives", unit=inv.name))
|
||||
_LOG.info("%s: the battery refusal ends, it is asked for power again: "
|
||||
"%s.", inv.name, why)
|
||||
# The site target may stand above what was placed (the refused share
|
||||
# was unmet). Restart it from the watts on the wire, or the reopened
|
||||
# unit gets the parked remainder at once (phase A: -1059 W).
|
||||
placed = float(sum(i.standing_w() for i in self.inverters))
|
||||
self.target = self.i_w = placed
|
||||
self.sat_count = 0
|
||||
|
||||
def _unit_probe(self, inv) -> bool:
|
||||
"""True while `inv` may try PROBE_W. One unit probes at a time."""
|
||||
now = self._now_mono
|
||||
if not inv.refused or inv.refused_at is None or now is None:
|
||||
return False
|
||||
if inv.probe_from is not None:
|
||||
if now - inv.probe_from < _ctl.PROBE_S:
|
||||
return True
|
||||
inv.probe_from, inv.refused_at = None, now
|
||||
return False
|
||||
if now - inv.refused_at < _ctl.PROBE_EVERY_S or any(
|
||||
i.probe_from is not None for i in self.inverters):
|
||||
return False
|
||||
inv.probe_from = now
|
||||
return True
|
||||
|
||||
def _unit_refusals(self) -> None:
|
||||
"""Engage and end each unit's refusal from its own reading."""
|
||||
for inv in self.inverters:
|
||||
b = inv.batt
|
||||
if inv.refused:
|
||||
# SAFETY-97: given and taken are counted from the idle reading.
|
||||
if (b is not None and inv.refused * (b - inv.idle_w)
|
||||
>= _ctl.PROBE_ANSWER_W):
|
||||
self._unit_refusal_end(
|
||||
inv, "the probe was answered"
|
||||
if inv.probe_from is not None
|
||||
else "the battery gives power")
|
||||
continue
|
||||
# The other direction: the battery takes PROBE_ANSWER_W or more
|
||||
# the other way for SETTLE_CYCLES, counted on its own reading
|
||||
# so one overshoot through zero does not hand its share back.
|
||||
inv.ref_count = (inv.ref_count + 1 if b is not None and
|
||||
-inv.refused * (b - inv.idle_w)
|
||||
>= _ctl.PROBE_ANSWER_W else 0)
|
||||
if inv.ref_count >= _ctl.SETTLE_CYCLES:
|
||||
self._unit_refusal_end(
|
||||
inv, "the battery takes power the other way")
|
||||
continue
|
||||
if inv.rejected_since is not None:
|
||||
# SAFETY-96: its writes are refused, so its board may have gone
|
||||
# to 0 W on its own; that is no refusal by the battery. The
|
||||
# count is kept, as across a gap.
|
||||
continue
|
||||
sign = self._gave_nothing(inv)
|
||||
if sign:
|
||||
self._unit_refusal_start(
|
||||
inv, sign,
|
||||
f"asked {inv.standing_w():+.0f} W, the battery gave "
|
||||
f"{b:+.0f} W for {_ctl.SETTLE_CYCLES} cycles")
|
||||
|
||||
def _gave_nothing(self, inv) -> int:
|
||||
"""The refusal evidence, one cycle of it: the sign `inv` has given
|
||||
under REFUSED_BELOW_W while asked over PROBE_W, one way, for
|
||||
SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1;
|
||||
SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the
|
||||
band is centred on the unit's idle reading."""
|
||||
b = inv.batt
|
||||
if b is None:
|
||||
return 0 # a gap keeps the count: the gate's trip seeds
|
||||
standing = inv.standing_w()
|
||||
sign = 1 if standing > 0 else -1
|
||||
# One direction throughout: a command that swaps sign every cycle reads
|
||||
# ~0 through the plant's lag, which is a battery following, not
|
||||
# refusing (TEST-19).
|
||||
if (abs(standing) > _ctl.PROBE_W
|
||||
and abs(b - inv.idle_w) < _ctl.REFUSED_BELOW_W):
|
||||
# SAFETY-94: a reading still moving towards the ask is a battery
|
||||
# catching up through its lag: start again.
|
||||
if (sign != inv.ref_sign or not inv.ref_count
|
||||
or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W):
|
||||
inv.ref_count, inv.ref_since, inv.ref_from = (
|
||||
0, self._now_mono, b)
|
||||
inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
|
||||
else:
|
||||
inv.ref_count = 0
|
||||
inv.ref_sign = sign
|
||||
if inv.ref_count >= _ctl.SETTLE_CYCLES and (
|
||||
inv.ref_since is None or self._now_mono is None
|
||||
or self._now_mono - inv.ref_since >= REFUSED_SPAN_S):
|
||||
return sign
|
||||
return 0
|
||||
|
||||
def _site_idle_w(self) -> float:
|
||||
"""SAFETY-97: the site reading at idle, the sum of the units'."""
|
||||
return float(sum(inv.idle_w for inv in self.inverters))
|
||||
|
||||
def _site_refused(self):
|
||||
"""Owner decision 4: every unit refuses the same way, so the site law
|
||||
holds 0 W (SAFETY-52's, in compute()). Otherwise None, the old law."""
|
||||
signs = {inv.refused for inv in self.inverters}
|
||||
return signs.pop() if len(signs) == 1 and 0 not in signs else None
|
||||
|
||||
@property
|
||||
def shadow(self) -> bool:
|
||||
"""SAFETY-10. Is the battery being left alone right now? A property,
|
||||
not a field (as `self.units`): the cycle, the write and the status
|
||||
page ask at three moments, and the write gate asks THIS, not a
|
||||
parameter passed down."""
|
||||
return shadow.engaged(self.o, control_started=self.auto)
|
||||
|
||||
def shadow_latches(self) -> tuple:
|
||||
"""The failsafe latches that are UP, named, for the shadow banner: a
|
||||
standing latch is standing when the controller stops being inert, so
|
||||
an operator who turns shadow off is not handed a battery that will
|
||||
not move with no reason shown."""
|
||||
out = []
|
||||
if self.circ_wd.tripped:
|
||||
out.append("circulating power")
|
||||
if self.site_sign_wd.tripped and len(self.inverters) > 1:
|
||||
out.append("site sign watchdog")
|
||||
for inv in self.inverters:
|
||||
if inv.sign_wd.tripped:
|
||||
out.append(f"{inv.name} sign watchdog")
|
||||
return tuple(out)
|
||||
|
||||
def write_refusal(self, value: float, inv=None) -> str | None:
|
||||
"""Why `value` may NOT be written to `inv` right now, or None if it
|
||||
may. SAFETY-28: a number reaches the wire only if THE SINGLE ALLOCATOR
|
||||
placed it, on this tick, for THIS unit (`Allocation.authorises()`); a
|
||||
second writer must file a Claim. 0 W is ALWAYS authorised and tested
|
||||
first, reading nothing: the failsafe writes run while the allocator has
|
||||
REFUSED, so a gate asking permission to command zero would deadlock the
|
||||
failsafe. It tests `value == 0.0`, not a flag the caller sets. DEV-02:
|
||||
the unit is an ARGUMENT (the record holds one share per unit); the
|
||||
default is for N=1 callers and REFUSES at N>=2.
|
||||
DECISIONS/MAIN-orchestration.md."""
|
||||
if value == 0.0:
|
||||
return None
|
||||
inv = self._only() if inv is None else inv
|
||||
rec = self.allocation
|
||||
if rec is None:
|
||||
return ("no allocation record exists yet - nothing has authorised "
|
||||
f"{value:+.0f} W on {inv.name}")
|
||||
return rec.authorises(inv.name, value, self.alloc_tick)
|
||||
|
||||
async def write_setpoint(self, value: float, *, force: bool = False,
|
||||
unit=None, last: bool = False) -> None:
|
||||
"""Put `value` on ONE inverter, or with `unit=None` on all. `unit=None`
|
||||
is for a number that is not a share (stop button, `shutdown()`,
|
||||
`maint_abort()`: 0 W means the same to every inverter). Writes are
|
||||
concurrent, each under its own budget (DEV-02). `last`:
|
||||
DECISIONS/SAFETY-137-last-write-is-zero.md."""
|
||||
if unit is None:
|
||||
targets = [i for i in self.inverters]
|
||||
if len(targets) > 1 and is_commandable(value) and float(value) != 0.0:
|
||||
# Loud, and it commands zero rather than the value asked for:
|
||||
# fail toward inaction, as every other refusal here.
|
||||
self.log_event(
|
||||
f"refusing to broadcast {float(value):+.0f} W to "
|
||||
f"{len(targets)} inverters - that is the ARCH-01 fault; "
|
||||
f"commanding 0 W", hint=messages.hint("all_blocked"))
|
||||
_LOG.error("Refused to send %+.0f W to all %s at once, so "
|
||||
"every inverter is set to 0 W. This is a software "
|
||||
"fault.", float(value),
|
||||
messages.count(len(targets), "inverter"))
|
||||
_LOG.debug("ARCH-01: a site target handed to each unit is "
|
||||
"supplied by each of them, so the gain at the meter "
|
||||
"becomes N times the per-unit gain. Only 0 W may be "
|
||||
"written site-wide, and shares come from the "
|
||||
"allocator.")
|
||||
value = 0.0
|
||||
else:
|
||||
targets = [unit]
|
||||
if not targets:
|
||||
return
|
||||
await asyncio.gather(*(self._write_unit(inv, value, force=force,
|
||||
last=last)
|
||||
for inv in targets))
|
||||
|
||||
async def _await_inflight(self, inv, budget_s: float) -> None:
|
||||
"""Wait, at most `budget_s`, for writes already on their way to `inv`; one
|
||||
still running is left to its own WRITE_BUDGET_S.
|
||||
DECISIONS/SAFETY-137-last-write-is-zero.md."""
|
||||
pending = [t for t in inv.inflight if not t.done()]
|
||||
if pending:
|
||||
await asyncio.wait(pending, timeout=budget_s)
|
||||
|
||||
async def _release_last(self, inv) -> bool:
|
||||
"""`release()` once any write in flight to `inv` has finished (at most
|
||||
SETTLE_BUDGET_S): the release is the LAST thing the unit hears.
|
||||
DECISIONS/SMA-10-two-idle-states.md."""
|
||||
await self._await_inflight(inv, SETTLE_BUDGET_S)
|
||||
return await self._driver(inv).release()
|
||||
|
||||
async def _hand_back(self, inv, *, force: bool = False) -> bool:
|
||||
"""SMA-10. Give ONE unit back: exactly one `release()`, then nothing.
|
||||
True when handed back. Never raises; a failure is retried on the next
|
||||
idle write. ⚠️ `released` is set before the first await (one release per
|
||||
unit, not two). ⚠️ It is not a write: no `note_write`, no `last_write`.
|
||||
DECISIONS/SMA-10-two-idle-states.md."""
|
||||
if inv.released:
|
||||
return True
|
||||
if not inv.driver.configured():
|
||||
return False
|
||||
# After a failure the next try waits one heartbeat; Stop and shutdown
|
||||
# (`force`) always try. DECISIONS/SMA-10-two-idle-states.md.
|
||||
now_m = time.monotonic()
|
||||
if not force and now_m < inv.release_retry_at:
|
||||
return False
|
||||
inv.released = True
|
||||
why = ""
|
||||
try:
|
||||
ok = bool(await _bounded(self._release_last(inv),
|
||||
RELEASE_BUDGET_S))
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
ok, why = False, f"took over {RELEASE_BUDGET_S:.0f} s"
|
||||
except Exception as err: # noqa: BLE001 - one unit never stops the rest
|
||||
ok, why = False, f"{type(err).__name__}: {err}"
|
||||
if ok:
|
||||
inv.last_write = None
|
||||
if inv.release_fails:
|
||||
_LOG.info("Handing back %s worked again after %d failed "
|
||||
"tries.", inv.name, inv.release_fails)
|
||||
inv.release_fails = 0
|
||||
inv.release_retry_at = 0.0
|
||||
inv.release_zero_sent = False
|
||||
self.log_event(f"{inv.name}: handed back",
|
||||
hint=messages.hint("handed_back", unit=inv.name))
|
||||
return True
|
||||
# SMA-10: a failed hand-back is tried again, and sent 0 W once below.
|
||||
inv.released = False
|
||||
inv.release_fails += 1
|
||||
inv.release_retry_at = now_m + float(self.o.get("heartbeat_s", 10))
|
||||
_LOG.debug("Handing back %s failed (try %d): %s", inv.name,
|
||||
inv.release_fails, why)
|
||||
if inv.release_fails == 1: # one line per failure streak
|
||||
_LOG.error("Handing back %s failed, so it is sent 0 W once and "
|
||||
"handing back is tried again.", inv.name)
|
||||
self.log_event(f"{inv.name}: hand-back failed",
|
||||
hint=messages.hint("hand_back_failed", unit=inv.name))
|
||||
if not inv.release_zero_sent:
|
||||
# ⚠️ ONCE: a 0 W re-sent on every try would reset the inverter's own
|
||||
# fallback timer for ever. DECISIONS/SMA-10-two-idle-states.md.
|
||||
inv.release_zero_sent = True
|
||||
try:
|
||||
if await _bounded(self._driver(inv).release_fallback(),
|
||||
FALLBACK_BUDGET_S):
|
||||
inv.last_write = 0.0
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOG.debug("The fallback write to %s failed: %r", inv.name, err)
|
||||
return False
|
||||
|
||||
async def _write_unit(self, inv, value: float, *, force: bool = False,
|
||||
last: bool = False) -> None:
|
||||
if not inv.driver.configured():
|
||||
return
|
||||
# SMA-10: a unit that hands back is released once and not written while
|
||||
# the add-on is idle (shutdown, stopped, shadow). ⚠️ A safety stop is
|
||||
# NOT idle: it falls through to the 0 W write. Control starting again
|
||||
# re-arms the unit; `stopping` never does.
|
||||
# DECISIONS/SMA-10-two-idle-states.md.
|
||||
if inv.driver.hands_back:
|
||||
if self.stopping or self.shadow or not self.auto:
|
||||
await self._hand_back(inv)
|
||||
return
|
||||
if inv.released or inv.release_fails or inv.release_zero_sent:
|
||||
# Control has the unit again: forget the hand-back and any
|
||||
# failed streak.
|
||||
inv.released = False
|
||||
inv.last_write = None
|
||||
inv.release_fails = 0
|
||||
inv.release_retry_at = 0.0
|
||||
inv.release_zero_sent = False
|
||||
entity = inv.driver.actuator_id() # for the log line below
|
||||
|
||||
# SAFETY-03: the clamp is on the ONLY path to the wire (cycle,
|
||||
# heartbeat, stop button, shutdown(); no optimizer, maintenance or
|
||||
# manual path routes around it). A non-finite value is loss of input,
|
||||
# not an out-of-range one (11.1): coerced to 0 W.
|
||||
if not is_commandable(value):
|
||||
self.log_event(f"non-finite setpoint {value!r} - commanding 0 W",
|
||||
hint=messages.hint("bad_value"))
|
||||
_LOG.error("refusing to write a non-finite setpoint %r - commanding "
|
||||
"0 W and continuing to command. This is a bug upstream, "
|
||||
"not a limit.", value)
|
||||
value = 0.0
|
||||
|
||||
# SAFETY-28: the refusal is HERE, above the clamp and below the
|
||||
# non-finite coercion (a nan is loss of input, not an unauthorised
|
||||
# number), and asked about `inv`, not the site.
|
||||
# DECISIONS/MAIN-orchestration.md.
|
||||
refusal = self.write_refusal(value, inv)
|
||||
if refusal is not None:
|
||||
self.log_event(
|
||||
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W",
|
||||
hint=messages.hint("write_blocked", unit=inv.name))
|
||||
_LOG.error("Refused to send %+.0f W to %s, so it is set to 0 W "
|
||||
"instead, which is a software fault. The reason: %s.",
|
||||
value, inv.name, refusal)
|
||||
value = 0.0
|
||||
|
||||
# This unit's own three numbers: `inv.max_w` (supervised cap on THIS
|
||||
# setpoint entity) and `inv.dev_min/dev_max` (what THIS device
|
||||
# reported). Read off `self.o` they were one inverter's rating applied
|
||||
# to every inverter.
|
||||
result = clamp_to_rating(value, inv.max_w, inv.dev_min, inv.dev_max)
|
||||
inv.clamp_last = result
|
||||
if result.engaged:
|
||||
# EVERY engagement is logged, never deduplicated: the action log
|
||||
# (15) is a different surface from the deduplicated notification.
|
||||
# The count says how hard the loop pushed.
|
||||
self.log_event(f"{inv.name}: {result.describe()}",
|
||||
hint=messages.hint("write_cut", unit=inv.name))
|
||||
value = result.applied_w
|
||||
|
||||
# SAFETY-10: shadow mode runs the whole decision path and commands
|
||||
# nothing, and the suppression is HERE, the last thing before the wire,
|
||||
# in the one method every write reaches (so no call site routes round
|
||||
# it). A substitution, not a return, like the two refusals above; below
|
||||
# SAFETY-28's gate and SAFETY-03's clamp; not a second writer (no
|
||||
# claim, no transport call).
|
||||
if self.shadow and value != shadow.WIRE_W:
|
||||
# UX-10: DEBUG, because it repeats every cycle in shadow mode; the
|
||||
# INFO line when shadow comes on says it once.
|
||||
_LOG.debug("Shadow mode: would send %+.0f W to %s, and holds "
|
||||
"%+.0f W. Nothing was sent to the inverter.",
|
||||
value, inv.name, shadow.WIRE_W)
|
||||
value = shadow.WIRE_W
|
||||
|
||||
# SAFETY-137: `last` (the Stop and shutdown 0 W) waits, bounded, for a
|
||||
# write already on its way; once `stopping`, every other write is dropped
|
||||
# here with no await before the write is made.
|
||||
# DECISIONS/SAFETY-137-last-write-is-zero.md.
|
||||
if last:
|
||||
await self._await_inflight(inv, SETTLE_BUDGET_S)
|
||||
elif self.stopping:
|
||||
_LOG.debug("Shutting down, so %+.0f W to %s is not sent.",
|
||||
value, inv.name)
|
||||
return
|
||||
changed = (inv.last_write is None or value != inv.last_write)
|
||||
if not (changed or force):
|
||||
return
|
||||
# BOUNDED, and the budget is AC 6: one hung transport would hold the
|
||||
# coroutine, the whole loop and every OTHER inverter's heartbeat. An
|
||||
# abandoned write is re-issued next tick (`last_write` moves only on an
|
||||
# ACCEPTED write).
|
||||
try:
|
||||
_w = self._driver(inv).write(value)
|
||||
# SMA-10 / SAFETY-137: kept so a release or last 0 W can wait for
|
||||
# it. DECISIONS/SAFETY-137-last-write-is-zero.md.
|
||||
_w = asyncio.ensure_future(_w)
|
||||
inv.inflight.add(_w)
|
||||
_w.add_done_callback(inv.inflight.discard)
|
||||
ok = await asyncio.wait_for(_w, timeout=WRITE_BUDGET_S)
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
ok = False
|
||||
_LOG.error("Sending %+.0f W to %s (%s) took over %.0f s and was "
|
||||
"dropped, so it is sent again next cycle.",
|
||||
value, inv.name, entity, WRITE_BUDGET_S)
|
||||
self.log_event(f"{inv.name}: write timed out after "
|
||||
f"{WRITE_BUDGET_S:.0f} s - re-issuing next tick",
|
||||
hint=messages.hint("write_slow", unit=inv.name))
|
||||
now = datetime.now(timezone.utc)
|
||||
inv.note_write(bool(ok), now)
|
||||
if ok:
|
||||
inv.last_write = value
|
||||
inv.last_write_at = now
|
||||
|
||||
async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None:
|
||||
"""Refresh every inverter whose own deadline has come. AC 6 lives here.
|
||||
⚠️ A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` so a check
|
||||
can inject a hang: "a hung write to A cannot consume B's layer-1
|
||||
budget"; inline, the mutation to a serial `for` loop passed the whole
|
||||
suite. (DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
due = [inv for inv in self.inverters
|
||||
if loop_now - inv.last_beat >= heartbeat_s]
|
||||
if not due:
|
||||
return
|
||||
# ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT: after, a
|
||||
# hanging write drifts into being re-sent every tick and a raising one
|
||||
# never advances. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
for inv in due:
|
||||
inv.last_beat = loop_now
|
||||
# ⚠️ CONCURRENTLY: in series, every inverter behind a hung one waits
|
||||
# out its whole budget, pushing a healthy sibling towards layer 1's ~30
|
||||
# s veto. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await asyncio.gather(*(
|
||||
self.write_setpoint(inv.target, force=True, unit=inv)
|
||||
for inv in due))
|
||||
|
||||
async def command_site_release(self) -> None:
|
||||
"""SMA-10. The add-on is going away (Stop, shutdown, restart, task death,
|
||||
config refusal): units that hand back are given back, the rest held at
|
||||
0 W as `command_site_zero()` does. ⚠️ Safety stops and refusals do NOT
|
||||
come here: they hold 0 W for EVERY unit. A roster with no hand-back unit
|
||||
is `command_site_zero()` line for line.
|
||||
DECISIONS/SMA-10-two-idle-states.md."""
|
||||
if not any(inv.driver.hands_back for inv in self.inverters):
|
||||
await self.command_site_zero(last=True)
|
||||
return
|
||||
self.target = 0.0
|
||||
for inv in self.inverters:
|
||||
inv.target = 0.0
|
||||
await asyncio.gather(*(
|
||||
self._hand_back(inv, force=True) if inv.driver.hands_back
|
||||
else self._write_unit(inv, 0.0, force=True, last=True)
|
||||
for inv in self.inverters))
|
||||
|
||||
async def command_site_zero(self, *, last: bool = False) -> None:
|
||||
"""Every inverter to 0 W, and every inverter's TARGET with it. ⚠️ NOT
|
||||
BOOKKEEPING (DEV-02): the heartbeat re-emits `inv.target`, so zeroing
|
||||
only `self.target` lets the next heartbeat put each unit's last share
|
||||
back on the wire. ⚠️ 0 W IS THE ONE VALUE THAT MAY BE WRITTEN SITE-WIDE
|
||||
(`write_setpoint()` refuses others: ARCH-01).
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
self.target = 0.0
|
||||
for inv in self.inverters:
|
||||
inv.target = 0.0
|
||||
await self.write_setpoint(0.0, force=True, last=last)
|
||||
+383
-7647
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
"""Module functions for the status rows and the Why line.
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
from .arbiter import ACTUATOR_SITE, SET
|
||||
from . import control as _ctl
|
||||
from . import messages
|
||||
|
||||
|
||||
def why_raised(resolution, claims) -> bool:
|
||||
"""F2 (OPT-11 P1 test): did the limit that bound the site target push it
|
||||
ABOVE what the winner asked (the peak floor asking for more power)? Then
|
||||
the Why line must not say "held back". False when nothing bound it."""
|
||||
if not resolution.bound_by or resolution.contradiction:
|
||||
return False
|
||||
asked = next((c.value for c in sorted(
|
||||
(c for c in claims if c.kind == SET
|
||||
and c.actuator == ACTUATOR_SITE and c.source == resolution.winner),
|
||||
key=lambda c: c.priority, reverse=True)), None)
|
||||
return asked is not None and resolution.target_w > float(asked)
|
||||
|
||||
|
||||
def why_key(winner: str, claims) -> str:
|
||||
"""The key of `messages.WHY_WINNER` for a resolution's winner. "safety" and
|
||||
"failsafe" stand for several causes, so their own claim reason is the
|
||||
key ("stopped", "inputs missing", "sign disagreement", or "unsplit" when
|
||||
this module refuses to split the power)."""
|
||||
if winner == "loop":
|
||||
# SAFETY-52: a loop holding a refused direction says so.
|
||||
return next((c.reason for c in claims if c.source == "loop"
|
||||
and c.reason in (_ctl.REFUSED, _ctl.PROBING)), winner)
|
||||
if winner in ("safety", "failsafe"):
|
||||
return next((c.reason for c in claims
|
||||
if c.source == winner and c.kind == "set"),
|
||||
"no claim was made" if not any(
|
||||
c.kind == "set" for c in claims) else "unsplit")
|
||||
return winner
|
||||
|
||||
|
||||
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): 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:
|
||||
if not provider:
|
||||
return tick_words
|
||||
return (f"{tick_words}. No price plan: none yet, or the last one is "
|
||||
f"over {max_age_s:.0f} s old")
|
||||
return (f"{tick_words}. Price plan from {age_s:.0f} s ago, for "
|
||||
f"{horizon_h:.0f} hours ahead: {plan.words}")
|
||||
|
||||
|
||||
def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
|
||||
why: str) -> str:
|
||||
"""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:
|
||||
return (f"Off, “{messages.LABELS['arbitrage_enabled']}” is not "
|
||||
f"switched on")
|
||||
if decided_at is None:
|
||||
return (f"On, but no decision yet, because control has not got that "
|
||||
f"far. Control says: {why or 'no reason recorded'}")
|
||||
age = (now - decided_at).total_seconds()
|
||||
stale = cycle_at is not None and decided_at < cycle_at
|
||||
head = (f"On, no decision this cycle ({why or 'no reason recorded'}), "
|
||||
f"the last one {age:.0f} s ago: " if stale else
|
||||
f"On, decided {age:.0f} s ago: ")
|
||||
if decision is None:
|
||||
return (f"On, no decision this cycle ({why or 'no reason recorded'}) "
|
||||
f"and none before")
|
||||
return head + decision.explain()
|
||||
@@ -109,6 +109,8 @@ from app.validate import validate_startup_config
|
||||
|
||||
UTC = timezone.utc
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
# point these fixtures build, so no period has ended and the whole series counts.
|
||||
@@ -779,9 +781,7 @@ check("...and cycle() calls it rather than inlining the conjunction, so a "
|
||||
"mutation back to a bare `engaged_now` is red rather than silent",
|
||||
"arbitrage.guard_state(self.peak_guard)" in
|
||||
ast.get_source_segment(
|
||||
open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read(),
|
||||
_fn(open(os.path.join(HERE, "app", "main.py"),
|
||||
encoding="utf-8").read(), "cycle")))
|
||||
main_files.source(), _fn(main_files.source(), "cycle")))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -907,8 +907,8 @@ check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export "
|
||||
# above calls decide() itself - and a required keyword-only parameter that no
|
||||
# caller fills is a TypeError only on the one path a suite does not run. Same
|
||||
# pin, same reason, as `weights=` in test_packshare.py part 10.
|
||||
_S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read()
|
||||
_S45_MAIN_TREE = ast.parse(_S45_MAIN)
|
||||
_S45_MAIN = main_files.source()
|
||||
_S45_MAIN_TREE = main_files.tree(_S45_MAIN)
|
||||
_s45_calls = [n for n in ast.walk(_S45_MAIN_TREE)
|
||||
if isinstance(n, ast.Call)
|
||||
and ast.unparse(n.func).endswith("arbitrage.decide")]
|
||||
@@ -2215,8 +2215,7 @@ print()
|
||||
print("8. the background refresh - the tick must never await a fetch")
|
||||
|
||||
|
||||
_MAIN_SRC = derived(lambda: open(os.path.join(HERE, "app", "main.py"),
|
||||
encoding="utf-8").read())
|
||||
_MAIN_SRC = derived(main_files.source)
|
||||
_cycle = derived(lambda: _fn(_MAIN_SRC, "cycle"))
|
||||
_refresh = derived(lambda: _fn(_MAIN_SRC, "run_tariff_refresh"))
|
||||
_read_inputs = derived(lambda: _fn(_MAIN_SRC, "read_inputs"))
|
||||
|
||||
@@ -1969,9 +1969,11 @@ print("9. SAFETY-40: the integrator bound is not a BUG SIGNAL, and the numbers "
|
||||
# The fixtures are chosen so the numbers the old text used DIFFER from the true
|
||||
# ones (MUTATION.md habit 4); the PRECONDITION checks pin that.
|
||||
import logging
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as _main_mod
|
||||
|
||||
_real_compute = _main_mod.compute
|
||||
_real_compute = main_files.live.compute
|
||||
|
||||
|
||||
class _LogCap(logging.Handler):
|
||||
@@ -1995,7 +1997,7 @@ def s40_run(grid, cycles, track, **over):
|
||||
cap = _LogCap()
|
||||
lg = logging.getLogger("goodwe")
|
||||
lg.addHandler(cap)
|
||||
_main_mod.compute = spy
|
||||
main_files.live.compute = spy
|
||||
|
||||
async def go():
|
||||
hass = _Hass(grid, track=track)
|
||||
@@ -2024,7 +2026,7 @@ def s40_run(grid, cycles, track, **over):
|
||||
try:
|
||||
hass, c, red = asyncio.run(go())
|
||||
finally:
|
||||
_main_mod.compute = _real_compute
|
||||
main_files.live.compute = _real_compute
|
||||
lg.removeHandler(cap)
|
||||
return ticks, hass, c, red, cap.records
|
||||
|
||||
@@ -2104,8 +2106,7 @@ check("...and its ERROR says it is a software fault and that control keeps "
|
||||
any(x.levelno == logging.ERROR and "software fault" in x.getMessage()
|
||||
and "control runs on" in x.getMessage() for x in log_o)
|
||||
and "reached 14 768 W on this project" in
|
||||
open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
|
||||
"main.py"), encoding="utf-8").read())
|
||||
main_files.source())
|
||||
check("...under the over-rating title, quoting a tick's own asked and sent "
|
||||
"numbers in that order, with a red status row",
|
||||
clamp_notes(hass_o)
|
||||
|
||||
@@ -149,9 +149,15 @@ print("2. the mirrors, DISCOVERED from the tree rather than listed here")
|
||||
# `app/` for module-level assignments whose name ends in OPTION_DEFAULTS and
|
||||
# grades whatever it finds, so the seventh is covered on the commit that
|
||||
# introduces it. That is the property the six hand-written pins cannot have.
|
||||
MODULES = sorted(p.name for p in APP.glob("*.py"))
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
# MAINT-21: app/main.py stands for itself and the files split from it.
|
||||
MODULES = sorted(p.name for p in APP.glob("*.py")
|
||||
if p.name == "main.py" or p.name not in main_files.files())
|
||||
TREES = {name: ast.parse(io.open(APP / name, encoding="utf-8").read(), name)
|
||||
for name in MODULES}
|
||||
TREES["main.py"] = main_files.tree()
|
||||
|
||||
MIRROR = {} # schema key -> (module, table name, value)
|
||||
TABLES = []
|
||||
|
||||
@@ -1272,6 +1272,9 @@ check("the operator's START press is the only way out, and it clears the "
|
||||
|
||||
print()
|
||||
print("8b. SAFETY-34: a Core-only restart - same add-on process, HA's memory wiped")
|
||||
import os # noqa: E402
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as _main_mod # noqa: E402
|
||||
|
||||
|
||||
@@ -1283,8 +1286,8 @@ async def _core_restart():
|
||||
|
||||
|
||||
_c = asyncio.run(_core_restart())
|
||||
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
|
||||
_main_mod._mono = lambda: _clock[0]
|
||||
_real_mono, _clock = main_files.live._mono, [main_files.live._mono()]
|
||||
main_files.live._mono = lambda: _clock[0]
|
||||
try:
|
||||
_c.hass = _DeliveryHass(b_soc=50.0)
|
||||
asyncio.run(_c.check_not_delivering())
|
||||
@@ -1295,7 +1298,7 @@ try:
|
||||
_late = [x for x in _c.hass.calls
|
||||
if "not_delivering" in str(x[2].get("notification_id", ""))]
|
||||
finally:
|
||||
_main_mod._mono = _real_mono
|
||||
main_files.live._mono = _real_mono
|
||||
check("SAFETY-34: the latch tripped in this process",
|
||||
_c.inverters[1].delivery_wd.tripped)
|
||||
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise",
|
||||
|
||||
@@ -75,6 +75,9 @@ from pathlib import Path
|
||||
|
||||
from named_absence import first, given, nth, present
|
||||
|
||||
import os # noqa: E402
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import main as app_main
|
||||
from app.inverter import LINK_STALE_S, UNIT_A, Inverter, build_roster
|
||||
from app.main import WRITE_BUDGET_S, Controller
|
||||
@@ -88,8 +91,10 @@ from app.arbiter import P_LOOP, P_SAFETY, Claim, resolve
|
||||
|
||||
fails = []
|
||||
HERE = Path(__file__).resolve().parent
|
||||
MAIN_SRC = io.open(HERE / "app" / "main.py", encoding="utf-8").read()
|
||||
MAIN_AST = ast.parse(MAIN_SRC)
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import main_files # noqa: E402
|
||||
MAIN_SRC = main_files.source()
|
||||
MAIN_AST = main_files.tree(MAIN_SRC)
|
||||
CTRL = [n for n in MAIN_AST.body
|
||||
if isinstance(n, ast.ClassDef) and n.name == "Controller"][0]
|
||||
|
||||
@@ -1097,8 +1102,8 @@ check("...and it is below hass.py's own 10 s HTTP timeout, so the budget is "
|
||||
# is healthy. The budget is shortened for the run - the shipped value is graded
|
||||
# above - because a 4 s wait per case is 8 s of suite time to measure something
|
||||
# that is already a ratio.
|
||||
_REAL_BUDGET = app_main.WRITE_BUDGET_S
|
||||
app_main.WRITE_BUDGET_S = 0.30
|
||||
_REAL_BUDGET = main_files.live.WRITE_BUDGET_S
|
||||
main_files.live.WRITE_BUDGET_S = 0.30
|
||||
|
||||
|
||||
async def _hung_heartbeat():
|
||||
@@ -1124,18 +1129,18 @@ async def _hung_heartbeat():
|
||||
_c6, _h6, _elapsed, _t0 = run(_hung_heartbeat())
|
||||
_b_at = [t - _t0 for e, _v, t in _h6.write_at if e == "number.sp_b"]
|
||||
print(f" A hangs forever, B is healthy: the pair took {_elapsed:.2f} s "
|
||||
f"(budget {app_main.WRITE_BUDGET_S:.2f} s); B's write reached the wire "
|
||||
f"(budget {main_files.live.WRITE_BUDGET_S:.2f} s); B's write reached the wire "
|
||||
f"{(_b_at[0] if _b_at else float('nan')):.3f} s in; wire saw "
|
||||
f"{_h6.writes}")
|
||||
check("⚠️ AC 6. B'S WRITE REACHED THE WIRE WHILE A WAS STILL HANGING. That is "
|
||||
"the whole criterion: a hung write to A cannot consume B's layer-1 "
|
||||
"budget, because B's write was never behind A's in a queue",
|
||||
_h6.writes == [("number.sp_b", 700.0)]
|
||||
and bool(_b_at) and _b_at[0] < app_main.WRITE_BUDGET_S / 2.0)
|
||||
and bool(_b_at) and _b_at[0] < main_files.live.WRITE_BUDGET_S / 2.0)
|
||||
check("...and the pair returned in about ONE budget rather than in two, which "
|
||||
"is what says the writes were concurrent and not merely bounded. Two "
|
||||
"bounded writes in series is still a sibling waiting for a corpse",
|
||||
_elapsed < app_main.WRITE_BUDGET_S * 1.9)
|
||||
_elapsed < main_files.live.WRITE_BUDGET_S * 1.9)
|
||||
check("...and the hung inverter is recorded as having FAILED its write, so "
|
||||
"`last_write` is not advanced and the next tick re-issues it by itself",
|
||||
_c6.inverters[0].last_write is None
|
||||
@@ -1176,7 +1181,7 @@ check("⚠️ AND THE DEFECT IS REPRODUCED BESIDE THE FIX. Written in series, B'
|
||||
"every inverter behind the hung one, and with the unbounded write this "
|
||||
"card replaces it was the HTTP stack's timeout - which is what put a "
|
||||
"healthy sibling within reach of layer 1's veto",
|
||||
_ser_b_at >= app_main.WRITE_BUDGET_S)
|
||||
_ser_b_at >= main_files.live.WRITE_BUDGET_S)
|
||||
|
||||
# AND THE DEADLINE ITSELF IS PER UNIT - graded through the shipped method, for
|
||||
# the reason the experiment above is: a `due` list computed in this file is a
|
||||
@@ -1219,7 +1224,7 @@ check("...and the heartbeat re-emits each unit's OWN share rather than the "
|
||||
# heartbeat is not the only place a hung inverter can hold the loop: every cycle
|
||||
# writes every unit's share, and in series that is the same defect at the cycle
|
||||
# rate rather than at the heartbeat rate.
|
||||
app_main.WRITE_BUDGET_S = 0.30
|
||||
main_files.live.WRITE_BUDGET_S = 0.30
|
||||
|
||||
|
||||
async def _hung_cycle():
|
||||
@@ -1248,17 +1253,17 @@ check("⚠️ AND A REAL CYCLE DELIVERS B'S SHARE WHILE A HANGS. This is the sam
|
||||
"`cycle()` go out together, so a hung inverter costs the loop one budget "
|
||||
"and costs its siblings nothing",
|
||||
_h6c.writes and _h6c.writes[0][0] == "number.sp_b"
|
||||
and bool(_cyc_b) and _cyc_b[0] < app_main.WRITE_BUDGET_S / 2.0
|
||||
and _cyc_elapsed < app_main.WRITE_BUDGET_S * 1.9)
|
||||
and bool(_cyc_b) and _cyc_b[0] < main_files.live.WRITE_BUDGET_S / 2.0
|
||||
and _cyc_elapsed < main_files.live.WRITE_BUDGET_S * 1.9)
|
||||
check("...and the share B was told is the allocator's, not the site target - "
|
||||
"the hung sibling does not turn the cycle into a broadcast",
|
||||
_h6c.writes[0][1] == float(_c6c.allocation.shares["inverter-b"])
|
||||
and _h6c.writes[0][1] != _c6c.target)
|
||||
|
||||
app_main.WRITE_BUDGET_S = _REAL_BUDGET
|
||||
main_files.live.WRITE_BUDGET_S = _REAL_BUDGET
|
||||
check("...and the shipped budget is restored after the experiments, so nothing "
|
||||
"below runs against a 0.3 s one",
|
||||
app_main.WRITE_BUDGET_S == WRITE_BUDGET_S)
|
||||
main_files.live.WRITE_BUDGET_S == WRITE_BUDGET_S)
|
||||
|
||||
|
||||
# ⚠️ AND THE OPERATOR'S STOP HAS TO MOVE THE SAME TWO THINGS. This is a hazard
|
||||
|
||||
@@ -22,6 +22,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools"))
|
||||
|
||||
import main_files # noqa: E402
|
||||
import app.main as M # noqa: E402
|
||||
from app import maintenance as MT # noqa: E402
|
||||
from app.maintenance import ( # noqa: E402
|
||||
@@ -367,7 +368,7 @@ def amain_zone(zone_body, env_tz):
|
||||
|
||||
M.load_options = lambda: {"setpoint_entity": "number.sp"}
|
||||
M.HomeAssistant = lambda session, **kw: _Hass()
|
||||
M.validate_startup_config = lambda *a, **k: []
|
||||
main_files.live.validate_startup_config = lambda *a, **k: []
|
||||
M.mode_announcement = lambda opts, store: ((), ())
|
||||
M.Controller = _controller
|
||||
M.aiohttp.ClientSession = lambda *a, **k: _Session(zone_body)
|
||||
|
||||
@@ -520,9 +520,11 @@ check("a fixed tariff set up counts as a gate: outside its cheap hours a pack "
|
||||
"that is not charging is closed, inside them it is open",
|
||||
c._price_gated is True
|
||||
and c._charge_gate_open(c.inverters[0]) is c.cheap)
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as main_mod # noqa: E402
|
||||
_real_gated, _calls = main_mod.static_gated, []
|
||||
main_mod.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now)
|
||||
_real_gated, _calls = main_files.live.static_gated, []
|
||||
main_files.live.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now)
|
||||
try:
|
||||
c._gated_key = None
|
||||
read(c)
|
||||
@@ -530,7 +532,7 @@ try:
|
||||
read(c)
|
||||
c._static_gated(c.o, datetime.now(UTC) + timedelta(hours=1))
|
||||
finally:
|
||||
main_mod.static_gated = _real_gated
|
||||
main_files.live.static_gated = _real_gated
|
||||
check("...worked out once an hour, not on every tick (3 reads in one hour = 1 "
|
||||
f"build; a new hour = 1 more): {len(_calls)} builds",
|
||||
len(_calls) == 2 and c._price_gated is True)
|
||||
|
||||
@@ -241,6 +241,27 @@ check("the output-clamp alarm quotes asked before sent",
|
||||
# ============================================================================== #
|
||||
print("\n2. no notification is worded outside app/messages.py")
|
||||
APP = HERE / "app"
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
|
||||
class _AppFile:
|
||||
"""An app/*.py file. `main.py` stands for itself and the files split from
|
||||
it (MAINT-21), so every scan below still sees the Controller whole."""
|
||||
|
||||
def __init__(self, path):
|
||||
self.name, self.stem, self._path = path.name, path.stem, path
|
||||
|
||||
def read_text(self, encoding="utf-8"):
|
||||
if self.name == "main.py":
|
||||
return main_files.source()
|
||||
return self._path.read_text(encoding=encoding)
|
||||
|
||||
|
||||
def _app_py():
|
||||
split = set(main_files.files()) - {"main.py"}
|
||||
return [_AppFile(f) for f in sorted(APP.glob("*.py"))
|
||||
if f.name not in split]
|
||||
|
||||
|
||||
# UX-08 2d: a refusal is counted as it is SHOWN. A placeholder that names a
|
||||
@@ -318,7 +339,7 @@ def notification_calls(tree):
|
||||
|
||||
|
||||
bad_calls, n_calls = [], 0
|
||||
for f in sorted(APP.glob("*.py")):
|
||||
for f in _app_py():
|
||||
for call in notification_calls(ast.parse(f.read_text(encoding="utf-8"))):
|
||||
n_calls += 1
|
||||
payload = call.args[2] if len(call.args) > 2 else None
|
||||
@@ -368,13 +389,13 @@ def _module_constants(tree):
|
||||
|
||||
|
||||
_MODCONST = {f.stem: _module_constants(ast.parse(f.read_text(encoding="utf-8")))
|
||||
for f in sorted(APP.glob("*.py"))}
|
||||
for f in _app_py()}
|
||||
|
||||
|
||||
def collect():
|
||||
"""(kind, where, text) for every user-facing literal of a ratcheted kind."""
|
||||
out = []
|
||||
for f in sorted(APP.glob("*.py")):
|
||||
for f in _app_py():
|
||||
if f.name == "messages.py":
|
||||
continue
|
||||
tree = ast.parse(f.read_text(encoding="utf-8"))
|
||||
@@ -543,7 +564,7 @@ for key, text in messages.hint_samples():
|
||||
p = row_problems(messages.GREEN, text)
|
||||
check(f"hint {text!r}{' - ' + '; '.join(p) if p else ''}", not p)
|
||||
_calls, _bare = 0, []
|
||||
for f in sorted(APP.glob("*.py")):
|
||||
for f in _app_py():
|
||||
for node in ast.walk(ast.parse(f.read_text(encoding="utf-8"))):
|
||||
if (isinstance(node, ast.Call)
|
||||
and ast.unparse(node.func).endswith("log_event")):
|
||||
@@ -1012,7 +1033,7 @@ def _static(spec):
|
||||
the named file (placeholders count as one word, as in part 3)."""
|
||||
fname, kind, name = spec
|
||||
out = []
|
||||
for f in ([APP / fname] if fname != "*" else sorted(APP.glob("*.py"))):
|
||||
for f in ([_AppFile(APP / fname)] if fname != "*" else _app_py()):
|
||||
tree = ast.parse(f.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if kind == "call" and isinstance(node, ast.Call) and node.args \
|
||||
@@ -1201,7 +1222,7 @@ def scan_log_lines(files, text=None, data=None, long_ok=None, long_seen=None):
|
||||
|
||||
|
||||
_FILES = [(f.name, ast.parse(f.read_text(encoding="utf-8")))
|
||||
for f in sorted(APP.glob("*.py"))]
|
||||
for f in _app_py()]
|
||||
_long_seen = set()
|
||||
_unknown, _stale, _bad = scan_log_lines(_FILES, long_seen=_long_seen)
|
||||
for k, sample, p in _bad:
|
||||
@@ -1415,7 +1436,7 @@ def _captured(logger_name, fn):
|
||||
|
||||
def _level_of(fname, words_in_line):
|
||||
"""The level of the log call in `fname` whose literal holds the words."""
|
||||
tree = ast.parse((APP / fname).read_text(encoding="utf-8"))
|
||||
tree = ast.parse(_AppFile(APP / fname).read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call) and node.args \
|
||||
and re.search(r"_LOG\.\w+$", ast.unparse(node.func)):
|
||||
@@ -1561,7 +1582,7 @@ check(f"a socket Home Assistant closed is a plain ConnectionResetError, which "
|
||||
# words. The keys are found in the code: each `Claim.set(source, ..., reason)`
|
||||
# through main.why_key(), plus main.py's own refusal to split ("unsplit").
|
||||
_SOURCES = {}
|
||||
for _f in sorted(APP.glob("*.py")):
|
||||
for _f in _app_py():
|
||||
_tree = ast.parse(_f.read_text(encoding="utf-8"))
|
||||
_consts = _module_constants(_tree)
|
||||
for _n in ast.walk(_tree):
|
||||
@@ -1781,7 +1802,7 @@ check(f"...and in the action log, which an installer reads at the default "
|
||||
and any(e.endswith(messages.NO_REASON_SAVED) for e in _rs_ev))
|
||||
|
||||
# UX-11 item 4: the action log's clamp count and the shadow-off entry.
|
||||
_main_src = (APP / "main.py").read_text(encoding="utf-8")
|
||||
_main_src = _AppFile(APP / "main.py").read_text(encoding="utf-8")
|
||||
check("the action log counts 'engagements' with a real plural and the "
|
||||
"shadow-off entry is plain, not 'engagement(s)' or 'SHADOW MODE OFF -'",
|
||||
"engagement(s)" not in _main_src and "SHADOW MODE OFF" not in _main_src
|
||||
|
||||
@@ -32,6 +32,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import homeview, messages, packmode # noqa: E402
|
||||
from app.control import MODE_BOUND # noqa: E402
|
||||
@@ -62,24 +63,24 @@ def store_with(**modes):
|
||||
|
||||
def spy_claims():
|
||||
"""Record every claim list main.py's own `resolve` is handed."""
|
||||
seen, real = [], app_main.resolve
|
||||
seen, real = [], main_files.live.resolve
|
||||
|
||||
def _spy(claims, **kw):
|
||||
seen.append(list(claims))
|
||||
return real(claims, **kw)
|
||||
app_main.resolve = _spy
|
||||
return seen, lambda: setattr(app_main, "resolve", real)
|
||||
main_files.live.resolve = _spy
|
||||
return seen, lambda: setattr(main_files.live, "resolve", real)
|
||||
|
||||
|
||||
def spy_bounds():
|
||||
"""Record the `bound` main.py hands `compute()` on every cycle."""
|
||||
seen, real = [], app_main.compute
|
||||
seen, real = [], main_files.live.compute
|
||||
|
||||
def _spy(*a, **kw):
|
||||
seen.append(kw.get("bound", "absent"))
|
||||
return real(*a, **kw)
|
||||
app_main.compute = _spy
|
||||
return seen, lambda: setattr(app_main, "compute", real)
|
||||
main_files.live.compute = _spy
|
||||
return seen, lambda: setattr(main_files.live, "compute", real)
|
||||
|
||||
|
||||
def flat(t):
|
||||
|
||||
@@ -37,6 +37,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
from named_absence import nth, present # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from app import arbitrage, dayplan, homeview, modetrade, packmode # noqa
|
||||
from app import priceruns, solarfc, validate # noqa: E402
|
||||
@@ -777,7 +778,7 @@ check("battery modes off: the forecast sensor is never read", READS[0] == 0)
|
||||
print("7. the Batteries screen and the Simple view")
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
|
||||
hours=0.6)
|
||||
rp.m.datetime = datetime
|
||||
main_files.live.datetime = datetime
|
||||
sb = homeview.solar_block(c, c.solar_fc.at)
|
||||
check("the forecast line names the forecast, what is counted and when",
|
||||
sb["line"].startswith("Solar still to come today:")
|
||||
@@ -812,14 +813,14 @@ check("a lost forecast says so: no forecast, Leftover solar solar only",
|
||||
c._charge_latch = latch
|
||||
# the latch ends at its end time; this replay ran in September, so ask at a
|
||||
# moment inside it
|
||||
_real_dt = rp.m.datetime
|
||||
rp.m.datetime = type("_DT", (datetime,), {"now": classmethod(
|
||||
_real_dt = main_files.live.datetime
|
||||
main_files.live.datetime = type("_DT", (datetime,), {"now": classmethod(
|
||||
lambda cls, tz=None: latch[0] - timedelta(hours=1))})
|
||||
check("OPT-34: with a charge under way (B charged in this run), the level "
|
||||
"stays: plan_targets() gives it (the line: test_opt34.py)",
|
||||
latch is not None and c.plan_targets() is not None
|
||||
and B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B])
|
||||
rp.m.datetime = _real_dt
|
||||
main_files.live.datetime = _real_dt
|
||||
check("the Why line wins over 'saves its power' only while holding",
|
||||
homeview.battery_why(modes_on=True, mode="leftover_solar", off=False,
|
||||
reading=True, phase="idle", shadow=False,
|
||||
|
||||
@@ -46,6 +46,7 @@ logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import homeview, modetrade, solarfc # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -404,14 +405,14 @@ def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
|
||||
|
||||
async def go():
|
||||
c, hass = build(store)
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
real_dt, real_hv = main_files.live.datetime, homeview.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
main_files.live.datetime = homeview.datetime = _DT
|
||||
task = None
|
||||
try:
|
||||
for t_pre in pre:
|
||||
@@ -477,7 +478,7 @@ def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
main_files.live.datetime, homeview.datetime = real_dt, real_hv
|
||||
return c
|
||||
|
||||
return asyncio.run(go()), rows, pre_rows
|
||||
|
||||
@@ -61,6 +61,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import homeview, solarfc # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -348,14 +349,14 @@ def run_night(spec):
|
||||
|
||||
async def go():
|
||||
cur = store
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
real_dt, real_hv = main_files.live.datetime, homeview.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
main_files.live.datetime = homeview.datetime = _DT
|
||||
try:
|
||||
# TEST-37: built under the scenario's clock, not the real one:
|
||||
# Controller.__init__ checks a stored latch's end against now().
|
||||
@@ -420,7 +421,7 @@ def run_night(spec):
|
||||
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
|
||||
out["latch"] = getattr(c, "_charge_latch", None) is not None
|
||||
finally:
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
main_files.live.datetime, homeview.datetime = real_dt, real_hv
|
||||
return out
|
||||
|
||||
return asyncio.run(go())
|
||||
@@ -722,7 +723,7 @@ from types import SimpleNamespace # noqa: E402
|
||||
from app import modetrade, priceruns # noqa: E402
|
||||
|
||||
CLOCK = [at(4, 1, 0)]
|
||||
_dt0 = m.datetime
|
||||
_dt0 = main_files.live.datetime
|
||||
|
||||
|
||||
class _FixedDT(_dt0):
|
||||
@@ -731,7 +732,7 @@ class _FixedDT(_dt0):
|
||||
return CLOCK[0] if tz is None else CLOCK[0].astimezone(tz)
|
||||
|
||||
|
||||
m.datetime = homeview.datetime = _FixedDT
|
||||
main_files.live.datetime = homeview.datetime = _FixedDT
|
||||
_tmp = tempfile.mkdtemp(prefix="o34-")
|
||||
lc = Controller(dict(opts()), rp.Hass(("a", "b"), lambda t: 300.0,
|
||||
{"a": 40.0, "b": 40.0}),
|
||||
@@ -858,7 +859,7 @@ lc._charge_latch = (at(4, 6, 10), {A: 80.0}, at(4, 1, 0))
|
||||
lt, _s = latch_step(None, resting, None, now=at(4, 6, 5), runs=r3)
|
||||
check("F4: a run that starts AT the bell (06:00-09:00) does carry it on, "
|
||||
"to its end + one slot", lt is not None and lt[0] == at(4, 9, 15))
|
||||
m.datetime = homeview.datetime = _dt0
|
||||
main_files.live.datetime = homeview.datetime = _dt0
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -49,6 +49,7 @@ logging.disable(logging.WARNING)
|
||||
from named_absence import nth, present # noqa: E402
|
||||
import ux54_scenarios as S # noqa: E402
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import dayplan, homeview, modetrade, packmode # noqa: E402
|
||||
from app import priceruns, solarfc, uitext # noqa: E402
|
||||
@@ -662,14 +663,14 @@ def run_night(spec):
|
||||
for d in ("2026-10-31", "2026-11-01", "2026-11-02")})
|
||||
|
||||
async def go():
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
real_dt, real_hv = main_files.live.datetime, homeview.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
main_files.live.datetime = homeview.datetime = _DT
|
||||
try:
|
||||
for t_pre in EVENING:
|
||||
clock[0] = t_pre
|
||||
@@ -722,7 +723,7 @@ def run_night(spec):
|
||||
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
|
||||
out["latch"] = getattr(c, "_charge_latch", None) is not None
|
||||
finally:
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
main_files.live.datetime, homeview.datetime = real_dt, real_hv
|
||||
return out
|
||||
|
||||
return asyncio.run(go())
|
||||
|
||||
@@ -34,6 +34,8 @@ import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import inf
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
from app import arbitrage, packshare
|
||||
from named_absence import first, nth, present
|
||||
@@ -1132,8 +1134,7 @@ check("...and misfiling it ON THE SITE is caught and REFUSED, not consumed. A "
|
||||
# main.py's own AST, the way test_peakguard.py part 12i pins the arguments of
|
||||
# `arbitrage.decide()`, and for the same reason: a call site that stopped
|
||||
# passing it is invisible to every fixture.
|
||||
_main_src = (Path(__file__).resolve().parent / "app" / "main.py").read_text(
|
||||
encoding="utf-8")
|
||||
_main_src = main_files.source()
|
||||
_resolve_calls = [n for n in ast.walk(ast.parse(_main_src))
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id == "resolve"]
|
||||
@@ -1158,9 +1159,7 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE
|
||||
# cycle's value, and logs. What it does NOT grade is the sentence it logs or
|
||||
# that a real cycle reaches it; that is stated here rather than implied.
|
||||
_cycle_src = first(
|
||||
f for cls in ast.walk(ast.parse(
|
||||
(Path(__file__).resolve().parent / "app" / "main.py")
|
||||
.read_text(encoding="utf-8")))
|
||||
f for cls in ast.walk(main_files.tree())
|
||||
if isinstance(cls, ast.ClassDef) and cls.name == "Controller"
|
||||
for f in cls.body
|
||||
if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle")
|
||||
|
||||
@@ -22,6 +22,7 @@ import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
import safety79_identity as rig # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
@@ -207,9 +208,8 @@ check("...and the user-facing peak notice built from the decision's limit "
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. each call site in main.py")
|
||||
with open(os.path.join(HERE, "app", "main.py"), encoding="utf-8") as f:
|
||||
_src = f.read()
|
||||
_tree = ast.parse(_src)
|
||||
_src = main_files.source()
|
||||
_tree = main_files.tree(_src)
|
||||
|
||||
|
||||
def calls(name):
|
||||
|
||||
@@ -135,6 +135,9 @@ from app.arbiter import (
|
||||
)
|
||||
from app.control import Tuning, compute, peak_at_risk
|
||||
from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w
|
||||
import os # noqa: E402
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import main as app_main
|
||||
from app.inverter import Inverter, build_roster
|
||||
from app.main import Controller
|
||||
@@ -1295,7 +1298,7 @@ print(" the wiring's own mutations - a default install, maintenance charging")
|
||||
# `peak_at_risk()` answers False forever. That is the whole point - it is the
|
||||
# configuration on which the old suspension could not fire.
|
||||
MAINT_CHARGING = {"phase": "charge"}
|
||||
_mainsrc = (HERE / "app" / "main.py").read_text(encoding="utf-8")
|
||||
_mainsrc = main_files.source()
|
||||
|
||||
|
||||
def maint_cycle(tag, want_floor_w=LIVE_MAX_W * 0.3, **kw):
|
||||
@@ -2709,7 +2712,7 @@ CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
|
||||
# silent gap in the scan.
|
||||
CLAIM_BUILDERS = {"peak_claim": "peak", "arb_claim": "arbitrage"}
|
||||
|
||||
_main_tree = ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8"))
|
||||
_main_tree = main_files.tree()
|
||||
_cycle_def = first((
|
||||
f
|
||||
for cls in ast.walk(_main_tree)
|
||||
@@ -2762,7 +2765,7 @@ def cycle_claims(run):
|
||||
call site and nowhere else.
|
||||
"""
|
||||
seen = []
|
||||
real = app_main.resolve
|
||||
real = main_files.live.resolve
|
||||
|
||||
def _spy(claims, **kw):
|
||||
# ⚠️ `**kw` PASSES THE ROSTER THROUGH UNREAD, AND THAT IS THE WHOLE
|
||||
@@ -2776,11 +2779,11 @@ def cycle_claims(run):
|
||||
seen.append(list(claims))
|
||||
return real(claims, **kw)
|
||||
|
||||
app_main.resolve = _spy
|
||||
main_files.live.resolve = _spy
|
||||
try:
|
||||
out = run()
|
||||
finally:
|
||||
app_main.resolve = real
|
||||
main_files.live.resolve = real
|
||||
return out, (seen[-1] if seen else [])
|
||||
|
||||
|
||||
@@ -4977,7 +4980,11 @@ check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide()
|
||||
# written, at every roster length, in every configuration, and with no fixture
|
||||
# required.
|
||||
_APP_SRC = {p.stem: p.read_text(encoding="utf-8")
|
||||
for p in sorted((HERE / "app").glob("*.py"))}
|
||||
for p in sorted((HERE / "app").glob("*.py"))
|
||||
if p.name == "main.py" or p.name not in main_files.files()}
|
||||
# MAINT-21: main.py and the files split from it, the mixins read as Controller.
|
||||
_APP_SRC["main"] = re.sub(r"^class \w+Mixin:", "class Controller:",
|
||||
main_files.source(), flags=re.M)
|
||||
_ROSTER_OPTION = "inverters" # the ONE seed. Everything else is closure.
|
||||
_WRAPPERS = {"list", "tuple", "sorted", "enumerate", "zip", "reversed",
|
||||
"filter", "iter", "set", "dict"}
|
||||
|
||||
@@ -118,6 +118,8 @@ from app.static_tariff import resolve_tariff
|
||||
|
||||
UTC = timezone.utc
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
# point these fixtures build, so no period has ended and the whole series counts.
|
||||
@@ -2015,8 +2017,8 @@ check("...so calling it without one is a TypeError, not a quiet regression",
|
||||
transport=transport_for(_body_today),
|
||||
now=NOW)), TypeError))
|
||||
|
||||
_MAIN = open(os.path.join(_APP, "main.py"), encoding="utf-8").read()
|
||||
_main_tree = ast.parse(_MAIN)
|
||||
_MAIN = main_files.source()
|
||||
_main_tree = main_files.tree(_MAIN)
|
||||
_refresh = first((n for n in ast.walk(_main_tree)
|
||||
if isinstance(n, ast.AsyncFunctionDef)
|
||||
and n.name == "run_tariff_refresh"), "run_tariff_refresh in main.py")
|
||||
|
||||
@@ -44,6 +44,8 @@ import tempfile
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from named_absence import nth
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import main as _main
|
||||
from app import messages, price as P, price_chain as C
|
||||
from app.price import (
|
||||
@@ -686,8 +688,8 @@ def controller(clock, o, tmp=None):
|
||||
return ctl
|
||||
|
||||
|
||||
_REAL_DT, _REAL_GET = _main.datetime, P.https_get
|
||||
_real_resolve = _main.resolve_tariff
|
||||
_REAL_DT, _REAL_GET = main_files.live.datetime, P.https_get
|
||||
_real_resolve = main_files.live.resolve_tariff
|
||||
|
||||
|
||||
class Wired:
|
||||
@@ -701,7 +703,7 @@ class Wired:
|
||||
self.refreshes = 0
|
||||
|
||||
def __enter__(self):
|
||||
_main.datetime = FakeDT
|
||||
main_files.live.datetime = FakeDT
|
||||
P.https_get = self.net
|
||||
W = self
|
||||
|
||||
@@ -709,12 +711,12 @@ class Wired:
|
||||
r = await _real_resolve(*a, **k)
|
||||
W.refreshes += 1
|
||||
return r
|
||||
_main.resolve_tariff = counting
|
||||
main_files.live.resolve_tariff = counting
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
_main.datetime, P.https_get = _REAL_DT, _REAL_GET
|
||||
_main.resolve_tariff = _real_resolve
|
||||
main_files.live.datetime, P.https_get = _REAL_DT, _REAL_GET
|
||||
main_files.live.resolve_tariff = _real_resolve
|
||||
|
||||
async def refresh(self, now=None):
|
||||
if now is not None:
|
||||
|
||||
@@ -692,7 +692,13 @@ def touches(src: str) -> bool:
|
||||
or "from .pv import" in src
|
||||
|
||||
|
||||
_touch = sorted(f.name for f in APP if touches(f.read_text(encoding="utf-8")))
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
# MAINT-21: the files split from app/main.py are counted as main.py.
|
||||
_SPLIT = set(main_files.files()) - {"main.py"}
|
||||
_touch = sorted({"main.py" if f.name in _SPLIT else f.name for f in APP
|
||||
if touches(f.read_text(encoding="utf-8"))})
|
||||
print(f" {len(APP)} modules under app/; TEL-11 material is in: {_touch}")
|
||||
check(f"PRECONDITION: the scan found the whole package, not a subset - "
|
||||
f"{len(APP)} modules", len(APP) >= 25 and "control.py" in
|
||||
|
||||
@@ -142,8 +142,11 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
|
||||
**LIVE), hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -LIVE["max_w"], LIVE["max_w"]
|
||||
import os # noqa: E402
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m
|
||||
real, real_dt = m.compute, m.datetime
|
||||
real, real_dt = main_files.live.compute, main_files.live.datetime
|
||||
clock = [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@@ -153,9 +156,9 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
|
||||
def now(cls, tz=None):
|
||||
return BASE + timedelta(seconds=clock[0])
|
||||
|
||||
m.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
if old:
|
||||
m.compute = lambda *a, **k: real(*a, **dict(k, refused=None))
|
||||
main_files.live.compute = lambda *a, **k: real(*a, **dict(k, refused=None))
|
||||
rows, trips, was = [], 0, False
|
||||
try:
|
||||
t = 0.0
|
||||
@@ -174,7 +177,7 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
|
||||
hass.cmd, c.i_w, c.loop_reason, c.batt_refused))
|
||||
t += TICK
|
||||
finally:
|
||||
m.compute, m.datetime = real, real_dt
|
||||
main_files.live.compute, main_files.live.datetime = real, real_dt
|
||||
return rows, trips, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ from app import control
|
||||
from app.control import PROBE_EVERY_S, PROBE_S, PROBE_W
|
||||
from app.main import Controller
|
||||
from app.arbiter import P_LOOP, P_PEAK, P_SAFETY, Claim, resolve
|
||||
import os # noqa: E402
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m
|
||||
|
||||
fails = []
|
||||
@@ -177,7 +180,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
|
||||
c.auto = True
|
||||
if old:
|
||||
_off(c)
|
||||
real_dt = m.datetime
|
||||
real_dt = main_files.live.datetime
|
||||
clock = [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@@ -185,7 +188,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
|
||||
def now(cls, tz=None):
|
||||
return BASE + timedelta(seconds=clock[0])
|
||||
|
||||
m.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
rows = []
|
||||
trips = {u: 0 for u in UNITS}
|
||||
was = {u: False for u in UNITS}
|
||||
@@ -211,7 +214,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
|
||||
db=c.inverters[1].delivery_wd.tripped))
|
||||
t += dt
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
return rows, trips, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ def check(name, cond):
|
||||
print("1. the harness's own preconditions")
|
||||
# --------------------------------------------------------------------------- #
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import main_files # noqa: E402
|
||||
import rehearse_roster as RR # noqa: E402
|
||||
|
||||
check("the tool refuses rather than passing when it cannot run: its four exit "
|
||||
@@ -210,6 +211,11 @@ def mutant_tree(edits) -> str:
|
||||
dest / "tools" / "predeploy_check.py")
|
||||
shutil.copyfile(HERE / "config.yaml", dest / "config.yaml")
|
||||
for rel, old, new in edits:
|
||||
if rel == "app/main.py":
|
||||
# MAINT-21: app/main.py stands for the files split from it.
|
||||
where = [n for n in main_files.files() if (dest / "app" / n)
|
||||
.read_text(encoding="utf-8").count(old) == 1]
|
||||
rel = f"app/{where[0]}" if len(where) == 1 else rel
|
||||
path = dest / rel
|
||||
text = path.read_text(encoding="utf-8")
|
||||
hits = text.count(old)
|
||||
|
||||
@@ -35,6 +35,7 @@ logging.disable(logging.WARNING)
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import control # noqa: E402
|
||||
from app.arbiter import Claim, P_ARBITRAGE # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -61,7 +62,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
|
||||
(a writer above the loop), or None."""
|
||||
log, rows, now = [], [], [0.0]
|
||||
real_power, real_tick = rig._Hass.power, rig.TICK
|
||||
real_log, real_resolve, real_c = (Controller.log_event, app_main.resolve,
|
||||
real_log, real_resolve, real_c = (Controller.log_event, main_files.live.resolve,
|
||||
rig.Controller)
|
||||
|
||||
def power(self, u):
|
||||
@@ -108,7 +109,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
|
||||
extra.update(s79.P1, peak_cap_w=cap)
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s107-"), "tariff.db")
|
||||
rig._Hass.power, rig.TICK = power, tick
|
||||
Controller.log_event, app_main.resolve = log_event, resolve
|
||||
Controller.log_event, main_files.live.resolve = log_event, resolve
|
||||
rig.Controller = functools.partial(real_c, peak_db_path=db)
|
||||
try:
|
||||
_, c = rig.run(n, extra=extra, dur=dur, load=house,
|
||||
@@ -117,7 +118,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
|
||||
store=s79.store_charging(n) if maint else rig._Store())
|
||||
finally:
|
||||
rig._Hass.power, rig.TICK = real_power, real_tick
|
||||
Controller.log_event, app_main.resolve = real_log, real_resolve
|
||||
Controller.log_event, main_files.live.resolve = real_log, real_resolve
|
||||
rig.Controller = real_c
|
||||
return rows, log, c
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import os # noqa: E402
|
||||
import sys # noqa: E402
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import main as _main
|
||||
from app import messages, pv, validate
|
||||
from app.main import Controller
|
||||
@@ -217,7 +221,7 @@ check(f"one read of the forecast entity per read_inputs() ({h.reads})",
|
||||
|
||||
print("4. maintenance headroom and the charge-only clamp use watts")
|
||||
seen = []
|
||||
_real = _main.maintenance_charge_floor
|
||||
_real = main_files.live.maintenance_charge_floor
|
||||
|
||||
|
||||
def spy(charge_w, fc, cap):
|
||||
@@ -226,7 +230,7 @@ def spy(charge_w, fc, cap):
|
||||
return out
|
||||
|
||||
|
||||
_main.maintenance_charge_floor = spy
|
||||
main_files.live.maintenance_charge_floor = spy
|
||||
try:
|
||||
def cycle(fc, **kw):
|
||||
c, h = make(fc, store={"phase": "charge"},
|
||||
@@ -264,7 +268,7 @@ try:
|
||||
check("no unit: forecast unused, clamp in place, as with the option empty",
|
||||
clamp(st(6000)) is False)
|
||||
finally:
|
||||
_main.maintenance_charge_floor = _real
|
||||
main_files.live.maintenance_charge_floor = _real
|
||||
|
||||
print("5. validator and strings")
|
||||
def notes(unit, key="peak_forecast_entity"):
|
||||
|
||||
@@ -59,6 +59,7 @@ logging.disable(logging.WARNING)
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import messages # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -413,8 +414,8 @@ _rg = {t: never(t, hw, seed=1, delay=0) for t, hw in RIG.items()}
|
||||
_rg_old = {t: never(t, hw, fix=False, seed=1, delay=0)
|
||||
for t, hw in RIG.items()}
|
||||
# SAFETY-119 as merged (bee7431) at 5 s: a settle of 12 cycles, budget 8.
|
||||
_rg_119 = patched(app_main, "HANDBACK_CYCLES", app_main.SHORT_CYCLES)(
|
||||
lambda: patched(app_main, "ROLLING_BOUND_CYCLES", 8)(
|
||||
_rg_119 = patched(main_files.live, "HANDBACK_CYCLES", app_main.SHORT_CYCLES)(
|
||||
lambda: patched(main_files.live, "ROLLING_BOUND_CYCLES", 8)(
|
||||
lambda: never(5.0, 5100.0, seed=1, delay=0)))
|
||||
for _t in _rg:
|
||||
print(f" rig shape, {_t:.0f} s meter, house {RIG[_t]:.0f} W: per "
|
||||
@@ -464,10 +465,10 @@ check("...every command the same as without the fix; without it the false "
|
||||
# the rig's shape (1 s, house 5050 W, seed 1).
|
||||
rig.TICK = 2.0
|
||||
_q25 = (run(house(25), delay=2),
|
||||
patched(app_main, "ROLLING_BOUND_CYCLES", 8)(
|
||||
patched(main_files.live, "ROLLING_BOUND_CYCLES", 8)(
|
||||
lambda: run(house(25), delay=2)))
|
||||
rig.TICK = 1.0
|
||||
_m14 = patched(app_main, "ROLLING_BOUND_CYCLES", 14)(
|
||||
_m14 = patched(main_files.live, "ROLLING_BOUND_CYCLES", 14)(
|
||||
lambda: never(1.0, 5050.0, seed=1, delay=0))
|
||||
_m11 = never(1.0, 5050.0, seed=1, delay=0)
|
||||
print(f" 2 s, quarter edge, seed 25: {notes(_q25[0][1])}, budget 8 "
|
||||
@@ -480,7 +481,7 @@ check("...the budget's margins: 11 is silent at 2 s seed 25 and warns every "
|
||||
and every_quarter(_m11[1]) and not every_quarter(_m14[1]))
|
||||
check("the rule is the one the sweep chose. Changing it means re-running "
|
||||
"the sweep in DECISIONS/SAFETY-121-bound-every-quarter.md",
|
||||
app_main.HANDBACK_CYCLES == 4 and app_main.ROLLING_BOUND_CYCLES == 11
|
||||
main_files.live.HANDBACK_CYCLES == 4 and main_files.live.ROLLING_BOUND_CYCLES == 11
|
||||
and app_main.ROLLING_S == 900.0 and app_main.SHORT_SPAN_S == 22.0)
|
||||
|
||||
# A start, or a blind cycle, is not a hand-back: the loop at its rail after
|
||||
|
||||
@@ -37,6 +37,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as M # noqa: E402
|
||||
from app import dayplan, modetrade, packmode, priceruns, solarfc # noqa: E402
|
||||
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
|
||||
@@ -492,13 +493,13 @@ for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00",
|
||||
"2027-03-28T00:00:05+00:00"):
|
||||
now = datetime.fromisoformat(iso)
|
||||
c, hass = controller(MODES["Leftover solar"], now)
|
||||
real_dt = M.datetime
|
||||
real_dt = main_files.live.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None, _n=now):
|
||||
return _n if tz is None else _n.astimezone(tz)
|
||||
M.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
try:
|
||||
async def one():
|
||||
await c.read_inputs(now_mono=0.0)
|
||||
@@ -510,7 +511,7 @@ for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00",
|
||||
except Slow:
|
||||
ok = False
|
||||
finally:
|
||||
M.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
check(f"a whole control cycle at {iso[:16]}Z (three forecast sensors a "
|
||||
"day) returns with a day plan", ok)
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ logging.disable(logging.WARNING)
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
import app.p1 as p1mod # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
@@ -164,14 +165,14 @@ def drive(o, *, peak=None, late=0.0, minutes=31, every=0, start=None,
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0
|
||||
c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0
|
||||
c.auto = True
|
||||
real = m.datetime, homeview.datetime, p1mod.time
|
||||
real = main_files.live.datetime, homeview.datetime, p1mod.time
|
||||
clock, simt = [t0], [0.0]
|
||||
|
||||
class _DT(real[0]):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
m.datetime = homeview.datetime = _DT
|
||||
main_files.live.datetime = homeview.datetime = _DT
|
||||
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0])
|
||||
real_load = _price.load_prices, _st.load_prices
|
||||
_price.load_prices = _st.load_prices = _no_fetch
|
||||
@@ -223,7 +224,7 @@ def drive(o, *, peak=None, late=0.0, minutes=31, every=0, start=None,
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime, p1mod.time = real
|
||||
main_files.live.datetime, homeview.datetime, p1mod.time = real
|
||||
_price.load_prices, _st.load_prices = real_load
|
||||
return c
|
||||
c = asyncio.run(go())
|
||||
|
||||
@@ -43,6 +43,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
import app.p1 as p1mod # noqa: E402
|
||||
from app import homeview, messages, peakguard, tariff # noqa: E402
|
||||
@@ -179,14 +180,14 @@ def drive(o, *, modes=OWNER, socs=(10.0, 10.0), house=600.0, minutes=46,
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0
|
||||
c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0
|
||||
c.auto = True
|
||||
real = m.datetime, homeview.datetime, p1mod.time
|
||||
real = main_files.live.datetime, homeview.datetime, p1mod.time
|
||||
clock, simt = [t0], [0.0]
|
||||
|
||||
class _DT(real[0]):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
m.datetime = homeview.datetime = _DT
|
||||
main_files.live.datetime = homeview.datetime = _DT
|
||||
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0])
|
||||
real_load = _price.load_prices, _st.load_prices
|
||||
_price.load_prices = _st.load_prices = _no_fetch
|
||||
@@ -237,7 +238,7 @@ def drive(o, *, modes=OWNER, socs=(10.0, 10.0), house=600.0, minutes=46,
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime, p1mod.time = real
|
||||
main_files.live.datetime, homeview.datetime, p1mod.time = real
|
||||
_price.load_prices, _st.load_prices = real_load
|
||||
return c
|
||||
out["c"] = asyncio.run(go())
|
||||
|
||||
@@ -38,6 +38,7 @@ logging.disable(logging.WARNING)
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import clamp, messages # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -202,7 +203,7 @@ check("⭐ N=2, A in its HOLD and B, which the loop drives, gives nothing: the "
|
||||
check("...it starts no earlier than the saturation cycles allow (3 x 5 s)",
|
||||
all(e.split(" ", 1)[0] >= "11:10:10" for c in (_c1, _c2)
|
||||
for e in c.events if "OUTPUT_CLAMP" in e))
|
||||
_quiet = patched(app_main, "delivery_short", lambda *a: False)(
|
||||
_quiet = patched(main_files.live, "delivery_short", lambda *a: False)(
|
||||
lambda: run(1, "drain", 1000.0, 20000, dur=1200.0, cut=gone(600, 1200)))
|
||||
check("non-vacuous: without the delivery rule the N=1 drain that gives "
|
||||
"nothing is silent (the loop's clamp never reaches its rail here)",
|
||||
@@ -214,7 +215,7 @@ check("a dip of two cycles is not an alarm", warned(_dip[1]) == (0, 0, 0))
|
||||
print("\n3. control coming back from maintenance does not jump")
|
||||
END = {"maintenance_hold_min": 10}
|
||||
_bounds = []
|
||||
_real_compute = app_main.compute
|
||||
_real_compute = main_files.live.compute
|
||||
|
||||
|
||||
def _spy_compute(*a, **kw):
|
||||
@@ -222,11 +223,11 @@ def _spy_compute(*a, **kw):
|
||||
return _real_compute(*a, **kw)
|
||||
|
||||
|
||||
app_main.compute = _spy_compute
|
||||
main_files.live.compute = _spy_compute
|
||||
try:
|
||||
_h2 = run(2, "hold", 4500.0, 20000, dur=900.0, extra=END)
|
||||
finally:
|
||||
app_main.compute = _real_compute
|
||||
main_files.live.compute = _real_compute
|
||||
_h2_old = no_bound(lambda: run(2, "hold", 4500.0, 20000, dur=900.0,
|
||||
extra=END))
|
||||
_h1 = run(1, "hold", 4500.0, 20000, dur=900.0, extra=END)
|
||||
@@ -380,8 +381,8 @@ def lag_runs():
|
||||
|
||||
|
||||
_lag = lagging(lag_runs)
|
||||
_lag_old = patched(app_main, "SHORT_CYCLES", 1)(
|
||||
lambda: patched(app_main, "SHORT_SPAN_S", 0.0)(
|
||||
_lag_old = patched(main_files.live, "SHORT_CYCLES", 1)(
|
||||
lambda: patched(main_files.live, "SHORT_SPAN_S", 0.0)(
|
||||
lambda: lagging(lag_runs)))
|
||||
_lag_dead = lagging(lambda: run(1, "drain", 1000.0, 20000, dur=900.0,
|
||||
cut=gone(600, 900)))
|
||||
@@ -432,7 +433,7 @@ def both_ways(asked, given, margin):
|
||||
|
||||
|
||||
_tp = taper(charge_runs)
|
||||
_tp_old = patched(app_main, "delivery_short", both_ways)(
|
||||
_tp_old = patched(main_files.live, "delivery_short", both_ways)(
|
||||
lambda: taper(charge_runs))
|
||||
print(f" a charge that tapers: {[warned(c) for _r, c in _tp]}, grading "
|
||||
f"charges too {[warned(c) for _r, c in _tp_old]}")
|
||||
|
||||
@@ -38,6 +38,7 @@ import types
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
import main_files # noqa: E402
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
@@ -703,8 +704,11 @@ check("⭐ the reserve alarm is graded on the real limit, not the aim: a "
|
||||
|
||||
_runs_src = {}
|
||||
for _f in ("main.py", "homeview.py"):
|
||||
with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh:
|
||||
_src = _fh.read()
|
||||
if _f == "main.py":
|
||||
_src = main_files.source()
|
||||
else:
|
||||
with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh:
|
||||
_src = _fh.read()
|
||||
for _part in _src.split('out.append({')[1:]:
|
||||
if '"runs": True' in _part.split("})", 1)[0]:
|
||||
_runs_src.setdefault(_f, []).append(
|
||||
|
||||
@@ -127,16 +127,17 @@ def without_fix(fn):
|
||||
"""release/1.0 8c438ab: held only for the peak, a drain stays pinned, the
|
||||
quarter's edge is not rolled over."""
|
||||
real = (Controller._held_back, Controller._drain_rise)
|
||||
import main_files # noqa: E402
|
||||
import app.main as m
|
||||
real_nq = m.next_quarter
|
||||
real_nq = main_files.live.next_quarter
|
||||
Controller._held_back = lambda self: self._peak_reserve()
|
||||
Controller._drain_rise = lambda self, claims, results: None
|
||||
m.next_quarter = lambda p, max_hold_s: p
|
||||
main_files.live.next_quarter = lambda p, max_hold_s: p
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._held_back, Controller._drain_rise = real
|
||||
m.next_quarter = real_nq
|
||||
main_files.live.next_quarter = real_nq
|
||||
|
||||
|
||||
def cmd(r):
|
||||
|
||||
@@ -37,6 +37,7 @@ logging.disable(logging.CRITICAL)
|
||||
|
||||
import safety94_rig as rig # noqa: E402
|
||||
import app.inverter as app_inverter # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import control # noqa: E402
|
||||
from app.freshness import BOUNDS, Freshness # noqa: E402
|
||||
@@ -59,19 +60,19 @@ class Before94(Freshness):
|
||||
|
||||
def without_fix(fn):
|
||||
"""Run `fn` with the gate's old clock and the old refusal evidence."""
|
||||
real_f, real_c = app_inverter.Freshness, app_main.compute
|
||||
real_p = app_main.REFUSED_PROGRESS_W
|
||||
real_f, real_c = app_inverter.Freshness, main_files.live.compute
|
||||
real_p = main_files.live.REFUSED_PROGRESS_W
|
||||
real_l = app_main.Controller._readings_late
|
||||
# SAFETY-128: that tree had no late-readings gate either.
|
||||
app_main.Controller._readings_late = lambda self, standing_w: None
|
||||
app_inverter.Freshness = Before94
|
||||
app_main.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None))
|
||||
app_main.REFUSED_PROGRESS_W = float("inf")
|
||||
main_files.live.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None))
|
||||
main_files.live.REFUSED_PROGRESS_W = float("inf")
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
app_inverter.Freshness, app_main.compute = real_f, real_c
|
||||
app_main.REFUSED_PROGRESS_W = real_p
|
||||
app_inverter.Freshness, main_files.live.compute = real_f, real_c
|
||||
main_files.live.REFUSED_PROGRESS_W = real_p
|
||||
app_main.Controller._readings_late = real_l
|
||||
|
||||
|
||||
@@ -315,7 +316,7 @@ check("compute(): a frozen loop at SETTLE_CYCLES reading -20 W engages with "
|
||||
and control.compute(**args, gave_nothing=False).refused == 0)
|
||||
|
||||
# The evidence restarts on progress towards the ask, not on jitter.
|
||||
print(f" REFUSED_PROGRESS_W {app_main.REFUSED_PROGRESS_W:.0f} W, span "
|
||||
print(f" REFUSED_PROGRESS_W {main_files.live.REFUSED_PROGRESS_W:.0f} W, span "
|
||||
f"{app_main.REFUSED_SPAN_S:.0f} s")
|
||||
|
||||
|
||||
@@ -356,7 +357,7 @@ check("a command that swaps direction every cycle is never evidence, and a "
|
||||
not any(flap) and 1 in away and away.index(1) == 22)
|
||||
check("REFUSED_PROGRESS_W is under the band's width and over the live "
|
||||
"readings' rest wander (25 W)",
|
||||
25.0 < app_main.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W)
|
||||
25.0 < main_files.live.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -36,6 +36,7 @@ logging.disable(logging.WARNING)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import main as app_main, packmode # noqa: E402
|
||||
from app.arbiter import Claim, P_MAINTENANCE, P_SAFETY # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -149,13 +150,13 @@ def run(n, house, *, store, extra=None, soc=None, reject=None, dead=None,
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt, clock = app_main.datetime, [0.0]
|
||||
real_dt, clock = main_files.live.datetime, [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return rig.BASE + timedelta(seconds=clock[0])
|
||||
app_main.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
rows = []
|
||||
try:
|
||||
t = 0.0
|
||||
@@ -182,7 +183,7 @@ def run(n, house, *, store, extra=None, soc=None, reject=None, dead=None,
|
||||
else g.floor_w))
|
||||
t += 2.0
|
||||
finally:
|
||||
app_main.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
return rows, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
@@ -336,7 +337,7 @@ def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
_real_time = M.time
|
||||
_real_time = main_files.live.time
|
||||
|
||||
|
||||
class _FakeTime:
|
||||
@@ -891,9 +892,9 @@ check("the units that raised, failed or hung are NOT marked handed back, so "
|
||||
for i in (1, 2, 4)))
|
||||
fb_raise.mode = "ok"
|
||||
FT.t = _real_time.monotonic() + 11.0 # one heartbeat later
|
||||
M.time = FT
|
||||
main_files.live.time = FT
|
||||
run(c.send_heartbeats(60_000.0, 10.0))
|
||||
M.time = _real_time
|
||||
main_files.live.time = _real_time
|
||||
check("...and the next idle write (a heartbeat later) retries the one that "
|
||||
"raised (now answers): released, and the healthy unit is not released a "
|
||||
"second time",
|
||||
@@ -955,7 +956,7 @@ for mode in ("false", "raise", "hang"):
|
||||
|
||||
# the cadence and the log, over N failed retries on a clock we move
|
||||
c, h, f = failing_site("false")
|
||||
M.time = FT
|
||||
main_files.live.time = FT
|
||||
FT.t = 5000.0
|
||||
_cap.clear()
|
||||
run(c.handle_action("auto_toggle")) # Stop: try 1 (forced)
|
||||
@@ -997,7 +998,7 @@ _cap.clear()
|
||||
run(c.handle_action("auto_toggle")) # Stop, fails again
|
||||
check("a NEW failure streak is logged again, once",
|
||||
len([1 for lv, m in _cap if lv >= logging.ERROR]) == 1)
|
||||
M.time = _real_time
|
||||
main_files.live.time = _real_time
|
||||
|
||||
# shutdown with a failed release: the one 0 W still goes out inside the bound
|
||||
c, h, f = failing_site("hang")
|
||||
@@ -1098,7 +1099,7 @@ check("the two new Last-event hints exist and are plain",
|
||||
.endswith("trying again"))
|
||||
|
||||
# --- 3j. the call sites -------------------------------------------------------
|
||||
src = (HERE / "app" / "main.py").read_text(encoding="utf-8")
|
||||
src = main_files.source()
|
||||
ss = src.splitlines()
|
||||
|
||||
|
||||
@@ -1115,7 +1116,8 @@ check("main.py: the Stop button and shutdown() call command_site_release(); "
|
||||
"the two maintenance-abort paths keep command_site_zero() (a third call "
|
||||
"is the all-GoodWe branch inside command_site_release itself)",
|
||||
len(rel) == 2 and len(zer) == 3
|
||||
and "async def command_site_release" in "\n".join(ss[zer[1] - 30:zer[1]]))
|
||||
and any("async def command_site_release" in "\n".join(ss[i - 30:i])
|
||||
for i in zer))
|
||||
|
||||
# =============================================================================
|
||||
print()
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""MAINT-21 tripwire: a test must patch a Controller global where it is read.
|
||||
|
||||
The Controller's code lives in app/main.py and in the files split from it
|
||||
(app/ctl_*.py, app/rows.py). A function reads a module global from its own
|
||||
file, so `app.main.NAME = fake` changes only the code that is still in
|
||||
main.py. This file fails when a test or tool still does that:
|
||||
|
||||
1. it patches an `app.main` attribute that main.py's own functions no
|
||||
longer read (a stale patch, silently inert), or
|
||||
2. it patches an `app.main` attribute that another file also reads (the
|
||||
patch reaches only part of the code).
|
||||
|
||||
The way to patch is `tools/main_files.py`: `main_files.live.NAME = fake` sets
|
||||
NAME in every file that reads it and raises when none does.
|
||||
|
||||
Part 3 runs the scan on spoilt sources and the patch helper on the real tree.
|
||||
Read MUTATION.md for the habit this follows. `python3 test_split_patches.py`
|
||||
"""
|
||||
|
||||
import ast
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
fails = []
|
||||
ALIASES = {"m", "M", "main", "app_main", "_main", "_main_mod", "main_mod"}
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def main_aliases(tree):
|
||||
"""Names bound to the module app.main in one file."""
|
||||
al = set()
|
||||
for n in ast.walk(tree):
|
||||
if isinstance(n, ast.Import):
|
||||
al.update(a.asname for a in n.names
|
||||
if a.name == "app.main" and a.asname)
|
||||
elif isinstance(n, ast.ImportFrom) and n.module == "app":
|
||||
al.update(a.asname or a.name for a in n.names if a.name == "main")
|
||||
for n in ast.walk(tree):
|
||||
if (isinstance(n, ast.Assign) and isinstance(n.value, ast.Name)
|
||||
and n.value.id in al):
|
||||
al.update(t.id for t in n.targets if isinstance(t, ast.Name))
|
||||
# `for mod in (app_main, homeview): mod.datetime = fake`
|
||||
if (isinstance(n, ast.For) and isinstance(n.target, ast.Name)
|
||||
and isinstance(n.iter, (ast.Tuple, ast.List))
|
||||
and any(isinstance(e, ast.Name) and e.id in al
|
||||
for e in n.iter.elts)):
|
||||
al.add(n.target.id)
|
||||
return al
|
||||
|
||||
|
||||
def flat(targets):
|
||||
"""The assignment targets, tuples and lists taken apart."""
|
||||
for t in targets:
|
||||
if isinstance(t, (ast.Tuple, ast.List)):
|
||||
yield from flat(t.elts)
|
||||
else:
|
||||
yield t
|
||||
|
||||
|
||||
def patches(src):
|
||||
"""[(line, name)]: every attribute of app.main a file assigns, or hands to
|
||||
setattr / a patching helper as `(app.main alias, "name", value)`."""
|
||||
tree = ast.parse(src)
|
||||
al = main_aliases(tree)
|
||||
out = []
|
||||
for n in ast.walk(tree):
|
||||
targets = []
|
||||
if isinstance(n, ast.Assign):
|
||||
targets = n.targets
|
||||
elif isinstance(n, (ast.AugAssign, ast.AnnAssign)):
|
||||
targets = [n.target]
|
||||
for t in flat(targets):
|
||||
# `app_main.NAME = x`, or `rig.m.NAME = x` where rig did
|
||||
# `import app.main as m`
|
||||
if isinstance(t, ast.Attribute) and (
|
||||
(isinstance(t.value, ast.Name) and t.value.id in al)
|
||||
or (isinstance(t.value, ast.Attribute)
|
||||
and t.value.attr in ALIASES)):
|
||||
out.append((n.lineno, t.attr))
|
||||
if (isinstance(n, ast.Call) and len(n.args) >= 3
|
||||
and isinstance(n.args[0], ast.Name) and n.args[0].id in al
|
||||
and isinstance(n.args[1], ast.Constant)
|
||||
and isinstance(n.args[1].value, str)
|
||||
and ast.unparse(n.func) != "getattr"):
|
||||
out.append((n.lineno, n.args[1].value))
|
||||
return out
|
||||
|
||||
|
||||
def problems(src, where="<source>", readers=main_files.reading):
|
||||
"""Messages for each patch in `src` that the rule above rejects."""
|
||||
out = []
|
||||
for line, name in patches(src):
|
||||
who = readers(name)
|
||||
if "main" not in who:
|
||||
out.append(f"{where}:{line} patches main.{name}, which main.py "
|
||||
f"does not read" + (f" (read by {who})" if who else ""))
|
||||
elif who != ["main"]:
|
||||
out.append(f"{where}:{line} patches main.{name}, which "
|
||||
f"{[w for w in who if w != 'main']} also read: use "
|
||||
f"main_files.live.{name}")
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. no test and no tool patches a main global the code reads elsewhere")
|
||||
sites = bad = 0
|
||||
for path in sorted(glob.glob(os.path.join(HERE, "test_*.py"))
|
||||
+ glob.glob(os.path.join(HERE, "tools", "*.py"))):
|
||||
if os.path.basename(path) == "test_split_patches.py":
|
||||
continue
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
found = problems(src, os.path.relpath(path, HERE))
|
||||
sites += len(patches(src))
|
||||
for msg in found:
|
||||
print(" " + msg)
|
||||
bad += len(found)
|
||||
check(f"{sites} patches of an app.main attribute found, {bad} stale or partial",
|
||||
sites > 0 and bad == 0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. the scan is not blind: spoilt sources are caught")
|
||||
HEAD = "import app.main as m\n"
|
||||
check("main.<a name nothing reads> is stale",
|
||||
len(problems(HEAD + "m.no_such_global_anywhere = 1\n")) == 1)
|
||||
check("main.datetime is partial (the clock is read in more than one file)"
|
||||
if len(main_files.reading("datetime")) > 1 else
|
||||
"main.datetime (one reader here, so nothing to catch yet)",
|
||||
len(problems(HEAD + "m.datetime = 1\n")) ==
|
||||
(1 if len(main_files.reading("datetime")) > 1 else 0))
|
||||
check("the same through setattr, a helper and an alias",
|
||||
len(problems(HEAD + "k = m\nsetattr(k, 'no_such_global_anywhere', 1)\n"
|
||||
"patched(m, 'no_such_global_anywhere', 1)(f)\n")) == 2)
|
||||
check("a patch through main_files.live is not flagged",
|
||||
problems(HEAD + "import main_files\n"
|
||||
"main_files.live.datetime = 1\n") == [])
|
||||
check("a name only main reads may still be patched on main",
|
||||
problems(HEAD + "m.load_options = 1\n") == [])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. main_files.live sets a global in every file that reads it")
|
||||
import importlib # noqa: E402
|
||||
|
||||
live = main_files.live
|
||||
for name in ("datetime", "compute", "_mono"):
|
||||
readers = main_files.reading(name)
|
||||
mods = [importlib.import_module("app." + r) for r in readers]
|
||||
before = [getattr(m, name) for m in mods]
|
||||
sentinel = object()
|
||||
setattr(live, name, sentinel)
|
||||
try:
|
||||
hit = all(getattr(m, name) is sentinel for m in mods)
|
||||
read_back = getattr(live, name) is sentinel
|
||||
finally:
|
||||
for m, old in zip(mods, before):
|
||||
setattr(m, name, old)
|
||||
check(f"{name}: set in all {len(mods)} reading file(s) {readers}, and "
|
||||
f"read back", bool(mods) and hit and read_back)
|
||||
try:
|
||||
live.no_such_global_anywhere = 1
|
||||
raised = False
|
||||
except AttributeError:
|
||||
raised = True
|
||||
check("a name no file reads raises instead of patching nothing", raised)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
sys.exit(1)
|
||||
print("all ok")
|
||||
@@ -81,6 +81,8 @@ from app.static_tariff import (
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import main_files # noqa: E402
|
||||
UTC = timezone.utc
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
@@ -893,7 +895,7 @@ check("the new module holds no option-defaults dict of its own, so this ticket "
|
||||
check("...and it reads the option through price.price_option(), the framework's "
|
||||
"single read path, rather than through opts.get(key, <a number>)",
|
||||
"price_option(opts" in _SRC and 'opts.get("static_profile"' not in _SRC)
|
||||
_MAIN = (HERE / "app" / "main.py").read_text(encoding="utf-8")
|
||||
_MAIN = main_files.source()
|
||||
check("...and main.py grew no `o.get(\"static_profile\", <a default>)` fallback "
|
||||
"either - it calls static_status(o, now), and the option is read once, "
|
||||
"inside the framework",
|
||||
|
||||
@@ -40,6 +40,7 @@ logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import homeview, modetrade, packmode # noqa: E402
|
||||
from app import price as _price, static_tariff as _st # noqa: E402
|
||||
@@ -196,7 +197,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
real_dt, real_hv = main_files.live.datetime, homeview.datetime
|
||||
clock = [start]
|
||||
|
||||
class _DT(real_dt):
|
||||
@@ -204,7 +205,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
main_files.live.datetime = homeview.datetime = _DT
|
||||
_real_load = _price.load_prices, _st.load_prices
|
||||
_price.load_prices = _st.load_prices = _no_fetch
|
||||
started = task_started(o)
|
||||
@@ -246,7 +247,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
main_files.live.datetime, homeview.datetime = real_dt, real_hv
|
||||
_price.load_prices, _st.load_prices = _real_load
|
||||
return c, started
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import arbitrage, homeview, modetrade, solarfc # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -175,7 +176,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
|
||||
# forecast IS lost, so it runs with a hold that never covers
|
||||
# (test_opt27.py tests the cover).
|
||||
c.fc_hold = _NoHold()
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
real_dt, real_hv = main_files.live.datetime, homeview.datetime
|
||||
clock = [t0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@@ -183,7 +184,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
main_files.live.datetime = homeview.datetime = _DT
|
||||
pts = None if points is None else points
|
||||
task = None
|
||||
if pts is None:
|
||||
@@ -248,7 +249,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
main_files.live.datetime, homeview.datetime = real_dt, real_hv
|
||||
return c
|
||||
|
||||
return asyncio.run(go()), rows
|
||||
|
||||
@@ -36,6 +36,7 @@ logging.disable(logging.WARNING)
|
||||
|
||||
import ux54_scenarios as S # noqa: E402
|
||||
import app.main as M # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import arbitrage, dayplan, dayview, homeview, solarfc # noqa: E402
|
||||
from app import tariff # noqa: E402
|
||||
from app.daycurve import SolarToday # noqa: E402
|
||||
@@ -90,7 +91,8 @@ class FakeDT(datetime):
|
||||
return NOW[0].astimezone(tz) if tz else NOW[0].replace(tzinfo=None)
|
||||
|
||||
|
||||
for _mod in (M, homeview, dayview): # what reads the wall clock
|
||||
main_files.live.datetime = FakeDT # the Controller's clock
|
||||
for _mod in (homeview, dayview): # the other readers of the clock
|
||||
_mod.datetime = FakeDT
|
||||
|
||||
TZ_MODULES = (tariff, dayplan, homeview, solarfc)
|
||||
|
||||
@@ -1161,9 +1161,12 @@ check("...and it does not raise the same card on every cycle - a per-PROCESS "
|
||||
|
||||
# SAFETY-34: a Core-ONLY restart. The add-on process lives on (measured on
|
||||
# TEST-23), HA's memory is wiped, so the SAME process must re-assert the card.
|
||||
import os # noqa: E402
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as _main_mod # noqa: E402
|
||||
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
|
||||
_main_mod._mono = lambda: _clock[0]
|
||||
_real_mono, _clock = main_files.live._mono, [main_files.live._mono()]
|
||||
main_files.live._mono = lambda: _clock[0]
|
||||
try:
|
||||
_h3 = _CircHass(a_w=0.0, b_w=0.0)
|
||||
_b.hass = _h3
|
||||
@@ -1173,7 +1176,7 @@ try:
|
||||
asyncio.run(_b.check_circulating())
|
||||
_late = [c for c in _h3.calls if c[0] == "persistent_notification"]
|
||||
finally:
|
||||
_main_mod._mono = _real_mono
|
||||
main_files.live._mono = _real_mono
|
||||
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise - no "
|
||||
"storm on the event bus", len(_early) == 0)
|
||||
check("SAFETY-34: a Core-only restart wipes the card and the add-on process "
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""MAINT-21: the Controller lives in app/main.py and the files split from it.
|
||||
|
||||
A test that reads `app/main.py` for a call, a comment or a def reads
|
||||
`source()` instead; a test that replaces a global that `main` used to read
|
||||
calls `patch_global()`, which sets it in each file whose code reads it. A name
|
||||
no file reads is an error: a stale patch cannot pass unnoticed.
|
||||
"""
|
||||
import ast
|
||||
import glob
|
||||
import importlib
|
||||
import os
|
||||
import symtable
|
||||
|
||||
APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "app")
|
||||
|
||||
|
||||
def files():
|
||||
"""main.py, rows.py and ctl_*.py, in that order, as bare file names."""
|
||||
split = ["rows.py"] + sorted(os.path.basename(p)
|
||||
for p in glob.glob(os.path.join(APP, "ctl_*.py")))
|
||||
return ["main.py"] + [f for f in split
|
||||
if os.path.exists(os.path.join(APP, f))]
|
||||
|
||||
|
||||
def text(name):
|
||||
with open(os.path.join(APP, name), encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def source():
|
||||
"""All the files' text, one after the other."""
|
||||
return "\n".join(text(f) for f in files())
|
||||
|
||||
|
||||
def tree(src=None):
|
||||
"""`ast.parse(source())` with every `*Mixin` class folded into
|
||||
`Controller`, so `Controller` has all its methods. Positions refer to
|
||||
`source()`."""
|
||||
src = source() if src is None else src
|
||||
mod = ast.parse(src)
|
||||
ctl = next(n for n in mod.body
|
||||
if isinstance(n, ast.ClassDef) and n.name == "Controller")
|
||||
keep = []
|
||||
for n in mod.body:
|
||||
if isinstance(n, ast.ClassDef) and n.name.endswith("Mixin"):
|
||||
ctl.body.extend(n.body)
|
||||
else:
|
||||
keep.append(n)
|
||||
mod.body = keep
|
||||
return mod
|
||||
|
||||
|
||||
def _function_reads(table, acc):
|
||||
for s in table.get_symbols():
|
||||
if (table.get_type() == "function" and s.is_global()
|
||||
and s.is_referenced()):
|
||||
acc.add(s.get_name())
|
||||
for child in table.get_children():
|
||||
_function_reads(child, acc)
|
||||
|
||||
|
||||
def reading(name):
|
||||
"""The bare module names (`main`, `ctl_cycle` ...) whose functions read
|
||||
`name` as a global."""
|
||||
out = []
|
||||
for f in files():
|
||||
acc = set()
|
||||
_function_reads(symtable.symtable(text(f), f, "exec"), acc)
|
||||
if name in acc:
|
||||
out.append(f[:-3])
|
||||
return out
|
||||
|
||||
|
||||
class _Live:
|
||||
"""The globals the Controller's code reads, wherever they are read.
|
||||
|
||||
`live.NAME = value` sets NAME in every file whose functions read it;
|
||||
`live.NAME` reads it. A name no file reads is an AttributeError, so a
|
||||
patch that changes nothing cannot pass unnoticed.
|
||||
"""
|
||||
|
||||
def __getattr__(self, name):
|
||||
mods = reading(name)
|
||||
if not mods:
|
||||
raise AttributeError(f"no file under app/ reads the global "
|
||||
f"{name!r}")
|
||||
return getattr(importlib.import_module("app." + mods[0]), name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
mods = reading(name)
|
||||
if not mods:
|
||||
raise AttributeError(f"no file under app/ reads the global "
|
||||
f"{name!r}: a patch of it changes nothing")
|
||||
for stem in mods:
|
||||
setattr(importlib.import_module("app." + stem), name, value)
|
||||
|
||||
|
||||
live = _Live()
|
||||
@@ -5,12 +5,19 @@
|
||||
|
||||
For each FILE (default: every goodwe_controller/app/*.py that differs from
|
||||
BASE_REF, default release/1.0) the ast.dump() of the tree with docstrings
|
||||
removed must be identical at BASE_REF and in the working tree. Also prints
|
||||
code lines vs comment-only + docstring lines (whole lines, via tokenize) before
|
||||
and after. Run from the repository root. Exit 1 on any code difference.
|
||||
removed must be identical at BASE_REF and in the working tree, and so must
|
||||
the `python -OO` compiled code (a wholly removed docstring leaves a no-op
|
||||
there). An added or removed app/*.py file is a code difference. The diff
|
||||
against BASE_REF may touch only goodwe_controller/app/*.py,
|
||||
goodwe_controller/DECISIONS/*.md and this script. Also prints code lines vs
|
||||
comment-only + docstring lines (whole lines, via tokenize) before and after,
|
||||
and the docstrings removed entirely or added. Run from the repository root.
|
||||
Exit 1 on any difference.
|
||||
"""
|
||||
import ast
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tokenize
|
||||
@@ -53,29 +60,87 @@ def counts(src):
|
||||
return len(code), len(doc) + len(com - code - doc)
|
||||
|
||||
|
||||
def qualnames(src):
|
||||
out = {}
|
||||
|
||||
def walk(node, prefix):
|
||||
for n in ast.iter_child_nodes(node):
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef,
|
||||
ast.ClassDef)):
|
||||
q = prefix + n.name
|
||||
out[q] = docstring(n) is not None
|
||||
walk(n, q + ".")
|
||||
out["<module>"] = docstring(ast.parse(src)) is not None
|
||||
walk(ast.parse(src), "")
|
||||
return out
|
||||
|
||||
|
||||
def code_sig(co):
|
||||
consts = tuple(code_sig(c) if hasattr(c, "co_code") else c
|
||||
for c in co.co_consts)
|
||||
return (co.co_name, co.co_code, consts, co.co_names, co.co_varnames,
|
||||
co.co_freevars, co.co_cellvars, co.co_flags, co.co_argcount,
|
||||
co.co_stacksize, co.co_exceptiontable)
|
||||
|
||||
|
||||
def oo_same(old, new):
|
||||
return (code_sig(compile(old, "f", "exec", optimize=2))
|
||||
== code_sig(compile(new, "f", "exec", optimize=2)))
|
||||
|
||||
|
||||
def git(*a):
|
||||
return subprocess.run(["git", *a], check=True, capture_output=True,
|
||||
text=True).stdout
|
||||
|
||||
|
||||
def exists(base, f):
|
||||
return subprocess.run(["git", "cat-file", "-e", f"{base}:{f}"],
|
||||
capture_output=True).returncode == 0
|
||||
|
||||
|
||||
ALLOWED = re.compile(r"^goodwe_controller/(app/[^/]+\.py|DECISIONS/[^/]+\.md|"
|
||||
r"tools/maint20_same_code\.py)$")
|
||||
|
||||
|
||||
def ratio(m, c):
|
||||
return f"{m / c:.2f}" if c else "n/a"
|
||||
|
||||
|
||||
def main(argv):
|
||||
base = argv[0] if argv else "release/1.0"
|
||||
files = argv[1:] or [
|
||||
f for f in git("diff", "--name-only", base, "--",
|
||||
"goodwe_controller/app").split()
|
||||
if f.endswith(".py")]
|
||||
bad = 0
|
||||
print(f"base {base} = {git('rev-parse', base).strip()}")
|
||||
changed = git("diff", "--name-only", base).split()
|
||||
for f in changed:
|
||||
if not ALLOWED.match(f):
|
||||
print(f"FAIL: diff touches {f}, outside app/*.py, DECISIONS/*.md "
|
||||
"and this script")
|
||||
bad += 1
|
||||
files = argv[1:] or [f for f in changed if re.match(
|
||||
r"^goodwe_controller/app/[^/]+\.py$", f)]
|
||||
for f in files:
|
||||
in_base, in_head = exists(base, f), os.path.exists(f)
|
||||
if in_base != in_head:
|
||||
print(f"CODE DIFFERS {f} "
|
||||
f"{'removed' if in_base else 'added'} file")
|
||||
bad += 1
|
||||
continue
|
||||
old = git("show", f"{base}:{f}")
|
||||
new = open(f, encoding="utf-8").read()
|
||||
same = dump(old) == dump(new)
|
||||
oo = oo_same(old, new)
|
||||
(c0, m0), (c1, m1) = counts(old), counts(new)
|
||||
print(f"{'SAME CODE' if same else 'CODE DIFFERS'} {f} "
|
||||
f"code {c0}->{c1} comment+doc {m0}->{m1} "
|
||||
f"ratio {m0 / c0:.2f}->{m1 / c1:.2f}")
|
||||
bad += not same
|
||||
print("OK: no code change" if not bad else f"FAIL: {bad} file(s) differ")
|
||||
f"ratio {ratio(m0, c0)}->{ratio(m1, c1)} "
|
||||
f"-OO {'same' if oo else 'DIFFERS'}")
|
||||
bad += (not same) + (not oo)
|
||||
qo, qn = qualnames(old), qualnames(new)
|
||||
for q in sorted(q for q in qo if qo[q] and not qn.get(q, True)):
|
||||
print(f" docstring REMOVED: {f}:{q}")
|
||||
for q in sorted(q for q in qn if qn[q] and not qo.get(q, True)):
|
||||
print(f" docstring ADDED: {f}:{q}")
|
||||
print("OK: no code change" if not bad else f"FAIL: {bad} problem(s)")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MAINT-21: prove that splitting app/main.py moved code and changed none.
|
||||
|
||||
python3 goodwe_controller/tools/maint21_same_ast.py [BASE_REF] [--docstrings]
|
||||
|
||||
Every module-level def, class and assignment of app/main.py at BASE_REF
|
||||
(default release/1.0), and every method of its `Controller`, is looked up by
|
||||
qualified name in the working tree: app/main.py, app/rows.py and the mixin
|
||||
classes of app/ctl_*.py (a mixin's methods count as `Controller.<name>`).
|
||||
Each must be found exactly once with an identical `ast.dump()`, docstrings
|
||||
removed unless --docstrings. Allowed extras: the `Controller` class header,
|
||||
`_LOG = logging.getLogger("goodwe")` in a module that logs, and import lines.
|
||||
Prints one line per file and `OK` or the differences. The diff against
|
||||
BASE_REF may touch only goodwe_controller/app, DECISIONS, test_*.py and
|
||||
tools. Run from the repository root on a clean checkout (untracked files are
|
||||
read, but the diff check only sees tracked ones). Exit 1 on any difference.
|
||||
"""
|
||||
import ast
|
||||
import collections
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
APP = "goodwe_controller/app"
|
||||
MIXIN = re.compile(r"^[A-Z][A-Za-z]*Mixin$")
|
||||
|
||||
|
||||
def git(*a):
|
||||
return subprocess.run(["git", *a], check=True, capture_output=True,
|
||||
text=True).stdout
|
||||
|
||||
|
||||
def bare(n, docstrings):
|
||||
if not docstrings:
|
||||
for x in ast.walk(n):
|
||||
if isinstance(x, (ast.FunctionDef, ast.AsyncFunctionDef,
|
||||
ast.ClassDef)) and x.body:
|
||||
e = x.body[0]
|
||||
if (isinstance(e, ast.Expr) and isinstance(e.value, ast.Constant)
|
||||
and isinstance(e.value.value, str)):
|
||||
x.body = x.body[1:] or [ast.Pass()]
|
||||
return ast.dump(n)
|
||||
|
||||
|
||||
def key_of(n):
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
return n.name
|
||||
if isinstance(n, ast.Assign):
|
||||
return "=" + ",".join(ast.unparse(t) for t in n.targets)
|
||||
if isinstance(n, ast.AnnAssign):
|
||||
return "=" + ast.unparse(n.target)
|
||||
return "?" + ast.unparse(n)[:60]
|
||||
|
||||
|
||||
def units(src, docstrings, mixins):
|
||||
"""{qualified name: [dump, ...]} for one file's text."""
|
||||
out = collections.defaultdict(list)
|
||||
tree = ast.parse(src)
|
||||
for n in tree.body:
|
||||
if isinstance(n, (ast.Import, ast.ImportFrom)):
|
||||
continue
|
||||
if (isinstance(n, ast.Expr) and isinstance(n.value, ast.Constant)
|
||||
and isinstance(n.value.value, str)):
|
||||
continue
|
||||
if isinstance(n, ast.ClassDef) and (
|
||||
n.name == "Controller" or (mixins and MIXIN.match(n.name))):
|
||||
for m in n.body:
|
||||
out["Controller." + key_of(m)].append(bare(m, docstrings))
|
||||
continue
|
||||
out[key_of(n)].append(bare(n, docstrings))
|
||||
return out
|
||||
|
||||
|
||||
def main(argv):
|
||||
docstrings = "--docstrings" in argv
|
||||
argv = [a for a in argv if not a.startswith("--")]
|
||||
base = argv[0] if argv else "release/1.0"
|
||||
bad = 0
|
||||
print(f"base {base} = {git('rev-parse', base).strip()}")
|
||||
for f in git("diff", "--name-only", base).split():
|
||||
if not re.match(r"^goodwe_controller/(app/[^/]+|DECISIONS/[^/]+|"
|
||||
r"test_[^/]+\.py|tools/[^/]+)$", f):
|
||||
print(f"FAIL: diff touches {f}")
|
||||
bad += 1
|
||||
old = units(git("show", f"{base}:{APP}/main.py"), docstrings, False)
|
||||
files = ["main.py", "rows.py"] + sorted(
|
||||
os.path.basename(p) for p in glob.glob(f"{APP}/ctl_*.py"))
|
||||
where = collections.defaultdict(list) # key -> [(file, dump)]
|
||||
for fn in files:
|
||||
p = f"{APP}/{fn}"
|
||||
if not os.path.exists(p):
|
||||
continue
|
||||
for k, dumps in units(open(p, encoding="utf-8").read(), docstrings,
|
||||
True).items():
|
||||
for d in dumps:
|
||||
where[k].append((fn, d))
|
||||
found = collections.Counter()
|
||||
problems = collections.defaultdict(list)
|
||||
for k, dumps in old.items():
|
||||
if k == "Controller":
|
||||
continue
|
||||
got = where.get(k, [])
|
||||
if k == "=_LOG": # one per module that logs, all the same
|
||||
got = got[:1] * len(dumps) if all(
|
||||
d == dumps[0] for _, d in got) else []
|
||||
if len(got) != len(dumps):
|
||||
problems["?"].append(f"{k}: {len(dumps)} in base, "
|
||||
f"{len(got)} in head")
|
||||
continue
|
||||
for (fn, d), want in zip(got, dumps):
|
||||
if d != want:
|
||||
problems[fn].append(f"{k}: AST differs")
|
||||
else:
|
||||
found[fn] += 1
|
||||
for k, got in where.items():
|
||||
if k not in old and k != "=_LOG":
|
||||
for fn, _ in got:
|
||||
problems[fn].append(f"{k}: not in base")
|
||||
for fn in files:
|
||||
if not os.path.exists(f"{APP}/{fn}"):
|
||||
continue
|
||||
if problems.get(fn):
|
||||
print(f"DIFFERS {fn}")
|
||||
for line in problems[fn]:
|
||||
print(" ", line)
|
||||
bad += len(problems[fn])
|
||||
else:
|
||||
print(f"OK {fn} {found[fn]} units identical")
|
||||
for line in problems.get("?", []):
|
||||
print("DIFFERS ", line)
|
||||
bad += 1
|
||||
total = sum(found.values())
|
||||
print(f"base units {sum(len(v) for k, v in old.items() if k != 'Controller')}"
|
||||
f", identical in head {total}")
|
||||
print("OK: code moved, none changed" if not bad else f"FAIL: {bad} problem(s)")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -26,6 +26,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
|
||||
@@ -157,7 +158,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt = m.datetime
|
||||
real_dt = main_files.live.datetime
|
||||
clock = [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@@ -165,7 +166,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
|
||||
def now(cls, tz=None):
|
||||
return BASE + timedelta(seconds=clock[0])
|
||||
|
||||
m.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
rows = []
|
||||
try:
|
||||
t = 0.0
|
||||
@@ -186,7 +187,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
|
||||
on_cycle(c, t)
|
||||
t += TICK
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
return rows, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ from datetime import datetime, timedelta, timezone
|
||||
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
@@ -348,7 +349,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
if patch is not None:
|
||||
patch(c)
|
||||
c.auto = True
|
||||
real_dt = m.datetime
|
||||
real_dt = main_files.live.datetime
|
||||
clock = [start]
|
||||
|
||||
class _DT(real_dt):
|
||||
@@ -356,7 +357,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
plan_hour = None
|
||||
try:
|
||||
t = 0.0
|
||||
@@ -416,7 +417,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
check(c, hass, now, pr)
|
||||
t += tick
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
return c
|
||||
c = asyncio.run(go())
|
||||
return stats, c
|
||||
|
||||
@@ -22,6 +22,7 @@ sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import main as app_main, packmode # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
@@ -127,13 +128,13 @@ def run(n, house, *, before=off, dur=T + 90.0, tick=1.0, reg_s=10.0,
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt, clock = app_main.datetime, [0.0]
|
||||
real_dt, clock = main_files.live.datetime, [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return rig.BASE + timedelta(seconds=clock[0])
|
||||
app_main.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
rows = []
|
||||
try:
|
||||
t = 0.0
|
||||
@@ -161,7 +162,7 @@ def run(n, house, *, before=off, dur=T + 90.0, tick=1.0, reg_s=10.0,
|
||||
reason=c.loop_reason))
|
||||
t += tick
|
||||
finally:
|
||||
app_main.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
return rows, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
@@ -222,7 +223,7 @@ class Rig:
|
||||
i.dev_min, i.dev_max = -MAXW[i.name[-1]], MAXW[i.name[-1]]
|
||||
c.auto = True
|
||||
self.c = c
|
||||
real_dt = m.datetime
|
||||
real_dt = main_files.live.datetime
|
||||
clock = [self.start]
|
||||
|
||||
class _DT(real_dt):
|
||||
@@ -230,7 +231,7 @@ class Rig:
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
floor = float(self.o.get("cycle_floor_s", 15))
|
||||
last_grid, last_cycle, last_n = object(), None, None
|
||||
try:
|
||||
@@ -260,7 +261,7 @@ class Rig:
|
||||
sample(self, c, t)
|
||||
t += 1.0
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
return c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import opt11p3_identity as p3 # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import packmode # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
@@ -76,7 +77,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None):
|
||||
if modes:
|
||||
extra.update(p3.ARB, battery_modes_enabled=True)
|
||||
seen, extra_rows = [], []
|
||||
real_resolve = m.resolve
|
||||
real_resolve = main_files.live.resolve
|
||||
|
||||
def spy(claims, **kw):
|
||||
seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi,
|
||||
@@ -96,7 +97,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None):
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s78-"), "tariff.db")
|
||||
real = rig.Controller
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
m.resolve = spy
|
||||
main_files.live.resolve = spy
|
||||
try:
|
||||
rows, c = rig.run(n, extra=extra, dur=dur, load=load, soc=s79.soc,
|
||||
cut=lambda u, t: False,
|
||||
@@ -105,7 +106,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None):
|
||||
store=store_cheap(n) if modes else None)
|
||||
finally:
|
||||
rig.Controller = real
|
||||
m.resolve = real_resolve
|
||||
main_files.live.resolve = real_resolve
|
||||
return [r + (x,) for r, x in zip(rows, extra_rows)], c
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from datetime import timedelta
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import packmode # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -140,13 +141,13 @@ def run(load, *, margin=50, dur=2700.0, rating=2000.0, cap=3500.0,
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt, clock = m.datetime, [0.0]
|
||||
real_dt, clock = main_files.live.datetime, [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return rig.BASE + timedelta(seconds=clock[0])
|
||||
m.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
rows, late = [], random.Random(8100)
|
||||
errors = []
|
||||
|
||||
@@ -173,7 +174,7 @@ def run(load, *, margin=50, dur=2700.0, rating=2000.0, cap=3500.0,
|
||||
rows.append((t, c.target, round(plant.hist[-1][1], 6)))
|
||||
t += TICK
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
logging.getLogger("goodwe").removeHandler(h)
|
||||
return plant, c, rows, errors
|
||||
plant, c, rows, errors = asyncio.run(go())
|
||||
|
||||
@@ -23,6 +23,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import tariff # noqa: E402
|
||||
|
||||
@@ -71,7 +72,7 @@ def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None,
|
||||
"""Rows (opt11_identity's fields plus every claim, the guard and the
|
||||
phases) and the controller. `month_peak` banks that peak this month."""
|
||||
seen, extra_rows = [], []
|
||||
real_resolve = m.resolve
|
||||
real_resolve = main_files.live.resolve
|
||||
|
||||
def spy(claims, **kw):
|
||||
seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi,
|
||||
@@ -93,7 +94,7 @@ def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None,
|
||||
"2026-09-02T10:00:00+00:00")).connection.commit()
|
||||
real = rig.Controller
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
m.resolve = spy
|
||||
main_files.live.resolve = spy
|
||||
try:
|
||||
rows, c = rig.run(n, extra=dict(s79.P1, **OPT, **(extra or {})),
|
||||
dur=dur, load=load, soc=soc or s79.soc,
|
||||
@@ -102,7 +103,7 @@ def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None,
|
||||
store=store or s79.store_charging(n))
|
||||
finally:
|
||||
rig.Controller = real
|
||||
m.resolve = real_resolve
|
||||
main_files.live.resolve = real_resolve
|
||||
return [r + (x,) for r, x in zip(rows, extra_rows)], c
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
|
||||
@@ -169,7 +170,7 @@ def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None,
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt = m.datetime
|
||||
real_dt = main_files.live.datetime
|
||||
clock = [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@@ -177,7 +178,7 @@ def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None,
|
||||
def now(cls, tz=None):
|
||||
return BASE + timedelta(seconds=clock[0])
|
||||
|
||||
m.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
out = {"stale": [], "refused": [], "rows": [], "c": c}
|
||||
was = {i.name: False for i in c.inverters}
|
||||
was_ref = {i.name: 0 for i in c.inverters}
|
||||
@@ -213,7 +214,7 @@ def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None,
|
||||
dict(plant.reg), [mm.phase for mm in c.maints]))
|
||||
t += 1.0
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
return out
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user