Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b270a66a5 | ||
|
|
bbc903b2b4 | ||
|
|
d8e02ad3f8 | ||
|
|
4482cd8201 | ||
|
|
3527c8a04c | ||
|
|
8790c4d38f | ||
|
|
1d393ced22 | ||
|
|
3516947b5e | ||
|
|
86eaef2517 |
@@ -1,7 +1,84 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.0
|
||||
|
||||
⚠️ **SCHEMA CHANGE, AND IT IS WHY THIS IS A MINOR BUMP RATHER THAN A PATCH.**
|
||||
[[SAFETY-32]] adds `bms_soc_floor: int(0,50)` as a **required** per-entry key on
|
||||
the `inverters` roster - no trailing `?`. A config that declares a roster and
|
||||
omits it on any entry is REFUSED at startup. An install running `inverters: []`,
|
||||
which is every single-inverter install including the reference one, is
|
||||
unaffected: the flat `bms_soc_floor` still describes the one pack.
|
||||
|
||||
CFG-04 recorded the rule this follows - the Supervisor keys the built image by
|
||||
`version:`, so a schema change without a bump is a schema nobody reads.
|
||||
|
||||
Contents, in merge order since 0.6.0 (`2d025ee`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `3934ad7` | MAINT-05 - one recalibration per pack, migration graded against the roster |
|
||||
| `67e649a` | SPIKE-04a - inverter B and pack B recorded, from datasheet and serial |
|
||||
| `3993f9f` | SAFETY-32 - per-pack `bms_soc_floor`, rule 8 per unit, package-wide roster pin |
|
||||
| `f72c48d` | UX-01 - add-on configuration in English and Dutch |
|
||||
| `86eaef2` | TEL-07 - a second MQTT device for inverter B; A's identity byte-identical |
|
||||
| `4482cd8` | SAFETY-41 - the clamp alarm stops narrating normal anti-windup |
|
||||
|
||||
⚠️ **0.6.0 WAS NEVER SECTIONED.** Its content sits below this heading rather than
|
||||
under one of its own, because the bump shipped without cutting the section. That
|
||||
is a defect in this file, not in the release: the deployed 0.6.0 is `2d025ee` and
|
||||
the table above is what is new since it. Splitting it retroactively would mean
|
||||
guessing a boundary; saying so is more useful than a guess.
|
||||
|
||||
## Unreleased
|
||||
|
||||
**`test_peakguard.py` answered two of its own checks with the wall clock
|
||||
(CI-06).** The file sits on the required `checks / unit (pull_request)` context
|
||||
and had a measured ~20 % flake rate. It cost a wrong merge diagnosis (parents
|
||||
green, merge red — read as a semantic merge conflict, it was the flake), a false
|
||||
red on two reviewers' CONTROL runs, and a false "mutation caught" that reached a
|
||||
merged card in four places. **A flaky check does not waste a re-run; it
|
||||
manufactures evidence for whichever hypothesis the reader already holds.**
|
||||
|
||||
Both causes are the control law's own gain `900 / remaining` applied to a
|
||||
wall-clock gap the file never intended to have. Neither fix widens anything.
|
||||
|
||||
* **The cross-time projection.** The "one cycle of the intent form is exactly
|
||||
the delivered form plus the command standing on the wire" check decided the
|
||||
intent form from a **second** `PeakTracker.project(..., datetime.now())` taken
|
||||
after the real cycle had returned, and compared it against a floor decided
|
||||
from the **first** projection — three lines under a comment saying both run
|
||||
"against the same frozen projection". `excess = 900 × (projected − ceiling) /
|
||||
remaining` moves at `excess / remaining` W/s, so at a 1500 W floor the 1.5 W
|
||||
tolerance bought exactly **`remaining / 1000` seconds**: 333 ms with 880 s
|
||||
left in the quarter, **6 ms with 10 s left**. The gap between the two
|
||||
statements measured **12.7–39.5 ms** over 26 instrumented runs. The check was
|
||||
decided by *where in the quarter it ran*. It now keeps the `Projection`
|
||||
**object** the real cycle graded and decides both forms from it — the clock is
|
||||
out of the comparison and the tolerance is **exact**, not widened. Measured on
|
||||
a real cycle: re-deciding the delivered form from that object reproduces the
|
||||
controller's own floor to **0.000000000000 W**.
|
||||
* **The fixture's own room.** `wired_over_limit()` laid telegrams with
|
||||
`range(0, span + 1, 5)`, leaving **0–5 s of the open block uncovered by
|
||||
construction**, and `excess` multiplies an uncovered second back up by
|
||||
`900 / remaining`. Measured on the shipped file: at **12 s of remaining a
|
||||
fixture that asked for a 1500 W floor delivered 613 W (−59 %)**, and at 2 s
|
||||
the guard **did not engage at all** — which fails "the real cycle files the
|
||||
peak claim", "…and the number written to the inverter is the guard's floor"
|
||||
and "the STATUS PAGE row for a floor-limited guard" **together**. The
|
||||
four-attempt retry could not help: four attempts take ~40 ms and all land in
|
||||
the same dying quarter. Fixed with one telegram stamped at `now` (what a real
|
||||
meter does, and what the docstring already claimed) plus `FIXTURE_ROOM_S`,
|
||||
**derived** from the 1 % accuracy the helper promises rather than picked:
|
||||
`net_w × lag / (remaining × want) ≤ 0.01 − lag/900` → **23.6 s → 24 s**, at
|
||||
the measured 0.1 s worst-case lag.
|
||||
|
||||
**Nothing was skipped, disabled or marked xfail** — the file still carries all
|
||||
of SAFETY-07 and SAFETY-25's forced-discharge SoC floor, and it gains a check:
|
||||
the delivered form re-decided from the cycle's own projection must reproduce the
|
||||
real controller's number to the bit, which pins the test's `decide()` kwargs as
|
||||
main.py's actual call rather than an approximation of it. **One tolerance got
|
||||
stricter** (1.5 W → exact), because removing the clock made exactness possible.
|
||||
|
||||
**Inverter B gets its own MQTT device, and inverter A keeps the one it has
|
||||
(TEL-07).** MQTT discovery announced **one** device, and every entity hung off
|
||||
one `unique_id` prefix, so at two inverters B's per-unit telemetry had nowhere
|
||||
|
||||
@@ -145,14 +145,94 @@ from datetime import datetime
|
||||
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
|
||||
REASON_CODE = "OUTPUT_CLAMP"
|
||||
|
||||
# Re-assert the notification this often while the clamp stays engaged. 30 s is
|
||||
# the planning cadence (2.3), i.e. slow enough not to flood and fast enough
|
||||
# that a pinned loop is never quiet for a whole billing quarter.
|
||||
REASSERT_S = 30.0
|
||||
# Re-assert the notification this often while the clamp stays engaged.
|
||||
#
|
||||
# ⚠️ RAISED 30 s -> 150 s on 2026-09-05, and the reason is a REGIME CHANGE, not a
|
||||
# change of mind about the alarm. 30 s was the planning cadence (2.3), chosen
|
||||
# when a loop-side engagement was a rare event: `main.py`'s note at the action
|
||||
# log records "60 cycles of the §10.3 runaway produce 0 write-path engagements
|
||||
# and 2 loop-side ones". On the live install the counter reached 48 loop-side
|
||||
# ENGAGEMENTS in ~25 minutes of ordinary operation - roughly one cycle in six,
|
||||
# not a pinned rail, but far more often than a guard sized for 2-in-60 assumed -
|
||||
# and the owner received a persistent notification about it all day. ⚠️ NOT
|
||||
# because of the 30 s re-assert - see the block below; those notifications never
|
||||
# consulted this constant at all.
|
||||
#
|
||||
# ⚠️ BOTH FIGURES ARE ENGAGEMENT COUNTS; only the regime differs - 2-in-60 was
|
||||
# measured over a §10.3 RUNAWAY, 48-in-305 was ordinary running on the live
|
||||
# install. The 48 is read off the live log, not measured in this repo.
|
||||
#
|
||||
# ⚠️ AND RAISING THIS CONSTANT ALONE DID NOT FIX THAT FLOOD - review found it
|
||||
# and the first version of this change was wrong. At ~5 s cycles a 1-in-6
|
||||
# engagement leaves ~25 s of clean cycles between engagements, past
|
||||
# CLEAR_CYCLES x 5 s = 15 s, so the alarm DE-ASSERTED between almost every one
|
||||
# and came back as a fresh rising edge. The rising edge used to fire
|
||||
# unconditionally, so those notifications never consulted this constant at all:
|
||||
# 48 notifications at 30 s, and still 48 at 150 s. The fix was to rate-limit the
|
||||
# RISING EDGE too and let `last_alarm` survive a de-assert, so this interval
|
||||
# bounds the notification rate across episodes rather than only within one.
|
||||
# Measured on that same window afterwards: 51 episodes, 11 notifications.
|
||||
#
|
||||
# WHY the loop reaches its integrator bound so much more often than the guard's
|
||||
# author saw: `max_w` on the live install is 5000 W, while the loop's slew was
|
||||
# derived against a 1500 W clamp - admin/goodwe `NOTES.md` §"Simulated 2 kW load
|
||||
# step (gain 0.6, clamp 1500)". ⚠️ THAT DERIVATION IS IN THE OTHER REPO; nothing
|
||||
# in this one corroborates it, so do not go looking here. SAFETY-39 is the ticket
|
||||
# that re-derives it, and fixing that is what would stop the railing; this
|
||||
# constant only changes how often the alarm repeats.
|
||||
#
|
||||
# ⚠️ 150 s IS THE REQUIREMENT'S EDGE WITH MARGIN, AND THE CHECK NOW ENFORCES THAT
|
||||
# EDGE. `test_clamp.py` part 2 grades the cadence in wall-clock terms - a pinned
|
||||
# loop must speak at least MIN_PER_QUARTER = 5 times inside the 900 s quarter
|
||||
# that bills the capacity tariff - which caps this constant at 900/5 = 180 s.
|
||||
# Measured on the worst sliding 900 s window: 180 s earns 5 alarms, 181 s earns
|
||||
# 4. 150 s earns 6.
|
||||
#
|
||||
# ⚠️ THAT CHECK USED TO PERMIT 224 s, AND THE FIX SHIPPED WITH THIS COMMIT. Its
|
||||
# sweep starts at i=0, where the rising edge is free, so it measured one alarm
|
||||
# more than a real quarter earns. A later editor raising this to 200 s would have
|
||||
# had a green suite and a requirement quietly unmet. `test_clamp.py` now grades
|
||||
# floor(QUARTER_S / REASSERT_S) directly.
|
||||
#
|
||||
# ⚠️ MIN_PER_QUARTER = 5 IS A TEST-LOCAL INTEGER, not a spec figure - it exists
|
||||
# only in `test_clamp.py` and the CHANGELOG entry that introduced it. The 900 s
|
||||
# quarter IS system-wide (`tariff.py`, `freeze.py`, `DOCS.md`). So the 180 s
|
||||
# ceiling rests on one number a test author chose; if that number is ever
|
||||
# revisited, this constant moves with it.
|
||||
#
|
||||
# ⚠️ THIS IS A STOP-GAP AND DOES NOT MAKE THE ALARM CORRECT. The message still
|
||||
# calls the integrator bound a BUG SIGNAL and still pairs a request from one
|
||||
# moment with a `self.target` read at another - SAFETY-40. Nothing here is
|
||||
# suppressed: every engagement is still counted, the action-log row is written on
|
||||
# every engaged cycle regardless, and the rising edge of every episode alarms
|
||||
# unconditionally, so no alarm is DROPPED - only delayed.
|
||||
REASSERT_S = 150.0
|
||||
|
||||
# Consecutive clean cycles before the alarm de-asserts (10.2 hysteresis).
|
||||
CLEAR_CYCLES = 3
|
||||
|
||||
# ⚠️ SAFETY-41. Consecutive ENGAGED cycles after which an episode speaks even if
|
||||
# the notification budget is spent. THE ESCALATION EXISTS BECAUSE THE RATE LIMIT
|
||||
# ALONE DELAYS A RUNAWAY, and review measured it: a benign one-cycle clamp
|
||||
# followed by a continuous runaway held the runaway's first notification for up
|
||||
# to 140 s - one REASSERT_S minus a cycle. On the live install, where ordinary
|
||||
# railing spends the budget essentially all the time, a runaway arriving during
|
||||
# normal operation would be delayed with near-certainty. That is the "benign
|
||||
# noise suppresses the bug signal" shape this module's docstring is written
|
||||
# against.
|
||||
#
|
||||
# THE DISCRIMINATOR IS RUN LENGTH, and the two populations genuinely differ: the
|
||||
# live flood was runs of length ONE (engage, de-assert, engage), while a §10.3
|
||||
# runaway engages on EVERY cycle. So a sustained run speaks regardless of budget,
|
||||
# and scattered chatter does not.
|
||||
#
|
||||
# Measured, shipped-without vs shipped-with - it costs nothing it was protecting:
|
||||
# live scatter flood (305 cycles, 1-in-6) 11 -> 11 notifications
|
||||
# chatter 1-engaged/3-clean over 8000 s 50 -> 50 notifications
|
||||
# pinned 900 s quarter (floor is 5) 6 -> 6 alarms
|
||||
# runaway delay after a benign episode 140 s -> 10 s
|
||||
SUSTAINED_CYCLES = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClampResult:
|
||||
@@ -258,6 +338,10 @@ class ClampWatchdog:
|
||||
self.last_alarm: datetime | None = None
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
# SAFETY-41. Consecutive engaged cycles, and whether THIS episode has
|
||||
# spoken yet - the two the escalation clause needs.
|
||||
self.run_len = 0
|
||||
self.spoken = False
|
||||
|
||||
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
|
||||
"""Advance one cycle. True when the caller should raise the alarm.
|
||||
@@ -269,26 +353,72 @@ class ClampWatchdog:
|
||||
self.engagements += 1
|
||||
self.clean_cycles = 0
|
||||
self.reason = reason
|
||||
self.run_len += 1
|
||||
if not self.active:
|
||||
self.active = True
|
||||
self.since = now
|
||||
self.episodes += 1
|
||||
self.spoken = False
|
||||
# ⚠️ SAFETY-41. THE RISING EDGE IS RATE-LIMITED TOO, and it did
|
||||
# not used to be. It fired unconditionally, so a condition that
|
||||
# engages and clears repeatedly raised one notification PER
|
||||
# EPISODE no matter how fast the episodes came - and
|
||||
# `REASSERT_S` never entered into it, because it only governs
|
||||
# the still-engaged path below.
|
||||
#
|
||||
# That is the shape the live install actually produced: 48
|
||||
# engagements in 25 minutes, scattered roughly one cycle in six.
|
||||
# At a ~5 s cycle the gaps are ~25 s of clean cycles, which
|
||||
# exceeds CLEAR_CYCLES x 5 s = 15 s, so the alarm DE-ASSERTED
|
||||
# between almost every engagement and each one came back as a
|
||||
# fresh rising edge. Measured on this class over that window:
|
||||
# 48 episodes and 48 notifications at REASSERT_S = 30 - and
|
||||
# still 48 at REASSERT_S = 150. Raising the re-assert removed
|
||||
# NONE of them, because none of them came from the re-assert.
|
||||
#
|
||||
# So the interval now bounds the NOTIFICATION RATE rather than
|
||||
# the repeat rate within one episode: `last_alarm` survives a
|
||||
# de-assert, and a new episode speaks only if the interval has
|
||||
# elapsed. ⚠️ NOTHING IS DROPPED. Every engagement is still
|
||||
# counted, every episode still increments, the action-log row is
|
||||
# still written on every engaged cycle, and a genuine runaway -
|
||||
# which by definition keeps engaging - is picked up by the
|
||||
# re-assert path within REASSERT_S. Delay, not loss.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return False
|
||||
# ⚠️ THE ESCALATION. An episode that has not yet spoken because the
|
||||
# budget was spent by an EARLIER, unrelated episode speaks anyway
|
||||
# once it has engaged SUSTAINED_CYCLES times in a row. A runaway
|
||||
# engages every cycle and reaches it; the scattered one-cycle
|
||||
# railing that produced the live flood never does.
|
||||
if not self.spoken and self.run_len >= SUSTAINED_CYCLES:
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
# Still engaged: re-assert on the slow cadence, not every cycle.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return False
|
||||
|
||||
self.run_len = 0
|
||||
if not self.active:
|
||||
return False
|
||||
self.clean_cycles += 1
|
||||
if self.clean_cycles >= CLEAR_CYCLES:
|
||||
self.active = False
|
||||
self.since = None
|
||||
self.last_alarm = None
|
||||
# ⚠️ `last_alarm` DELIBERATELY SURVIVES THE DE-ASSERT. Clearing it
|
||||
# here is what made the next rising edge speak immediately however
|
||||
# soon it arrived, which is the flood described at the rising edge
|
||||
# above. The episode ends; the notification budget does not reset
|
||||
# with it.
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
return False
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.6.0"
|
||||
version: "0.7.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
|
||||
@@ -78,7 +78,8 @@ import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.clamp import (
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, ClampWatchdog, clamp_to_rating,
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog,
|
||||
clamp_to_rating,
|
||||
is_commandable,
|
||||
)
|
||||
from app.allocator import Allocation, UnitPlan, plan
|
||||
@@ -239,11 +240,112 @@ print(f" pinned on the rail for {QUARTER_S:.0f} s at 1 s cycles: "
|
||||
check(f"a loop pinned for a whole billing quarter speaks at least "
|
||||
f"{MIN_PER_QUARTER} times (it spoke {alarms})",
|
||||
alarms >= MIN_PER_QUARTER)
|
||||
# ⚠️ REVIEW-FOUND, SAFETY-41, and it is the SAME DEFECT as the one recorded
|
||||
# twenty lines above, one notch weaker. The sweep above runs i = 0..899 and the
|
||||
# rising edge at i=0 is FREE, so it measures `1 + floor(899 / REASSERT_S)` - one
|
||||
# alarm more than a real quarter earns. Measured on this very check:
|
||||
# REASSERT_S = 180 -> 5 alarms, green (and 180 is the true edge: on the worst
|
||||
# REASSERT_S = 224 -> 5 alarms, GREEN sliding 900 s window 180 gives 5 and
|
||||
# REASSERT_S = 225 -> 4 alarms, red 181 gives 4)
|
||||
# So the check as written permits 224 s while the REQUIREMENT permits 180 s, and
|
||||
# anyone raising the constant to 200 s would get a green suite and conclude the
|
||||
# floor was met when a real quarter would earn 4 alarms. Grade the cadence
|
||||
# DIRECTLY, so the check enforces the ceiling its own comment claims for it.
|
||||
check(f"...and the CADENCE itself honours the floor, independent of where the "
|
||||
f"sweep happens to start: floor({QUARTER_S:.0f}/{REASSERT_S:.0f}) = "
|
||||
f"{int(QUARTER_S // REASSERT_S)} >= {MIN_PER_QUARTER}",
|
||||
QUARTER_S // REASSERT_S >= MIN_PER_QUARTER)
|
||||
check(f"...and on no more than {MAX_FRACTION:.0%} of cycles (it was "
|
||||
f"{alarms / QUARTER_S:.1%})", alarms <= MAX_FRACTION * QUARTER_S)
|
||||
check("...while every single cycle is still counted",
|
||||
pinned.engagements == int(QUARTER_S))
|
||||
|
||||
# --- THE SCATTERED REGIME, WHICH IS THE ONE THE OWNER ACTUALLY HIT -----------
|
||||
# ⚠️ REVIEW-FOUND, SAFETY-41, and it falsified the first version of this card.
|
||||
# The rising edge used to fire UNCONDITIONALLY, so REASSERT_S governed only the
|
||||
# still-engaged path. A condition that engages and clears repeatedly therefore
|
||||
# raised one notification PER EPISODE at any value of the constant.
|
||||
#
|
||||
# That is what the live install produced: 48 engagements in ~25 minutes,
|
||||
# scattered about one cycle in six. At the measured ~5 s cycle the gaps are ~25 s
|
||||
# of clean cycles, past CLEAR_CYCLES x 5 s = 15 s, so the alarm de-asserted
|
||||
# between almost every engagement. Raising REASSERT_S 30 -> 150 removed NONE of
|
||||
# them. The interval now bounds the notification RATE across episodes.
|
||||
#
|
||||
# The stimulus is written in wall-clock seconds and in the shape the field
|
||||
# actually produced, so it does not move when the constant does.
|
||||
SCATTER_CYCLE_S = 5.0
|
||||
SCATTER_ONE_IN = 6
|
||||
SCATTER_CYCLES = 305
|
||||
|
||||
scat = ClampWatchdog()
|
||||
scat_alarms = sum(1 for i in range(SCATTER_CYCLES)
|
||||
if scat.note(i % SCATTER_ONE_IN == 0, "scattered",
|
||||
T0 + timedelta(seconds=SCATTER_CYCLE_S * i)))
|
||||
_scat_span = SCATTER_CYCLE_S * SCATTER_CYCLES
|
||||
print(f" scattered 1-in-{SCATTER_ONE_IN} over {_scat_span:.0f} s at "
|
||||
f"{SCATTER_CYCLE_S:.0f} s cycles: {scat.episodes} episodes, "
|
||||
f"{scat.engagements} engagements, {scat_alarms} alarm(s)")
|
||||
check(f"a condition that de-asserts between engagements is rate-limited ACROSS "
|
||||
f"episodes, not once per episode ({scat_alarms} alarms for "
|
||||
f"{scat.episodes} episodes)",
|
||||
scat_alarms < scat.episodes)
|
||||
check(f"...and the rate honours the same interval the pinned case does: at most "
|
||||
f"ceil({_scat_span:.0f}/{REASSERT_S:.0f}) + 1 = "
|
||||
f"{int(_scat_span // REASSERT_S) + 1}",
|
||||
scat_alarms <= int(_scat_span // REASSERT_S) + 1)
|
||||
check("...while EVERY episode and EVERY engagement is still counted - the "
|
||||
"budget is on notifications, never on evidence",
|
||||
scat.episodes == scat.engagements == SCATTER_CYCLES // SCATTER_ONE_IN + 1)
|
||||
# ⚠️ THE COUNTERFACTUAL, AND THE FIRST VERSION OF IT COULD NOT FAIL FOR THE
|
||||
# DEFECT IT NAMED. It read `ClampWatchdog().note(True, ...) is True` - a FRESH
|
||||
# object, so it only ever exercised the `last_alarm is None` branch, which is the
|
||||
# trivially-true one. A fresh watchdog has no earlier episode to be held back BY.
|
||||
# That is the third instance in this file of the family recorded at line 216 and
|
||||
# at the 224 s sweep, and it was guarding the most safety-critical property in
|
||||
# the change. Review found it.
|
||||
#
|
||||
# The stimulus must therefore SPEND THE BUDGET FIRST: a benign one-cycle clamp,
|
||||
# a gap long enough to de-assert, then a continuous runaway. Without the
|
||||
# escalation clause the runaway's first notification is held for up to
|
||||
# REASSERT_S - measured at 140 s before it was added.
|
||||
_esc = ClampWatchdog()
|
||||
_esc.note(True, "benign", T0) # spends the budget
|
||||
for _i in range(1, 1 + CLEAR_CYCLES + 1): # de-assert
|
||||
_esc.note(False, "", T0 + timedelta(seconds=5.0 * _i))
|
||||
_RUNAWAY_AT = 5.0 * (CLEAR_CYCLES + 2)
|
||||
_esc_fires = [5.0 * (CLEAR_CYCLES + 2 + _j)
|
||||
for _j in range(200)
|
||||
if _esc.note(True, "runaway",
|
||||
T0 + timedelta(seconds=5.0 * (CLEAR_CYCLES + 2 + _j)))]
|
||||
_esc_delay = _esc_fires[0] - _RUNAWAY_AT if _esc_fires else None
|
||||
print(f" budget spent by a benign episode, then a continuous runaway from "
|
||||
f"t={_RUNAWAY_AT:.0f}s: first alarm at t={_esc_fires[0]:.0f}s "
|
||||
f"(delay {_esc_delay:.0f}s)")
|
||||
check(f"⚠️ a SUSTAINED runaway speaks even when an earlier, unrelated episode "
|
||||
f"spent the budget - it is not held for REASSERT_S ({_esc_delay:.0f}s "
|
||||
f"delay, escalation is {SUSTAINED_CYCLES} cycles)",
|
||||
_esc_delay is not None and _esc_delay < REASSERT_S / 2)
|
||||
check("...and it speaks at the escalation boundary rather than immediately, "
|
||||
"which is what keeps the scattered flood suppressed",
|
||||
_esc_delay >= 5.0 * (SUSTAINED_CYCLES - 1))
|
||||
|
||||
# ⚠️ `last_alarm` SURVIVING THE DE-ASSERT IS NOW LOAD-BEARING, AND NOTHING
|
||||
# PINNED IT. Review built the mutation: if the de-assert path ALSO refreshed
|
||||
# `last_alarm = now`, the chatter case collapses from 50 notifications to 1 - the
|
||||
# alarm never fires again. That is the "alarm that never fires" class, which is a
|
||||
# different bug from one that fires later, and the suite was green on it.
|
||||
_starve = ClampWatchdog()
|
||||
_starve_fires = sum(1 for _i in range(1600)
|
||||
if _starve.note(_i % 4 == 0, "chatter",
|
||||
T0 + timedelta(seconds=5.0 * _i)))
|
||||
print(f" chatter 1-engaged/{CLEAR_CYCLES}-clean over 8000 s: "
|
||||
f"{_starve.episodes} episodes, {_starve_fires} notification(s)")
|
||||
check(f"a de-assert does not RESET the notification budget, so repeated chatter "
|
||||
f"cannot starve the alarm to silence ({_starve_fires} notifications over "
|
||||
f"8000 s, not 1)",
|
||||
_starve_fires > 10)
|
||||
|
||||
# --- THE HYSTERESIS, PINNED AGAINST THE CHATTER DIRECTION --------------------
|
||||
# ⚠️ REVIEW-FOUND. This used to issue `range(CLEAR_CYCLES - 1)` clean cycles,
|
||||
# which at CLEAR_CYCLES = 1 issues ZERO of them and passes while printing
|
||||
|
||||
@@ -109,6 +109,7 @@ import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import inf, isinf
|
||||
from pathlib import Path
|
||||
@@ -1062,6 +1063,58 @@ check("...and the status page says NOT COVERED rather than 'idle' or a green "
|
||||
# block with no seconds remaining - so the whole setup is retried on a fresh
|
||||
# Controller rather than left to flake. It is bounded at three attempts: a fourth
|
||||
# would mean something is wrong that is not a boundary.
|
||||
#
|
||||
# ⚠️⚠️ CI-06. AND THE QUARTER MUST HAVE ROOM LEFT TO SIZE A FIXTURE IN, WHICH IS
|
||||
# A SECOND AND MUCH WIDER RACE THAN THE BOUNDARY ONE ABOVE. It is where three of
|
||||
# this file's four known-flaky checks came from, and it is not a boundary
|
||||
# effect - it is the control law's own gain applied to this helper's arithmetic.
|
||||
#
|
||||
# Every second of the open block the telegrams do NOT cover is credited at the
|
||||
# HELD value instead of the measured one, so the projection loses `net_w x
|
||||
# uncovered / 900` W - and `excess = 900 x (projected - ceiling) / remaining`
|
||||
# multiplies that back up by `900 / remaining`. The floor this helper delivers
|
||||
# is therefore
|
||||
#
|
||||
# floor ~ want x (1 - uncovered/900) - net_w x uncovered / remaining
|
||||
#
|
||||
# and the second term is the whole story: at 600 s left one uncovered second
|
||||
# costs 6 W of a 1500 W floor, at 12 s left it costs 292 W, and at 3 s left it
|
||||
# takes the projection under the ceiling and THE GUARD DOES NOT ENGAGE AT ALL -
|
||||
# which fails "the real cycle files the peak claim", "...and the number written
|
||||
# to the inverter is the guard's floor" and "the STATUS PAGE row for a
|
||||
# floor-limited guard" together, for no reason except the minute the suite ran.
|
||||
# Measured on this tree as shipped: at 12 s left the fixture asked for 1500 W
|
||||
# and delivered 613 W, a -59 % fixture that stayed green and graded a different
|
||||
# site than the one its text names.
|
||||
#
|
||||
# TWO uncovered stretches produced it and both are closed here:
|
||||
#
|
||||
# 1. THE TAIL. `range(0, span + 1, 5)` stops at the last 5 s mark at or before
|
||||
# `now`, so 0-5 s of the block were never covered by construction. One
|
||||
# final telegram stamped at `now` closes it - which is also what a real
|
||||
# meter does, and what this helper's own docstring already claims it does.
|
||||
# 2. THE LAG between that last telegram and `cycle()`'s own `datetime.now()`.
|
||||
# That one is real work and cannot be removed, only bounded: measured at
|
||||
# 12-96 ms over 23 fixtures on this tree under contention.
|
||||
#
|
||||
# So the ROOM the helper needs is derived from what it promises rather than
|
||||
# picked. Requiring the delivered floor to be within 1 % of the floor asked for,
|
||||
# at the shipped ceiling and this file's smallest fixture (`net_w` ~ 3500 W,
|
||||
# `want` 1500 W), and taking the lag at 0.1 s - past every one of the 23
|
||||
# measurements:
|
||||
#
|
||||
# net_w x lag / (remaining x want) <= 0.01 - lag/900
|
||||
# remaining >= 3500 x 0.1 / (1500 x 0.00989) = 23.6 s -> FIXTURE_ROOM_S
|
||||
#
|
||||
# Below that the helper WAITS OUT THE REST OF THE QUARTER rather than grading a
|
||||
# fixture the arithmetic cannot deliver. The wait is at most FIXTURE_ROOM_S and
|
||||
# is taken on about 2.6 % of runs; a suite that silently graded a -59 % fixture
|
||||
# instead was the alternative. ⚠️ This is not a widened tolerance - the checks
|
||||
# downstream are unchanged and the 1 % is a requirement on the FIXTURE, not on
|
||||
# any assertion.
|
||||
FIXTURE_ROOM_S = 24.0
|
||||
|
||||
|
||||
def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||
standing_w=None, soc=55.0, prepare=None):
|
||||
"""A real Controller driven one real cycle over a real over-limit quarter.
|
||||
@@ -1081,6 +1134,13 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||
computed backwards from `remaining` at the moment of the run.
|
||||
"""
|
||||
for attempt in range(4):
|
||||
# ⚠️ ROOM FIRST, AND BEFORE THE CONTROLLER IS BUILT. See FIXTURE_ROOM_S.
|
||||
# Retrying does not help here - four attempts take ~40 ms each and all
|
||||
# four would land in the same dying quarter - so the tail of the block
|
||||
# is waited out once, which puts the next attempt at ~900 s of room.
|
||||
_room = QUARTER_S - (datetime.now(timezone.utc).timestamp() % QUARTER_S)
|
||||
if _room < FIXTURE_ROOM_S:
|
||||
time.sleep(_room + 0.05)
|
||||
ctl, hs = built(db if attempt == 0 else Path(f"{db}.{attempt}"), opts,
|
||||
store=_Store(store) if store is not None else None)
|
||||
ctl.auto, ctl.soc, ctl.batt = True, soc, batt_w
|
||||
@@ -1099,6 +1159,13 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||
net_w = CAP_W + want_floor_w * remaining / QUARTER_S
|
||||
for i in range(0, span + 1, 5):
|
||||
ctl.p1.submit(sample(blk + timedelta(seconds=i), net_w))
|
||||
# ⚠️ AND ONE TELEGRAM AT `now` ITSELF. The averager credits a sample's
|
||||
# value over the interval to the NEXT sample, so without this the block
|
||||
# is covered only to the last 5 s mark and 0-5 s of it are extrapolated
|
||||
# from the held value. See the header: at 12 s of remaining that alone
|
||||
# turned a 1500 W fixture into a 613 W one. A real meter does not stop
|
||||
# sending at the last multiple of five either.
|
||||
ctl.p1.submit(sample(now, net_w))
|
||||
# ⚠️ ASSIGNED, not read back from `p1.net_w`. This helper stamps
|
||||
# `ingest_mono` with an epoch second so the averager (which keys on
|
||||
# `ingest_ts`) replays correctly, while `net_w` keys on a MONOTONIC age
|
||||
@@ -1273,6 +1340,23 @@ _shipped_decide = peakguard.decide
|
||||
|
||||
def _recording_decide(projection, **kw):
|
||||
_seen.update(kw)
|
||||
# ⚠️ CI-06. THE PROJECTION OBJECT ITSELF IS KEPT, AND THAT IS WHAT MAKES THE
|
||||
# COMPARISON BELOW A COMPARISON RATHER THAN A RACE. The check under
|
||||
# "THE CONSEQUENCE, ITERATED" says both forms are run "against the same
|
||||
# frozen projection", and until this line existed that sentence was FALSE:
|
||||
# the intent form was decided against a SECOND `PeakTracker.project(
|
||||
# b3.p1.averager, datetime.now())` taken after the cycle had returned, so
|
||||
# the two numbers came from two different instants of the wall clock. The
|
||||
# control law is `excess = 900 x (projected - ceiling) / remaining`, whose
|
||||
# derivative in time is `excess / remaining`, so at a 1500 W floor the two
|
||||
# instants separated at 1500 / remaining W per second and the check's 1.5 W
|
||||
# tolerance bought exactly `remaining / 1000` SECONDS - 900 ms at the top of
|
||||
# a quarter and 6 ms with ten seconds left of it. Measured on this tree, the
|
||||
# gap between the two statements is 13-40 ms idle and larger under load, so
|
||||
# the check was answered by WHERE IN THE QUARTER IT RAN about `gap_ms / 900`
|
||||
# of the time. Keeping the object removes the clock from the comparison
|
||||
# altogether: the tolerance below is EXACT, not widened.
|
||||
_seen["projection"] = projection
|
||||
return _shipped_decide(projection, **kw)
|
||||
|
||||
|
||||
@@ -1294,15 +1378,36 @@ check("B3: main.py sizes the floor from the MEASURED battery power, not from "
|
||||
# against the same frozen projection - the inverter never follows, so the meter
|
||||
# never moves - and the two are allowed to separate over time rather than being
|
||||
# judged at a single point where they need not differ.
|
||||
_proj3 = PeakTracker.project(b3.p1.averager, datetime.now(timezone.utc))
|
||||
#
|
||||
# ⚠️ CI-06. "THE SAME FROZEN PROJECTION" IS NOW THE SAME OBJECT. `_proj3` is the
|
||||
# `Projection` the real cycle actually graded, captured by `_recording_decide`
|
||||
# above, and not a second projection of the same averager taken at a later
|
||||
# `datetime.now()`. Every number below therefore comes from ONE instant, and the
|
||||
# tolerances that used to absorb the wall clock are gone.
|
||||
_proj3 = _seen["projection"]
|
||||
_kw = dict(cap_w=CAP_W, reading=b3.peak.peak(), achievable_w=LIVE_MAX_W,
|
||||
max_hold_s=b3.p1.averager.max_hold_s, soc=SOC_OK,
|
||||
soc_floor=SOC_FLOOR)
|
||||
_intent = _shipped_decide(_proj3, batt_w=STANDING, **_kw)
|
||||
# The delivered form, re-decided from the SAME projection. It has to reproduce
|
||||
# the real controller's own floor EXACTLY or `_kw` is not the call main.py
|
||||
# makes - which is a second thing this now grades and did not before, and the
|
||||
# one that keeps the identity below honest rather than self-referential.
|
||||
_delivered = _shipped_decide(_proj3, batt_w=0.0, **_kw)
|
||||
check("the delivered form re-decided from the cycle's OWN projection is the "
|
||||
"number the real Controller wrote, to the bit. The kwargs below are "
|
||||
"main.py's call and not an approximation of it, so the identity that "
|
||||
"follows is a statement about `decide()` and not about this fixture",
|
||||
near(_delivered.floor_w, b3.peak_guard.floor_w))
|
||||
check("one cycle of the intent form is exactly the delivered form plus the "
|
||||
"command standing on the wire - which is the whole defect in one line: "
|
||||
"watts that were asked for and never arrived, counted as if they had",
|
||||
near(_intent.floor_w - b3.peak_guard.floor_w, STANDING, 1.5))
|
||||
"watts that were asked for and never arrived, counted as if they had. "
|
||||
"⚠️ EXACT, not within 1.5 W: both forms are decided from one Projection "
|
||||
"object, so a tolerance here would only be hiding a clock. CI-06 - the "
|
||||
"1.5 W it used to carry bought `remaining / 1000` seconds and made this "
|
||||
"line answer by where in the quarter the suite happened to run",
|
||||
near(_intent.floor_w - _delivered.floor_w, STANDING)
|
||||
and not _intent.saturated and not _delivered.saturated)
|
||||
|
||||
_i_cmd, _d_batt = STANDING, 0.0
|
||||
_i_track, _d_track, _i_sat_at = [], [], None
|
||||
|
||||
Reference in New Issue
Block a user