DEV-02: per-instance Inverter objects - rating, SoC, watchdogs, heartbeat and store keys stop being scalars #43
+18
-1
@@ -83,7 +83,24 @@ rejected.
|
||||
|
||||
⚠️ **If you are testing the failsafe by hand, note that stopping and starting the
|
||||
add-on will not clear a trip.** That is the point of it; use the *Start / stop
|
||||
control* button twice, or delete `sign_tripped` from `/data/state.json`.
|
||||
control* button twice, or delete the trip from `/data/state.json`.
|
||||
|
||||
⚠️ **DEV-02 renamed that key. There is one `SignWatchdog` per inverter, so the
|
||||
LIVE latch lives under `unit/<inverter>/sign_tripped`** —
|
||||
`unit/inverter-a/sign_tripped` on a single-inverter site. **That is the one to
|
||||
delete if you are clearing a trip by hand**; deleting the flat one alone clears
|
||||
nothing.
|
||||
|
||||
⚠️ **The flat `sign_tripped` is still maintained, though — it is the ROLLBACK
|
||||
VIEW, not a fossil.** It is written alongside the namespaced key on a trip and
|
||||
cleared alongside it when you press *Start / stop control*, so an add-on you
|
||||
**downgrade** past this version still finds the latch it should. It is also read
|
||||
on **every** start, and the one disagreement it can produce is acted on: *the
|
||||
flat key says tripped and no inverter came up latched* means the latch was
|
||||
written by an older add-on while this install was rolled back — or the migration
|
||||
lost it — and the controller **re-arms the latch and shouts in the log** rather
|
||||
than coming up disarmed with a green banner. So if you edit `/data/state.json`
|
||||
by hand, clear **both** spellings or the next start will put the trip back.
|
||||
|
||||
⚠️ `test_loop.py` takes about **95 seconds** (measured 93 s) and that is not a bug. It
|
||||
drives the real control loop, which sleeps 1 s a tick, eight times over, to prove a
|
||||
|
||||
@@ -2,6 +2,68 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
**Every per-device value stops being a Controller scalar: rating, SoC, battery
|
||||
power, watchdogs, heartbeat, write bookkeeping and store keys are now
|
||||
per-instance (DEV-02).** `app/inverter.py` introduces an `Inverter` object that
|
||||
owns its entities, its rating, its device range, its `SignWatchdog`, its
|
||||
`last_write` and its `last_beat`, under **store keys namespaced by unit name**.
|
||||
The Controller holds a list of them and keeps only what is genuinely site-level:
|
||||
the P1 reading, the site integrator, and the tariff quarter.
|
||||
|
||||
* **What the scalars were silently asserting.** One `dev_min`/`dev_max` pair was
|
||||
read from one `setpoint_entity`, so a second inverter's rating was **never
|
||||
read at all** and its clamp, its freeze rail and the peak guard's
|
||||
`achievable_w` were the first one's numbers. One `soc` fed the maintenance
|
||||
FSM, SAFETY-24's pack floor and arbitrage's bounds. One `SignWatchdog` - whose
|
||||
own docstring says *"one instance per actuator"* - latched under **flat** store
|
||||
keys, so a trip caused by B latched A across restarts and one operator press
|
||||
cleared both. One `last_beat` and one serialised `hass.call()` meant a hung
|
||||
write to A ate B's 30 s layer-1 budget.
|
||||
* **`actual_w` became SITE level while the readings became per-instance, and
|
||||
those are opposite directions on purpose.** `compute()`'s anti-windup test is
|
||||
`abs(prev_w - actual_w) > saturation_w` with `prev_w` the **site** target, so
|
||||
feeding it one inverter's power compares a site number against a unit number,
|
||||
trips the saturation freeze permanently and leaves a standing grid error.
|
||||
Measured on `admin/goodwe`'s two-master rig, 2 kW load: with one unit's power
|
||||
the aggregate trace at the meter is split-dependent by **510 W at `gain: 0.2`,
|
||||
250 W at 0.4 and 50 W at 0.6**; with the site sum **every split is
|
||||
bit-identical at every gain the schema accepts**. That was ALLOC-01's
|
||||
disclosed finding, graded in `test_alloc.py` part 8 and now closed there.
|
||||
* **The store migration is graded, not merely performed.** `sign_tripped` /
|
||||
`sign_reason` move to `unit/<name>/...` once, verified by **reading back**
|
||||
what was written, and the version marker is only set when nothing that carried
|
||||
state was lost. The flat keys are left in the file so a rollback still finds
|
||||
its latch. And `Controller.__init__` compares the pre-migration flat latch
|
||||
against what unit A actually came up with: if the latch did not survive it is
|
||||
**re-armed from the legacy value and shouted about**, because a latch that
|
||||
silently fails to migrate is a disarmed safety guard on an install whose state
|
||||
file says it is protected.
|
||||
* **Heartbeats are independent, and the budget is derived.** Each inverter owns
|
||||
its own deadline, the refreshes are dispatched **concurrently**, and each write
|
||||
is bounded by `WRITE_BUDGET_S` (4 s). The inequality that fixes that number is
|
||||
graded rather than asserted: `4 + heartbeat_s`'s schema maximum of `25 < 30`,
|
||||
layer 1's `FIRMWARE_INGEST_VETO_S`. Verified by injecting a transport that
|
||||
accepts and never answers: B's write reaches the wire while A is still
|
||||
hanging, and the serialised shape it replaces is run beside it.
|
||||
* **`live_units()` answers honestly.** A unit whose every write has been rejected
|
||||
for a whole layer-1 window is not commandable; the allocator drops it, the
|
||||
survivors carry its share, the action log names it, and the dropped unit is
|
||||
commanded **0 W** rather than left standing on its last share. A roster entry
|
||||
with **no** `setpoint_entity` is deliberately *not* handled there - that is a
|
||||
configuration that cannot be delivered, and it reaches main.py's refusal
|
||||
branch, which commands 0 W and names the inverter.
|
||||
* **At N=1 nothing moves.** The removed scalars survive as read/write properties
|
||||
of the sole inverter, so every existing replay in this repository runs
|
||||
unmodified - and they **refuse at N>=2** rather than answering for the first
|
||||
inverter, which is the tripwire on a consumer that was never made
|
||||
per-instance. `test_inverter.py` is the new file (77 checks); `test_alloc.py`
|
||||
part 8, `test_peakguard.py` 10g/10h, `test_watchdog.py` and `test_clamp.py`
|
||||
were re-aimed rather than relaxed.
|
||||
* **Inverter B still does not exist.** `build_roster()` returns exactly one
|
||||
inverter, from the flat options, and is the one function CFG-04 changes. Every
|
||||
two-unit figure above is architecture, not a measurement of a second device;
|
||||
the second rating in the checks is marked as a hypothesis.
|
||||
|
||||
**The write path is gated on the allocation record, not on how many config keys
|
||||
exist (SAFETY-28).** specs §1.5 makes *"any configuration in which two writers
|
||||
can reach one actuator"* a non-goal. Until now that non-goal was enforced by
|
||||
|
||||
@@ -306,7 +306,13 @@ does. That is deliberate on both counts:
|
||||
always vanishes the instant the trip works. A watchdog that took that as proof
|
||||
the fault was over would hand the inverter straight back to the loop that was
|
||||
driving the wrong way, three minutes later, forever.
|
||||
- The trip is written to `/data/state.json` and restored on startup. Restarting
|
||||
- The trip is written to `/data/state.json` — under
|
||||
`unit/<inverter>/sign_tripped`, one key per inverter since DEV-02, so a trip
|
||||
caused by one inverter cannot latch another — and restored on startup. The
|
||||
pre-DEV-02 flat `sign_tripped` is written and cleared alongside it as the
|
||||
**rollback view**, so downgrading the add-on past DEV-02 still finds the
|
||||
latch, and a trip taken while the install is rolled back is re-armed by the
|
||||
version that comes back up rather than silently lost. Restarting
|
||||
the add-on is the first thing most people try when they see a red banner, and
|
||||
a latch that a restart clears would bring the controller back **armed, into the
|
||||
same fault, unattended** — and under a Supervisor that restarts a crashed add-on
|
||||
|
||||
@@ -127,6 +127,63 @@ Two rules come out of it, and both are cheap:
|
||||
function, that pair is the first thing to try — TEST-16's two survivors were
|
||||
adjacent lines in one function and neither was individually live.
|
||||
|
||||
## The third way a check lies: its DATA cannot reach the failure it names
|
||||
|
||||
Stale bytecode manufactures survivors from a mutation that never ran. Masking
|
||||
manufactures them from a second guard doing the first one's work. This one
|
||||
manufactures them from a **fixture**: the check runs, the assertion is
|
||||
evaluated, the sentence above it is true — and the numbers it compares are
|
||||
arranged so that the failure it describes could not have changed the answer.
|
||||
|
||||
It is not hypothetical and it is not rare. Three instances in one week, all
|
||||
found by review rather than by a red line:
|
||||
|
||||
* **ARB-03** shipped a call-site scan asserting that every `claims.append` in
|
||||
`cycle()` routes through `actuator_of()`. Its own parse **saw 3 of the 8
|
||||
appends**; the other five were invisible to it and would have passed however
|
||||
they were routed.
|
||||
* **ALLOC-01** shipped a replay generator to hunt for envelope escapes. It found
|
||||
**0**. A widened generator over the same allocator found **39,991**.
|
||||
* **DEV-02** shipped `"each unit's supervised bound is ITS OWN max_w — give
|
||||
inverter B a different rating and the claim filed on inverter B moves with
|
||||
it"`, comparing inverter B's bound against `_b2.max_w`. The fixture defaulted
|
||||
B's rating to A's, so it compared `(-5000, 5000)` against `(-5000, 5000)` —
|
||||
**a number against itself**. Filing both per-unit bounds from
|
||||
`self.inverters[0]` left **all 19 files green**.
|
||||
|
||||
Note what these have in common with a masked rung: the mutation *runs*, and the
|
||||
suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly
|
||||
can see it, because the code under test really did behave identically on the
|
||||
stimulus it was given. The defect is upstream of the assertion, in what the
|
||||
check was handed.
|
||||
|
||||
**The rule, and it is one line:**
|
||||
|
||||
> **Any check asserting "X differs from Y" must be run once with X and Y
|
||||
> actually made different.**
|
||||
|
||||
Three cheap habits fall out of it, and all three are now used in this repository:
|
||||
|
||||
1. **Write the PRECONDITION as its own check.** `test_peakguard.py` 10g asserts
|
||||
that A and B have different ratings *before* it asserts that B's bound is
|
||||
B's. If a later edit makes them equal again, the precondition goes red rather
|
||||
than the claim going quietly vacuous.
|
||||
2. **Assert against literals, not against the expression under test.**
|
||||
`== [(-_b2.max_w, _b2.max_w)]` cannot distinguish "B's number" from "the
|
||||
number this fixture happens to have twice"; `== (-3000.0, 3000.0)` beside a
|
||||
printed `A = 5000` can.
|
||||
3. **Print what the check saw.** Every one of the three above would have been
|
||||
caught by a human reading one line of output — `supervised bounds by
|
||||
actuator: {'inverter-a': (-5000, 5000), 'inverter-b': (-5000, 5000)}` is
|
||||
self-evidently not evidence of a difference.
|
||||
|
||||
And the mutation-table consequence: **a variant is not covered because a
|
||||
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
|
||||
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
|
||||
static routing pin catches. The *bound* variant — right actuator, wrong
|
||||
numbers — is a different defect with a different fix, and it was absent. Both
|
||||
are in the table now.
|
||||
|
||||
## Where each surface stands
|
||||
|
||||
* **Local and dev-harness runs — the exposed one.** This is where FCST-03 hit
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"""One inverter, as an OBJECT rather than as a set of Controller scalars.
|
||||
|
||||
WHAT THIS FIXES, in one sentence: every piece of per-device state in this
|
||||
add-on used to be a single scalar on the Controller, and several of those
|
||||
scalars are SAFETY INPUTS - so a second inverter would have been graded against
|
||||
the first one's numbers.
|
||||
|
||||
The scalars, and what each one was silently asserting:
|
||||
|
||||
`dev_min` / `dev_max` ONE pair, read from ONE `setpoint_entity`. A second
|
||||
inverter's rating was never read at all, so its clamp,
|
||||
its freeze rail and the peak guard's `achievable_w`
|
||||
were all inverter A's.
|
||||
`soc` ONE state of charge into the maintenance FSM,
|
||||
SAFETY-24's pack floor and arbitrage's bounds. An
|
||||
average lets the emptier pack be driven below its own
|
||||
floor; a maximum parks the fuller one.
|
||||
`batt` ONE battery power into the anti-windup saturation
|
||||
detector - see the SITE/UNIT note at the bottom of
|
||||
this docstring, because this one goes the OTHER WAY.
|
||||
`sign_wd` ONE SignWatchdog, whose own docstring says "one
|
||||
instance per actuator", latched under FLAT store keys.
|
||||
A trip caused by B latched A across restarts and one
|
||||
operator action cleared both.
|
||||
`last_write` ONE write bookkeeping, and ONE serialised `hass.call()`
|
||||
behind it: a hung write to A ate B's 30 s layer-1
|
||||
budget.
|
||||
|
||||
⚠️ WHAT IS DELIBERATELY *NOT* HERE, AND IT IS THE HALF THAT IS EASY TO GET
|
||||
BACKWARDS. THERE IS NO `Tuning` ON THIS OBJECT AND THERE IS NO INTEGRATOR.
|
||||
§6.2 and ALLOC-01's stability argument are ONE integrator on P1, ABOVE the
|
||||
split: the site target S is divided into per-unit shares, so the gain seen at
|
||||
the meter is a CONVEX COMBINATION of the per-unit gains and a bad split cannot
|
||||
destabilise the site. Give each inverter its own `Tuning` and somebody will
|
||||
eventually call `compute()` with it, and the moment two integrators regulate
|
||||
one meter the whole argument is gone - that is the two-writers failure the
|
||||
arbiter exists to prevent, one level up.
|
||||
|
||||
The one field of `Tuning` that genuinely IS per-device is `max_w`, the
|
||||
supervised cap on what may be WRITTEN to one setpoint entity (§11.3), and it is
|
||||
here as `max_w`. The SITE `Tuning` the loop runs on takes its bound from the
|
||||
roster - `sum(inv.max_w)` - so at N=1 it is byte-identical to the shipped
|
||||
number and at N>=2 it is what the site can actually deliver. That is the whole
|
||||
of "the rating stops being a scalar"; a per-unit Tuning would be three more
|
||||
numbers nobody reads and one more place a limit has to be kept in step (CFG-03
|
||||
already tracks three).
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
SITE OR UNIT? THE TWO HALVES OF THE N=1 IDENTITY, AND THEY MOVE IN OPPOSITE
|
||||
DIRECTIONS
|
||||
|
||||
At N=1 §6.2's identity holds: the fabricated grid reading and the battery
|
||||
setpoint are the same number, so "the site" and "the inverter" are the same
|
||||
thing and every scalar above was RIGHT. Splitting them is not one change, it is
|
||||
two, and they point opposite ways:
|
||||
|
||||
COMMANDING becomes per-unit. One setpoint entity per inverter, one rating,
|
||||
one clamp, one watchdog, one heartbeat.
|
||||
SENSING, for the loop, becomes SITE. `compute()` regulates the METER. Its
|
||||
`actual_w` is the anti-windup input to
|
||||
`abs(prev_w - actual_w) > saturation_w`, where
|
||||
`prev_w` is the SITE target. Feed it ONE unit's
|
||||
power at a roster of two and that test compares
|
||||
a site number against a unit number, trips the
|
||||
freeze permanently and leaves a standing grid
|
||||
error.
|
||||
|
||||
That second half is MEASURED, not argued. On admin/goodwe's two-master rig
|
||||
(`tools/alloc_sim.py --splits --feedback unit`), with the loop fed one
|
||||
inverter's power the aggregate trace at the meter becomes a function of the
|
||||
split - up to 510 W apart at gain 0.2, 250 W at gain 0.4, 50 W at gain 0.6 -
|
||||
while the allocator's shares sum to S in every one of those runs. The
|
||||
counterfactual is the proof: change that ONE input to the site total and every
|
||||
split is bit-identical at every gain the schema accepts. ALLOC-01's review
|
||||
isolated it and test_alloc.py part 8 graded it as a disclosed defect waiting on
|
||||
this card.
|
||||
|
||||
So this object owns `batt` - THIS inverter's own power, which is what a
|
||||
per-unit consumer needs - and the Controller sums them into the site figure the
|
||||
loop is fed. Getting that backwards reintroduces the divergence.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
LIVENESS, AND WHY IT IS A METHOD WITH A REASON
|
||||
|
||||
`Controller.live_units()` decides which inverters may be commanded this tick,
|
||||
and `allocator.plan()` DROPS what it does not list - the survivors then carry
|
||||
the whole site target. A wrong answer there is a real change in what the site
|
||||
can deliver, so `is_live()` answers one question and says which: CAN THIS
|
||||
CONTROLLER PUT A NUMBER ON THIS INVERTER RIGHT NOW.
|
||||
|
||||
ONE way it cannot, and exactly one: every write has been REJECTED for a whole
|
||||
`LINK_STALE_S` window. Not one rejection - `hass.set_number()` returns False for
|
||||
a transient 400 as readily as for a device that has gone - but a continuous run
|
||||
of them with no acceptance in between.
|
||||
|
||||
⚠️ AND NOT "IT HAS NO `setpoint_entity`", THOUGH THAT IS ALSO A UNIT THIS
|
||||
CONTROLLER CANNOT COMMAND. The two look the same and want opposite answers. A
|
||||
link that WAS working and stopped is an event: the survivors carry its share,
|
||||
the record says so, and the site goes on running at reduced strength, which is
|
||||
the best available outcome. A roster entry with no writer configured at all is a
|
||||
CONFIGURATION that cannot be delivered - it was never reachable, nothing has
|
||||
gone wrong on the wire, and quietly running the site at part strength on a
|
||||
roster somebody wrote down wrong is how a half-driven site reads as healthy on
|
||||
every dashboard. That one belongs to main.py's refusal branch, which commands
|
||||
0 W and NAMES the inverter; putting it here would absorb it into the ordinary
|
||||
drop and delete the last trigger that branch has. See `Controller.cycle()`.
|
||||
|
||||
⚠️ AND NOT "ITS SENSORS ARE MISSING" EITHER. A pack whose SoC did not answer is
|
||||
a third fault with a third remedy: `inputs_ok` already stops the whole site at
|
||||
P_SAFETY for that, and dropping the unit from the SPLIT instead would hand its
|
||||
share to a sibling while the site kept regulating - the opposite of what a
|
||||
missing measurement should cause. Liveness is about the WRITE PATH, and only
|
||||
about a write path that has stopped answering.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from .clamp import ClampResult
|
||||
from .store import unit_key
|
||||
from .watchdog import SignWatchdog, SignWatchdogConfig
|
||||
|
||||
# How long every write to one inverter must have been REJECTED before this
|
||||
# controller stops claiming it can command it.
|
||||
#
|
||||
# ⚠️ IT IS LAYER 1'S OWN WINDOW, NOT A NEW OPINION ABOUT LINKS. The ESP32
|
||||
# commands 0 W and keeps commanding it after ~30 s without a refresh
|
||||
# (`validate.FIRMWARE_INGEST_VETO_S`), so a link this controller has not been
|
||||
# able to write for a whole 30 s is a link the hardware is about to take away
|
||||
# from it anyway. Claiming it as commandable past that point is the add-on
|
||||
# telling the allocator it can deliver watts that the firmware has already
|
||||
# decided it will not. No config key: CFG-03 tracks three places a default has
|
||||
# to be kept in step and this does not earn a fourth - it is derived from a
|
||||
# constant the firmware and validate.py already share.
|
||||
LINK_STALE_S = 30.0
|
||||
|
||||
# The one inverter this add-on drives today. ⚠️ A NAME, NOT A CONFIGURATION -
|
||||
# it exists so that a per-inverter claim has an actuator to be filed ON, which
|
||||
# is what makes the site/unit split something a check can grade rather than a
|
||||
# comment. It lives here rather than in main.py now that the roster does.
|
||||
UNIT_A = "inverter-a"
|
||||
|
||||
# The per-unit keys this object keeps in the Store, namespaced by unit name.
|
||||
# ⚠️ THE VALUES ARE THE PRE-DEV-02 FLAT KEY NAMES, and that is what makes the
|
||||
# migration mechanical rather than transcribed: `store.migrate_unit_keys()`
|
||||
# reads this same mapping, so a key renamed here cannot silently stop being
|
||||
# migrated. See store.py.
|
||||
UNIT_STORE_KEYS = ("sign_tripped", "sign_reason")
|
||||
|
||||
|
||||
class Inverter:
|
||||
"""ONE inverter: its entities, its rating, its watchdogs, its store keys.
|
||||
|
||||
Constructed once per configured inverter and held in `Controller.inverters`.
|
||||
Everything on it is about THIS device; nothing on it is about the site.
|
||||
|
||||
⚠️ `hass` IS NOT A CONSTRUCTOR ARGUMENT. This object is built in
|
||||
`Controller.__init__`, which runs before any I/O and, in four existing
|
||||
checks, with `hass=None` entirely. The device range is read LATER, by
|
||||
`Controller.run_control()`, from THIS unit's own `setpoint_entity` - which
|
||||
is the whole of "each instance's rating is read from its own entity".
|
||||
"""
|
||||
|
||||
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
||||
batt_entity="", batt_invert=False, max_w=2000.0,
|
||||
store=None, sign_cfg=None):
|
||||
self.name = str(name)
|
||||
self.setpoint_entity = str(setpoint_entity or "")
|
||||
self.soc_entity = str(soc_entity or "")
|
||||
self.batt_entity = str(batt_entity or "")
|
||||
self.batt_invert = bool(batt_invert)
|
||||
# The supervised cap on what may be written to THIS setpoint entity.
|
||||
# See the module docstring on why this is a number and not a `Tuning`.
|
||||
self.max_w = float(max_w)
|
||||
|
||||
# --- the device's own reported range, read from its own entity -------
|
||||
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
|
||||
# often not up when the add-on boots, and (None, None) is the honest
|
||||
# report that the range checks were skipped rather than invented.
|
||||
self.dev_min = None
|
||||
self.dev_max = None
|
||||
|
||||
# --- this unit's own measurements ------------------------------------
|
||||
self.soc = None
|
||||
self.batt = None
|
||||
|
||||
# --- what this unit was last told, and what it last accepted ---------
|
||||
# `target` is THIS unit's share of the site target from the last
|
||||
# resolution - the number its heartbeat re-emits and the number
|
||||
# SAFETY-02 grades against the meter. At N=1 it IS the site target,
|
||||
# because the allocator is a pass-through there.
|
||||
self.target = 0.0
|
||||
self.last_write = None
|
||||
self.last_write_at = None
|
||||
self.last_write_ok = None
|
||||
# ⚠️ TWO TIMESTAMPS, NOT ONE FLAG. `last_write_ok` is the last write's
|
||||
# verdict and it flips on a single transient 400; liveness needs a RUN
|
||||
# of rejections, so `rejected_since` records when the current run
|
||||
# started and is cleared by any acceptance.
|
||||
self.rejected_since = None
|
||||
# The heartbeat's own deadline, on the event loop's monotonic clock.
|
||||
# ⚠️ PER UNIT, WHICH IS THE WHOLE OF AC 6. One shared `last_beat` and
|
||||
# one serialised write meant a hung call to A pushed B's refresh past
|
||||
# layer 1's ~30 s veto - i.e. one inverter's transport fault zeroing a
|
||||
# healthy sibling. Starts at 0.0, which is what the single scalar did.
|
||||
self.last_beat = 0.0
|
||||
|
||||
# --- SAFETY-03's per-write evidence ----------------------------------
|
||||
# What THIS unit's last write clamped, or None before it has run. The
|
||||
# ALARM (`ClampWatchdog`) stays one per controller - clamp.py says so in
|
||||
# its own docstring, and it is an anti-flood counter over an event bus
|
||||
# rather than a property of a device - but the EVIDENCE is per unit,
|
||||
# because a clamp on B says nothing about A.
|
||||
self.clamp_last: ClampResult | None = None
|
||||
|
||||
# --- SAFETY-02, one instance per actuator, under NAMESPACED keys -----
|
||||
# ⚠️ THE WATCHDOG'S OWN DOCSTRING SAID "one instance per actuator" WHILE
|
||||
# THE CONTROLLER HELD EXACTLY ONE. Restored from THIS unit's own keys,
|
||||
# so a trip caused by B latches B and leaves A commanding - and an
|
||||
# operator clearing one does not silently clear the other.
|
||||
self.store = store
|
||||
data = getattr(store, "data", {}) if store is not None else {}
|
||||
self.sign_wd = SignWatchdog(
|
||||
sign_cfg or SignWatchdogConfig(),
|
||||
tripped=bool(data.get(self.key("sign_tripped"), False)),
|
||||
reason=str(data.get(self.key("sign_reason"), "") or ""))
|
||||
|
||||
# -- store ---------------------------------------------------------------
|
||||
def key(self, name: str) -> str:
|
||||
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
|
||||
return unit_key(self.name, name)
|
||||
|
||||
def store_set(self, name: str, value) -> None:
|
||||
if self.store is not None:
|
||||
self.store.set(self.key(name), value)
|
||||
|
||||
# -- the numbers other modules ask this object for -----------------------
|
||||
def rating_w(self) -> float:
|
||||
"""The largest magnitude this unit can be commanded, in W.
|
||||
|
||||
`min(max_w, |dev_min|, |dev_max|)` - the supervised cap and whatever the
|
||||
device itself reports, whichever is smaller. It is the same conjunction
|
||||
`freeze.rail_ceiling_w()` computes for the freeze rail and the same one
|
||||
main.py hands the peak guard as `achievable_w`, which is why it is
|
||||
written once here: three transcriptions of a rating is three chances for
|
||||
inverter B to be graded against inverter A's number.
|
||||
|
||||
A bound that could not be read is SKIPPED, not treated as zero: (None,
|
||||
None) means ESPHome was not up, and answering 0 W there would say this
|
||||
inverter can do nothing.
|
||||
"""
|
||||
out = abs(float(self.max_w))
|
||||
for bound in (self.dev_min, self.dev_max):
|
||||
if bound is not None and math.isfinite(float(bound)):
|
||||
out = min(out, abs(float(bound)))
|
||||
return out
|
||||
|
||||
def has_writer(self) -> bool:
|
||||
return bool(self.setpoint_entity)
|
||||
|
||||
# -- liveness ------------------------------------------------------------
|
||||
def note_write(self, ok: bool, now: datetime) -> None:
|
||||
"""Record one write's verdict. The only thing that moves liveness."""
|
||||
self.last_write_ok = bool(ok)
|
||||
if ok:
|
||||
self.rejected_since = None
|
||||
elif self.rejected_since is None:
|
||||
self.rejected_since = now
|
||||
|
||||
def is_live(self, now: datetime, stale_s: float = LINK_STALE_S):
|
||||
"""`(live, why_not)` - may this controller command this unit THIS TICK?
|
||||
|
||||
Read the module docstring before widening this - in particular on why a
|
||||
unit with NO `setpoint_entity` is deliberately still "live" here and is
|
||||
refused one layer up instead. The allocator DROPS what `live_units()`
|
||||
does not list and the survivors carry the whole site target, so a false
|
||||
"not live" is a site target divided among fewer inverters than exist -
|
||||
and at a roster of one it is the allocator refusing outright and the
|
||||
site commanding 0 W.
|
||||
|
||||
⚠️ ANSWERED PER TICK, NEVER CACHED, for `allocator.plan()`'s reason: a
|
||||
link goes stale between one cycle and the next.
|
||||
"""
|
||||
if self.rejected_since is not None:
|
||||
held = (now - self.rejected_since).total_seconds()
|
||||
if held >= float(stale_s):
|
||||
return False, (f"every write rejected for {held:.0f} s "
|
||||
f"(limit {float(stale_s):.0f} s)")
|
||||
return True, ""
|
||||
|
||||
def link_note(self, now: datetime) -> str:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
if not self.has_writer():
|
||||
return (f"{self.name}: NO SETPOINT ENTITY - this controller cannot "
|
||||
f"command it, and the site refuses rather than running at "
|
||||
f"part strength")
|
||||
live, why = self.is_live(now)
|
||||
if not live:
|
||||
return f"{self.name}: NOT COMMANDABLE - {why}"
|
||||
if self.rejected_since is not None:
|
||||
held = (now - self.rejected_since).total_seconds()
|
||||
return (f"{self.name}: writes rejected for {held:.0f} s - still "
|
||||
f"commandable until {LINK_STALE_S:.0f} s")
|
||||
return ""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (f"<Inverter {self.name} sp={self.setpoint_entity!r} "
|
||||
f"max_w={self.max_w:.0f} dev=({self.dev_min}, {self.dev_max})>")
|
||||
|
||||
|
||||
def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
"""The site's inverters, in roster order. TODAY THAT IS EXACTLY ONE.
|
||||
|
||||
⚠️ INVERTER B DOES NOT EXIST YET (~2 weeks out) AND THIS FUNCTION MUST NOT
|
||||
INVENT IT. There is no `inverters:` list in config.yaml, no second
|
||||
`setpoint_entity`, and no measured rating, ramp or lag for a second device -
|
||||
SPIKE-04 measures those and CFG-04 shapes the option that carries them. What
|
||||
this card delivers is that the CODE no longer assumes there is one; adding
|
||||
the second entry is a config change to THIS FUNCTION and to nothing else,
|
||||
which is the point.
|
||||
|
||||
So: unit A is built from the flat options, exactly as the shipped scalars
|
||||
were, and the flat options keep their names. An install that upgrades reads
|
||||
identically - same entities, same rating, same behaviour - which is AC 8.
|
||||
"""
|
||||
return [Inverter(
|
||||
UNIT_A,
|
||||
setpoint_entity=opts.get("setpoint_entity", ""),
|
||||
soc_entity=opts.get("soc_entity", ""),
|
||||
batt_entity=opts.get("batt_entity", ""),
|
||||
batt_invert=bool(opts.get("batt_invert")),
|
||||
max_w=float(opts.get("max_w", 2000)),
|
||||
store=store, sign_cfg=sign_cfg)]
|
||||
|
||||
+1054
-219
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,21 @@ _LOG = logging.getLogger("goodwe.store")
|
||||
# commanding -1000, -2000 W before re-tripping 180 s later. Under a supervisor
|
||||
# that restarts a crashed add-on that is a loop, three minutes of wrong-direction
|
||||
# power at a time, forever.
|
||||
#
|
||||
# ⚠️ AND SINCE DEV-02 THE TWO SAFETY-02 KEYS ARE THE *LEGACY* SPELLING. They are
|
||||
# FLAT - one `sign_tripped` for a controller that now holds a LIST of inverters -
|
||||
# so a trip caused by inverter B would latch inverter A across restarts, and one
|
||||
# operator action would clear both. The LIVE latch is per inverter, under
|
||||
# `unit/<name>/...`, and that is the one the watchdog is restored from.
|
||||
#
|
||||
# ⚠️ THE FLAT PAIR IS NOT DEAD, THOUGH: it is the ROLLBACK VIEW. It is written
|
||||
# alongside the namespaced keys on a trip and cleared alongside them on the
|
||||
# operator's clear, so an add-on that is DOWNGRADED past this schema still finds
|
||||
# the latch it should - and so that a trip taken while the install was rolled
|
||||
# back is still visible to the version that comes back up. Rule 2 of
|
||||
# `migrate_unit_keys()` and `Controller._grade_store_migration()` carry the
|
||||
# argument; the short version is that a latch is only worth persisting if it
|
||||
# survives the version change an operator makes while staring at the banner.
|
||||
DEFAULTS = {
|
||||
"phase": "idle",
|
||||
"phase_started": None,
|
||||
@@ -48,6 +63,188 @@ DEFAULTS = {
|
||||
"sign_reason": "",
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DEV-02: PER-INVERTER KEYS, AND THE MIGRATION IS THE RISKY HALF
|
||||
#
|
||||
# ⚠️ A LATCH THAT SILENTLY FAILS TO MIGRATE IS A DISARMED SAFETY GUARD ON AN
|
||||
# INSTALL THAT BELIEVES IT IS PROTECTED. SAFETY-02's whole reason for being
|
||||
# persisted is that "restart the add-on" is the FIRST thing an operator staring
|
||||
# at a FAILSAFE banner tries, and a latch held only in memory comes back armed,
|
||||
# into the fault, unattended. A latch that is written down under a key nothing
|
||||
# reads any more is exactly the same failure with a longer paper trail: the file
|
||||
# still says `sign_tripped: true`, the banner is green, and the controller
|
||||
# resumes driving the wrong way.
|
||||
#
|
||||
# So this is a MIGRATION WITH A POSTCONDITION, not a copy. `migrate_unit_keys()`
|
||||
# reports what it did AND reads the result back; `Controller.__init__` grades
|
||||
# that report and refuses to come up quietly if the latch did not survive. The
|
||||
# test that grades it BREAKS the migration and watches the guard fire.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# 0 = the flat, one-inverter layout that shipped before DEV-02.
|
||||
# 1 = per-inverter keys under `unit/<name>/...`.
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEMA_KEY = "schema_version"
|
||||
|
||||
# Which flat keys belong to ONE inverter, and therefore move into its namespace.
|
||||
# ⚠️ THE MAINTENANCE KEYS ARE NOT IN HERE AND THAT IS DELIBERATE. `phase`,
|
||||
# `phase_started`, `last_completed` and `last_start_attempt` belong to the
|
||||
# maintenance state machine, of which this add-on still holds exactly ONE - a
|
||||
# monthly recalibration is a pack procedure and per-pack SCHEDULING is a card of
|
||||
# its own, not something to infer from a key rename. Moving them here would
|
||||
# quietly re-home an install's whole maintenance history under a unit name; they
|
||||
# stay where they are, untouched, which is the other half of "migrates without
|
||||
# losing its latch OR ITS HISTORY".
|
||||
LEGACY_UNIT_KEYS = ("sign_tripped", "sign_reason")
|
||||
|
||||
|
||||
def unit_key(unit: str, key: str) -> str:
|
||||
"""The namespaced Store key for `key` on inverter `unit`.
|
||||
|
||||
⚠️ ONE SPELLING, IN ONE PLACE. `Inverter.key()` calls this and so does the
|
||||
migration, so a check can assert that what was migrated is what is read.
|
||||
Two transcriptions of a key name is one copy plus a future disagreement
|
||||
about which one holds the latch - and the copy that drifts is always the one
|
||||
that reads.
|
||||
"""
|
||||
return f"unit/{unit}/{key}"
|
||||
|
||||
|
||||
class MigrationReport:
|
||||
"""What `migrate_unit_keys()` did, and whether it can be believed.
|
||||
|
||||
⚠️ IT CARRIES `verified`, WHICH IS READ BACK FROM THE STORE RATHER THAN
|
||||
ASSUMED FROM THE ASSIGNMENT. That is the difference between this and a
|
||||
two-line copy: the caller is able to ask "is the latch that was in the file
|
||||
before now under the key the watchdog reads", and a `False` there is a
|
||||
disarmed guard, which is an ERROR and not a log line nobody sees.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.ran = False # did anything need moving
|
||||
self.from_version = 0
|
||||
self.moved = {} # new key -> value written
|
||||
self.kept = [] # keys already namespaced, left alone
|
||||
self.verified = True # every move read back equal
|
||||
self.problems = [] # human-readable, one per failed move
|
||||
# ⚠️ THE SUBSET THAT MATTERS, AND THE DISTINCTION IS NOT PEDANTRY. A
|
||||
# store that drops a write of `sign_tripped=False` has lost the
|
||||
# DEFAULT - the same statement "absent" already makes, so nothing is
|
||||
# gone. One that drops a write of `True` has lost A LATCH. Only the
|
||||
# second is a disarmed safety guard, and only the second may be allowed
|
||||
# to shout: an alarm that fires on the harmless case is an alarm an
|
||||
# operator learns to dismiss before the real one arrives.
|
||||
self.lost = []
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""Nothing that CARRIED STATE failed to land."""
|
||||
return not self.lost
|
||||
|
||||
def explain(self) -> str:
|
||||
if not self.ran:
|
||||
return (f"store already at schema {self.from_version}; no "
|
||||
f"per-inverter migration needed")
|
||||
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
|
||||
f"namespaces: " + ", ".join(sorted(self.moved)))
|
||||
if self.kept:
|
||||
out += " (left alone, already namespaced: " + ", ".join(sorted(self.kept)) + ")"
|
||||
if self.problems:
|
||||
out += " - PROBLEMS: " + "; ".join(self.problems)
|
||||
if self.lost:
|
||||
out += " - STATE LOST: " + "; ".join(self.lost)
|
||||
return out
|
||||
|
||||
|
||||
def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
"""Move the pre-DEV-02 flat per-inverter keys into `unit`'s namespace.
|
||||
|
||||
`unit` is the FIRST inverter in the roster, which on every install that can
|
||||
possibly have a flat key is the only one it ever had. A store written by a
|
||||
version that knew about more than one inverter is already at
|
||||
SCHEMA_VERSION and is not touched.
|
||||
|
||||
THE THREE RULES, each one a case that would otherwise be a wrong answer:
|
||||
|
||||
1. IT NEVER OVERWRITES AN EXISTING NAMESPACED VALUE. A store that has both
|
||||
spellings has been written by this version, and the namespaced one is
|
||||
where the WATCHDOG is restored from. Preferring the flat one HERE would
|
||||
resurrect a trip the operator has already cleared, because a migration
|
||||
runs before anything can tell the two apart. Deciding between them is
|
||||
main.py's job and it has the one rule that can: the flat key carries new
|
||||
information only when NOTHING namespaced is latched.
|
||||
2. THE FLAT KEYS ARE LEFT IN THE FILE, AND THEY ARE KEPT LIVE. Deleting
|
||||
them buys tidiness and costs a rollback: an operator who downgrades the
|
||||
add-on after a migration would find no latch at all, which is the
|
||||
disarmed guard again in the other direction. Left in place, a downgrade
|
||||
finds the trip it had.
|
||||
|
||||
⚠️ AND "LEFT IN PLACE" IS NOT ENOUGH ON ITS OWN, WHICH IS WHAT THIS RULE
|
||||
USED TO SAY. The sentence here read "they go stale from this moment -
|
||||
nothing writes them again - and the direction that staleness fails in is
|
||||
a SPURIOUS latch, never a missing one". That was true of the latch this
|
||||
migration MOVES and false of every trip that happens AFTERWARDS: nothing
|
||||
flat was written for those, so a rollback found `sign_tripped: false` and
|
||||
came up DRIVING. So main.py mirrors both keys on a trip and clears both
|
||||
on the operator's clear. The flat pair is the ROLLBACK VIEW of the latch,
|
||||
maintained, not a fossil - which is also what lets main.py's
|
||||
`_grade_store_migration()` run on every start and catch a trip that
|
||||
happened while the install was rolled back (see its docstring: the flat
|
||||
key saying tripped while no inverter came up tripped is the one
|
||||
disagreement that can occur, and it is a DOWNGRADE-WINDOW LATCH).
|
||||
3. THE VERSION MARKER IS WRITTEN LAST. A crash between the copies and the
|
||||
marker leaves the migration to run again, and rule 1 makes running it
|
||||
again a no-op. A marker written first would leave a half-migrated store
|
||||
looking finished.
|
||||
"""
|
||||
rep = MigrationReport()
|
||||
data = getattr(store, "data", {})
|
||||
rep.from_version = int(data.get(SCHEMA_KEY, 0) or 0)
|
||||
if rep.from_version >= SCHEMA_VERSION:
|
||||
return rep
|
||||
|
||||
rep.ran = True
|
||||
for legacy in LEGACY_UNIT_KEYS:
|
||||
target = unit_key(unit, legacy)
|
||||
if target in data:
|
||||
rep.kept.append(target)
|
||||
continue
|
||||
# ⚠️ `DEFAULTS[legacy]` RATHER THAN A LITERAL. An install that never
|
||||
# tripped has no `sign_tripped` in its file at all, and the value it
|
||||
# then lands on has to be the one `Store.load()` would have supplied -
|
||||
# otherwise "absent" and "never tripped" stop being the same statement,
|
||||
# which is the distinction store.py's own header is about.
|
||||
value = data.get(legacy, DEFAULTS.get(legacy))
|
||||
store.set(target, value)
|
||||
rep.moved[target] = value
|
||||
|
||||
# THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION. This
|
||||
# is the difference between this function and a two-line copy: the caller
|
||||
# can ask "is the latch that was in the file before now under the key the
|
||||
# watchdog reads", through the same `unit_key()` the watchdog's constructor
|
||||
# reads through, and a `False` there is a guard that is off on an install
|
||||
# whose state file says it is on.
|
||||
for target, value in rep.moved.items():
|
||||
got = getattr(store, "data", {}).get(target, "<<missing>>")
|
||||
if got == value:
|
||||
continue
|
||||
rep.verified = False
|
||||
note = f"{target} was written as {value!r} and reads back as {got!r}"
|
||||
rep.problems.append(note)
|
||||
legacy = target.rsplit("/", 1)[-1]
|
||||
if value != DEFAULTS.get(legacy):
|
||||
rep.lost.append(note)
|
||||
|
||||
if rep.ok:
|
||||
store.set(SCHEMA_KEY, SCHEMA_VERSION)
|
||||
else:
|
||||
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
|
||||
# next start rather than be recorded as finished - the one thing worse
|
||||
# than a failed migration is a failed migration a later version skips.
|
||||
_LOG.error("per-inverter store migration LOST STATE and has not been "
|
||||
"marked done: %s", rep.explain())
|
||||
return rep
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: str = "/data/state.json"):
|
||||
|
||||
@@ -672,29 +672,68 @@ check("...and the broadcast export overshoot at gain 0.6 - the half that has no
|
||||
feedback="unit")[1:]) <= -400.0
|
||||
and min(g for _t, g in close_loop(0.6, 0.5)[1:]) >= 0.0)
|
||||
|
||||
# ⚠️ THE ONE THAT DOES NOT HOLD, AND IT IS REPORTED RATHER THAN HIDDEN.
|
||||
# ⚠️ THE ONE THAT DID NOT HOLD, AND DEV-02 IS WHAT CLOSED IT. This block was
|
||||
# written as a DISCLOSURE - "the allocator is not the cause, and it is DEV-02's
|
||||
# to close" - and it is kept, with its stimulus intact, because the defect it
|
||||
# describes is exactly the thing a regression would restore. What has changed is
|
||||
# only which of the two rows the SHIPPED add-on is on.
|
||||
#
|
||||
# THE DEFECT. There was one `batt_entity`, so at a roster of two `actual_w` was
|
||||
# ONE unit's power used as a site measurement - the SENSING half of the same
|
||||
# N=1 identity ALLOC-01 removed from the COMMANDING half. `compute()`'s
|
||||
# anti-windup test is `abs(prev_w - actual_w) > saturation_w` with `prev_w` the
|
||||
# SITE target, so that comparison put a site number against a unit number,
|
||||
# tripped the saturation freeze permanently and left a standing grid error.
|
||||
#
|
||||
# MEASURED, on admin/goodwe's own two-master rig rather than only here
|
||||
# (`tools/alloc_sim.py --splits --feedback unit`, 2 kW load, 40 cycles):
|
||||
#
|
||||
# gain 0.2 worst |delta| between splits 510 W
|
||||
# gain 0.4 250 W
|
||||
# gain 0.6 50 W
|
||||
# gain 0.8 and above 0 W
|
||||
#
|
||||
# and with `--feedback site` - the counterfactual, which is what DEV-02 makes
|
||||
# the shipped sensing - every split is bit-identical at every one of those
|
||||
# gains. Both rows are still run below, because the comparison is the evidence.
|
||||
_unit_fb = {s: close_loop(0.6, s, feedback="unit")
|
||||
== close_loop(0.6, 0.5, feedback="unit") for s in _splits}
|
||||
print(f" with the loop fed ONE inverter's power instead of the site's, at "
|
||||
f"gain 0.6: " + ", ".join(f"{int(s*100)}/{int((1-s)*100)}="
|
||||
f"{'same' if v else 'DIFFERS'}"
|
||||
for s, v in _unit_fb.items()))
|
||||
check("⚠️ SPLIT-INVARIANCE IS A PROPERTY OF THE LOOP'S INPUTS, NOT ONLY OF THE "
|
||||
"ALLOCATOR, AND THE SHIPPED ADD-ON BREAKS IT FROM THE OTHER END. There is "
|
||||
"ONE `inverter_power` sensor, so at a roster of two `actual_w` is ONE "
|
||||
"unit's power used as a site measurement - the SENSING half of the same "
|
||||
"N=1 identity this card removes from the COMMANDING half. Feed the loop "
|
||||
"that and the aggregate trace becomes a function of the split. The "
|
||||
"allocator is not the cause: its shares sum to S in every one of these "
|
||||
"runs. Measured on the real rig too (tools/alloc_sim.py --splits "
|
||||
"--gain 0.6), and it is DEV-02's to close",
|
||||
not all(_unit_fb.values()))
|
||||
check("...and the counterfactual is what says the sensor is the cause rather "
|
||||
"than the allocator: change that ONE input to the site total and every "
|
||||
"split is bit-identical again, at every gain",
|
||||
print(f" with the loop fed ONE inverter's power - the PRE-DEV-02 sensing - "
|
||||
f"at gain 0.6: " + ", ".join(f"{int(s*100)}/{int((1-s)*100)}="
|
||||
f"{'same' if v else 'DIFFERS'}"
|
||||
for s, v in _unit_fb.items()))
|
||||
_worst_unit = max(
|
||||
max(abs(a[1] - b[1]) for a, b in zip(close_loop(g, s, feedback="unit"),
|
||||
close_loop(g, 0.5, feedback="unit")))
|
||||
for g in (0.2, 0.4, 0.6) for s in _splits)
|
||||
print(f" worst split-dependence under that sensing, gains 0.2-0.6: "
|
||||
f"{_worst_unit:.0f} W at the meter")
|
||||
check("⚠️ THE DEFECT IS STILL REPRODUCIBLE, WHICH IS WHAT MAKES THE FIX BELOW "
|
||||
"MEAN SOMETHING. Fed ONE inverter's power the aggregate trace at the "
|
||||
"meter is a function of the split, by hundreds of watts below gain 1.0. "
|
||||
"The allocator is not the cause - its shares sum to S in every one of "
|
||||
"these runs - and a check that had DELETED this row when the sensing was "
|
||||
"fixed would leave the next reader unable to tell a working loop from an "
|
||||
"untested one",
|
||||
not all(_unit_fb.values()) and _worst_unit > 100.0)
|
||||
check("⚠️ AND DEV-02 PUT THE SHIPPED ADD-ON ON THE OTHER ROW: `actual_w` is now "
|
||||
"the SITE SUM of the per-instance battery readings "
|
||||
"(`Controller.site_batt_w`, pinned at main.py's call site by "
|
||||
"test_inverter.py part 5), and every split is bit-identical again at "
|
||||
"every gain. This is the line that goes red if anyone hands the loop one "
|
||||
"unit's power again - which is the tempting shape, because it is what a "
|
||||
"single `batt_entity` gives you",
|
||||
all(close_loop(g, s, feedback="site") == close_loop(g, 0.5,
|
||||
feedback="site")
|
||||
for g in (0.2, 0.4, 0.6, 1.0) for s in _splits))
|
||||
for g in (0.05, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0) for s in _splits))
|
||||
check("...and the fix is not vacuous in the other direction either: the SHARES "
|
||||
"still differ between splits under site feedback, so 'bit-identical at "
|
||||
"the meter' is a statement about the aggregate and not about an allocator "
|
||||
"that stopped allocating",
|
||||
close_loop(0.6, 0.9, feedback="site")
|
||||
== close_loop(0.6, 0.5, feedback="site")
|
||||
and plan(2000.0, TWO, weights={A: 0.9, Bee: 0.1}).shares
|
||||
!= plan(2000.0, TWO, weights={A: 0.5, Bee: 0.5}).shares)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
@@ -84,6 +84,7 @@ from app.clamp import (
|
||||
from app.allocator import Allocation, UnitPlan, plan
|
||||
from app.arbiter import P_LOOP, Claim, UnitBounds, allocate, resolve
|
||||
from app.control import Tuning, compute
|
||||
from app.inverter import Inverter
|
||||
from app.main import Controller
|
||||
|
||||
fails = []
|
||||
@@ -782,13 +783,21 @@ class _RefactoredController(Controller):
|
||||
Neither changes when the guard runs relative to the write, so every check in
|
||||
`evidence_checks()` must stay green. If one of them goes red here, the check
|
||||
is grading the text of main.py and has to be thrown away.
|
||||
|
||||
⚠️ THE SIGNATURE GAINED `unit=` IN DEV-02 AND THIS FIXTURE FOLLOWED IT, which
|
||||
is the honest update and not a weakening: `write_setpoint()` now addresses ONE
|
||||
inverter (or, with `unit=None`, commands the whole site to 0 W), because a
|
||||
controller with a roster cannot have one write path that means "the
|
||||
inverter". A delegate that dropped the argument would silently re-broadcast
|
||||
at N>=2, so it is passed through here exactly as a real refactor would have
|
||||
to pass it through.
|
||||
"""
|
||||
|
||||
async def _emit_setpoint(self, value, *, force=False):
|
||||
await Controller.write_setpoint(self, value, force=force)
|
||||
async def _emit_setpoint(self, value, *, force=False, unit=None):
|
||||
await Controller.write_setpoint(self, value, force=force, unit=unit)
|
||||
|
||||
async def write_setpoint(self, value, *, force=False):
|
||||
await self._emit_setpoint(value, force=force)
|
||||
async def write_setpoint(self, value, *, force=False, unit=None):
|
||||
await self._emit_setpoint(value, force=force, unit=unit)
|
||||
|
||||
async def _clamp_guard(self):
|
||||
await Controller.check_output_clamp(self)
|
||||
@@ -1102,9 +1111,20 @@ async def _failsafe_wire():
|
||||
"max_w": LIVE_MAX_W, "soc_entity": "sensor.soc",
|
||||
"batt_entity": "sensor.batt", "meter_entity": "sensor.grid"},
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.units = (A_UNIT, B_HYPO)
|
||||
# ⚠️ DEV-02: THE ROSTER IS GROWN BY ADDING AN `Inverter`, NOT BY ASSIGNING
|
||||
# `units`. `Controller.units` is now DERIVED from `Controller.inverters` and
|
||||
# has no setter, because a settable roster is a site that claims two
|
||||
# actuators while holding one writer - a half-driven site with nothing to
|
||||
# say so. The object carries the writer, which is what makes this fixture a
|
||||
# roster of two rather than a tuple of two names.
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W # while the roster is one
|
||||
_b = Inverter(B_HYPO, setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc", batt_entity="sensor.batt",
|
||||
max_w=LIVE_MAX_W, store=c.store)
|
||||
_b.dev_min, _b.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
_b.soc, _b.batt = 50.0, 0.0
|
||||
c.inverters.append(_b)
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
for _ in range(3):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
@@ -1117,7 +1137,7 @@ print(f" allocator refused every tick "
|
||||
check("the allocator having failed, the 0 W failsafe still REACHES THE WIRE - "
|
||||
"this is the deadlock the gate must not have, driven through the real "
|
||||
"cycle() and its real refusal branch rather than asserted",
|
||||
not ctl_fs.allocation.ok and hass_fs.writes == [0.0]
|
||||
not ctl_fs.allocation.ok and hass_fs.writes == [0.0, 0.0]
|
||||
and ctl_fs.target == 0.0)
|
||||
check("...and it was the ALLOCATOR's refusal that produced it, not the gate's "
|
||||
"- if the gate were the thing zeroing this cycle the action log would "
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,7 @@ from app.arbiter import (
|
||||
from app.control import Tuning, compute, peak_at_risk
|
||||
from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w
|
||||
from app import main as app_main
|
||||
from app.inverter import Inverter
|
||||
from app.main import Controller
|
||||
from app.p1 import (
|
||||
PLAUSIBLE_MAX_W, P1Ingest, QUARTER_S, QuarterAverager, make_sample,
|
||||
@@ -921,6 +922,7 @@ class _Pub:
|
||||
class _Hass:
|
||||
def __init__(self):
|
||||
self.writes = []
|
||||
self.per_entity = []
|
||||
self.notifications = []
|
||||
# SAFETY-25. The whole payload, not only the id. AC 3 is about what the
|
||||
# operator READS, so a fixture that records only that a notification
|
||||
@@ -936,6 +938,11 @@ class _Hass:
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append(value)
|
||||
# DEV-02. WHICH inverter each write went to, which is the only way to
|
||||
# tell "both units were commanded their own share" from "one unit was
|
||||
# commanded twice". `writes` keeps its old shape - a list of floats -
|
||||
# because a dozen checks above read it that way.
|
||||
self.per_entity.append((entity, value))
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
@@ -1289,8 +1296,14 @@ _soc_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "soc"))
|
||||
print(f" main.py's decide(batt_w=...) is `{_batt_src}`, decide(soc=...) is "
|
||||
f"`{_soc_src}`")
|
||||
check("...and the call site says so in the source: the argument is the measured "
|
||||
"battery attribute, not the standing command and not the resolved target",
|
||||
_batt_src == "self.batt")
|
||||
"battery attribute, not the standing command and not the resolved target. "
|
||||
"⚠️ DEV-02 MOVED WHICH ATTRIBUTE AND NOT WHICH KIND OF THING IT IS: "
|
||||
"`self.batt` was one inverter's power used as a site measurement, and "
|
||||
"`site_batt_w` is the SUM over the roster - still measured, still not "
|
||||
"`standing` and not `self.target`. The reduction itself is graded in "
|
||||
"test_inverter.py; what is pinned here is that a MEASUREMENT is what "
|
||||
"sizes the floor",
|
||||
_batt_src == "self.site_batt_w")
|
||||
# ⚠️ SAFETY-24, AND ON THE SAME SINGLE CALL SITE. This grader takes the FIRST
|
||||
# `.decide(` in main.py and reads a NAME rather than a value, so a second call
|
||||
# site - or a decoy - would defeat it; this card deliberately adds neither, and
|
||||
@@ -1298,8 +1311,14 @@ check("...and the call site says so in the source: the argument is the measured
|
||||
check("...and `soc` is handed over as the MEASURED reading, bare. Not "
|
||||
"`self.soc or 100`, not a `float(..., 100)`, not the maintenance floor: "
|
||||
"a full battery is exactly the value that disarms SAFETY-24's floor, and "
|
||||
"NOTES.md 4.4b records this project shipping that exact defaulting once",
|
||||
_soc_src == "self.soc")
|
||||
"NOTES.md 4.4b records this project shipping that exact defaulting once. "
|
||||
"⚠️ DEV-02 MADE IT `pack_floor_soc` - THE EMPTIEST PACK - and the "
|
||||
"defaulting hazard is unchanged and still the point: that property is a "
|
||||
"bare `min()` over measured readings that answers None if ANY unit did "
|
||||
"not read, because `decide()` treats None as floor-limited, which costs a "
|
||||
"shave rather than a pack. An `or 100` anywhere on that path is the same "
|
||||
"defect with two inverters",
|
||||
_soc_src == "self.pack_floor_soc")
|
||||
check(f"...and main.py has exactly ONE `peakguard.decide(` call site (it has "
|
||||
f"{len(_peak_calls)}), so the call graded above is unambiguously the "
|
||||
f"shipped one. Other modules may call their own decide() - OPT-04's "
|
||||
@@ -2545,9 +2564,57 @@ check("...and the arbiter agrees the routing is legal, on the list main.py "
|
||||
# And the day the roster grows, the SAME cycle must partition. Graded through
|
||||
# main.py's own `actuator_of()` on a real Controller - the method every
|
||||
# `claims.append` above routes through - rather than through a copy of it.
|
||||
c_src.units = ("inverter-a", "inverter-b")
|
||||
#
|
||||
# ⚠️ DEV-02: THE ROSTER IS GROWN BY ADDING A REAL `Inverter`, NOT BY SETTING
|
||||
# `c.units`. It used to be a tuple somebody could assign beside a roster of one,
|
||||
# i.e. a site claiming two actuators with one writer - which is a half-driven
|
||||
# site with nothing to say so - and `Controller.units` is now DERIVED from
|
||||
# `Controller.inverters` and has no setter. Building the object is also what
|
||||
# makes these checks mean more than they did: the second unit has its own
|
||||
# setpoint entity, its own rating and its own device range, so the claims below
|
||||
# are filed from ITS numbers rather than from a copy of A's.
|
||||
#
|
||||
# ⚠️ INVERTER B DOES NOT EXIST YET. Its rating here is a HYPOTHESIS: nothing in
|
||||
# this file is a measured parameter for a second device (SPIKE-04 measures one).
|
||||
# What is graded is the ARCHITECTURE - which actuator each claim lands on, and
|
||||
# whose number each per-unit bound is built from.
|
||||
#
|
||||
# ⚠️ AND IT IS 3000 W RATHER THAN A's 5000 W, WHICH IS NOT COSMETIC. THE RULE,
|
||||
# STATED AT THE LINE THAT NEEDED IT: ANY CHECK ASSERTING "X DIFFERS FROM Y" MUST
|
||||
# BE RUN ONCE WITH X AND Y ACTUALLY MADE DIFFERENT. This helper used to default
|
||||
# B's rating and device range to A's `LIVE_MAX_W`, and the AC 4 check below
|
||||
# compared inverter B's `supervised` bound against `_b2.max_w` - which was
|
||||
# LITERALLY THE SAME NUMBER as A's. It read as evidence and observed nothing:
|
||||
# filing both per-unit claims from `self.inverters[0]`'s numbers instead of
|
||||
# `inv`'s left all 19 files green. That is the third check this session whose
|
||||
# DATA could not reach the failure it named (ARB-03's call-site scan saw 3 of 8
|
||||
# sites; ALLOC-01's replay generator saw 0 escapes where a widened one saw
|
||||
# 39,991), so the number is different HERE, once, for every fixture in the file.
|
||||
B_RATING_HYPOTHESIS = LIVE_MAX_W - 2000.0
|
||||
|
||||
|
||||
def second_inverter(ctl, *, name="inverter-b", setpoint="number.sp_b",
|
||||
max_w=None, dev=None):
|
||||
"""Append a real second `Inverter` to `ctl`, and return a restore()."""
|
||||
rating = B_RATING_HYPOTHESIS if max_w is None else max_w
|
||||
inv = Inverter(name, setpoint_entity=setpoint, soc_entity="sensor.soc_b",
|
||||
batt_entity="sensor.batt_b", max_w=rating, store=ctl.store)
|
||||
inv.dev_min, inv.dev_max = dev or (-rating, rating)
|
||||
# The readings a real cycle needs from it. `inputs_ok` requires EVERY unit
|
||||
# to have answered, which is the point - a pack nothing can grade stops the
|
||||
# site rather than being quietly dropped from the split.
|
||||
inv.soc, inv.batt = ctl.inverters[0].soc, 0.0
|
||||
original = list(ctl.inverters)
|
||||
ctl.inverters.append(inv)
|
||||
|
||||
def restore():
|
||||
ctl.inverters[:] = original
|
||||
return inv, restore
|
||||
|
||||
|
||||
_b, _restore_b = second_inverter(c_src)
|
||||
_routed = {s: c_src.actuator_of(s) for s in CLAIM_SOURCES}
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
_restore_b()
|
||||
print(" with a second inverter on the roster, main.py routes: "
|
||||
+ ", ".join(f"{s}->{a}" for s, a in sorted(_routed.items())))
|
||||
check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
|
||||
@@ -2563,7 +2630,7 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
|
||||
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE})
|
||||
check("...and the roster it was restored to is the shipped one, so nothing "
|
||||
"below inherits a two-inverter Controller",
|
||||
c_src.units == (app_main.UNIT_A,) and len(c_src.units) == 1)
|
||||
c_src.units == (app_main.UNIT_A,) and len(c_src.inverters) == 1)
|
||||
|
||||
# ⚠️ AND THE ROUTING TABLE BEING RIGHT IS NOT THE SAME AS THE CALL SITES USING
|
||||
# IT RIGHT. `actuator_of("maintenance")` written at the `device` append routes a
|
||||
@@ -2618,7 +2685,27 @@ _direct = [n.args[0] for n in _appends if isinstance(n.args[0], ast.Call)]
|
||||
_indirect = [n.args[0].id for n in _appends if isinstance(n.args[0], ast.Name)]
|
||||
_routing = sorted(((c.args[0].value, append_actuator(c), c.lineno)
|
||||
for c in _direct), key=lambda r: r[2])
|
||||
_misrouted = [r for r in _routing if r[1] != f"self.actuator_of({r[0]!r})"]
|
||||
# ⚠️ DEV-02 GAVE `actuator_of()` A SECOND ARGUMENT - the roster entry the claim
|
||||
# is ABOUT - so a per-inverter append inside the `for inv in self.inverters:`
|
||||
# loop reads `self.actuator_of('device', inv)`. The pin is unchanged in what it
|
||||
# grades: the FIRST argument, the string literal, must still be the source the
|
||||
# claim is filed under. What the second argument may be is deliberately NOT
|
||||
# constrained here - it is a roster entry, and which one is a question the
|
||||
# dynamic two-inverter cycle below answers on real claims - but its PRESENCE is,
|
||||
# because a per-inverter claim built in that loop and routed with no `inv` files
|
||||
# inverter B's rating on inverter A. That is graded at the line: a source
|
||||
# classified SCOPE_UNIT must pass one.
|
||||
def _routing_literal(a):
|
||||
"""The first argument of `self.actuator_of(...)`, or None if it is not one."""
|
||||
if not a.startswith("self.actuator_of("):
|
||||
return None
|
||||
return a[len("self.actuator_of("):-1].split(",")[0].strip()
|
||||
|
||||
|
||||
_misrouted = [r for r in _routing if _routing_literal(r[1]) != repr(r[0])]
|
||||
_unit_sources_unrouted = [
|
||||
r for r in _routing
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
|
||||
print(f" {len(_direct)} direct appends, each read at its own line: "
|
||||
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
|
||||
check("all eight of `Controller.cycle()`'s DIRECT appends route through "
|
||||
@@ -2638,6 +2725,17 @@ check("⚠️ AND AT EVERY ONE OF THE EIGHT THE LITERAL IT ROUTES THROUGH IS THE
|
||||
"`actuator_of(\"peak\")` at the maintenance append is a different string "
|
||||
"here even though it is the same VALUE at the shipped roster of one",
|
||||
_misrouted == [])
|
||||
check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
|
||||
"`actuator_of()` takes the roster entry as its second argument and "
|
||||
"defaults to the FIRST one when it is left out - which is right for the "
|
||||
"site-scoped sources and is the whole defect for a per-inverter one: a "
|
||||
"`device` limit built inside the roster loop and routed without `inv` "
|
||||
"files inverter B's rating on inverter A, where it binds the wrong "
|
||||
"share. Five of the eight appends are SCOPE_UNIT - `device`, "
|
||||
"`supervised`, `failsafe` and maintenance's two - and all five pass one",
|
||||
_unit_sources_unrouted == []
|
||||
and len([r for r in _routing
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 5)
|
||||
check("...and the only appends this pin does NOT reach are the two that file a "
|
||||
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
|
||||
"`actuator=` at the call site to mis-type. Their routing is pinned where "
|
||||
@@ -2656,17 +2754,18 @@ check("...and between them the ten appends account for exactly the eight "
|
||||
# cycle proving the routing reaches the arbiter THROUGH main.py rather than only
|
||||
# through this file's reading of main.py. It grades three of the eight appends.
|
||||
# The static pin above is what covers the other five.
|
||||
c_src.units = ("inverter-a", "inverter-b")
|
||||
_n2_writes_before = len(c_src.hass.writes)
|
||||
_b2, _restore_b2 = second_inverter(c_src)
|
||||
_n2_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
_, _cl2 = cycle_claims(lambda: run_cycle(c_src))
|
||||
_n2_target, _n2_reason = c_src.target, c_src.reason
|
||||
_n2_events = list(c_src.events)
|
||||
_n2_writes = c_src.hass.writes[_n2_writes_before:]
|
||||
_n2_last = c_src.last_write
|
||||
_n2_writes = c_src.hass.per_entity[_n2_writes_before:]
|
||||
_n2_shares = {i.name: i.target for i in c_src.inverters}
|
||||
_n2_last = {i.name: i.last_write for i in c_src.inverters}
|
||||
_n2_alloc = c_src.allocation
|
||||
finally:
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
_restore_b2()
|
||||
print(" a REAL two-inverter cycle files: "
|
||||
+ ", ".join(f"{c.source}@{c.actuator}" for c in _cl2))
|
||||
check("a real cycle at a roster of TWO partitions its own claims correctly - "
|
||||
@@ -2676,18 +2775,73 @@ check("a real cycle at a roster of TWO partitions its own claims correctly - "
|
||||
"`device`, `supervised` and `loop`, so it is the static pin above and not "
|
||||
"this line that covers `failsafe`, `safety` and the two `maintenance` "
|
||||
"appends", len(_cl2) >= 3 and scope_violations(_cl2) == [])
|
||||
check("...and the partition is the expected one on the sources this state "
|
||||
"reaches: the two rating limits bind the unit, the loop claim stays on "
|
||||
"the grid connection",
|
||||
{c.source: c.actuator for c in _cl2}
|
||||
== {"device": "inverter-a", "supervised": "inverter-a",
|
||||
"loop": ACTUATOR_SITE})
|
||||
check("⚠️ AND THE RATING LIMITS ARE FILED ONCE PER INVERTER, ON ITS OWN "
|
||||
"ACTUATOR - which is DEV-02 in one line. Before this card there was ONE "
|
||||
"`device` pair, read from ONE `setpoint_entity`, filed once: a second "
|
||||
"inverter's rating was never read and never bound anything, so its share "
|
||||
"was clamped against the first one's numbers. Now the loop over the "
|
||||
"roster files a pair per unit and the loop claim stays on the grid "
|
||||
"connection",
|
||||
sorted((c.source, c.actuator) for c in _cl2)
|
||||
== [("device", "inverter-a"), ("device", "inverter-b"),
|
||||
("loop", ACTUATOR_SITE), ("supervised", "inverter-a"),
|
||||
("supervised", "inverter-b")])
|
||||
check("...and naming those three is not an accident of this fixture but the "
|
||||
"reason the static pin exists - five of the eight sources need a state "
|
||||
"no check in this file reaches",
|
||||
{c.source for c in _cl2}
|
||||
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
|
||||
"arbitrage"})
|
||||
# ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three
|
||||
# checks above grade WHICH ACTUATOR each claim lands on. They say nothing about
|
||||
# WHOSE NUMBERS the bound is built from - and those are two different defects
|
||||
# with two different fixes. Filing the per-unit `device` and `supervised` claims
|
||||
# from `self.inverters[0]`'s numbers while leaving `actuator=self.actuator_of(
|
||||
# ..., inv)` alone routes every claim correctly and bounds inverter B by
|
||||
# inverter A's rating: at A=5000/B=3000 the planned envelope for inverter-b
|
||||
# becomes (-5000, 5000) instead of (-3000, 3000), which is a 2000 W
|
||||
# over-command the arbiter believes is supervised.
|
||||
#
|
||||
# ⚠️ THE RULE THIS LINE EXISTS TO OBEY: ANY CHECK ASSERTING "X DIFFERS FROM Y"
|
||||
# MUST BE RUN ONCE WITH X AND Y ACTUALLY MADE DIFFERENT. The version this
|
||||
# replaces compared inverter B's bound against `_b2.max_w` while
|
||||
# `second_inverter()` defaulted `max_w` to A's `LIVE_MAX_W` - so it compared
|
||||
# 5000 against 5000, and the mutation above left all 19 files GREEN. The
|
||||
# PRECONDITION below is what stops that happening again: it fails if the two
|
||||
# ratings are ever made equal, so the check cannot go quietly vacuous.
|
||||
_sup2 = {c.actuator: (c.lo, c.hi) for c in _cl2 if c.source == "supervised"}
|
||||
_dev2 = {c.actuator: (c.lo, c.hi) for c in _cl2 if c.source == "device"}
|
||||
print(f" supervised bounds by actuator: {_sup2}; device bounds: {_dev2}")
|
||||
check("PRECONDITION, AND IT IS THE WHOLE POINT: A AND B HAVE DIFFERENT "
|
||||
"RATINGS. A check that 'B's bound is B's own number' cannot observe "
|
||||
"anything while B's number IS A's number, and this file shipped that "
|
||||
"check for a whole review round",
|
||||
_b2.max_w != c_src.inverters[0].max_w
|
||||
and (_b2.dev_min, _b2.dev_max)
|
||||
!= (c_src.inverters[0].dev_min, c_src.inverters[0].dev_max))
|
||||
check("⚠️ AND EACH UNIT'S `supervised` BOUND IS ITS OWN `max_w`, NOT A COPY OF "
|
||||
"THE FIRST UNIT'S. That is the sentence AC 4 is, and it is now stated "
|
||||
"against LITERAL, DIFFERENT numbers rather than against an expression "
|
||||
"that could be reading the same object twice: B is bounded at "
|
||||
f"+/-{B_RATING_HYPOTHESIS:.0f} W and A at +/-{LIVE_MAX_W:.0f} W",
|
||||
_sup2 == {"inverter-a": (-LIVE_MAX_W, LIVE_MAX_W),
|
||||
"inverter-b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS)})
|
||||
check("...and so is each unit's `device` bound, which is the other number the "
|
||||
"same loop files and the one that was NEVER READ before this card - one "
|
||||
"`dev_min`/`dev_max` pair came off one `setpoint_entity`, so a second "
|
||||
"inverter's reported range bound nothing at all",
|
||||
_dev2 == {"inverter-a": (-LIVE_MAX_W, LIVE_MAX_W),
|
||||
"inverter-b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS)})
|
||||
check("...and the bounds the ARBITER RESOLVED for inverter B - not just the "
|
||||
"claims filed at it - are B's own, because a claim filed correctly and "
|
||||
"then resolved against the wrong envelope would be the same defect one "
|
||||
"layer down",
|
||||
_n2_alloc is not None
|
||||
and next((p for p in _n2_alloc.plans if p.unit == "inverter-b"), None)
|
||||
is not None
|
||||
and (lambda p: p.lo == -B_RATING_HYPOTHESIS
|
||||
and p.hi == B_RATING_HYPOTHESIS)(
|
||||
next(p for p in _n2_alloc.plans if p.unit == "inverter-b")))
|
||||
|
||||
|
||||
# --- 10h. ALLOC-01: WHAT MAIN.PY DOES WITH AN ALLOCATION IT CANNOT DELIVER ---
|
||||
@@ -2711,13 +2865,19 @@ check("...and naming those three is not an accident of this fixture but the "
|
||||
# 10h-3 every master is stale: no envelope set, so the allocator itself
|
||||
# refuses and `allocated=False` is reached for real.
|
||||
#
|
||||
# ⚠️ AND 10h-1 IS THE GUARD ALLOC-01 HAD TO ADD RATHER THAN ONE IT INHERITED.
|
||||
# Making `allocate()` succeed at N>=2 would otherwise have been a REGRESSION:
|
||||
# there is one `setpoint_entity` in this add-on, so a controller handed a valid
|
||||
# 450/450 split and a roster of two would have written `self.target` - 900, the
|
||||
# SITE number - to the single inverter it can reach, while a second sat
|
||||
# uncommanded. Not the broadcast fault, but the same shape of over-command, and
|
||||
# the allocator's own arithmetic says the share is half of it.
|
||||
# ⚠️ AND 10h-1 IS THE HALF DEV-02 CHANGED, SO IT HAS BEEN RE-AIMED AGAIN AND
|
||||
# THE PREVIOUS AIM IS RECORDED HERE RATHER THAN DELETED. ALLOC-01 had to ADD
|
||||
# this guard: there was one `setpoint_entity` in this add-on, so a controller
|
||||
# handed a valid 450/450 split and a roster of two would have written
|
||||
# `self.target` - 900, the SITE number - to the single inverter it could reach
|
||||
# while a second sat uncommanded. Not the broadcast fault, but the same shape of
|
||||
# over-command.
|
||||
#
|
||||
# DEV-02 gives it a writer per inverter, so that trigger is gone and the
|
||||
# deliverable case is now the ORDINARY one: both units are commanded THEIR OWN
|
||||
# SHARE, which is what 10h-1 grades below. The refusal survives with a trigger
|
||||
# it can still reach - a roster entry with NO setpoint entity of its own - and
|
||||
# 10h-1b drives it.
|
||||
print(f" at N=2 the allocator produced {_n2_alloc.explain()}")
|
||||
print(f" and main.py resolved to {_n2_target:+.0f} W, reason "
|
||||
f"{_n2_reason!r}, wrote {_n2_writes}")
|
||||
@@ -2736,53 +2896,105 @@ check("...and it did NOT broadcast: neither share is the site target, and "
|
||||
"THAT is the line a placeholder `{u: target for u in units}` goes red on",
|
||||
all(v != _n2_alloc.sum_w for v in _n2_alloc.shares.values())
|
||||
or _n2_alloc.sum_w == 0)
|
||||
check("⚠️ AND MAIN.PY REFUSES TO ACT ON A SPLIT IT CANNOT DELIVER. It drives "
|
||||
"ONE inverter - one `setpoint_entity`, one power sensor - and the "
|
||||
"allocation names two. Writing the site target to the one it can reach "
|
||||
"would over-command that inverter by exactly the other's share; "
|
||||
"commanding a partial site is a worse instruction than commanding "
|
||||
"nothing. So the resolution it acts on is the 0 W failsafe it "
|
||||
"substitutes, and `explain()` says so - this is the branch a `if False:` "
|
||||
"deleted with the suite still green",
|
||||
_n2_target == 0.0 and "failsafe" in _n2_reason)
|
||||
check("...and it says WHY, in the action log, in the operator's own words - it "
|
||||
"names the inverter it has no writer for and the card that gives it one",
|
||||
any("no writer for inverter-b" in e and "DEV-02" in e
|
||||
for e in _n2_events))
|
||||
check("...and then it WRITES that 0 W, on the same line as every other cycle. "
|
||||
"It is a substitution, not an abstention: the setpoint is commanded to "
|
||||
"zero rather than left wherever the last cycle put it, which is the "
|
||||
"fail-toward-inaction direction and the opposite of leaving a stale "
|
||||
"command standing", _n2_writes == [0.0] and _n2_last == 0.0)
|
||||
check("...and the 0 W is NOT what the site target happened to be - resolve the "
|
||||
"same claims at a roster of ONE, where the seam passes through, and a "
|
||||
"real number comes back. That is what says the branch does work rather "
|
||||
"than agreeing with the resolution by luck",
|
||||
resolve(_cl2, units=("inverter-a",)).allocated is True
|
||||
and resolve(_cl2, units=("inverter-a",)).target_w != 0.0)
|
||||
check("⚠️ AND MAIN.PY NOW DELIVERS THE SPLIT. THIS IS DEV-02'S DELIVERABLE, "
|
||||
"and it is the line that used to read 'refusing to command a partial "
|
||||
"site'. Each inverter is written ITS OWN SHARE, to ITS OWN setpoint "
|
||||
"entity, in one cycle - two entities, two numbers, and the two numbers "
|
||||
"are the allocator's, not the site target repeated",
|
||||
sorted(_n2_writes)
|
||||
== sorted([("number.sp", float(_n2_alloc.shares["inverter-a"])),
|
||||
("number.sp_b", float(_n2_alloc.shares["inverter-b"]))]))
|
||||
check("...and neither entity was handed the SITE target, which is the "
|
||||
"broadcast fault stated as a check on the wire rather than on the "
|
||||
"allocator. Each unit supplying the site number IN FULL is 4000 W "
|
||||
"peak-to-peak forever at gain 1.0 on admin/goodwe's two-master rig, with "
|
||||
"not one guard firing",
|
||||
_n2_target == 0.0 or all(v != _n2_target for _e, v in _n2_writes))
|
||||
check("...and the resolution is NOT the 0 W failsafe any more: the site target "
|
||||
"is a real number and it reached the hardware. That is the whole "
|
||||
"difference this card makes to this part",
|
||||
_n2_target != 0.0 and "failsafe" not in _n2_reason)
|
||||
check("...and each inverter's own `last_write` records what IT was told, so "
|
||||
"the heartbeat re-emits a SHARE rather than the site number and "
|
||||
"SAFETY-02 grades each unit against the command IT is standing on",
|
||||
_n2_last == {u: float(v) for u, v in _n2_alloc.shares.items()})
|
||||
check("...and the action log no longer carries the 'no writer' refusal, "
|
||||
"because there is a writer. This is the line that goes red if that "
|
||||
"branch is left standing with its old trigger",
|
||||
not any("no writer" in e for e in _n2_events))
|
||||
|
||||
# --- 10h-1b. THE RE-AIMED REFUSAL: A ROSTER ENTRY WITH NO WRITER ------------
|
||||
# ⚠️ THE INSTINCT SURVIVES ITS TRIGGER, WHICH IS WHAT ALLOC-01 ASKED FOR WHEN IT
|
||||
# SAID RE-AIM RATHER THAN DELETE - and PR #40's reviewer is why: replacing
|
||||
# main.py's sibling branch with `if False:` left the whole suite green, so an
|
||||
# unreachable-but-right guard was one refactor from being deleted as dead code.
|
||||
# A configured inverter with no `setpoint_entity` of its own is still a unit
|
||||
# this controller cannot put a number on, and commanding the ones it CAN reach
|
||||
# would over-command them by exactly the missing unit's share.
|
||||
_nw, _restore_nw = second_inverter(c_src, name="inverter-c", setpoint="")
|
||||
_nw_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
_, _cl_nw = cycle_claims(lambda: run_cycle(c_src))
|
||||
_nw_target, _nw_reason = c_src.target, c_src.reason
|
||||
_nw_events = list(c_src.events)
|
||||
_nw_writes = c_src.hass.per_entity[_nw_writes_before:]
|
||||
finally:
|
||||
_restore_nw()
|
||||
print(f" a roster entry with no setpoint entity: main.py resolved to "
|
||||
f"{_nw_target:+.0f} W, reason {_nw_reason!r}, wrote {_nw_writes}")
|
||||
print(" and logged: "
|
||||
+ next((e for e in _nw_events if "ARBITER" in e), "(nothing)"))
|
||||
check("⚠️ A UNIT WITH NO WRITER TAKES THE SITE TO THE 0 W FAILSAFE. It is a "
|
||||
"CONFIGURATION that cannot be delivered - not a link that went away, "
|
||||
"which is the dropped case below and is announced and survived - and a "
|
||||
"site running at part strength on a roster somebody wrote down wrong "
|
||||
"reads as healthy on every dashboard",
|
||||
_nw_target == 0.0 and "failsafe" in _nw_reason)
|
||||
check("...and it says WHY, naming the inverter it has no setpoint entity for",
|
||||
any("no setpoint entity for inverter-c" in e for e in _nw_events))
|
||||
check("...and then it WRITES that 0 W to the unit it CAN reach, on the same "
|
||||
"line as every other cycle. A substitution, not an abstention: the "
|
||||
"inverter holds its last command forever, so failing toward inaction "
|
||||
"means commanding zero, not falling silent",
|
||||
_nw_writes == [("number.sp", 0.0)])
|
||||
check("...and the 0 W is NOT what the site target happened to be - the same "
|
||||
"claims at a roster whose units all have writers resolve to a real "
|
||||
"number, which is what says the branch does work rather than agreeing "
|
||||
"with the resolution by luck",
|
||||
resolve(_cl_nw, units=("inverter-a",)).allocated is True
|
||||
and resolve(_cl_nw, units=("inverter-a",)).target_w != 0.0)
|
||||
|
||||
# --- 10h-2. A DEAD LINK, AND THE SURVIVOR NOT ABSORBING IT SILENTLY ---------
|
||||
# ⚠️ STIMULATED THROUGH main.py's OWN SEAM, NOT THROUGH A PATCHED ARBITER.
|
||||
# `Controller.live_units()` is the production method the allocator's `live`
|
||||
# argument comes from; today it answers "the whole roster" because this
|
||||
# controller IS its one inverter's only link, and DEV-02 replaces its body with
|
||||
# a per-master freshness test. Overriding what it returns for one cycle is
|
||||
# therefore stimulating the state DEV-02 will produce, not mocking the thing
|
||||
# under test - and it is the only way to reach AC 6 through a real cycle before
|
||||
# DEV-02 lands.
|
||||
c_src.units = ("inverter-a", "inverter-b")
|
||||
_stale_writes_before = len(c_src.hass.writes)
|
||||
_real_live = c_src.live_units
|
||||
# argument comes from.
|
||||
#
|
||||
# ⚠️ AND SINCE DEV-02 IT IS DRIVEN BY THE REAL STIMULUS RATHER THAN OVERRIDDEN.
|
||||
# This used to replace the method with a lambda, because at a roster of one
|
||||
# "its master is stale" had no expression separate from "this controller is
|
||||
# blind". `Inverter.is_live()` gives it one: a link whose every write has been
|
||||
# REJECTED for a whole `LINK_STALE_S` window is not commandable, which is layer
|
||||
# 1's own veto period - the point at which the firmware takes that inverter to
|
||||
# zero anyway. So inverter B is put into exactly that state and the shipped
|
||||
# method is asked, which is what makes this a check on `live_units()` instead of
|
||||
# a check on a lambda this file wrote.
|
||||
_bs, _restore_bs = second_inverter(c_src)
|
||||
_bs.rejected_since = datetime.now(timezone.utc) - timedelta(
|
||||
seconds=app_main.LINK_STALE_S + 5)
|
||||
_bs.last_write_ok = False
|
||||
_stale_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
c_src.live_units = lambda: ("inverter-a",)
|
||||
check("PRECONDITION: the SHIPPED `live_units()` - not a lambda - drops the "
|
||||
"inverter whose writes have been rejected for a whole layer-1 window, "
|
||||
"and keeps the healthy one",
|
||||
c_src.live_units() == ("inverter-a",))
|
||||
_, _cl_stale = cycle_claims(lambda: run_cycle(c_src))
|
||||
_stale_target = c_src.target
|
||||
_stale_alloc = c_src.allocation
|
||||
_stale_events = list(c_src.events)
|
||||
_stale_writes = c_src.hass.writes[_stale_writes_before:]
|
||||
_stale_writes = c_src.hass.per_entity[_stale_writes_before:]
|
||||
finally:
|
||||
c_src.live_units = _real_live
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
_restore_bs()
|
||||
print(f" inverter-b's master stale: {_stale_alloc.explain()}")
|
||||
print(" and logged: "
|
||||
+ next((e for e in _stale_events if "ALLOCATOR" in e), "(nothing)"))
|
||||
@@ -2798,7 +3010,13 @@ check("...and the SURVIVOR CARRIES THE WHOLE SITE TARGET, which is right - "
|
||||
"same event as a sibling with no writer",
|
||||
_stale_alloc.shares == {"inverter-a": _stale_alloc.sum_w}
|
||||
and _stale_target == float(_stale_alloc.sum_w)
|
||||
and _stale_writes == [_stale_target])
|
||||
and ("number.sp", _stale_target) in _stale_writes)
|
||||
check("⚠️ AND THE DROPPED UNIT IS COMMANDED 0 W, NOT LEFT STANDING. The "
|
||||
"inverter holds its last command FOREVER - it has no meter timeout of its "
|
||||
"own - so 'not in the split' has to mean zero on the wire. Leaving it "
|
||||
"silent would leave a dead-link inverter pushing its last share while the "
|
||||
"survivor carried the whole site target on top of it",
|
||||
("number.sp_b", 0.0) in _stale_writes)
|
||||
check("⚠️ AND IT DOES NOT ABSORB IT SILENTLY, which is the whole of AC 6 and of "
|
||||
"SAFETY-30. The action log names the inverter, the reason it left, and "
|
||||
"the consequence - that what remains now carries its share. A site "
|
||||
@@ -2808,18 +3026,26 @@ check("⚠️ AND IT DOES NOT ABSORB IT SILENTLY, which is the whole of AC 6 and
|
||||
for e in _stale_events))
|
||||
|
||||
# --- 10h-3. EVERY MASTER STALE: THE ALLOCATOR'S OWN REFUSAL, REACHED --------
|
||||
c_src.units = ("inverter-a", "inverter-b")
|
||||
_dead_writes_before = len(c_src.hass.writes)
|
||||
# Same stimulus, both units. ⚠️ AND IT REACHES A ROSTER OF ONE TOO: this is the
|
||||
# state `live_units()`'s own docstring warns about - the sole inverter dropping
|
||||
# takes the SITE to 0 W - so it is driven here rather than left as a sentence.
|
||||
_bd, _restore_bd = second_inverter(c_src)
|
||||
_gone = datetime.now(timezone.utc) - timedelta(
|
||||
seconds=app_main.LINK_STALE_S + 5)
|
||||
_bd.rejected_since, _bd.last_write_ok = _gone, False
|
||||
c_src.inverters[0].rejected_since = _gone
|
||||
c_src.inverters[0].last_write_ok = False
|
||||
_dead_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
c_src.live_units = lambda: ()
|
||||
_, _cl_dead = cycle_claims(lambda: run_cycle(c_src))
|
||||
_dead_target, _dead_reason = c_src.target, c_src.reason
|
||||
_dead_alloc = c_src.allocation
|
||||
_dead_events = list(c_src.events)
|
||||
_dead_writes = c_src.hass.writes[_dead_writes_before:]
|
||||
_dead_writes = [v for _e, v in c_src.hass.per_entity[_dead_writes_before:]]
|
||||
finally:
|
||||
c_src.live_units = _real_live
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
_restore_bd()
|
||||
c_src.inverters[0].rejected_since = None
|
||||
c_src.inverters[0].last_write_ok = True
|
||||
print(f" every master stale: {_dead_alloc.explain()}")
|
||||
print(" and logged: "
|
||||
+ next((e for e in _dead_events if "ARBITER" in e), "(nothing)"))
|
||||
@@ -2835,11 +3061,11 @@ check("...and main.py substitutes the 0 W failsafe on it, writes that 0 W, and "
|
||||
"puts the allocator's OWN sentence in the action log rather than a "
|
||||
"paraphrase - so the operator reads which of the refusals fired",
|
||||
_dead_target == 0.0 and "failsafe" in _dead_reason
|
||||
and _dead_writes == [0.0]
|
||||
and set(_dead_writes) == {0.0} and len(_dead_writes) == 2
|
||||
and any("ARBITER: REFUSED" in e and ALLOC_REFUSE_EMPTY in e
|
||||
for e in _dead_events))
|
||||
check("...and the roster it was restored to is the shipped one, so nothing "
|
||||
"below inherits a two-inverter Controller or a patched liveness",
|
||||
check("...and the roster it was restored to is the shipped one, with a healthy "
|
||||
"link, so nothing below inherits a two-inverter Controller or a dead one",
|
||||
c_src.units == (app_main.UNIT_A,)
|
||||
and c_src.live_units() == (app_main.UNIT_A,))
|
||||
|
||||
|
||||
@@ -278,6 +278,18 @@ print("end to end: the trip is COMMAND ZERO, not alarm-only")
|
||||
import asyncio # noqa: E402
|
||||
|
||||
from app.main import Controller # noqa: E402
|
||||
# ⚠️ DEV-02. THE KEY SPELLING IS IMPORTED, NEVER TRANSCRIBED. `sign_tripped` and
|
||||
# `sign_reason` are now NAMESPACED PER INVERTER - flat, a trip caused by B
|
||||
# latched A across every restart and one operator press cleared both. A check
|
||||
# that spelled `"unit/inverter-a/sign_tripped"` by hand here would go on passing
|
||||
# against a main.py that had started writing somewhere else, which is this
|
||||
# repository's own named failure mode: the copy that drifts is always the
|
||||
# grading one. `unit_key()` is the one definition and `Inverter.key()` calls it.
|
||||
from app.inverter import UNIT_A # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
TRIPPED_KEY = unit_key(UNIT_A, "sign_tripped")
|
||||
REASON_KEY = unit_key(UNIT_A, "sign_reason")
|
||||
|
||||
|
||||
class _Hass:
|
||||
@@ -430,9 +442,24 @@ async def _restart():
|
||||
|
||||
pre, post, store, hass2 = asyncio.run(_restart())
|
||||
check("it tripped before the restart", pre.sign_wd.tripped)
|
||||
check("the trip reached the store", store.data["sign_tripped"] is True)
|
||||
check("the trip reached the store, UNDER THE TRIPPED INVERTER'S OWN KEY",
|
||||
store.data[TRIPPED_KEY] is True)
|
||||
check("...with the reason beside it, so the operator is told why",
|
||||
"SIGN_DISAGREEMENT" in store.data["sign_reason"])
|
||||
"SIGN_DISAGREEMENT" in store.data[REASON_KEY])
|
||||
check("⚠️ ...AND THE FLAT PRE-DEV-02 KEY IS MIRRORED, AS THE ROLLBACK VIEW. "
|
||||
"This assertion used to read the other way - that the flat key was NOT "
|
||||
"written - on the argument that writing it would latch every other "
|
||||
"inverter on the site. That argument was right about the READ and wrong "
|
||||
"about the WRITE: nothing at runtime reads the flat key, and the one "
|
||||
"thing that does (`_grade_store_migration()`, on every start) ignores it "
|
||||
"whenever ANY namespaced latch is set, which a sibling's trip always "
|
||||
"leaves. What the absence actually cost was the ROLLBACK: store.py's "
|
||||
"rule 2 keeps this key so a downgrade finds the latch, and a trip taken "
|
||||
"after the migration wrote nothing here - so an operator who downgraded "
|
||||
"came up DRIVING. `test_inverter.py` part 3 is where 'B's trip does not "
|
||||
"latch A across a restart' is graded, on a real roster of two",
|
||||
store.data.get("sign_tripped") is True
|
||||
and "SIGN_DISAGREEMENT" in str(store.data.get("sign_reason", "")))
|
||||
check("the new Controller comes back TRIPPED", post.sign_wd.tripped)
|
||||
check("...and says why, restored rather than re-detected",
|
||||
"SIGN_DISAGREEMENT" in post.sign_wd.reason)
|
||||
@@ -447,11 +474,14 @@ check("the banner still leads with the failsafe",
|
||||
# a one-press clear would be reachable by an operator who never read the banner.
|
||||
asyncio.run(post.handle_action("auto_toggle")) # -> stopped
|
||||
check("pressing stop does not clear the failsafe",
|
||||
post.sign_wd.tripped and store.data["sign_tripped"] is True)
|
||||
post.sign_wd.tripped and store.data[TRIPPED_KEY] is True)
|
||||
asyncio.run(post.handle_action("auto_toggle")) # -> started
|
||||
check("pressing start DOES clear it, and clears the store with it",
|
||||
not post.sign_wd.tripped and store.data["sign_tripped"] is False
|
||||
and store.data["sign_reason"] == "")
|
||||
check("pressing start DOES clear it, and clears the store with it - BOTH "
|
||||
"spellings, which is what stops the every-start latch guard re-arming a "
|
||||
"failsafe the operator has already dealt with",
|
||||
not post.sign_wd.tripped and store.data[TRIPPED_KEY] is False
|
||||
and store.data[REASON_KEY] == ""
|
||||
and store.data.get("sign_tripped") is False)
|
||||
# One more restart from the cleared store, to prove the clear persisted too.
|
||||
fresh = Controller({"setpoint_entity": "number.sp", "meter_source": "off"},
|
||||
_Hass(), store, _P(), stop=asyncio.Event())
|
||||
@@ -473,7 +503,7 @@ check("...so the next restart comes back armed and untripped, as it should",
|
||||
|
||||
class _CrashOnFlag(_MemStore):
|
||||
def set(self, k, v):
|
||||
if k == "sign_tripped" and v:
|
||||
if k == TRIPPED_KEY and v:
|
||||
raise RuntimeError("power cut, mid-trip")
|
||||
super().set(k, v)
|
||||
|
||||
@@ -503,9 +533,9 @@ async def _crash_mid_write():
|
||||
crashed_store, died = asyncio.run(_crash_mid_write())
|
||||
check("the second write is the one that sets the flag", died)
|
||||
check("a crash between the two writes leaves the REASON on disk",
|
||||
"SIGN_DISAGREEMENT" in crashed_store.data.get("sign_reason", ""))
|
||||
"SIGN_DISAGREEMENT" in crashed_store.data.get(REASON_KEY, ""))
|
||||
check("...and never a latch with nothing to explain it",
|
||||
not crashed_store.data.get("sign_tripped"))
|
||||
not crashed_store.data.get(TRIPPED_KEY))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
python3 tools/alloc_sim.py --sweep # AC 3: gain to 1.0 at every split
|
||||
python3 tools/alloc_sim.py --all
|
||||
|
||||
# DEV-02: the split-invariance finding ALLOC-01's review handed on, and the
|
||||
# sensing change that closed it. The two rows are the evidence.
|
||||
python3 tools/alloc_sim.py --splits --gain 0.4 --feedback unit # 250 W apart
|
||||
python3 tools/alloc_sim.py --splits --gain 0.4 --feedback site # identical
|
||||
|
||||
WHY THIS IS A SCRIPT AND NOT A `test_*.py`
|
||||
==========================================
|
||||
It needs admin/goodwe checked out beside this repository, and the add-on's CI
|
||||
@@ -121,20 +126,28 @@ def run_site_feedback(sitesim, control, mode, gain, load_w=2000.0, cycles=40,
|
||||
cycle_s=None, dt=0.05):
|
||||
"""`sitesim.run_site()` WITH EXACTLY ONE LINE CHANGED, and it is named here.
|
||||
|
||||
⚠️ THIS IS A COPY AND IT IS A COPY ON PURPOSE. `run_site` feeds `compute()`
|
||||
ONE inverter's power as `actual_w`, and its docstring says why: the shipped
|
||||
add-on binds a single `inverter_power` sensor, so an operator who wires up a
|
||||
second inverter has a loop that sees unit A only, and a rig that fed the sum
|
||||
would be KINDER than the deployment. That is correct for grading the
|
||||
broadcast fault and it is exactly the variable this experiment has to move,
|
||||
because it is the one input to the loop that is a function of the SPLIT.
|
||||
⚠️ THIS IS A COPY AND IT IS A COPY ON PURPOSE. `run_site` lives in
|
||||
admin/goodwe, which this file reads and never writes, and it feeds
|
||||
`compute()` ONE inverter's power as `actual_w` because that is what the
|
||||
add-on gave it: a single `batt_entity`, so an operator who wired up a second
|
||||
inverter had a loop that saw unit A only. A rig that fed the sum would have
|
||||
been KINDER than the deployment.
|
||||
|
||||
So this differs from `run_site` in one expression - `site.battery`, the
|
||||
site's total inverter power, instead of `inverters[0].plant.actual` - and in
|
||||
nothing else. It is not evidence about the shipped add-on; it is the
|
||||
counterfactual that says WHICH of the two candidate causes explains the
|
||||
split-dependence that `--splits` measures at gain 0.6. The add-on does not
|
||||
have this sensor yet. DEV-02 is where it would come from.
|
||||
nothing else.
|
||||
|
||||
⚠️ AND SINCE DEV-02 THAT ONE EXPRESSION IS THE SHIPPED ONE, WHICH TURNS THIS
|
||||
FUNCTION FROM A COUNTERFACTUAL INTO THE MODEL. `Controller.site_batt_w` is
|
||||
the SUM of the per-instance battery readings and is what main.py hands
|
||||
`compute()`; `--feedback site` is therefore the add-on as it now stands, and
|
||||
`--feedback unit` is the PRE-DEV-02 sensing, kept because the comparison is
|
||||
the evidence. Measured here, 2 kW load, 40 cycles: fed one unit's power the
|
||||
aggregate trace at the meter is split-dependent by 510 W at gain 0.2, 250 W
|
||||
at 0.4 and 50 W at 0.6; fed the site total every split is bit-identical at
|
||||
every gain. `run_site` still models the old sensing and must not be
|
||||
"corrected" to the new one - the row it produces is what the fix is measured
|
||||
against.
|
||||
"""
|
||||
from plantsim import INVERTER_A, INVERTER_B, Inverter, Site
|
||||
cycle_s = sitesim.CYCLE_S if cycle_s is None else cycle_s
|
||||
@@ -240,10 +253,11 @@ def main():
|
||||
ap.add_argument("--load", type=float, default=2000.0)
|
||||
ap.add_argument("--gain", type=float, default=1.0)
|
||||
ap.add_argument("--feedback", choices=("unit", "site"),
|
||||
default="unit",
|
||||
help="what the loop is fed as actual_w: one "
|
||||
"inverter's power (the shipped add-on) or "
|
||||
"the site total (the counterfactual)")
|
||||
default="site",
|
||||
help="what the loop is fed as actual_w: the site total "
|
||||
"(the shipped add-on since DEV-02, the default here) "
|
||||
"or one inverter's power (the PRE-DEV-02 sensing, "
|
||||
"kept because the comparison is the evidence)")
|
||||
a = ap.parse_args()
|
||||
if not (a.table or a.splits or a.sweep):
|
||||
a.all = True
|
||||
|
||||
Reference in New Issue
Block a user