Merge pull request 'SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min' (#141) from SAFETY-52-refused-hold into release/1.0
infra-pin / fresh (push) Successful in 1s
infra-pin / fresh (push) Successful in 1s
This commit was merged in pull request #141.
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
# SAFETY-52 - a battery that refuses is asked for 0 W
|
||||||
|
|
||||||
|
**Decided 2026-09-27 by the owner, on the phase A plan (option D3). All four
|
||||||
|
questions answered yes.**
|
||||||
|
|
||||||
|
## The defect
|
||||||
|
|
||||||
|
At the BMS cut-out the battery gives ~0 W whatever it is asked. The saturation
|
||||||
|
freeze (`control.py`, `frozen`) then holds the request where it stood, and its
|
||||||
|
SAFETY-13 exit cannot fire, because a reading of ~0 against a large ask lies
|
||||||
|
inside the interval a limit can produce. The request stood at +5000 W for
|
||||||
|
11.87 h on 2026-09-06 and at +1940 W for 3 h 55 m on the bus on 2026-09-19. It
|
||||||
|
moved again only when the grid error changed sign. SAFETY-48's lead cap only
|
||||||
|
changes where `i_w` parks (one slew step ahead instead of at the rail). When
|
||||||
|
the request moved after the reading went flat, the stale-reading gate won
|
||||||
|
instead and ran SAFETY-59's ~70 s trip cycle.
|
||||||
|
|
||||||
|
## What was decided
|
||||||
|
|
||||||
|
1. **A refused direction is held at 0 W**, reason `battery refuses`. It engages
|
||||||
|
in `compute()` when the freeze has held `SETTLE_CYCLES` and the reading is
|
||||||
|
below `REFUSED_BELOW_W` (100 W, the probe's own answer) against an ask beyond
|
||||||
|
`saturation_w`. The drop to 0 skips
|
||||||
|
the slew limit (it only removes an ask nobody meets). The direction is
|
||||||
|
carried like `sat_count` (`refused=`, `Decision.refused`).
|
||||||
|
2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`,
|
||||||
|
`PROBE_S`; twice `BATT_OBLIGED_S`). A reading of at least 100 W in the held
|
||||||
|
direction ends the refusal, during a probe or not. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm
|
||||||
|
the gate. The clock is main.py's `now_mono`; `compute()` stays clockless.
|
||||||
|
3. **A gate trip while the loop is frozen on a ~0 W reading is a refusal** for
|
||||||
|
control (`Controller._refusal_from_stale`). This is what stops the ~70 s
|
||||||
|
cycle. The log keeps SAFETY-56's two-explanation wording.
|
||||||
|
4. **No SoC in the loop.** SAFETY-25 Option A stands. Nothing reads SoC.
|
||||||
|
|
||||||
|
Other exits: the loop wants the other direction (charge is accepted at the
|
||||||
|
floor), control stopped or started, another writer on the wire (the arbiter
|
||||||
|
reseed). A gap in the readings keeps the refusal, because the gate's own stop is
|
||||||
|
what seeds it.
|
||||||
|
|
||||||
|
**One inverter only.** At N>1, OPT-09's `pack` limit already closes an empty
|
||||||
|
battery's discharge end and moves its share. There, a one-unit gate trip must
|
||||||
|
not refuse the site. `compute()` is passed `refused=None` there, and that path
|
||||||
|
is release/1.0's law unchanged (the `i_w=None` equivalence sweep in
|
||||||
|
`test_control.py` still holds).
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
`test_refusal.py` drives the real Controller and gate (the 09-06 and 09-19
|
||||||
|
shapes, recovery with no sign change, the probe cadence, every exit).
|
||||||
|
`tools/replay_refusal.py` replays the 16-day capture, same model as
|
||||||
|
`replay_recorded_loop.py`, and should be read for deltas:
|
||||||
|
|
||||||
|
| plant | law | gate trips | request >1 kW above delivery | longest held | import / export kWh | worst 15-min |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W |
|
||||||
|
| fitted | new | 130 (133 after review) | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W |
|
||||||
|
| offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W |
|
||||||
|
| offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W |
|
||||||
|
|
||||||
|
**Traced in review (#141).** The +22 W worst quarter is the old law's worst
|
||||||
|
quarter too. There the old law stands at +2060 W on a pack giving 0 W, and the
|
||||||
|
new law asks 0 W, which the fitted plant answers with its -22 W offset: 22 W
|
||||||
|
charged from the grid. The fit above the floor reads `batt = cmd - 28 W`, so a
|
||||||
|
real inverter asked 0 W may do the same. Of the 71 new-law trips with no
|
||||||
|
old-law trip within 2 min, 59 come 85-97 s after an answered probe. The offset has lifted the modelled pack
|
||||||
|
just above its floor, the probe is answered, control ramps, the pack empties
|
||||||
|
again mid-ramp, and the gate trips, which re-seeds the hold. The other 12
|
||||||
|
follow an other-direction exit the same way. On a site this is one
|
||||||
|
`STALE_READING` ERROR line per answered probe at most, and only on a battery
|
||||||
|
that answers a probe and then cuts out again. Catching the re-cut before the
|
||||||
|
gate needs a faster engage than `SETTLE_CYCLES`, which accuses a healthy slow
|
||||||
|
battery, so it is left to SAFETY-58/59. After the review changes below the
|
||||||
|
fitted-plant row reads 133 trips (the delivery exit now also catches answers
|
||||||
|
that arrive after the probe window); the other figures are unchanged. Rejected alternatives and their figures are in
|
||||||
|
the phase A plan: bounding the integrator by demand made the worst quarter
|
||||||
|
+541 W worse or cycled the gate 3368 times, and an SoC envelope needs the
|
||||||
|
configured floor to be right and needs SoC.
|
||||||
|
|
||||||
|
## Review changes (#141)
|
||||||
|
|
||||||
|
- **"Gives nothing" is below 100 W, not below 500 W.** At `saturation_w` a
|
||||||
|
battery derated to 100-499 W (cold, a tapering battery) was held at 0 W for
|
||||||
|
~93 % of the time: 300 W derated gave 86 Wh in 4 h against 1 200 Wh on the
|
||||||
|
old law. The capture has 0.11 h in that band, so the replay does not move.
|
||||||
|
- **Any delivery ends the hold, not only an answered probe.** With
|
||||||
|
`meter_poll_s: 1` the 12 cycles last 12 s, less than a healthy battery's
|
||||||
|
reading lag (up to `BATT_OBLIGED_S`, 17 s). That engaged on a healthy step and
|
||||||
|
held it for 15 min. Now the late reading ends it within seconds (2 s held in
|
||||||
|
`test_refusal.py` part 5b).
|
||||||
|
- **N>1 is stated as a limit in DOCS.** The pack limit there covers an empty
|
||||||
|
battery only when its cut-out level is configured right.
|
||||||
|
|
||||||
|
## Not covered
|
||||||
|
|
||||||
|
A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a
|
||||||
|
genuine limit (SAFETY-13 case a) and still holds its request.
|
||||||
|
|
||||||
|
## What SAFETY-58 and SAFETY-59 must take into account
|
||||||
|
|
||||||
|
- **SAFETY-58** (the first read after a rest trips at once): a probe never arms
|
||||||
|
the gate (200 < 250 W), but the refusal seeding reads the trip. If 58 delays
|
||||||
|
the first verdict, the seed arrives later. The 09-19 shape then needs the
|
||||||
|
compute-side engagement (12 cycles) to win, which it does only while the
|
||||||
|
request stands still. Keep `test_refusal.py` part 4 green.
|
||||||
|
- **SAFETY-59** (DOCS says a ~70 s pattern proves a failed register): with this
|
||||||
|
change a refusing battery no longer produces the pattern at one inverter. The
|
||||||
|
DOCS text should say so, and that the `battery refuses` reason is the
|
||||||
|
discriminator the add-on now has. At N>1 the old pattern can still occur.
|
||||||
|
- If either changes `MOVE_W`, `PROBE_W` must stay below it.
|
||||||
|
`test_refusal.py` part 2 checks the inequality.
|
||||||
@@ -526,7 +526,7 @@ accumulator is doing:
|
|||||||
| `Loop integrator lead` | W | that value minus **the command it was computed against**, i.e. the command the previous cycle left standing |
|
| `Loop integrator lead` | W | that value minus **the command it was computed against**, i.e. the command the previous cycle left standing |
|
||||||
| `Loop reason` | — | the law's own word for this cycle |
|
| `Loop reason` | — | the law's own word for this cycle |
|
||||||
|
|
||||||
`Loop reason` takes one of eight values:
|
`Loop reason` takes one of ten values:
|
||||||
|
|
||||||
| value | what happened |
|
| value | what happened |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -538,6 +538,27 @@ accumulator is doing:
|
|||||||
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
|
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
|
||||||
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
|
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
|
||||||
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
|
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
|
||||||
|
| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
|
||||||
|
| `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
|
||||||
|
|
||||||
|
**When the battery refuses.** At its cut-out level the battery gives nothing,
|
||||||
|
whatever it is asked. The add-on then asks 0 W instead of repeating its last
|
||||||
|
request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every
|
||||||
|
15 minutes it asks 200 W for about half a minute. Control resumes as soon as
|
||||||
|
the battery gives 100 W or more, when the grid needs charging instead, and when
|
||||||
|
control is stopped or started. 200 W is below the 250 W the stale-reading check
|
||||||
|
needs, so a test never trips it. A battery that gives some power, even 100 W,
|
||||||
|
is not refusing and keeps its request.
|
||||||
|
|
||||||
|
⚠️ **One inverter only.** With several inverters this hold is off. There, the
|
||||||
|
battery level limit moves an empty battery's share to the others, but only if
|
||||||
|
its “Battery cut-out level” matches where the battery really stops. A battery
|
||||||
|
that refuses at another level, or for another reason (cold, a battery fault),
|
||||||
|
can still hold its last request until the grid starts to export.
|
||||||
|
|
||||||
|
*For installers:* SAFETY-52. The event log reads `BATTERY REFUSES …` and
|
||||||
|
`BATTERY REFUSES ENDED: …`, and the INFO line ends in `BATTERY_REFUSES`.
|
||||||
|
`DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures.
|
||||||
|
|
||||||
⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will
|
⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will
|
||||||
often disagree.** `Why` is the *arbiter's* answer in words — *Keeping the grid
|
often disagree.** `Why` is the *arbiter's* answer in words — *Keeping the grid
|
||||||
|
|||||||
@@ -265,6 +265,34 @@ _SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
|
|||||||
# `slew_limit_w()` and therefore with the rail.
|
# `slew_limit_w()` and therefore with the rail.
|
||||||
SLEW_LEAD_STEPS = 1
|
SLEW_LEAD_STEPS = 1
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SAFETY-52. A BATTERY THAT REFUSES IS ASKED FOR 0 W, NOT HELD AT ITS LAST ASK.
|
||||||
|
#
|
||||||
|
# At the BMS cut-out the pack delivers ~0 W whatever it is asked, so the
|
||||||
|
# saturation freeze below holds the command where it stood (the recorded
|
||||||
|
# 09-06 case: +5000 W for 11.87 h) until the grid error changes sign. The
|
||||||
|
# freeze's own exit cannot help: a reading of ~0 against a large ask is inside
|
||||||
|
# the interval a limit can produce. So a refused DIRECTION is held at 0 W,
|
||||||
|
# carried between cycles like `sat_count`, and left again when the loop wants
|
||||||
|
# the other direction, when a small probe is answered, or when the caller
|
||||||
|
# resets it (stop, start, another writer). The record is DECISIONS/SAFETY-52.
|
||||||
|
#
|
||||||
|
# PROBE_W is below freshness.MOVE_W (250 W) on purpose: a probe that small can
|
||||||
|
# never arm the stale-reading gate, so it cannot start the gate's ~70 s cycle.
|
||||||
|
# test_refusal.py pins the inequality. The cadence and length are the owner's
|
||||||
|
# 2026-09-27 answer (every 15 minutes) and freshness.BATT_OBLIGED_S twice over
|
||||||
|
# (17 s is how long a healthy reading needs to show a command).
|
||||||
|
REFUSED = "battery refuses"
|
||||||
|
PROBING = "battery probe"
|
||||||
|
PROBE_W = 200.0
|
||||||
|
PROBE_ANSWER_W = PROBE_W / 2
|
||||||
|
# "Gives nothing" is below the probe's own answer, not below `saturation_w`: a
|
||||||
|
# pack derated to 100-499 W is a limit, and at 500 W it kept ~7 % of its power
|
||||||
|
# (review of #141). The capture's refusals read -4 W.
|
||||||
|
REFUSED_BELOW_W = PROBE_ANSWER_W
|
||||||
|
PROBE_EVERY_S = 900.0
|
||||||
|
PROBE_S = 34.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Tuning:
|
class Tuning:
|
||||||
@@ -346,6 +374,9 @@ class Decision:
|
|||||||
i_bound_w: float = 0.0
|
i_bound_w: float = 0.0
|
||||||
requested_w: float = 0.0
|
requested_w: float = 0.0
|
||||||
clamped_w: float = 0.0
|
clamped_w: float = 0.0
|
||||||
|
# SAFETY-52. The refused direction to carry back in (+1 discharge, -1
|
||||||
|
# charge, 0 none), or None when the caller does not carry it.
|
||||||
|
refused: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def slew_limit_w(tuning: Tuning) -> float:
|
def slew_limit_w(tuning: Tuning) -> float:
|
||||||
@@ -442,6 +473,8 @@ def compute(
|
|||||||
tuning: Tuning,
|
tuning: Tuning,
|
||||||
sat_count: int = 0,
|
sat_count: int = 0,
|
||||||
i_w: float | None = None,
|
i_w: float | None = None,
|
||||||
|
refused: int | None = None,
|
||||||
|
probe: bool = False,
|
||||||
) -> Decision:
|
) -> Decision:
|
||||||
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
||||||
|
|
||||||
@@ -449,6 +482,9 @@ def compute(
|
|||||||
`grid_w` net grid power, + = importing
|
`grid_w` net grid power, + = importing
|
||||||
`actual_w` what the inverter reports it is doing, + = discharging
|
`actual_w` what the inverter reports it is doing, + = discharging
|
||||||
`i_w` the carried integrator, or None to seed it from `prev_w`
|
`i_w` the carried integrator, or None to seed it from `prev_w`
|
||||||
|
`refused` SAFETY-52's carried refused direction, or None to leave it out
|
||||||
|
(the `i_w=None` equivalence path does)
|
||||||
|
`probe` the caller's clock says a refused direction may try PROBE_W now
|
||||||
"""
|
"""
|
||||||
reason = "tracking"
|
reason = "tracking"
|
||||||
|
|
||||||
@@ -739,6 +775,27 @@ def compute(
|
|||||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||||
reason = "saturated-freeze"
|
reason = "saturated-freeze"
|
||||||
|
|
||||||
|
# --- SAFETY-52: a refused direction is held at 0 W ----------------------
|
||||||
|
# Engages only where the freeze has held for SETTLE_CYCLES and the unit
|
||||||
|
# delivers ~nothing: a reading below REFUSED_BELOW_W against an ask beyond
|
||||||
|
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
|
||||||
|
# limit because it only removes an ask the battery is not meeting.
|
||||||
|
if refused is not None:
|
||||||
|
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
|
||||||
|
and abs(prev_w) > tuning.saturation_w
|
||||||
|
and abs(actual_w) < REFUSED_BELOW_W):
|
||||||
|
refused = 1 if prev_w > 0 else -1
|
||||||
|
if refused and refused * i_w < 0:
|
||||||
|
refused = 0 # the loop wants the other way
|
||||||
|
elif refused and refused * actual_w >= PROBE_ANSWER_W:
|
||||||
|
refused = 0 # it delivers: a probe answered,
|
||||||
|
# or a slow reading arriving
|
||||||
|
elif refused:
|
||||||
|
wants = refused * i_w > 0
|
||||||
|
i_w = 0.0
|
||||||
|
target = refused * PROBE_W if probe and wants else 0.0
|
||||||
|
reason = PROBING if probe and wants else REFUSED
|
||||||
|
|
||||||
# --- quantise ----------------------------------------------------------
|
# --- quantise ----------------------------------------------------------
|
||||||
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
|
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
|
||||||
# response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent
|
# response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent
|
||||||
@@ -749,7 +806,7 @@ def compute(
|
|||||||
|
|
||||||
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||||
i_clamped, clamped, float(i_bound_w), float(want),
|
i_clamped, clamped, float(i_bound_w), float(want),
|
||||||
float(clamped_w))
|
float(clamped_w), refused)
|
||||||
|
|
||||||
|
|
||||||
def maintenance_charge_floor(
|
def maintenance_charge_floor(
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from .arbiter import (
|
|||||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY,
|
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY,
|
||||||
Claim, Resolution, actuator_for, resolve,
|
Claim, Resolution, actuator_for, resolve,
|
||||||
)
|
)
|
||||||
|
from . import control as _ctl
|
||||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||||
from .delivery import (
|
from .delivery import (
|
||||||
DELIVERY_OPTION_DEFAULTS, DeliveryConfig, UnitDelivery,
|
DELIVERY_OPTION_DEFAULTS, DeliveryConfig, UnitDelivery,
|
||||||
@@ -241,6 +242,10 @@ def why_key(winner: str, claims) -> str:
|
|||||||
disagreement"). A failsafe with no claim of its own is the arbiter's "no
|
disagreement"). A failsafe with no claim of its own is the arbiter's "no
|
||||||
claim was made", or main.py's refusal to split the power ("unsplit").
|
claim was made", or main.py's refusal to split the power ("unsplit").
|
||||||
"""
|
"""
|
||||||
|
if winner == "loop":
|
||||||
|
# SAFETY-52: a loop holding a refused direction says so.
|
||||||
|
return next((c.reason for c in claims if c.source == "loop"
|
||||||
|
and c.reason in (_ctl.REFUSED, _ctl.PROBING)), winner)
|
||||||
if winner in ("safety", "failsafe"):
|
if winner in ("safety", "failsafe"):
|
||||||
return next((c.reason for c in claims
|
return next((c.reason for c in claims
|
||||||
if c.source == winner and c.kind == "set"),
|
if c.source == winner and c.kind == "set"),
|
||||||
@@ -925,6 +930,12 @@ class Controller:
|
|||||||
self.target = 0.0
|
self.target = 0.0
|
||||||
self.sat_count = 0
|
self.sat_count = 0
|
||||||
self.i_w = 0.0 # the loop's integrator, carried between cycles
|
self.i_w = 0.0 # the loop's integrator, carried between cycles
|
||||||
|
# SAFETY-52. The direction the battery refuses (+1 / -1 / 0), carried
|
||||||
|
# like `i_w`, and when the refusal (or its last probe) started.
|
||||||
|
self.batt_refused = 0
|
||||||
|
self.batt_refused_at = None
|
||||||
|
self._now_mono = None
|
||||||
|
self._refusal_auto = False
|
||||||
# TEL-10. What the last cycle's loop did, kept for publication. Seeded
|
# TEL-10. What the last cycle's loop did, kept for publication. Seeded
|
||||||
# to the SAME values the "stopped or blind" branch of `cycle()` writes,
|
# to the SAME values the "stopped or blind" branch of `cycle()` writes,
|
||||||
# because before the first cycle the loop has not run - which is the
|
# because before the first cycle the loop has not run - which is the
|
||||||
@@ -1058,6 +1069,69 @@ class Controller:
|
|||||||
self.loop_pass_mono = None
|
self.loop_pass_mono = None
|
||||||
|
|
||||||
# -- helpers -------------------------------------------------------------
|
# -- helpers -------------------------------------------------------------
|
||||||
|
# --- SAFETY-52: a battery that refuses is asked for 0 W ------------------
|
||||||
|
def _refusal_probe(self) -> bool:
|
||||||
|
"""True while a refused direction may try `PROBE_W` (every 15 min)."""
|
||||||
|
if (not self.batt_refused or self.batt_refused_at is None
|
||||||
|
or self._now_mono is None):
|
||||||
|
return False
|
||||||
|
held = self._now_mono - self.batt_refused_at
|
||||||
|
if held >= _ctl.PROBE_EVERY_S + _ctl.PROBE_S:
|
||||||
|
self.batt_refused_at = self._now_mono
|
||||||
|
return False
|
||||||
|
return held >= _ctl.PROBE_EVERY_S
|
||||||
|
|
||||||
|
def _refusal_start(self, sign: int, evidence: str) -> None:
|
||||||
|
self.batt_refused = sign
|
||||||
|
self.batt_refused_at = self._now_mono
|
||||||
|
self.log_event(f"BATTERY REFUSES {evidence}",
|
||||||
|
hint=messages.hint("battery_refuses"))
|
||||||
|
_LOG.info("The battery refuses to give power, so the add-on asks 0 W "
|
||||||
|
"and tries %.0f W every %.0f minutes: BATTERY_REFUSES.",
|
||||||
|
_ctl.PROBE_W, _ctl.PROBE_EVERY_S / 60)
|
||||||
|
_LOG.debug("Evidence: BATTERY_REFUSES: %s.", evidence)
|
||||||
|
|
||||||
|
def _refusal_end(self, why: str) -> None:
|
||||||
|
if not self.batt_refused:
|
||||||
|
return
|
||||||
|
self.batt_refused, self.batt_refused_at = 0, None
|
||||||
|
self.log_event(f"BATTERY REFUSES ENDED: {why}",
|
||||||
|
hint=messages.hint("battery_gives"))
|
||||||
|
_LOG.info("The battery refusal ends, control asks for power again: %s.",
|
||||||
|
why)
|
||||||
|
|
||||||
|
def _refusal_update(self, decision, asked_w, batt_w, probe) -> None:
|
||||||
|
"""Follow `compute()`'s carried refused direction, and say so."""
|
||||||
|
now = decision.refused or 0
|
||||||
|
if now and not self.batt_refused:
|
||||||
|
self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery "
|
||||||
|
f"gave {batt_w:+.0f} W for "
|
||||||
|
f"{decision.sat_count} cycles")
|
||||||
|
elif self.batt_refused and not now:
|
||||||
|
gave = self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W
|
||||||
|
self._refusal_end("the probe was answered" if gave and probe
|
||||||
|
else "the battery gives power" if gave
|
||||||
|
else "the grid now asks the other direction")
|
||||||
|
|
||||||
|
def _refusal_from_stale(self, inv, now_mono) -> None:
|
||||||
|
"""A stale-reading stop while the loop is frozen on ~0 W is a refusal.
|
||||||
|
|
||||||
|
Only at one inverter: with several, one empty battery must not stop the
|
||||||
|
site (OPT-09's pack limit moves its share to the others).
|
||||||
|
"""
|
||||||
|
t = self.tuning
|
||||||
|
standing = inv.standing_w()
|
||||||
|
if (len(self.inverters) == 1 and (self.auto or self.shadow)
|
||||||
|
and not self.batt_refused
|
||||||
|
and self.sat_count >= t.saturation_cycles
|
||||||
|
and inv.batt_fresh.held is not None
|
||||||
|
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
|
||||||
|
and abs(standing) > t.saturation_w):
|
||||||
|
self._now_mono = now_mono
|
||||||
|
self._refusal_start(1 if standing > 0 else -1,
|
||||||
|
f"stale reading {inv.batt_fresh.held:+.0f} W "
|
||||||
|
f"while asked {standing:+.0f} W")
|
||||||
|
|
||||||
def log_event(self, msg: str, hint: str | None = None) -> None:
|
def log_event(self, msg: str, hint: str | None = None) -> None:
|
||||||
"""`msg` goes on the action log, the add-on's own technical record.
|
"""`msg` goes on the action log, the add-on's own technical record.
|
||||||
`hint` is `messages.hint()`'s plain words for the same event, and is
|
`hint` is `messages.hint()`'s plain words for the same event, and is
|
||||||
@@ -1840,6 +1914,7 @@ class Controller:
|
|||||||
"""
|
"""
|
||||||
o = self.o
|
o = self.o
|
||||||
now_mono = freshness.monotonic() if now_mono is None else float(now_mono)
|
now_mono = freshness.monotonic() if now_mono is None else float(now_mono)
|
||||||
|
self._now_mono = now_mono
|
||||||
if self.p1_enabled:
|
if self.p1_enabled:
|
||||||
# ⚠️ P1 is the only authoritative measurement of what the utility
|
# ⚠️ P1 is the only authoritative measurement of what the utility
|
||||||
# sees (§5.1). When it is stale this is None, which falls into the
|
# sees (§5.1). When it is stale this is None, which falls into the
|
||||||
@@ -2003,6 +2078,7 @@ class Controller:
|
|||||||
if inv.batt_fresh.stale and not was_stale:
|
if inv.batt_fresh.stale and not was_stale:
|
||||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason,
|
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason,
|
||||||
hint=messages.hint("battery_stuck", unit=inv.name))
|
hint=messages.hint("battery_stuck", unit=inv.name))
|
||||||
|
self._refusal_from_stale(inv, now_mono)
|
||||||
# ⚠️ SAFETY-57 REWROTE THE LAST SENTENCE OF THIS LINE AND NOTHING
|
# ⚠️ SAFETY-57 REWROTE THE LAST SENTENCE OF THIS LINE AND NOTHING
|
||||||
# ELSE IN IT. The escape clause used to read "It clears itself the
|
# ELSE IN IT. The escape clause used to read "It clears itself the
|
||||||
# moment the register reports a different value", full stop - and
|
# moment the register reports a different value", full stop - and
|
||||||
@@ -3340,6 +3416,12 @@ class Controller:
|
|||||||
# loop should be. It is safe because the loop's `prev_w` is
|
# loop should be. It is safe because the loop's `prev_w` is
|
||||||
# `self.target`, which the end of this method re-seeds to what is
|
# `self.target`, which the end of this method re-seeds to what is
|
||||||
# actually standing on the wire.
|
# actually standing on the wire.
|
||||||
|
# SAFETY-52: a refusal learned while control ran does not survive a
|
||||||
|
# stop or a start (shadow mode keeps the loop running when stopped).
|
||||||
|
if bool(self.auto) != self._refusal_auto:
|
||||||
|
self._refusal_auto = bool(self.auto)
|
||||||
|
self._refusal_end("control started" if self.auto
|
||||||
|
else "control stopped")
|
||||||
if (self.auto or self.shadow) and self.inputs_ok:
|
if (self.auto or self.shadow) and self.inputs_ok:
|
||||||
# ⚠️ prev is the ARBITER's last output, not the loop's own last
|
# ⚠️ prev is the ARBITER's last output, not the loop's own last
|
||||||
# wish. If something outranked the loop, that is what the hardware
|
# wish. If something outranked the loop, that is what the hardware
|
||||||
@@ -3357,6 +3439,13 @@ class Controller:
|
|||||||
# stale period as power.
|
# stale period as power.
|
||||||
if self.loop_w is None or self.target != self.loop_w:
|
if self.loop_w is None or self.target != self.loop_w:
|
||||||
self.i_w = self.target
|
self.i_w = self.target
|
||||||
|
# SAFETY-52: another writer overrode the loop, so a refusal learned
|
||||||
|
# against the loop's own ask no longer describes the wire. A gap
|
||||||
|
# (`loop_w` None: blind or stopped) keeps it: the stale-reading stop
|
||||||
|
# is what seeds it, and dropping it there restarts the ~70 s cycle.
|
||||||
|
if self.loop_w is not None and self.target != self.loop_w:
|
||||||
|
self._refusal_end("another writer")
|
||||||
|
probe = self._refusal_probe()
|
||||||
# ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF.
|
# ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF.
|
||||||
# The anti-windup test inside compute() is
|
# The anti-windup test inside compute() is
|
||||||
# `abs(prev_w - actual_w) > saturation_w`, and `prev_w` is the SITE
|
# `abs(prev_w - actual_w) > saturation_w`, and `prev_w` is the SITE
|
||||||
@@ -3385,6 +3474,10 @@ class Controller:
|
|||||||
tuning=self.tuning,
|
tuning=self.tuning,
|
||||||
sat_count=self.sat_count,
|
sat_count=self.sat_count,
|
||||||
i_w=self.i_w,
|
i_w=self.i_w,
|
||||||
|
# SAFETY-52 at one inverter only: with several, OPT-09's pack
|
||||||
|
# limit already closes an empty battery's discharge end.
|
||||||
|
refused=self.batt_refused if len(self.inverters) == 1 else None,
|
||||||
|
probe=probe,
|
||||||
)
|
)
|
||||||
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
|
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
|
||||||
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
|
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
|
||||||
@@ -3393,6 +3486,7 @@ class Controller:
|
|||||||
self.sat_count = decision.sat_count
|
self.sat_count = decision.sat_count
|
||||||
self.i_w = decision.i_w
|
self.i_w = decision.i_w
|
||||||
self.loop_w = decision.target_w
|
self.loop_w = decision.target_w
|
||||||
|
self._refusal_update(decision, loop_prev, site_batt, probe)
|
||||||
# --- TEL-10 ------------------------------------------------------
|
# --- TEL-10 ------------------------------------------------------
|
||||||
# ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT
|
# ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT
|
||||||
# THE CARD IS. `self.i_w` is carried between cycles and published
|
# THE CARD IS. `self.i_w` is carried between cycles and published
|
||||||
|
|||||||
@@ -802,6 +802,9 @@ WHY_WINNER = {
|
|||||||
# failsafe winner. test_messages.py part 7 finds every one in the code.
|
# failsafe winner. test_messages.py part 7 finds every one in the code.
|
||||||
"sign disagreement": "Safety stop, power went the wrong way",
|
"sign disagreement": "Safety stop, power went the wrong way",
|
||||||
"unsplit": "The power could not be split, all at 0 W",
|
"unsplit": "The power could not be split, all at 0 W",
|
||||||
|
# SAFETY-52: the loop's two words for a battery that gives nothing.
|
||||||
|
"battery refuses": "The battery refuses to give power",
|
||||||
|
"battery probe": "Testing if the battery gives power again",
|
||||||
}
|
}
|
||||||
WHY_LIMIT = {
|
WHY_LIMIT = {
|
||||||
"peak(quarter peak)": "the peak limit",
|
"peak(quarter peak)": "the peak limit",
|
||||||
@@ -852,6 +855,8 @@ HINTS = {
|
|||||||
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
|
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
|
||||||
"battery_stuck": "{unit}: battery power reading stopped changing",
|
"battery_stuck": "{unit}: battery power reading stopped changing",
|
||||||
"battery_back": "{unit}: battery power reading is used again",
|
"battery_back": "{unit}: battery power reading is used again",
|
||||||
|
"battery_refuses": "The battery refuses to give power, the add-on asks 0 W",
|
||||||
|
"battery_gives": "The battery refusal ended, control asks for power again",
|
||||||
"bad_value": "The add-on made an invalid power value, inverters set to 0 W",
|
"bad_value": "The add-on made an invalid power value, inverters set to 0 W",
|
||||||
"write_blocked": "{unit}: a power request was blocked, it is set to 0 W",
|
"write_blocked": "{unit}: a power request was blocked, it is set to 0 W",
|
||||||
"all_blocked": "A power request was blocked, inverters set to 0 W",
|
"all_blocked": "A power request was blocked, inverters set to 0 W",
|
||||||
|
|||||||
@@ -497,8 +497,13 @@ check(f"AC 1: NOT ONE of the {len(emitted)} values that reached the device "
|
|||||||
# largest emitted value is what it is and not 5000 W. The clamp is what makes it
|
# largest emitted value is what it is and not 5000 W. The clamp is what makes it
|
||||||
# bounded; the freeze is what makes it small. A check that read
|
# bounded; the freeze is what makes it small. A check that read
|
||||||
# `max(emitted) == max_w` would have been asserting the freeze, not the clamp.
|
# `max(emitted) == max_w` would have been asserting the freeze, not the clamp.
|
||||||
|
# SAFETY-52 re-baseline: an inverter reporting 0 W for SETTLE_CYCLES is a
|
||||||
|
# battery that refuses, so the command then drops to 0 W. The freeze still
|
||||||
|
# engaged first, and the event log says so.
|
||||||
check(f"...and the saturation freeze is what pins it at {max(emitted):,.0f} W "
|
check(f"...and the saturation freeze is what pins it at {max(emitted):,.0f} W "
|
||||||
f"rather than the rail", ctl_r.sat_count >= ctl_r.tuning.saturation_cycles)
|
f"rather than the rail",
|
||||||
|
any("saturation freeze" in e for e in ctl_r.events)
|
||||||
|
and max(emitted) < LIVE_MAX_W)
|
||||||
|
|
||||||
notes_r = clamp_notes(hass_r)
|
notes_r = clamp_notes(hass_r)
|
||||||
print(f" the loop clamped on {ctl_r.clamp_wd.engagements} of {60} cycles, "
|
print(f" the loop clamped on {ctl_r.clamp_wd.engagements} of {60} cycles, "
|
||||||
@@ -580,8 +585,12 @@ check("UX-07 item 1: during a bound episode an earlier over-rating count is "
|
|||||||
|
|
||||||
# ⚠️ NOT A TRIP. 11.2's action is "clamp and alarm"; the loop keeps running and
|
# ⚠️ NOT A TRIP. 11.2's action is "clamp and alarm"; the loop keeps running and
|
||||||
# keeps writing the clamped value. If a later cut changes that it must come here.
|
# keeps writing the clamped value. If a later cut changes that it must come here.
|
||||||
check(f"it is NOT a trip - the command stands at {ctl_r.target:+.0f} W, not 0 W",
|
# SAFETY-52: the loop itself now asks 0 W of an inverter that gives nothing,
|
||||||
ctl_r.target != 0.0 and ctl_r.target == hass_r.writes[-1])
|
# with its own reason. That is the loop's decision, not a stop.
|
||||||
|
check(f"it is NOT a trip - the command stands at {ctl_r.target:+.0f} W because "
|
||||||
|
f"the loop says '{ctl_r.loop_reason}', not because of a stop",
|
||||||
|
ctl_r.loop_reason == "battery refuses"
|
||||||
|
and ctl_r.target == hass_r.writes[-1])
|
||||||
check("...and the controller still reports itself running, not failsafe",
|
check("...and the controller still reports itself running, not failsafe",
|
||||||
ctl_r.auto and not ctl_r.sign_wd.tripped)
|
ctl_r.auto and not ctl_r.sign_wd.tripped)
|
||||||
|
|
||||||
|
|||||||
@@ -1147,8 +1147,10 @@ async def _one():
|
|||||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||||
"meter_source": "off", "max_w": 2000, "gain": 0.6,
|
"meter_source": "off", "max_w": 2000, "gain": 0.6,
|
||||||
# ⚠️ THE SAME 1 s AS THE TWO-INVERTER FIXTURE, AND THAT IS WHAT
|
# ⚠️ THE SAME 1 s AS THE TWO-INVERTER FIXTURE, AND THAT IS WHAT
|
||||||
# MAKES THIS SECTION NON-VACUOUS. The run below is 2 s of wall clock
|
# MAKES THIS SECTION NON-VACUOUS. The run below is 1.1 s of wall
|
||||||
# against a 1 s window, so a hold is not what stops this firing -
|
# clock against a 1 s window (SAFETY-52 shortened it from 2 s, and
|
||||||
|
# "the window was never even opened" below is what still makes it
|
||||||
|
# non-vacuous), so a hold is not what stops this firing -
|
||||||
# the reduction is. With a shipped-length hold "nothing was
|
# the reduction is. With a shipped-length hold "nothing was
|
||||||
# reported" would be true of a fixture that simply ran out of time.
|
# reported" would be true of a fixture that simply ran out of time.
|
||||||
"bms_soc_floor": _FLOOR_PCT, "delivery_hold_s": 1,
|
"bms_soc_floor": _FLOOR_PCT, "delivery_hold_s": 1,
|
||||||
@@ -1158,7 +1160,10 @@ async def _one():
|
|||||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -2000.0, 2000.0
|
c.inverters[0].dev_min, c.inverters[0].dev_max = -2000.0, 2000.0
|
||||||
c.auto = True
|
c.auto = True
|
||||||
hass.ctrl = c
|
hass.ctrl = c
|
||||||
await _drive(c, n=20, dt=0.1)
|
# SAFETY-52: from SETTLE_CYCLES of delivering nothing the loop asks 0 W
|
||||||
|
# (the battery refuses), so the run stops just before that. 11 cycles is
|
||||||
|
# still 1.1 s against the 1 s hold.
|
||||||
|
await _drive(c, n=11, dt=0.1)
|
||||||
return c, hass, store
|
return c, hass, store
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1399,6 +1399,7 @@ import logging # noqa: E402
|
|||||||
from app import hass as _hass, p1 as _p1, validate as _validate, web as _web # noqa: E402
|
from app import hass as _hass, p1 as _p1, validate as _validate, web as _web # noqa: E402
|
||||||
from app.arbiter import Claim # noqa: E402
|
from app.arbiter import Claim # noqa: E402
|
||||||
from app.main import Controller, why_key # noqa: E402
|
from app.main import Controller, why_key # noqa: E402
|
||||||
|
from app import control as _control # noqa: E402
|
||||||
from app import main as _main # noqa: E402
|
from app import main as _main # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@@ -1585,6 +1586,11 @@ for _f in sorted(APP.glob("*.py")):
|
|||||||
_keys = {why_key(s, [Claim.set(s, 0, 0.0, r)]) for s, r in _SOURCES}
|
_keys = {why_key(s, [Claim.set(s, 0, 0.0, r)]) for s, r in _SOURCES}
|
||||||
_keys |= {why_key("failsafe", []),
|
_keys |= {why_key("failsafe", []),
|
||||||
why_key("failsafe", [Claim.set("loop", 0, 1.0, "tracking")])}
|
why_key("failsafe", [Claim.set("loop", 0, 1.0, "tracking")])}
|
||||||
|
# SAFETY-52: the loop's claim reason is computed, so the scan cannot see it.
|
||||||
|
_keys |= {why_key("loop", [Claim.set("loop", 0, 0.0, _r)])
|
||||||
|
for _r in (_control.REFUSED, _control.PROBING)}
|
||||||
|
check("...including the loop's two battery-refuses reasons, which get words of "
|
||||||
|
"their own", {_control.REFUSED, _control.PROBING} <= _keys)
|
||||||
check(f"the scan found the claims ({len(_SOURCES)}), including the latched "
|
check(f"the scan found the claims ({len(_SOURCES)}), including the latched "
|
||||||
f"per-inverter stop the 2d test saw as 'Other'",
|
f"per-inverter stop the 2d test saw as 'Other'",
|
||||||
("failsafe", "sign disagreement") in _SOURCES and len(_SOURCES) >= 8)
|
("failsafe", "sign disagreement") in _SOURCES and len(_SOURCES) >= 8)
|
||||||
|
|||||||
@@ -4412,12 +4412,16 @@ check(f"AC, AND THE WHOLE OF DEFECT 4: 'trading is off' and 'trading is on "
|
|||||||
f"`off (arbitrage_enabled)` for both - naming an option that is `true` "
|
f"`off (arbitrage_enabled)` for both - naming an option that is `true` "
|
||||||
f"in the options blob",
|
f"in the options blob",
|
||||||
_T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
|
_T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
|
||||||
|
# ⚠️ THE "Why" ROW OF THE SAME RENDER, NOT A SECOND READ OF `why_words`. Since
|
||||||
|
# UX-10 the waiting sentence counts its seconds when it is read, so reading the
|
||||||
|
# property again here, after the other renders above, could cross a second
|
||||||
|
# ("1 second" in the row, "2 seconds" now) - CI run 953 on PR #141 did.
|
||||||
check(f"...and the armed-but-held row carries the CONTROL LOOP'S OWN REASON "
|
check(f"...and the armed-but-held row carries the CONTROL LOOP'S OWN REASON "
|
||||||
f"({_T11_ON_HELD[1].why_words!r}), so the operator is told which input is "
|
f"({_T11_ON_HELD[0]['Why']!r}), so the operator is told which input is "
|
||||||
f"missing rather than being sent to look at a switch that is already on. "
|
f"missing rather than being sent to look at a switch that is already on. "
|
||||||
f"The reason comes from main.py, not from this file",
|
f"The reason comes from main.py, not from this file",
|
||||||
_T11_ON_HELD[1].why_words
|
_T11_ON_HELD[0]["Why"].startswith("A reading is missing")
|
||||||
and _T11_ON_HELD[1].why_words in _T11_ON_HELD[0]["Arbitrage"])
|
and _T11_ON_HELD[0]["Why"] in _T11_ON_HELD[0]["Arbitrage"])
|
||||||
check(f"...and a cycle that DID reach the step renders the strategy's own "
|
check(f"...and a cycle that DID reach the step renders the strategy's own "
|
||||||
f"sentence - {_T11_ON_OK[1].arb.explain()[:50]!r} - rather than a "
|
f"sentence - {_T11_ON_OK[1].arb.explain()[:50]!r} - rather than a "
|
||||||
f"re-derivation of it, which is the guarantee OPT-04 put on this row and "
|
f"re-derivation of it, which is the guarantee OPT-04 put on this row and "
|
||||||
|
|||||||
@@ -0,0 +1,475 @@
|
|||||||
|
"""Runnable check for SAFETY-52. `python3 test_refusal.py`
|
||||||
|
|
||||||
|
A battery that refuses to give power (the BMS cut-out) used to leave the
|
||||||
|
command standing at its last ask for hours: +5000 W for 11.87 h on 2026-09-06,
|
||||||
|
+1940 W for 3 h 55 m on the bus on 2026-09-19. Now a refused direction is held
|
||||||
|
at 0 W, a 200 W probe asks every 15 minutes, and the refusal ends on charge, on
|
||||||
|
an answered probe, on stop/start and when another writer takes the wire.
|
||||||
|
DECISIONS/SAFETY-52 has the record and the replay figures.
|
||||||
|
|
||||||
|
Mutating the modules this file grades? Purge `__pycache__` first, or run
|
||||||
|
`tools/mutation_run.sh`, which does - see MUTATION.md.
|
||||||
|
|
||||||
|
Parts 1-5 drive the REAL `Controller` (read_inputs + cycle, the real
|
||||||
|
SAFETY-37/57 gate) against a scripted battery whose reading stands at exactly
|
||||||
|
-4 W for any request >= -4 W at the cut-out, and follows otherwise. Time is the
|
||||||
|
`now_mono` handed to `read_inputs()`, so 12 hours run in seconds. `OLD` runs the
|
||||||
|
same fixture with `refused=None` forced into `compute()` - release/1.0's law -
|
||||||
|
so every "new" figure is shown beside the one it replaces. Part 4 also runs
|
||||||
|
`compute()` alone, as the bus saw 09-19 before the gate existed.
|
||||||
|
|
||||||
|
Mutations, each on a copy of this tree purged of `__pycache__`, graded with
|
||||||
|
`python3 -B` over test_refusal, test_control, test_clamp, test_telemetry and
|
||||||
|
test_messages (MUTATION.md). Reds are failing checks, on this tree:
|
||||||
|
|
||||||
|
# mutation reds
|
||||||
|
-- ------------------------------------------------------- ---------------
|
||||||
|
R1 control.py: the engage `if` -> `if False and ...` 8 here + 1 clamp
|
||||||
|
(release/1.0's behaviour)
|
||||||
|
R2 control.py: the exit on the other direction removed 1
|
||||||
|
R3 control.py: the exit on an answered probe removed 2
|
||||||
|
R4 control.py: `PROBE_W = 300.0`, above the gate's 250 W 4
|
||||||
|
R5 control.py: the hold keeps the old target, not 0 W 4
|
||||||
|
R6 main.py: `_refusal_from_stale()` returns at once 1
|
||||||
|
R7 main.py: another writer no longer ends the refusal 1
|
||||||
|
R8 main.py: stop/start no longer ends the refusal 1
|
||||||
|
R9 main.py: the probe window never opens 4
|
||||||
|
R10 main.py: seed from a stale stop below `saturation_w` again, 1
|
||||||
|
not below REFUSED_BELOW_W (review of #141). The same mutation
|
||||||
|
in control.py's engage clause SURVIVES, and that is expected:
|
||||||
|
the delivery exit (R11) ends such a refusal in the same cycle.
|
||||||
|
R11 control.py: only an answered PROBE ends the refusal 2
|
||||||
|
(`and probe` back in the delivery exit; review of #141)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app import control, freshness
|
||||||
|
from app.control import PROBE_EVERY_S, PROBE_S, PROBE_W, REFUSED, PROBING
|
||||||
|
from app.main import Controller, why_key
|
||||||
|
from app.arbiter import Claim
|
||||||
|
from app import messages
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
LIVE = {"max_w": 5000.0, "gain": 0.6, "slew_w": 400.0, "target_grid_w": -10.0,
|
||||||
|
"deadband_w": 15, "step_w": 10}
|
||||||
|
TICK = 5.0 # the meter's measured median tick (replay_recorded_loop)
|
||||||
|
CUT_W = -4.0 # what the register reads at the cut-out, 2026-09-06
|
||||||
|
H = 3600.0
|
||||||
|
BASE = datetime(2026, 9, 6, 19, 30, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
def __init__(self):
|
||||||
|
self.data = {}
|
||||||
|
|
||||||
|
def set(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_time(self, *a):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _Pub:
|
||||||
|
enabled = False
|
||||||
|
|
||||||
|
def publish(self, values):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _Hass:
|
||||||
|
"""House load, and a battery that follows unless `cut(t)` says it refuses.
|
||||||
|
|
||||||
|
At the cut-out the reading is exactly CUT_W for any request >= CUT_W (a flat
|
||||||
|
register, which is what arms or does not arm the stale-reading gate). A
|
||||||
|
charge request is followed: the pack accepts charge at the floor.
|
||||||
|
`cap(t)` limits discharge while not cut (the 09-06 pack gave 4570 W at 10 %).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, load, cut, cap=lambda t: 1e9):
|
||||||
|
self.load, self.cut, self.cap = load, cut, cap
|
||||||
|
self.t = 0.0
|
||||||
|
self.cmd = 0.0
|
||||||
|
self.writes = []
|
||||||
|
|
||||||
|
def batt(self):
|
||||||
|
if self.cut(self.t) and self.cmd >= CUT_W:
|
||||||
|
return CUT_W
|
||||||
|
return float(round(min(self.cmd, self.cap(self.t))))
|
||||||
|
|
||||||
|
async def limits(self, entity):
|
||||||
|
return (-LIVE["max_w"], LIVE["max_w"], 1.0)
|
||||||
|
|
||||||
|
async def number(self, entity, invert=False):
|
||||||
|
b = self.batt()
|
||||||
|
return {"sensor.grid": self.load(self.t) - b, "sensor.soc": 9.0,
|
||||||
|
"sensor.batt": b}.get(entity)
|
||||||
|
|
||||||
|
async def set_number(self, entity, value):
|
||||||
|
self.writes.append((self.t, float(value)))
|
||||||
|
self.cmd = float(value)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def call(self, *a):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
|
||||||
|
"""Real cycles every TICK seconds of `now_mono`. `events[t] = fn(c)` pokes
|
||||||
|
the controller (stop, start, another writer) at that instant.
|
||||||
|
|
||||||
|
Returns [(t, grid, batt, setpoint, i_w, loop_reason, batt_refused)], the
|
||||||
|
number of gate trips, and the controller."""
|
||||||
|
async def go():
|
||||||
|
hass = _Hass(load, cut, cap)
|
||||||
|
c = Controller(dict(
|
||||||
|
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||||
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||||
|
"meter_source": "off", "heartbeat_s": 10, "stale_input_s": 15},
|
||||||
|
**LIVE), hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||||
|
c.auto = True
|
||||||
|
c.dev_min, c.dev_max = -LIVE["max_w"], LIVE["max_w"]
|
||||||
|
import app.main as m
|
||||||
|
real, real_dt = m.compute, m.datetime
|
||||||
|
clock = [0.0]
|
||||||
|
|
||||||
|
class _DT(real_dt):
|
||||||
|
"""`cycle()`'s wall clock follows `now_mono`, so the 15 s input hold
|
||||||
|
and the gate's release run in simulated seconds too."""
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return BASE + timedelta(seconds=clock[0])
|
||||||
|
|
||||||
|
m.datetime = _DT
|
||||||
|
if old:
|
||||||
|
m.compute = lambda *a, **k: real(*a, **dict(k, refused=None))
|
||||||
|
rows, trips, was = [], 0, False
|
||||||
|
try:
|
||||||
|
t = 0.0
|
||||||
|
while t < dur:
|
||||||
|
hass.t = clock[0] = t
|
||||||
|
for et, fn in list((events or {}).items()):
|
||||||
|
if et <= t:
|
||||||
|
fn(c)
|
||||||
|
del events[et]
|
||||||
|
await c.read_inputs(now_mono=t)
|
||||||
|
stale = c.inverters[0].batt_fresh.stale
|
||||||
|
trips += stale and not was
|
||||||
|
was = stale
|
||||||
|
await c.cycle()
|
||||||
|
rows.append((t, hass.load(t) - hass.batt(), hass.batt(),
|
||||||
|
hass.cmd, c.i_w, c.loop_reason, c.batt_refused))
|
||||||
|
t += TICK
|
||||||
|
finally:
|
||||||
|
m.compute, m.datetime = real, real_dt
|
||||||
|
return rows, trips, c
|
||||||
|
return asyncio.run(go())
|
||||||
|
|
||||||
|
|
||||||
|
def at(rows, t):
|
||||||
|
return min(rows, key=lambda r: abs(r[0] - t))
|
||||||
|
|
||||||
|
|
||||||
|
def longest_hold_h(rows, floor_w=250.0, until=None):
|
||||||
|
"""Longest run of one identical request at or above `floor_w`, in hours."""
|
||||||
|
best = cur = 0.0
|
||||||
|
last = None
|
||||||
|
for r in rows:
|
||||||
|
if until is not None and r[0] >= until:
|
||||||
|
break
|
||||||
|
cur = cur + TICK if (r[3] == last and abs(r[3]) >= floor_w) else 0.0
|
||||||
|
last = r[3]
|
||||||
|
best = max(best, cur)
|
||||||
|
return best / H
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print("1. the 09-06 shape: a load step takes the request to 5000 W, the battery "
|
||||||
|
"cuts out, the load ends")
|
||||||
|
# ==============================================================================
|
||||||
|
def load906(t):
|
||||||
|
return 1020 if t < 60 else 4800 if t < 162 else 940 if t < 11.9 * H else -600
|
||||||
|
|
||||||
|
|
||||||
|
def cut906(t):
|
||||||
|
return t >= 132
|
||||||
|
|
||||||
|
|
||||||
|
def cap906(t):
|
||||||
|
return 4570.0
|
||||||
|
|
||||||
|
|
||||||
|
old906, otrips906, _ = run(load906, cut906, 12.2 * H, cap906, old=True)
|
||||||
|
new906, ntrips906, c906 = run(load906, cut906, 12.2 * H, cap906)
|
||||||
|
for name, rows, tr in (("old", old906, otrips906), ("new", new906, ntrips906)):
|
||||||
|
print(f" {name}: request at 2 min {at(rows, 120)[3]:+.0f} W, at 6 h "
|
||||||
|
f"{at(rows, 6 * H)[3]:+.0f} W (i_w {at(rows, 6 * H)[4]:+.0f}), longest "
|
||||||
|
f"hold {longest_hold_h(rows):.2f} h, gate trips {tr}")
|
||||||
|
check("PRECONDITION: the old law holds +5000 W for the whole night on this "
|
||||||
|
"fixture, as it did on 2026-09-06 - the fixture reproduces the card",
|
||||||
|
at(old906, 6 * H)[3] == 5000.0 and longest_hold_h(old906) > 11.5)
|
||||||
|
check("the request reaches the rail before the cut, so the new law starts from "
|
||||||
|
"the same place", at(new906, 120)[3] == 5000.0)
|
||||||
|
t0 = next((r[0] for r in new906 if r[5] == REFUSED), None)
|
||||||
|
check(f"after the cut the battery-refuses hold takes the request to 0 W within "
|
||||||
|
f"about a minute (at t={t0})", t0 is not None and 132 < t0 <= 132 + 90
|
||||||
|
and at(new906, t0)[3] == 0.0)
|
||||||
|
check("...and it stays refused overnight, with the integrator at 0, and no "
|
||||||
|
"request of 250 W or more stands for longer than one probe interval",
|
||||||
|
at(new906, 6 * H)[6] == 1 and at(new906, 6 * H)[4] == 0.0
|
||||||
|
and longest_hold_h(new906, until=11.9 * H) < PROBE_EVERY_S / H)
|
||||||
|
check(f"...and the stale-reading gate does not cycle: {ntrips906} trips in "
|
||||||
|
f"12 hours (the old law's latch also kept it quiet: {otrips906})",
|
||||||
|
ntrips906 == 0)
|
||||||
|
morning = [r for r in new906 if r[0] >= 11.9 * H + 60]
|
||||||
|
check("when solar makes the grid export, the refusal ends and the battery "
|
||||||
|
"charges - the exit on the other direction",
|
||||||
|
morning and morning[-1][6] == 0 and morning[-1][3] < -400)
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print("2. the probe: 200 W every 15 minutes, below the gate's 250 W")
|
||||||
|
# ==============================================================================
|
||||||
|
probes = []
|
||||||
|
for r in new906:
|
||||||
|
if r[3] == PROBE_W and (not probes or r[0] - probes[-1][1] > TICK):
|
||||||
|
probes.append([r[0], r[0]])
|
||||||
|
elif r[3] == PROBE_W:
|
||||||
|
probes[-1][1] = r[0]
|
||||||
|
starts = [p[0] for p in probes]
|
||||||
|
gaps = [b - a for a, b in zip(starts, starts[1:])]
|
||||||
|
print(f" {len(probes)} probes, first at {starts[0] / 60 if starts else 0:.1f} "
|
||||||
|
f"min, gaps {sorted(set(round(g) for g in gaps))} s, lengths "
|
||||||
|
f"{sorted(set(round(p[1] - p[0] + TICK) for p in probes))} s")
|
||||||
|
check("the probe asks exactly PROBE_W, which is below the stale-reading gate's "
|
||||||
|
"threshold, so a probe can never arm it",
|
||||||
|
PROBE_W < freshness.MOVE_W and all(r[3] in (0.0, PROBE_W)
|
||||||
|
for r in new906 if r[6] == 1))
|
||||||
|
check("there is a probe every 15 minutes through the night (at least 40 in "
|
||||||
|
"11.8 hours), each about 34 s long",
|
||||||
|
len(probes) >= 40 and all(PROBE_EVERY_S <= g <= PROBE_EVERY_S + PROBE_S
|
||||||
|
+ 2 * TICK for g in gaps)
|
||||||
|
and all(PROBE_S - TICK <= p[1] - p[0] + TICK <= PROBE_S + TICK
|
||||||
|
for p in probes))
|
||||||
|
check("...and a probe reads as a probe on the loop's own reason row",
|
||||||
|
any(r[5] == PROBING for r in new906))
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print("3. recovery with no sign change: the battery gives power again at 2 h")
|
||||||
|
# ==============================================================================
|
||||||
|
def cut_rec(t):
|
||||||
|
return 132 <= t < 2 * H
|
||||||
|
|
||||||
|
|
||||||
|
oldr, _, _ = run(load906, cut_rec, 2.6 * H, cap906, old=True)
|
||||||
|
newr, _, crec = run(load906, cut_rec, 2.6 * H, cap906)
|
||||||
|
after_o = [r for r in oldr if 2 * H <= r[0] < 2 * H + 600]
|
||||||
|
after_n = [r for r in newr if 2 * H <= r[0] < 2.6 * H]
|
||||||
|
exp_o = sum(max(0.0, -r[1]) * TICK for r in after_o) / 3600
|
||||||
|
exp_n = sum(max(0.0, -r[1]) * TICK for r in after_n) / 3600
|
||||||
|
print(f" old: worst grid {min(r[1] for r in after_o):+.0f} W, export "
|
||||||
|
f"{exp_o:.0f} Wh; new: worst grid {min(r[1] for r in after_n):+.0f} W, "
|
||||||
|
f"export {exp_n:.0f} Wh")
|
||||||
|
check("PRECONDITION: the old law answers a recovering battery with its "
|
||||||
|
"standing +5000 W and exports kilowatts into a 940 W house",
|
||||||
|
min(r[1] for r in after_o) < -3000)
|
||||||
|
check("the new law never exports more than the probe while it waits, and the "
|
||||||
|
"answered probe ends the refusal so control resumes",
|
||||||
|
min(r[1] for r in after_n) > -PROBE_W - 50
|
||||||
|
and after_n[-1][6] == 0 and abs(after_n[-1][1] - (-10)) < 100)
|
||||||
|
check("...and the event log says the probe was answered",
|
||||||
|
any("BATTERY REFUSES ENDED: the probe was answered" in e
|
||||||
|
for e in crec.events))
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print("4. the 09-19 shape: a stale-reading stop on a frozen ~0 W reading "
|
||||||
|
"is a refusal")
|
||||||
|
# ==============================================================================
|
||||||
|
# The request steps +1540 -> +1940 and more as the battery cuts out mid-ramp:
|
||||||
|
# the request moves after the reading went flat, so the gate trips before the
|
||||||
|
# hold's 12 cycles - the ~70 s cycle SAFETY-59 measured. The trip seeds the hold.
|
||||||
|
def load919(t):
|
||||||
|
return 1530 if t < 60 else 2400 if t < 1800 else 900 if t < 4 * H else -600
|
||||||
|
|
||||||
|
|
||||||
|
def cut919(t):
|
||||||
|
return t >= 65
|
||||||
|
|
||||||
|
|
||||||
|
def run919(old):
|
||||||
|
return run(load919, cut919, 4.2 * H, old=old)
|
||||||
|
|
||||||
|
|
||||||
|
old919, otr919, _ = run919(True)
|
||||||
|
new919, ntr919, c919 = run919(False)
|
||||||
|
print(f" old: gate trips {otr919}, longest hold {longest_hold_h(old919):.2f} h;"
|
||||||
|
f" new: gate trips {ntr919}, longest hold {longest_hold_h(new919):.2f} h")
|
||||||
|
check("PRECONDITION: on the old law the gate trips over and over all night "
|
||||||
|
"(the SAFETY-59 cycle) or the request stands for hours",
|
||||||
|
otr919 > 20 or longest_hold_h(old919) > 1.0)
|
||||||
|
check("the new law stops after at most two trips and holds 0 W, and the event "
|
||||||
|
"log says the battery refuses",
|
||||||
|
ntr919 <= 2 and at(new919, 2 * H)[3] in (0.0, PROBE_W)
|
||||||
|
and any("BATTERY REFUSES" in e for e in c919.events))
|
||||||
|
|
||||||
|
# The same shape through `compute()` alone, as the bus saw it on 2026-09-19
|
||||||
|
# before the gate existed: +1540 -> +1940, then a battery giving nothing.
|
||||||
|
T = control.Tuning(**LIVE)
|
||||||
|
res = {}
|
||||||
|
for name, carry in (("old", None), ("new", 0)):
|
||||||
|
# sat_count 2: the +1540 and +1940 cycles were already short (the plan)
|
||||||
|
prev, i_w, sat, ref, cmds = 1940.0, 2340.0, 2, carry, []
|
||||||
|
for _ in range(2000): # ~2.8 h at 5 s
|
||||||
|
d = control.compute(prev, 1950.0, CUT_W, T, sat, i_w, refused=ref)
|
||||||
|
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
|
||||||
|
cmds.append(prev)
|
||||||
|
res[name] = cmds
|
||||||
|
print(f" compute() alone: old stands at {res['old'][-1]:+.0f} W "
|
||||||
|
f"({sum(v == 1940.0 for v in res['old'])} of 2000 cycles at +1940 W), "
|
||||||
|
f"new at {res['new'][-1]:+.0f} W from cycle "
|
||||||
|
f"{res['new'].index(0.0) if 0.0 in res['new'] else None}")
|
||||||
|
check("PRECONDITION: without the gate the old law parks at +1940 W, the 7 065 "
|
||||||
|
"identical writes the bus recorded", set(res["old"]) == {1940.0})
|
||||||
|
check("...and the new law holds 0 W within SETTLE_CYCLES cycles",
|
||||||
|
res["new"].index(0.0) < control.SETTLE_CYCLES
|
||||||
|
and set(res["new"][control.SETTLE_CYCLES:]) == {0.0})
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print("5. the other exits: stop, start, another writer")
|
||||||
|
# ==============================================================================
|
||||||
|
def stop(c):
|
||||||
|
c.auto = False
|
||||||
|
|
||||||
|
|
||||||
|
def start(c):
|
||||||
|
c.auto = True
|
||||||
|
|
||||||
|
|
||||||
|
rows, _, c = run(load906, cut906, 1200, cap906, events={600.0: stop,
|
||||||
|
700.0: start})
|
||||||
|
check("stopping control ends the refusal, and starting again begins from "
|
||||||
|
"nothing refused", at(rows, 590)[6] == 1 and at(rows, 650)[6] == 0
|
||||||
|
and at(rows, 705)[6] == 0)
|
||||||
|
|
||||||
|
|
||||||
|
def other_writer(c):
|
||||||
|
c.loop_w = 1234.0 # the arbiter gave the wire something the loop did not ask
|
||||||
|
|
||||||
|
|
||||||
|
rows, _, c = run(load906, cut906, 900, cap906, events={600.0: other_writer})
|
||||||
|
check("another writer on the wire ends the refusal (the arbiter reseed)",
|
||||||
|
at(rows, 590)[6] == 1 and at(rows, 605)[6] == 0)
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print("5b. review of #141: a pack that gives SOME power, and a reading that is "
|
||||||
|
"only late, are not a refusal")
|
||||||
|
# ==============================================================================
|
||||||
|
# A pack derated to 100-499 W (cold, BMS taper) gives what it can against a
|
||||||
|
# larger ask. Engaging below `saturation_w` (500 W) held it at 0 W for ~93 % of
|
||||||
|
# the time; "gives nothing" is below the probe's own answer.
|
||||||
|
# `compute()` alone: through the Controller the stale-reading gate trips on the
|
||||||
|
# flat 300 W first, and that fixture then passes whatever this threshold is.
|
||||||
|
def derated(carry, cap_w=300.0, n=1800):
|
||||||
|
prev, i_w, sat, ref, gave, held = 0.0, 0.0, 0, carry, 0.0, 0
|
||||||
|
b = 0.0
|
||||||
|
for _ in range(n): # 2.5 h at 5 s
|
||||||
|
d = control.compute(prev, 2000.0 - b, b, T, sat, i_w, refused=ref)
|
||||||
|
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
|
||||||
|
held += bool(ref)
|
||||||
|
b = max(0.0, min(prev, cap_w)) # one tick late
|
||||||
|
gave += b
|
||||||
|
return gave / n, held
|
||||||
|
|
||||||
|
|
||||||
|
T = control.Tuning(**LIVE)
|
||||||
|
gave_old, _ = derated(None)
|
||||||
|
gave_new, held_new = derated(0)
|
||||||
|
print(f" pack derated to 300 W: old law {gave_old:.0f} W average, new "
|
||||||
|
f"{gave_new:.0f} W, held as refusing on {held_new} cycles")
|
||||||
|
check("a pack that gives a steady 300 W against a 2000 W ask is never held at "
|
||||||
|
"0 W as refusing, and keeps giving what the old law got from it",
|
||||||
|
held_new == 0 and gave_new == gave_old and gave_old > 290.0)
|
||||||
|
|
||||||
|
|
||||||
|
def lagging(tick, dead_s=6.0, publish_s=10.0, phase_s=5.0, dur=600.0):
|
||||||
|
"""`compute()` on a HEALTHY pack whose reading lags: it follows the command
|
||||||
|
of `dead_s` ago, published every `publish_s`. A 1 s meter (`meter_poll_s`
|
||||||
|
1) makes SETTLE_CYCLES only 12 s, shorter than that lag."""
|
||||||
|
prev, i_w, sat, ref, hist, reg, pub = 0.0, 0.0, 0, 0, [], 0.0, -phase_s
|
||||||
|
held = engaged = 0
|
||||||
|
t = 0.0
|
||||||
|
while t < dur:
|
||||||
|
then = [c for tc, c in hist if tc <= t - dead_s]
|
||||||
|
b = then[-1] if then else 0.0
|
||||||
|
if t - pub >= publish_s:
|
||||||
|
reg, pub = b, t
|
||||||
|
d = control.compute(prev, (3000.0 if t >= 10 else 0.0) - b, reg, T,
|
||||||
|
sat, i_w, refused=ref)
|
||||||
|
engaged += bool(d.refused) and not ref
|
||||||
|
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
|
||||||
|
hist.append((t, prev))
|
||||||
|
held += tick * bool(ref)
|
||||||
|
t += tick
|
||||||
|
return engaged, held
|
||||||
|
|
||||||
|
|
||||||
|
lag1 = lagging(1.0)
|
||||||
|
print(f" 1 s meter, healthy pack with a late reading: engaged {lag1[0]} "
|
||||||
|
f"time(s), held {lag1[1]:.0f} s")
|
||||||
|
check("a healthy pack whose reading is merely late is let go as soon as the "
|
||||||
|
"reading shows it delivering - not held until the next probe",
|
||||||
|
lag1[1] <= 10.0)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed(held_w):
|
||||||
|
"""`_refusal_from_stale()` on a unit standing at +2000 W, frozen."""
|
||||||
|
c = Controller(dict(
|
||||||
|
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||||
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||||
|
"meter_source": "off"}, **LIVE),
|
||||||
|
_Hass(lambda t: 0.0, lambda t: False), _Store(), _Pub(),
|
||||||
|
stop=asyncio.Event())
|
||||||
|
c.auto, c.sat_count = True, c.tuning.saturation_cycles
|
||||||
|
inv = c.inverters[0]
|
||||||
|
inv.last_write, inv.batt_fresh.held = 2000.0, held_w
|
||||||
|
c._refusal_from_stale(inv, 100.0)
|
||||||
|
return c.batt_refused
|
||||||
|
|
||||||
|
|
||||||
|
check("a stale-reading stop on a register standing at 300 W is not a refusal "
|
||||||
|
"(it gives something), and one at -4 W is",
|
||||||
|
asyncio.run(_seed(300.0)) == 0 and asyncio.run(_seed(CUT_W)) == 1)
|
||||||
|
d = control.compute(0.0, 1500.0, 1400.0, T, 0, 0.0, refused=1, probe=False)
|
||||||
|
check("...i.e. a held direction that reads 100 W or more ends the refusal "
|
||||||
|
"without waiting for a probe", d.refused == 0)
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print("6. words")
|
||||||
|
# ==============================================================================
|
||||||
|
w = messages.why(why_key("loop", [Claim.set("loop", 0, 0.0, REFUSED)]), 0.0)
|
||||||
|
wp = messages.why(why_key("loop", [Claim.set("loop", 0, PROBE_W, PROBING)]),
|
||||||
|
PROBE_W)
|
||||||
|
print(f" {w!r} / {wp!r}")
|
||||||
|
check("the page's Why line says the battery refuses, not 'Keeping the grid "
|
||||||
|
"near zero: 0 W'", w == "The battery refuses to give power: 0 W")
|
||||||
|
check("...and a probe says what it is", wp.startswith("Testing if the battery"))
|
||||||
|
check("...and an ordinary loop cycle still reads as the loop",
|
||||||
|
why_key("loop", [Claim.set("loop", 0, 10.0, "tracking")]) == "loop")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||||
|
sys.exit(1)
|
||||||
|
print("all checks passed")
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""SAFETY-52's evidence: the 16-day capture replayed with and without the
|
||||||
|
battery-refuses hold. `python3 tools/replay_refusal.py`
|
||||||
|
|
||||||
|
Same disturbance, fitted plant and pack floor as `replay_recorded_loop.py`,
|
||||||
|
plus main.py's reseed, 15 s input hold and 0 W rules and the REAL SAFETY-37/57
|
||||||
|
gate on a register published every 10 s. `old` is `compute()` with
|
||||||
|
`refused=None`, which is release/1.0's law unchanged; `new` carries the refusal,
|
||||||
|
runs the probe on main.py's clock and seeds a refusal from a gate trip.
|
||||||
|
Modelled watt-hours: read the deltas, not the absolutes (see that tool).
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ADDON = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ADDON))
|
||||||
|
sys.path.insert(0, str(ADDON / "tools"))
|
||||||
|
import replay_recorded_loop as R # noqa: E402
|
||||||
|
from app import freshness, control as NEW # noqa: E402
|
||||||
|
|
||||||
|
def go(which, rows, fit, publish_s=10.0, stale_input_s=15.0):
|
||||||
|
law = NEW
|
||||||
|
tun = law.Tuning(**R.LIVE)
|
||||||
|
t0, g0, b0, sp0, soc0 = rows[0]
|
||||||
|
pl = R.Plant(*fit[:4], soc0); pl.seed(t0, b0, sp0)
|
||||||
|
st = dict(target=sp0, i_w=sp0, sat=0, loop_w=sp0, ref=0, anchor=None)
|
||||||
|
fr = freshness.Freshness(freshness.BOUNDS["batt"]); reg, reg_t = None, -1e18
|
||||||
|
bad, trips, was = None, 0, False
|
||||||
|
M = dict(und=0.0, imp=0.0, exp=0.0, q={}, run=0.0, mx=0.0, last=None, refh=0.0, probes=0)
|
||||||
|
for i in range(1, len(rows)):
|
||||||
|
t, gr, ba, sp, soc = rows[i]
|
||||||
|
dt = t - rows[i - 1][0]
|
||||||
|
if dt > R.TICK_CAP_S:
|
||||||
|
pl = R.Plant(*fit[:4], soc); pl.seed(t, ba, sp)
|
||||||
|
st.update(target=sp, i_w=sp, sat=0, loop_w=sp, ref=0, anchor=None)
|
||||||
|
fr = freshness.Freshness(freshness.BOUNDS["batt"]); reg_t = -1e18; bad = None
|
||||||
|
continue
|
||||||
|
b = pl.step(t, dt, st["target"]); grid = gr + ba - b
|
||||||
|
if t - reg_t >= publish_s:
|
||||||
|
reg, reg_t = float(round(b)), t
|
||||||
|
fr.submit(reg, st["target"], t); batt = fr.reading()
|
||||||
|
if fr.stale and not was:
|
||||||
|
trips += 1
|
||||||
|
if (which == "new" and not st["ref"] and st["sat"] >= tun.saturation_cycles
|
||||||
|
and abs(reg) < NEW.REFUSED_BELOW_W and abs(st["target"]) > tun.saturation_w):
|
||||||
|
st["ref"], st["anchor"] = (1 if st["target"] > 0 else -1), t
|
||||||
|
was = fr.stale
|
||||||
|
if batt is None:
|
||||||
|
bad = t if bad is None else bad
|
||||||
|
if t - bad >= stale_input_s:
|
||||||
|
st.update(target=0.0, i_w=0.0, loop_w=None)
|
||||||
|
else:
|
||||||
|
bad = None
|
||||||
|
if st["loop_w"] is None or st["target"] != st["loop_w"]:
|
||||||
|
st["i_w"] = st["target"]
|
||||||
|
if which == "old":
|
||||||
|
d = law.compute(st["target"], grid, batt, tun, st["sat"], st["i_w"],
|
||||||
|
refused=None)
|
||||||
|
else:
|
||||||
|
probe = False
|
||||||
|
if st["ref"] and st["anchor"] is not None:
|
||||||
|
h = t - st["anchor"]
|
||||||
|
if h >= NEW.PROBE_EVERY_S + NEW.PROBE_S: st["anchor"] = t
|
||||||
|
else: probe = h >= NEW.PROBE_EVERY_S
|
||||||
|
d = law.compute(st["target"], grid, batt, tun, st["sat"], st["i_w"], refused=st["ref"], probe=probe)
|
||||||
|
if d.refused and not st["ref"]: st["anchor"] = t
|
||||||
|
if not d.refused: st["anchor"] = None
|
||||||
|
if probe and d.target_w == NEW.PROBE_W and M["last"] != NEW.PROBE_W: M["probes"] += 1
|
||||||
|
st["ref"] = d.refused
|
||||||
|
st.update(sat=d.sat_count, i_w=d.i_w, loop_w=d.target_w, target=d.target_w)
|
||||||
|
tg = st["target"]
|
||||||
|
if tg - b > 1000: M["und"] += dt / 3600
|
||||||
|
if st["ref"]: M["refh"] += dt / 3600
|
||||||
|
M["imp"] += max(0, grid) * dt / 3.6e6; M["exp"] += max(0, -grid) * dt / 3.6e6
|
||||||
|
k = math.floor(t / 900); a = M["q"].setdefault(k, [0.0, 0.0]); a[0] += grid * dt; a[1] += dt
|
||||||
|
M["run"] = M["run"] + dt if (M["last"] == tg and abs(tg) >= 250) else 0.0
|
||||||
|
M["mx"] = max(M["mx"], M["run"]); M["last"] = tg
|
||||||
|
worst = max(max(0.0, s / d) for s, d in M["q"].values() if d > 600)
|
||||||
|
return trips, M, worst
|
||||||
|
|
||||||
|
def main():
|
||||||
|
rows = R.stream()
|
||||||
|
fit = R.fit_plant(rows, stride=7)
|
||||||
|
print(f"fit {[round(x, 2) for x in fit]}, {len(rows)} meter instants")
|
||||||
|
for label, f in (("fitted plant", fit),
|
||||||
|
("plant offset 0 W", (fit[0], fit[1], fit[2], 0.0, fit[4]))):
|
||||||
|
for which in ("old", "new"):
|
||||||
|
tr, M, w = go(which, rows, f)
|
||||||
|
print(f"{label:17s} {which}: gate trips {tr:4d} | request >1 kW above "
|
||||||
|
f"delivery {M['und']:6.1f} h | longest held >=250 W "
|
||||||
|
f"{M['mx'] / 3600:5.2f} h | import {M['imp']:6.1f} kWh export "
|
||||||
|
f"{M['exp']:5.1f} kWh | worst 15-min {w:5.0f} W | refused "
|
||||||
|
f"{M['refh']:5.1f} h, probes {M['probes']}", flush=True)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user