Merge release/1.0 (SAFETY-07) into FCST-03, and close two degenerate stimuli
checks / unit (pull_request) Successful in 3m13s
checks / unit (pull_request) Successful in 3m13s
CHANGELOG.md was the entire collision - SAFETY-07 lands in peakguard.py, main.py and the arbiter, none of which this branch touches. Both entries kept. X1. `_firm_or_die` guarding only `cheapest` survived, because the check passed the SAME provisional point as both cheapest and dearest: a guard on either role alone raises, so the stimulus could not tell "the buy hour is guarded" from "both are". Now one call per role with a firm point in the other seat, plus the positive arm so the two refusals are discriminating. X6. `firm_points()` returning the carried hours too survived, because its two consumers - the §11.4 refusal and cheap_now - guard provenance independently and both keep answering correctly. The accessor is now graded directly: exactly the published points, by identity and in order. Both are the degeneracy rule this file states at the top, in a new dress - a stimulus that cannot distinguish the two things it grades. ⚠️ AND THE REASON THE M9 MUTATION "SURVIVED" IN MY EARLIER REPORT: a stale __pycache__. Reverting `self.provisional` to `here.provisional` changes neither the file size nor, at one-second resolution, the mtime, so CPython kept the MUTATED bytecode and a green run afterwards proved nothing about that line. Every mutation here now clears __pycache__ first; re-run that way, M9 is caught. The committed source was always correct - the checks were not being run against it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
@@ -65,6 +65,73 @@ default install — §12's opening sentence.
|
|||||||
**Zero new options.** No `config.yaml` key, no new defaults dict, no `o.get(k,
|
**Zero new options.** No `config.yaml` key, no new defaults dict, no `o.get(k,
|
||||||
X)` in main.py: the module reads no options at all, so CFG-03 did not widen.
|
X)` in main.py: the module reads no options at all, so CFG-03 did not widen.
|
||||||
|
|
||||||
|
**The grid-following loop is now capped against the month's billed capacity peak
|
||||||
|
(SAFETY-07).** New `app/peakguard.py`. TARIFF-01 measured the quarter; this is the
|
||||||
|
consumer it was built for. Every cycle the guard projects the open quarter,
|
||||||
|
compares it to a ceiling and files ONE arbiter claim: a lower bound on the
|
||||||
|
setpoint at `P_PEAK`. That shape is both behaviours §10.1 asks for — a hard clamp
|
||||||
|
on charging and pre-emptive discharge — and it cannot cap discharge or export,
|
||||||
|
because a floor cannot. `excess = 900 × (projected − ceiling) ÷ remaining`, and
|
||||||
|
the floor is the MEASURED battery power plus that: a floor built on the standing
|
||||||
|
command winds itself to the rail whenever the inverter fails to follow, and one
|
||||||
|
built on delivered power cannot.
|
||||||
|
|
||||||
|
**It trips where SAFETY-18 only notifies, and the captures decided that.** On the
|
||||||
|
healthy reference day it engages on zero of 95 quarters; the action is
|
||||||
|
level-triggered, unlatched and re-derived every cycle, so a false engagement costs
|
||||||
|
one cycle of discharge rather than a parked battery. Replayed on 2026-08-20 the
|
||||||
|
billed quarter goes from **5029.8 W — above the inverter's own 5000 W rating — to
|
||||||
|
3485 W**, under the soft limit and without saturating. Quarters under the limit
|
||||||
|
across both committed days: 189/190 → 190/190. Two days is not thirty; AC 3 is
|
||||||
|
still TARIFF-01's open item and this does not claim it.
|
||||||
|
|
||||||
|
**Blindness forbids importing, and it is checked before the projection is
|
||||||
|
graded.** A fresh averager projects a confident 0 W, which is under every ceiling
|
||||||
|
there is; a meter gap longer than `meter_max_age_s` makes the projection read LOW;
|
||||||
|
and a frozen meter reading export projects 0 W at full coverage. All three become
|
||||||
|
a floor of 0 W, which is one-directional and cannot cost pack energy. That is the
|
||||||
|
backstop SAFETY-18 could not be — its alarm arrives at 300 s of a 900 s quarter
|
||||||
|
and commands nothing. The frozen term is `freeze.looks_frozen()` itself, evaluated
|
||||||
|
on this cycle's standing command, not a second copy of the conjunction.
|
||||||
|
|
||||||
|
**The ceiling is `peak_cap_w`, raised only by an OBSERVED reading.** `NO_RECORD`
|
||||||
|
and `NOT_OBSERVED` both leave it at the cap, so an unreadable peak store makes the
|
||||||
|
guard STRICTER. The gate is the reading's STATE, not whether a number happens to
|
||||||
|
be attached to it: today a `peak_w is not None` test behaves identically, and
|
||||||
|
depending on that coincidence is how a future last-known-peak field would silently
|
||||||
|
raise the ceiling off a read that failed. `headroom_w()` is deliberately not
|
||||||
|
consumed — `cap − peak` turns a zero peak into FULL freedom.
|
||||||
|
|
||||||
|
**Two defects found building it, both in the interaction rather than the
|
||||||
|
arithmetic.** A LIMIT does not out-vote another LIMIT — `arbiter.resolve()` applies
|
||||||
|
every limit at or above the winner's priority and takes the most restrictive — so
|
||||||
|
the peak floor (`lo ≥ 0`) and the maintenance charge-only shaping (`hi ≤ 0`)
|
||||||
|
resolved to a CONTRADICTION and 0 W, parking the battery in the exact quarter it
|
||||||
|
should shave. Fixed by suppression rather than priority: main.py drops the
|
||||||
|
maintenance shaping while the guard is engaged, which is what that branch already
|
||||||
|
did for the optional `peak_forecast_entity` — a key that is empty on a default
|
||||||
|
install, so until now the suspension could not fire on the site that needed it.
|
||||||
|
And thresholding `excess > 0` released the claim the instant it started working,
|
||||||
|
producing seventeen engage/release cycles in one 900 s quarter (§10.2's
|
||||||
|
oscillation) and leaving that quarter 49 W over; the threshold is now the floor
|
||||||
|
itself, which is the same arithmetic read as a level rather than as a switch.
|
||||||
|
|
||||||
|
**`PeakTracker.project()` no longer reads a private name with a default.**
|
||||||
|
`getattr(averager, "_last_net", 0.0)` failed quiet AND in the disarming direction:
|
||||||
|
a rename credits 0 W held, the projection reads low, and the money guard does not
|
||||||
|
engage. `QuarterAverager.held_net_w` is now a documented public property, read
|
||||||
|
without a default, and `Projection` carries it. TEST-13 item 4.
|
||||||
|
|
||||||
|
**No new option, and no fourth defaults site.** The ceiling is `peak_cap_w`, the
|
||||||
|
coverage threshold is `meter_max_age_s`, the freeze predicate is `meter_freeze_s`,
|
||||||
|
and the achievable discharge comes from `max_w` and the device rating.
|
||||||
|
`config.yaml` and `validate.py` are untouched; §14's `capacity_peak_limit_kw`
|
||||||
|
stays declined as the kW/W mismatch trap it is. On `meter_source: off` there are
|
||||||
|
no telegrams, so the guard does not run at all and the status page says NOT
|
||||||
|
COVERED — running it there would have blinded it permanently and stopped every
|
||||||
|
legacy install from charging, which is what the first cut did until
|
||||||
|
`test_clamp.py`'s export case caught it.
|
||||||
|
|
||||||
**Static day/night and fully-fixed tariffs, first-class (TARIFF-03).**
|
**Static day/night and fully-fixed tariffs, first-class (TARIFF-03).**
|
||||||
§9.2 puts a large share of the Belgian installed base on a static profile, and
|
§9.2 puts a large share of the Belgian installed base on a static profile, and
|
||||||
an EMS that only works under dynamic pricing is an EMS for a minority. A new
|
an EMS that only works under dynamic pricing is an EMS for a minority. A new
|
||||||
|
|||||||
@@ -439,6 +439,11 @@ maintenance schedule.
|
|||||||
|
|
||||||
#### Capacity-tariff exposure of the grid-following loop
|
#### Capacity-tariff exposure of the grid-following loop
|
||||||
|
|
||||||
|
⚠️ **This section described the exposure BEFORE SAFETY-07. The loop is now
|
||||||
|
capped — see "The capacity-peak guard" below — and what follows is kept because it
|
||||||
|
is still the clearest statement of WHAT is being guarded and why the number is
|
||||||
|
worth what it is.**
|
||||||
|
|
||||||
⚠️ **`peak_cap_w` caps MAINTENANCE charging. It does not cap the loop.** The
|
⚠️ **`peak_cap_w` caps MAINTENANCE charging. It does not cap the loop.** The
|
||||||
grid-following loop is bounded by `max_w` and by the device rating, and by
|
grid-following loop is bounded by `max_w` and by the device rating, and by
|
||||||
nothing else. That is deliberate — the loop exists to *reduce* what crosses the
|
nothing else. That is deliberate — the loop exists to *reduce* what crosses the
|
||||||
@@ -463,16 +468,72 @@ What exists today, and what does not:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude |
|
| `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude |
|
||||||
| Maintenance charging capped under `peak_cap_w` | ✅ |
|
| Maintenance charging capped under `peak_cap_w` | ✅ |
|
||||||
| **Loop capped under `peak_cap_w`** | ❌ — not built. This is SAFETY-07 |
|
| **Loop capped under `peak_cap_w`** | ✅ — SAFETY-07, on every `meter_source` but `off` |
|
||||||
| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** |
|
| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** |
|
||||||
| Frozen meter *stopped* | ❌ — the alarm does not command anything |
|
| Frozen meter *stopped* | ✅ — SAFETY-07 forbids importing while the meter is frozen |
|
||||||
| SoC guard on the loop | ❌ — the BMS is the only energy backstop |
|
| SoC guard on the loop | ❌ — the BMS is the only energy backstop |
|
||||||
|
|
||||||
So the frozen-meter alarm below **narrows the window; it does not close it.** On
|
On the legacy `meter_source: off` path there are no P1 telegrams, so there is no
|
||||||
a capacity-tariff site where an unattended runaway is unacceptable, the mitigation
|
quarter-hour average to guard, and the status page says **NOT COVERED**. There
|
||||||
today is operational: set `max_w` no higher than the loop actually needs (the
|
the mitigation is still operational: set `max_w` no higher than the loop actually
|
||||||
tuning was measured at 2000 W and a 1.78 kW load step is tracked fine there), fit
|
needs (the tuning was measured at 2000 W and a 1.78 kW load step is tracked fine
|
||||||
the e-stop, and watch for the alarm.
|
there), fit the e-stop, and watch for the alarm.
|
||||||
|
|
||||||
|
### The capacity-peak guard
|
||||||
|
|
||||||
|
**What it does.** Every cycle the add-on projects the open quarter to its
|
||||||
|
end-of-interval average — from P1 telegrams only, never from the command — and
|
||||||
|
compares it to a ceiling. Over the ceiling it files one claim: a **lower bound on
|
||||||
|
the setpoint**, at `P_PEAK`. That single shape is both behaviours §10.1 asks for:
|
||||||
|
a hard clamp on charging while the bound sits above the standing charge, and
|
||||||
|
pre-emptive discharge while it sits above zero. It can never cap discharge or
|
||||||
|
export, because a floor cannot.
|
||||||
|
|
||||||
|
**The number.** `excess = 900 × (projected − ceiling) ÷ seconds remaining`, and
|
||||||
|
the floor is the **measured** battery power plus that. The 900/remaining factor
|
||||||
|
is the design rather than an accident: it is 1.0 at the top of the quarter and
|
||||||
|
grows, so the guard is gentlest when its projection rests on the least evidence
|
||||||
|
and becomes aggressive only once the quarter is nearly measured.
|
||||||
|
|
||||||
|
**The ceiling** is `peak_cap_w`, raised to the month's banked peak when that
|
||||||
|
already stands higher — once a month's bill is set at 5013 W, holding later
|
||||||
|
quarters to 3500 W buys nothing and costs real charging. **That relaxation is
|
||||||
|
granted only by an OBSERVED reading:** a peak store that cannot be read leaves the
|
||||||
|
ceiling at the cap, so a broken instrument makes the guard stricter, never looser.
|
||||||
|
|
||||||
|
**When it cannot see, it forbids importing.** Three states are not evidence and
|
||||||
|
are checked before the projection is graded: no open quarter (a fresh averager
|
||||||
|
projects a confident 0 W, which is under every ceiling there is), a meter gap
|
||||||
|
longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all
|
||||||
|
three the claim becomes a floor of **0 W**. That is the backstop for the frozen
|
||||||
|
meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter
|
||||||
|
and commands nothing, and this stops the import.
|
||||||
|
|
||||||
|
**It trips, where the freeze detector only notifies,** and the captures are why.
|
||||||
|
On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters.
|
||||||
|
The action is level-triggered and unlatched — re-derived from a fresh projection
|
||||||
|
every cycle, released the instant the projection drops back — so a false
|
||||||
|
engagement costs one cycle of discharge rather than a parked battery. And it is a
|
||||||
|
LIMIT, so every stop in the watchdog table still outranks it.
|
||||||
|
|
||||||
|
**Replayed on 2026-08-20**, the day the vendor controller failed: the billed
|
||||||
|
quarter measured **5029.8 W**, above the inverter's own 5000 W rating, and the
|
||||||
|
guard brings it to **3485 W** — under the soft limit, without ever asking for more
|
||||||
|
than the inverter can give. Across both committed days the quarters under the
|
||||||
|
limit go from 189/190 to 190/190. Two days is not thirty and does not close AC 3;
|
||||||
|
it is the comparison two days can support.
|
||||||
|
|
||||||
|
**What it alarms on is saturation, not engagement.** An engagement is an ordinary
|
||||||
|
control action and goes to the action log and the status page. A *saturated*
|
||||||
|
guard — asking for everything the inverter has while the quarter is still
|
||||||
|
projected over — raises one notification (`goodwe_peak_saturated`), because that
|
||||||
|
is the one state where the money will be spent and shedding load is the only
|
||||||
|
thing left that changes the bill.
|
||||||
|
|
||||||
|
**No new option.** The ceiling is `peak_cap_w`, which already shipped. The
|
||||||
|
coverage threshold is `meter_max_age_s`, the freeze predicate is
|
||||||
|
`meter_freeze_s`, and the achievable discharge comes from `max_w` and the device
|
||||||
|
rating — every one of them a key that already exists.
|
||||||
|
|
||||||
### The winding-loop alarm (the output clamp)
|
### The winding-loop alarm (the output clamp)
|
||||||
|
|
||||||
|
|||||||
@@ -33,17 +33,20 @@ from datetime import datetime, timezone
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from .arbiter import (
|
from .arbiter import (
|
||||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_SAFETY, Claim, resolve,
|
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
|
||||||
)
|
)
|
||||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||||
from .clamp import (
|
from .clamp import (
|
||||||
REASON_CODE as CLAMP_REASON, ClampResult, ClampWatchdog, clamp_to_rating,
|
REASON_CODE as CLAMP_REASON, ClampResult, ClampWatchdog, clamp_to_rating,
|
||||||
is_commandable,
|
is_commandable,
|
||||||
)
|
)
|
||||||
from .freeze import FreezeWatchdog, FreezeWatchdogConfig, rail_w
|
from .freeze import (
|
||||||
|
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_w,
|
||||||
|
)
|
||||||
from .hass import HomeAssistant
|
from .hass import HomeAssistant
|
||||||
from .maintenance import IDLE, MaintConfig, Maintenance
|
from .maintenance import IDLE, MaintConfig, Maintenance
|
||||||
from .mqtt import MqttPublisher
|
from .mqtt import MqttPublisher
|
||||||
|
from . import peakguard
|
||||||
from .p1 import P1Ingest, build_source, is_enabled
|
from .p1 import P1Ingest, build_source, is_enabled
|
||||||
from .static_tariff import static_status
|
from .static_tariff import static_status
|
||||||
from .tariff import PeakStore, PeakTracker
|
from .tariff import PeakStore, PeakTracker
|
||||||
@@ -189,6 +192,22 @@ class Controller:
|
|||||||
cap_w=float(opts.get("peak_cap_w", 3500)))
|
cap_w=float(opts.get("peak_cap_w", 3500)))
|
||||||
self.peak.restore(self.p1.averager)
|
self.peak.restore(self.p1.averager)
|
||||||
_LOG.info("capacity-tariff peak store: %s", self.peak.restore_state)
|
_LOG.info("capacity-tariff peak store: %s", self.peak.restore_state)
|
||||||
|
|
||||||
|
# SAFETY-07. The guard TARIFF-01 was built for. It is stateless by
|
||||||
|
# design (peakguard.py holds no history between calls), so the only
|
||||||
|
# state here is what an EDGE needs: what the last cycle concluded, so
|
||||||
|
# the action log and the notification fire on transitions rather than
|
||||||
|
# once per cycle for a quarter of an hour.
|
||||||
|
#
|
||||||
|
# ⚠️ NO CONFIG OF ITS OWN, for the reason the clamp watchdog gives ~90
|
||||||
|
# lines up and TARIFF-01 repeats: the ceiling is `peak_cap_w`, already
|
||||||
|
# shipped and already validated, and every other term is read off
|
||||||
|
# something the arbiter or the averager already owns. CFG-03 still
|
||||||
|
# tracks three places, not four.
|
||||||
|
self.peak_guard = None
|
||||||
|
self._peak_engaged = False
|
||||||
|
self._peak_saturated = False
|
||||||
|
self._maint_shape_suspended = False
|
||||||
# Set by amain() once the transport is built, so the status page can show
|
# Set by amain() once the transport is built, so the status page can show
|
||||||
# what only the transport knows (homewizard_local's unchanged_s). Stays
|
# what only the transport knows (homewizard_local's unchanged_s). Stays
|
||||||
# None when P1 is off, or under a transport that has no such counter.
|
# None when P1 is off, or under a transport that has no such counter.
|
||||||
@@ -424,6 +443,77 @@ class Controller:
|
|||||||
self.log_event("inputs missing - commanding 0 W")
|
self.log_event("inputs missing - commanding 0 W")
|
||||||
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing"))
|
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing"))
|
||||||
|
|
||||||
|
# --- SAFETY-07: the quarter-peak projection guard --------------------
|
||||||
|
# ONE `lo` limit at P_PEAK, and peakguard.py carries the whole argument.
|
||||||
|
# What is decided HERE and nowhere else is what the guard is allowed to
|
||||||
|
# see and what it is allowed to ask for:
|
||||||
|
#
|
||||||
|
# ⚠️ `frozen` IS EVALUATED ON THIS CYCLE'S STANDING COMMAND, NOT ON
|
||||||
|
# `self.freeze_wd.active`. The watchdog object is advanced at the END of
|
||||||
|
# cycle() (check_meter_frozen), so reading its level here would grade
|
||||||
|
# this quarter against the PREVIOUS cycle's conjunction - one cycle of
|
||||||
|
# lag, in a guard whose entire job is to act inside a 900 s quarter.
|
||||||
|
# `standing` is the value SAFETY-02 already computed above for exactly
|
||||||
|
# this meaning: the command on the wire that this meter reading is a
|
||||||
|
# response to. The predicate itself is freeze.py's shipped
|
||||||
|
# `looks_frozen()`, not a copy of it - see loop_is_following() on what a
|
||||||
|
# transcribed safety predicate costs.
|
||||||
|
#
|
||||||
|
# ⚠️ `achievable_w` BOUNDS THE FLOOR SO THIS CLAIM CANNOT CONTRADICT THE
|
||||||
|
# SAFETY LIMITS ABOVE. `lo > hi` in the arbiter is a CONTRADICTION and
|
||||||
|
# resolves to 0 W - the battery parked, in the one minute of the month it
|
||||||
|
# should be discharging hardest. maintenance_charge_w had to learn the
|
||||||
|
# same lesson (config.yaml, validate.py rule 6); this claim is built so
|
||||||
|
# the config cannot express it.
|
||||||
|
#
|
||||||
|
# ⚠️ AND IT IS FILED EVERY CYCLE, unconditionally, like SAFETY-02's. A
|
||||||
|
# claim is not sticky. Filed once and skipped next cycle, the floor
|
||||||
|
# lapses and the actuator goes silently back to whatever was pushing the
|
||||||
|
# peak. Which of the higher-priority claims outranks it is arbiter.py's
|
||||||
|
# business, not this block's - and the answer is already right: a
|
||||||
|
# maintenance DRAIN or HOLD owns the setpoint at P_MAINTENANCE and
|
||||||
|
# commands discharge or zero, neither of which can create offtake, while
|
||||||
|
# a maintenance CHARGE is only ever a P_MAINT_SHAPE limit (40) and is
|
||||||
|
# therefore bound by this floor. That is §9.1's "money outranks the
|
||||||
|
# maintenance schedule", now as a consequence of the priorities.
|
||||||
|
#
|
||||||
|
# ⚠️ AND IT IS NOT RUN AT ALL WITHOUT P1. `meter_source: off` is the
|
||||||
|
# legacy single-entity path: there is no telegram stream, so
|
||||||
|
# `p1.averager` has no open block, so the guard would be BLIND on every
|
||||||
|
# cycle forever - and blind means "no charging". That is not a
|
||||||
|
# conservative default, it is a controller that silently stops charging
|
||||||
|
# on every install that has not opted in to P1 yet; test_clamp.py's
|
||||||
|
# export case caught it doing exactly that. The billed quantity simply
|
||||||
|
# is not measured on that path, and the honest report is NOT COVERED -
|
||||||
|
# the same answer, for the same reason, that the status page already
|
||||||
|
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`.
|
||||||
|
dev_top = self.dev_max if self.dev_max is not None else max_w
|
||||||
|
self.peak_guard = None if not self.p1_enabled else peakguard.decide(
|
||||||
|
self.peak.project(self.p1.averager, now),
|
||||||
|
batt_w=self.batt,
|
||||||
|
cap_w=float(self.o.get("peak_cap_w", 3500)),
|
||||||
|
reading=self.peak.peak(now),
|
||||||
|
achievable_w=min(max_w, abs(float(dev_top))),
|
||||||
|
max_hold_s=self.p1.averager.max_hold_s,
|
||||||
|
frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None),
|
||||||
|
standing, max_w, self.freeze_wd.cfg),
|
||||||
|
)
|
||||||
|
peak_claim = (peakguard.claim(self.peak_guard)
|
||||||
|
if self.peak_guard is not None else None)
|
||||||
|
if peak_claim is not None:
|
||||||
|
claims.append(peak_claim)
|
||||||
|
# ⚠️ EDGE-LOGGED, not level-logged. The action log holds 40 lines; a
|
||||||
|
# guard that appended one every 15 s for a whole quarter would push the
|
||||||
|
# cause of the event off the end of the log that is meant to explain it.
|
||||||
|
# SAFETY-03 logs every engagement because a clamp engagement IS the
|
||||||
|
# count that matters there; here the count is the seconds, and the
|
||||||
|
# engagement's own reason line already carries them.
|
||||||
|
engaged_now = self.peak_guard is not None and self.peak_guard.engaged
|
||||||
|
if engaged_now != self._peak_engaged:
|
||||||
|
self._peak_engaged = engaged_now
|
||||||
|
if self.peak_guard is not None:
|
||||||
|
self.log_event(self.peak_guard.explain())
|
||||||
|
|
||||||
# --- maintenance -----------------------------------------------------
|
# --- maintenance -----------------------------------------------------
|
||||||
if result.owns_setpoint:
|
if result.owns_setpoint:
|
||||||
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
||||||
@@ -435,9 +525,44 @@ class Controller:
|
|||||||
# claimed, so the loop can discharge and shave the peak. When a
|
# claimed, so the loop can discharge and shave the peak. When a
|
||||||
# dedicated peak-shaving strategy arrives it will claim SET at
|
# dedicated peak-shaving strategy arrives it will claim SET at
|
||||||
# P_PEAK and outrank this limit without any code here changing.
|
# P_PEAK and outrank this limit without any code here changing.
|
||||||
if peak_at_risk(self.peak_fc, float(self.o.get("peak_cap_w", 3500))):
|
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
|
||||||
self.log_event("peak at risk - maintenance charge shaping suspended")
|
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's claim
|
||||||
|
# is `lo >= 0`. Both are limits, arbiter.py applies EVERY limit at or
|
||||||
|
# above the winner's priority, and most restrictive wins - so a peak
|
||||||
|
# event during a maintenance charge window files `lo > hi`, which is
|
||||||
|
# a CONTRADICTION and resolves to 0 W. The battery would park in the
|
||||||
|
# exact quarter it should be shaving. Priority does not save it:
|
||||||
|
# clause 2 has no notion of a higher-priority limit overriding a
|
||||||
|
# lower one, and it should not - "most restrictive" is what makes the
|
||||||
|
# safety bounds unconditional.
|
||||||
|
#
|
||||||
|
# So the higher-priority claimant SUPPRESSES the lower one rather
|
||||||
|
# than out-voting it, which is what this branch already did for
|
||||||
|
# `peak_forecast_entity`. That entity is optional and empty on a
|
||||||
|
# default install, so until now the suspension could not fire on the
|
||||||
|
# site that needed it. `self.peak_guard` is measured from the meter
|
||||||
|
# and is always there.
|
||||||
|
#
|
||||||
|
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is not
|
||||||
|
# tidiness. It was inline, and a reviewer mutated it back to its
|
||||||
|
# historical broken form - re-gated on `peak_forecast_entity` alone -
|
||||||
|
# and every suite in this repository stayed green while a default
|
||||||
|
# install parked at 0 W in the quarter that sets the month's bill.
|
||||||
|
# Read shaping_suspended() before touching this: both terms are
|
||||||
|
# there, and why neither is sufficient alone.
|
||||||
|
if peakguard.shaping_suspended(
|
||||||
|
self.peak_guard, self.peak_fc,
|
||||||
|
float(self.o.get("peak_cap_w", 3500))):
|
||||||
|
# Edge-logged: level-logging here would write one line per cycle
|
||||||
|
# for as long as the event lasts and flush the 40-line action log
|
||||||
|
# of the very context that explains it.
|
||||||
|
if not self._maint_shape_suspended:
|
||||||
|
self._maint_shape_suspended = True
|
||||||
|
self.log_event("peak at risk - maintenance charge shaping "
|
||||||
|
"suspended so the peak claim is not "
|
||||||
|
"contradicted")
|
||||||
else:
|
else:
|
||||||
|
self._maint_shape_suspended = False
|
||||||
hi = 0.0
|
hi = 0.0
|
||||||
reason = "charge-only"
|
reason = "charge-only"
|
||||||
if self.cheap:
|
if self.cheap:
|
||||||
@@ -552,6 +677,11 @@ class Controller:
|
|||||||
# is not, and test_freeze.py part 5 fails on exactly that.
|
# is not, and test_freeze.py part 5 fails on exactly that.
|
||||||
await self.check_meter_frozen()
|
await self.check_meter_frozen()
|
||||||
|
|
||||||
|
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------
|
||||||
|
# Its boundary is unconstrained: it grades `self.peak_guard`, which this
|
||||||
|
# method assigned above and nothing since has touched.
|
||||||
|
await self.check_peak_saturated()
|
||||||
|
|
||||||
# --- TARIFF-01, and it is LAST on purpose -----------------------------
|
# --- TARIFF-01, and it is LAST on purpose -----------------------------
|
||||||
# Banking a quarter is bookkeeping about a moment that has already
|
# Banking a quarter is bookkeeping about a moment that has already
|
||||||
# happened; a slow SD card must never sit between the meter and the
|
# happened; a slow SD card must never sit between the meter and the
|
||||||
@@ -643,6 +773,59 @@ class Controller:
|
|||||||
f"for was itself out of range."),
|
f"for was itself out of range."),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async def check_peak_saturated(self) -> None:
|
||||||
|
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter.
|
||||||
|
|
||||||
|
⚠️ AN ENGAGEMENT IS NOT AN ALARM, AND THE DIFFERENCE IS §10.3's DOCTRINE
|
||||||
|
READ CAREFULLY. "Treat an out-of-range computed setpoint as a bug signal
|
||||||
|
- clamp it, then alarm" is SAFETY-03's rule and it is right there: the
|
||||||
|
loop computing 14 768 W is a WINDING INTEGRATOR, a defect, and clipping
|
||||||
|
it silently is what let it keep climbing behind a dashboard full of legal
|
||||||
|
numbers. A peak floor above the inverter's rating is not a defect. It is
|
||||||
|
a house load bigger than the inverter - 2026-08-20 15:45 ran ~5900 W
|
||||||
|
against a 5000 W unit - and alarming on the ordinary case is how an
|
||||||
|
operator learns to dismiss the notification that matters. So an
|
||||||
|
engagement is an ordinary control action: it goes to the §15 action log
|
||||||
|
and the status page, and no further.
|
||||||
|
|
||||||
|
What DOES earn the operator's attention is saturation: the guard asked
|
||||||
|
for more discharge than the inverter can give, so the quarter is going
|
||||||
|
over the ceiling anyway and the controller has already done everything
|
||||||
|
available to it. That is the one state where the money is going to be
|
||||||
|
spent and a human is the only remaining actuator.
|
||||||
|
|
||||||
|
Edge-triggered, and de-asserting arms it again - the same shape as
|
||||||
|
FreezeWatchdog's `evaluate()`, and for the same reason: one notification
|
||||||
|
per episode, not one per cycle.
|
||||||
|
"""
|
||||||
|
g = self.peak_guard
|
||||||
|
saturated = bool(g is not None and g.saturated)
|
||||||
|
was, self._peak_saturated = self._peak_saturated, saturated
|
||||||
|
if not saturated or was:
|
||||||
|
return
|
||||||
|
self.log_event("PEAK GUARD SATURATED - " + g.reason)
|
||||||
|
_LOG.error(
|
||||||
|
"SAFETY-07 - %s. The guard is asking for everything the inverter "
|
||||||
|
"has and the quarter is still projected over the ceiling: this "
|
||||||
|
"quarter will set or raise the month's billed capacity peak. The "
|
||||||
|
"controller is STILL RUNNING and still shaving; there is nothing "
|
||||||
|
"further it can do. Shed load.", g.reason)
|
||||||
|
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||||
|
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
||||||
|
# is not a frozen meter and is not an out-of-range setpoint.
|
||||||
|
await self.hass.call("persistent_notification", "create", {
|
||||||
|
"notification_id": "goodwe_peak_saturated",
|
||||||
|
"title": "GoodWe: the capacity-peak guard has run out of inverter",
|
||||||
|
"message": (
|
||||||
|
f"{g.reason}.\n\nThe battery is being commanded to everything "
|
||||||
|
f"it can give and the quarter is STILL projected over the "
|
||||||
|
f"{g.ceiling_w:.0f} W ceiling, so this quarter will set or raise "
|
||||||
|
f"the month's billed capaciteitstarief peak. This is not a "
|
||||||
|
f"controller fault - the load is simply larger than the "
|
||||||
|
f"inverter. Shedding load in the next few minutes is the only "
|
||||||
|
f"thing that changes the bill."),
|
||||||
|
})
|
||||||
|
|
||||||
async def check_meter_frozen(self) -> None:
|
async def check_meter_frozen(self) -> None:
|
||||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||||
|
|
||||||
@@ -918,6 +1101,40 @@ class Controller:
|
|||||||
f"{self.peak.cap_w:.0f} W soft limit"
|
f"{self.peak.cap_w:.0f} W soft limit"
|
||||||
+ f"; {self.peak.recorded} quarter(s) banked"
|
+ f"; {self.peak.recorded} quarter(s) banked"
|
||||||
+ f"; on start: {self.peak.restore_state}"})
|
+ f"; on start: {self.peak.restore_state}"})
|
||||||
|
|
||||||
|
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
|
||||||
|
# are opposite states and a page that spelled them the same way
|
||||||
|
# would show a green line over a guard that is switched off. `None`
|
||||||
|
# is a fourth state on purpose: before the first cycle the guard has
|
||||||
|
# concluded nothing, and saying "idle" then would be a claim about a
|
||||||
|
# decision that has not been taken.
|
||||||
|
_pg = self.peak_guard
|
||||||
|
if _pg is None:
|
||||||
|
out.append({"ok": True, "warn": True,
|
||||||
|
"text": "peak guard: no decision yet - the first "
|
||||||
|
"control cycle has not run"})
|
||||||
|
elif _pg.saturated:
|
||||||
|
out.append({"ok": False, "warn": False,
|
||||||
|
"text": "PEAK GUARD SATURATED - " + _pg.reason})
|
||||||
|
elif _pg.blind:
|
||||||
|
out.append({"ok": False, "warn": True, "text": _pg.explain()})
|
||||||
|
else:
|
||||||
|
out.append({"ok": not _pg.engaged, "warn": _pg.engaged,
|
||||||
|
"text": _pg.explain()})
|
||||||
|
else:
|
||||||
|
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point.
|
||||||
|
# SAFETY-07 is NOT COVERED on the legacy single-entity path - the
|
||||||
|
# quarter-hour average is measured from P1 telegrams and this path
|
||||||
|
# has none - and a row that only rendered where the guard runs would
|
||||||
|
# leave the one install that is unprotected showing nothing at all.
|
||||||
|
# Same answer, same reason, as the freeze detector's NOT COVERED row
|
||||||
|
# a few lines up.
|
||||||
|
out.append({"ok": False, "warn": True,
|
||||||
|
"text": f"peak guard: NOT COVERED on meter_source "
|
||||||
|
f"{o.get('meter_source')} - the quarter-hour "
|
||||||
|
f"average is measured from P1 telegrams and this "
|
||||||
|
f"path has none, so a runaway cannot be capped"})
|
||||||
|
|
||||||
rows = [("battery SoC", self.soc, o.get("soc_entity")),
|
rows = [("battery SoC", self.soc, o.get("soc_entity")),
|
||||||
("battery power", self.batt, o.get("batt_entity"))]
|
("battery power", self.batt, o.get("batt_entity"))]
|
||||||
if not self.p1_enabled:
|
if not self.p1_enabled:
|
||||||
|
|||||||
@@ -286,6 +286,26 @@ class QuarterAverager:
|
|||||||
"""Offtake watt-seconds accumulated in the open block so far."""
|
"""Offtake watt-seconds accumulated in the open block so far."""
|
||||||
return self._acc
|
return self._acc
|
||||||
|
|
||||||
|
@property
|
||||||
|
def held_net_w(self) -> float:
|
||||||
|
"""The signed net power currently being held forward, in W.
|
||||||
|
|
||||||
|
PUBLIC BECAUSE SAFETY-07 DEPENDS ON IT, AND THAT IS THE WHOLE REASON.
|
||||||
|
`PeakTracker.project()` extrapolates the rest of the quarter from this
|
||||||
|
value and used to reach it as `getattr(averager, "_last_net", 0.0)`. A
|
||||||
|
default on a private name fails QUIET and fails in the DISARMING
|
||||||
|
direction: rename `_last_net` and every projection silently credits 0 W
|
||||||
|
held, which reads LOW, which puts the peak guard under the ceiling
|
||||||
|
exactly when the meter says it is over. Filed as TEST-13 item 4 and
|
||||||
|
fixed here rather than inherited - a money guard may not rest on a read
|
||||||
|
that can go wrong without a symptom.
|
||||||
|
|
||||||
|
Positive is offtake, matching `P1Sample.net_w`. It is the raw held
|
||||||
|
value, NOT floored at zero: the flooring is the accumulator's rule (only
|
||||||
|
offtake is billed) and belongs where it is applied, not here.
|
||||||
|
"""
|
||||||
|
return self._last_net
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def offtake_avg_w(self) -> float:
|
def offtake_avg_w(self) -> float:
|
||||||
"""Average offtake over the part of the open block seen so far."""
|
"""Average offtake over the part of the open block seen so far."""
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
"""SAFETY-07 - the quarter-peak projection guard, and the only thing in this
|
||||||
|
add-on that is allowed to spend the loop's freedom to save money.
|
||||||
|
|
||||||
|
WHAT IT IS. TARIFF-01 built the INSTRUMENT: `tariff.py` measures the number the
|
||||||
|
Belgian capaciteitstarief bills on - the highest 15-minute average offtake of the
|
||||||
|
calendar month - and banks it where a restart cannot reach. It files no claim and
|
||||||
|
commands nothing, and says so in its own first paragraph. This module is the
|
||||||
|
CONSUMER it named. It turns that measurement into one arbiter claim and nothing
|
||||||
|
else: no writes, no I/O, no clock, no state between calls. Given a projection and
|
||||||
|
two measured numbers it returns a decision; `main.py` files it.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
THE HAZARD, WITH THE EURO FIGURE
|
||||||
|
|
||||||
|
specs.md:583 records a measured case: ONE 4998 W quarter moved a monthly peak
|
||||||
|
from 4832 W to 5013 W and billed the whole month at it. The capacity tariff has
|
||||||
|
no averaging and no forgiveness - the month's single worst quarter is the bill.
|
||||||
|
|
||||||
|
SAFETY-18 shipped the ALARM for the way this system produces such a quarter by
|
||||||
|
itself: a meter that answers 200 OK forever with a document that never changes,
|
||||||
|
which every freshness mechanism in this stack reads as healthy while the loop
|
||||||
|
integrates a constant error to the rail and holds it there. freeze.py's own
|
||||||
|
table: at the live install's `max_w: 5000`, a meter frozen at >= 3000 W rails the
|
||||||
|
command in 75 s. SAFETY-18 notifies at `meter_freeze_s` (300 s default), against
|
||||||
|
a 900 s billing quarter. That is about nine minutes of human reaction time and
|
||||||
|
nothing else - freeze.py says so in the words "THIS CUT DOES NOT CAP THAT."
|
||||||
|
|
||||||
|
This is the cap. It is deliberately NOT a second freeze detector: it consumes
|
||||||
|
freeze.py's shipped predicate rather than transcribing it.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
THE MECHANISM: ONE `lo` LIMIT AT `P_PEAK`, AND WHY THAT SHAPE
|
||||||
|
|
||||||
|
specs.md 10.1 row 2 asks for exactly two behaviours - "pre-emptive discharge as
|
||||||
|
the projected quarter average approaches the soft limit; hard clamp on charging
|
||||||
|
above it" - and 9.1 constrains the lever: "Discharge and export are never capped
|
||||||
|
by the peak guard; only charging and import are."
|
||||||
|
|
||||||
|
A LOWER BOUND on the setpoint is both of those in one object, and it is the only
|
||||||
|
shape that is:
|
||||||
|
|
||||||
|
* incapable of capping discharge - a floor never stops the loop discharging
|
||||||
|
HARDER, so 9.1's asymmetry is structural here rather than remembered;
|
||||||
|
* incapable of capping export, for the same reason;
|
||||||
|
* a hard clamp on charging whenever the floor is above the standing charge;
|
||||||
|
* pre-emptive discharge whenever the floor is above zero.
|
||||||
|
|
||||||
|
It is a LIMIT, not a SET, and that matters at both ends of the priority table.
|
||||||
|
`P_PEAK` (50) is above `P_LOOP` (10), so it binds the grid-following loop. It is
|
||||||
|
BELOW `P_MAINTENANCE` (60) and `P_SAFETY` (100), so a maintenance phase that owns
|
||||||
|
the actuator and every layer-1 stop still outrank it: arbiter.py's clause 2 only
|
||||||
|
applies limits whose priority is >= the WINNING SET's, so a P_SAFETY 0 W stop is
|
||||||
|
not dragged off zero by this floor. That is the correct order - a controller
|
||||||
|
pushing power the wrong way is wrong however much money it is trying to save.
|
||||||
|
|
||||||
|
⚠️ AND A LIMIT DOES NOT OUT-VOTE ANOTHER LIMIT. This was assumed, and it is
|
||||||
|
false: arbiter.py clause 2 applies EVERY limit at or above the winner's priority
|
||||||
|
and takes the MOST RESTRICTIVE, with no notion of priority between them - which
|
||||||
|
is exactly what makes the safety bounds unconditional and must not be changed.
|
||||||
|
So this floor (`lo >= 0`) and the maintenance charge-only shaping (`hi <= 0`) do
|
||||||
|
not resolve in P_PEAK's favour when both are filed. They resolve to `lo > hi`, a
|
||||||
|
CONTRADICTION, which arbiter.py answers with 0 W - the battery parked in the one
|
||||||
|
quarter of the month it should be shaving hardest. The rule for two limits that
|
||||||
|
can cross is therefore SUPPRESSION, not priority: main.py drops the maintenance
|
||||||
|
shaping for as long as this guard is engaged, which is the same thing that branch
|
||||||
|
already did for `peak_forecast_entity` and is why 9.1's "money outranks the
|
||||||
|
maintenance schedule" reads as a suppression there rather than as a priority
|
||||||
|
here. The floor's clamp to `achievable_w` closes the only other pair that could
|
||||||
|
cross - the P_SAFETY rating bounds.
|
||||||
|
|
||||||
|
AND IT IS WHY OPT-04 NEEDS NO CHANGE HERE. 9.1 requires peak shaving to outrank
|
||||||
|
arbitrage. Arbitrage does not exist yet, but it will arrive as a SET below
|
||||||
|
P_PEAK, and arbiter.py clause 2 will bind it with this floor on the day it lands,
|
||||||
|
with no edit to this file. Expressing the priority as a claim rather than as an
|
||||||
|
if/else in main.py is the whole reason that holds.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
THE FLOOR IS DERIVED, AND EVERY TERM OF IT IS MEASURED
|
||||||
|
|
||||||
|
Let `ceiling_w` be the quarter average that must not be exceeded and `remaining`
|
||||||
|
the seconds left in the open block. `project()` gives
|
||||||
|
|
||||||
|
projected = (acc + held * remaining) / 900
|
||||||
|
|
||||||
|
so the held level that would land the quarter exactly on the ceiling is
|
||||||
|
`held + 900 * (ceiling - projected) / remaining`, and the watts that must come
|
||||||
|
off the meter are the difference between the two:
|
||||||
|
|
||||||
|
excess_w = 900 * (projected - ceiling) / remaining
|
||||||
|
|
||||||
|
That single expression carries the whole control law, and two properties of it
|
||||||
|
are the argument for this design:
|
||||||
|
|
||||||
|
1. THE CLAIM IS FILED WHILE THE FLOOR IS POSITIVE, NOT WHILE THE EXCESS IS.
|
||||||
|
`excess` is what must come off the METER, so it falls to zero the moment the
|
||||||
|
battery is already taking it - and a guard that disengaged there would hand
|
||||||
|
the actuator back, watch the offtake spring straight to the raw load, and
|
||||||
|
engage again next cycle. That is not a hypothetical: it is what the first
|
||||||
|
cut of this file did to 2026-08-20's billed quarter on replay - seventeen
|
||||||
|
engage/release cycles in 900 s, and the quarter still finished 49 W over the
|
||||||
|
limit it had spent a quarter of an hour holding. 10.2 names the cost
|
||||||
|
exactly: "an EMS that oscillates is worse than no EMS, both for hardware
|
||||||
|
wear and - per 9.1 - for the peak it is trying to avoid."
|
||||||
|
|
||||||
|
`batt_w + excess` IS the quantity to threshold: the offtake there would be
|
||||||
|
with the battery at zero, less the level this quarter can still afford. Read
|
||||||
|
as a LEVEL rather than as a switch, the same arithmetic gives a claim that
|
||||||
|
is filed for as long as a positive floor is needed and decays continuously
|
||||||
|
to nothing as the load falls. So there is still no hysteresis constant in
|
||||||
|
this file - not because oscillation was ruled out by argument, but because
|
||||||
|
the thresholded quantity is the one that does not oscillate.
|
||||||
|
|
||||||
|
2. THE GAIN IS 900/remaining, WHICH IS 1.0 AT THE START OF THE QUARTER AND
|
||||||
|
GROWS. This is the answer to the degeneracy at the top of a block, where the
|
||||||
|
projection is an extrapolation from almost no evidence: the guard is at its
|
||||||
|
GENTLEST exactly when its evidence is weakest, and becomes aggressive only
|
||||||
|
once most of the quarter is behind it and the projection is nearly a
|
||||||
|
measurement. The formula supplies that; no minimum-evidence threshold was
|
||||||
|
invented, because inventing one would delay the guard in the case it exists
|
||||||
|
for - a sustained rail from second zero.
|
||||||
|
|
||||||
|
The floor itself is then
|
||||||
|
|
||||||
|
floor_w = batt_w + excess_w
|
||||||
|
|
||||||
|
`batt_w` IS THE MEASURED BATTERY POWER, NOT THE STANDING COMMAND. Both were
|
||||||
|
available. The measured one is right for two independent reasons. First, 9.1's
|
||||||
|
settled constraint is that the utility bills what FLOWED - the arithmetic
|
||||||
|
`grid = load - batt` is true of delivered power and false of intent. Second, and
|
||||||
|
concretely: when the inverter cannot follow (the saturation case control.py
|
||||||
|
already freezes the integrator for), a floor built on the command asks for
|
||||||
|
`command + excess` every cycle while the command never arrives, and winds itself
|
||||||
|
to the rail; a floor built on the measurement asks for `delivered + excess`,
|
||||||
|
which stops rising the moment delivery stops rising. The delivered form cannot
|
||||||
|
wind up. The intent form can.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
THE CEILING IS `peak_cap_w`, RAISED ONLY BY AN OBSERVATION
|
||||||
|
|
||||||
|
11.2's row is "Quarter peak | Projected quarter average approaches THE MONTHLY
|
||||||
|
PEAK | Clamp the command", and 9.1's soft limit is `peak_cap_w`. Both matter,
|
||||||
|
because the money is spent on `max(month's peak so far, this quarter)`:
|
||||||
|
|
||||||
|
ceiling_w = max(peak_cap_w, the month's banked peak)
|
||||||
|
|
||||||
|
Once a month's peak already stands at 5013 W, holding every later quarter to
|
||||||
|
3500 W buys nothing - the bill for that month is already 5013 W - while the
|
||||||
|
holding itself costs real charging the site wanted. Raising the ceiling to a peak
|
||||||
|
that has already been paid for is what stops one bad quarter from parking the
|
||||||
|
battery's charging for three weeks.
|
||||||
|
|
||||||
|
AND THAT RELAXATION IS GRANTED ONLY BY AN `OBSERVED` READING. `NO_RECORD` and
|
||||||
|
`NOT_OBSERVED` both leave the ceiling at `peak_cap_w`, the tighter of the two, so
|
||||||
|
a peak store that cannot be read makes this guard STRICTER and never looser. That
|
||||||
|
direction is the whole point: tariff.py's `PeakReading` exists because a database
|
||||||
|
that did not answer must not become a number, and the number it would have become
|
||||||
|
here - a zero peak - is exactly the one that reads as "no peak recorded, full
|
||||||
|
freedom". It is also why this module does NOT consume `headroom_w()`, whose
|
||||||
|
disarming failure it would otherwise inherit: a peak read as 0 W returns FULL
|
||||||
|
headroom, and full headroom is a guard that is not there.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
BLINDNESS COMES FIRST, AND IT IS A DIFFERENT ACTION
|
||||||
|
|
||||||
|
A projection is evidence only if the quantity under it was actually measured.
|
||||||
|
Three ways it is not, all of them checked BEFORE the projection is graded,
|
||||||
|
because grading a stimulus where the quantity it grades is degenerate is how a
|
||||||
|
guard passes its own tests while being switched off:
|
||||||
|
|
||||||
|
* NO OPEN BLOCK. `project()` answers a fresh averager with
|
||||||
|
`Projection(0.0, 0.0, 0.0, 0.0, False, None, 0.0)` - a ZERO projection, which
|
||||||
|
compares as comfortably under any ceiling. That is not "the quarter is fine",
|
||||||
|
it is "there is no quarter". Graded as a number it silently disarms the guard
|
||||||
|
on exactly the startup path where nothing else is watching.
|
||||||
|
|
||||||
|
* COVERAGE. A meter gap longer than one `max_hold_s` window leaves seconds that
|
||||||
|
`QuarterAverager` deliberately does not accumulate (p1.py: holding a dead
|
||||||
|
meter's last value forward is how a fabricated peak gets onto a permanent
|
||||||
|
record). Those seconds make the projection read LOW, which is the
|
||||||
|
under-guarding direction. The threshold is `max_hold_s` itself - the point
|
||||||
|
p1.py already stops trusting a held sample - not a new number.
|
||||||
|
|
||||||
|
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
|
||||||
|
rather than re-written here. A frozen meter is the worst case of all: the
|
||||||
|
samples keep ARRIVING, so coverage stays perfect and the projection is
|
||||||
|
confident - and if the stuck value is an export, the projection is a
|
||||||
|
confident ZERO while the real meter runs at the rail. This is the case
|
||||||
|
SAFETY-07 exists to backstop, and it is the case a projection cannot see.
|
||||||
|
|
||||||
|
THE BLIND ACTION IS `floor_w = 0.0`: the battery may not IMPORT while the billed
|
||||||
|
quantity is unobservable. It is the minimal action that closes the hazard, and it
|
||||||
|
is one-directional - forbidding charging can only ever lower delivered offtake,
|
||||||
|
never raise it - so a false blindness costs a charge the site wanted and cannot
|
||||||
|
cost energy the pack did not want to give. Contrast the graded branch, which can
|
||||||
|
force discharge; blindness deliberately does not earn that, because there is by
|
||||||
|
definition no measurement to size it from.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
WHY THIS ONE TRIPS WHERE SAFETY-18 ONLY NOTIFIES
|
||||||
|
|
||||||
|
SAFETY-18 shipped notification-first, and config.yaml says why in one line: "A
|
||||||
|
false trip parks the battery; a false alarm costs an alert." That reasoning is
|
||||||
|
right for a DETECTOR whose negative case had to be established on a rig run. It
|
||||||
|
does not carry to this guard, and the two committed captures say so:
|
||||||
|
|
||||||
|
* MEASURED FALSE-ENGAGEMENT RATE ON THE HEALTHY DAY: zero. 2026-08-23's worst
|
||||||
|
quarter is a few hundred watts against a 3500 W ceiling and nothing in its 95
|
||||||
|
quarters comes near engaging. test_peakguard.py re-measures that every run
|
||||||
|
from the committed capture rather than quoting it here.
|
||||||
|
* THE ACTION IS LEVEL-TRIGGERED AND UNLATCHED. It is re-derived from a fresh
|
||||||
|
projection every cycle and releases the instant the projection drops back
|
||||||
|
under the ceiling. There is no state to clear and no operator to wake. A
|
||||||
|
false engagement costs one cycle of discharge, not a parked battery - which
|
||||||
|
is precisely the cost SAFETY-18 could not bound and this one can.
|
||||||
|
* THE ACTION IS A LIMIT, NOT A SET. Every safety stop in 11.2's table outranks
|
||||||
|
it, so the worst thing a bug in this file can do is fail to relax a floor -
|
||||||
|
and a floor is bounded above by `achievable_w`, which main.py takes from the
|
||||||
|
same `max_w` and device rating the arbiter already enforces.
|
||||||
|
|
||||||
|
So it trips. It is also the only guard here whose trigger IS the billed quantity
|
||||||
|
itself, measured from parsed P1 telegrams, rather than a proxy for it.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
SATURATION IS REPORTED, NOT ALARMED AS A BUG
|
||||||
|
|
||||||
|
10.3's doctrine - "treat an out-of-range computed setpoint as a bug signal, clamp
|
||||||
|
it AND alarm" - is SAFETY-03's, and it is right there: a loop computing 14 768 W
|
||||||
|
is a winding integrator, i.e. a defect. A peak floor above the inverter's rating
|
||||||
|
is NOT a defect. It is the ordinary physics of a 5900 W house load on a 5000 W
|
||||||
|
inverter, which is literally what 2026-08-20 15:45 was. So the floor is clamped
|
||||||
|
to `achievable_w` and the decision carries `saturated`, and main.py raises ONE
|
||||||
|
notification on that - not because the controller is broken, but because it is
|
||||||
|
the one case where the money WILL be spent and the controller has already done
|
||||||
|
everything it can. An engagement that is not saturated is an ordinary control
|
||||||
|
action and goes to the action log, like the clamp's engagements do.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
NO NEW OPTION. CFG-03 STILL TRACKS THREE PLACES.
|
||||||
|
|
||||||
|
`peak_cap_w` already ships, is already validated `int(500,15000)`, and is already
|
||||||
|
read by `control.peak_at_risk()`. `max_hold_s` comes off the averager the caller
|
||||||
|
already owns; `achievable_w` off `max_w` and the device rating the arbiter
|
||||||
|
already enforces; the freeze predicate off `meter_freeze_s`. config.yaml and
|
||||||
|
validate.py are untouched by this ticket, exactly as TARIFF-01 left them, and
|
||||||
|
14's `capacity_peak_limit_kw` stays declined - a kW key beside a W key is the
|
||||||
|
mismatch trap 14's own warning box describes.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
⚠️ WHAT THIS GUARD DOES NOT HAVE: AN ENERGY BACKSTOP. SEE SAFETY-24.
|
||||||
|
|
||||||
|
The graded branch can FORCE DISCHARGE, and nothing in this file or in the loop
|
||||||
|
below it consults the state of charge - `soc_floor` belongs to the maintenance
|
||||||
|
cycle, and freeze.py already records that for the grid-following loop "the BMS
|
||||||
|
is the sole energy backstop". On the 2026-08-20 replay the guard asks for 878 Wh
|
||||||
|
in a day. Against a pack sitting near 9 % that is not nothing, and the guard will
|
||||||
|
keep asking as long as the meter says the quarter is over the ceiling.
|
||||||
|
|
||||||
|
That is deliberate for THIS card and it is filed as SAFETY-24. It is also the
|
||||||
|
reason this must not go to a client install before SAFETY-24 lands: on the
|
||||||
|
reference site the BMS is a known quantity and an operator is present, and on a
|
||||||
|
client site neither is true.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
|
||||||
|
|
||||||
|
The sum/max/net reduction is an open product question with the owner. TARIFF-01
|
||||||
|
put it behind `billed_w()` and defaulted to NET. Everything this module consumes
|
||||||
|
- `PeakReading.peak_w`, `Projection.projected_w` - is already reduced by that one
|
||||||
|
function, so whichever way the owner answers, this file does not change and does
|
||||||
|
not need re-testing. Nothing here indexes a phase.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .arbiter import P_PEAK, Claim
|
||||||
|
from .control import peak_at_risk
|
||||||
|
from .tariff import QUARTER_S
|
||||||
|
|
||||||
|
# Why the projection may not be graded. Reasons, not booleans: the status page
|
||||||
|
# and the action log both have to say WHICH, and "blind" alone is unactionable.
|
||||||
|
BLIND_NO_BLOCK = "no open quarter - the meter has produced no sample yet"
|
||||||
|
BLIND_COVERAGE = "meter gap"
|
||||||
|
BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
|
||||||
|
|
||||||
|
# Where the ceiling came from. Only the second is a relaxation, and only an
|
||||||
|
# OBSERVED reading may produce it.
|
||||||
|
CEILING_CAP = "peak_cap_w"
|
||||||
|
CEILING_MONTH_PEAK = "the month's banked peak"
|
||||||
|
|
||||||
|
SOURCE = "peak"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PeakDecision:
|
||||||
|
"""What the guard concluded this cycle, with everything it concluded it from.
|
||||||
|
|
||||||
|
Every field is derived from a measurement taken in the same cycle. There is
|
||||||
|
no `floor_w_or(default)` and there must not be one, for `PeakReading`'s
|
||||||
|
reason: an accessor that turns "not measured" into a number re-creates the
|
||||||
|
defect the type exists to remove.
|
||||||
|
"""
|
||||||
|
|
||||||
|
engaged: bool
|
||||||
|
floor_w: float | None # the `lo` the claim carries; None = no claim
|
||||||
|
ceiling_w: float
|
||||||
|
ceiling_from: str
|
||||||
|
blind: str | None = None
|
||||||
|
projected_w: float | None = None
|
||||||
|
excess_w: float = 0.0
|
||||||
|
wanted_w: float | None = None # the floor before `achievable_w` clamped it
|
||||||
|
saturated: bool = False
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
def explain(self) -> str:
|
||||||
|
if not self.engaged:
|
||||||
|
if self.projected_w is None:
|
||||||
|
return f"peak guard idle - {self.reason}"
|
||||||
|
return (f"peak guard idle - quarter projects "
|
||||||
|
f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W "
|
||||||
|
f"ceiling ({self.ceiling_from})")
|
||||||
|
return self.reason
|
||||||
|
|
||||||
|
|
||||||
|
def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
|
||||||
|
"""The average this quarter may not exceed, and where the number came from.
|
||||||
|
|
||||||
|
ONLY an OBSERVED reading may raise it. `NO_RECORD` (the store answered, this
|
||||||
|
month has nothing yet) and `NOT_OBSERVED` (the store did not answer) both
|
||||||
|
return the cap, which is the tighter answer. A failed read therefore makes
|
||||||
|
the guard stricter, never looser - see the module note.
|
||||||
|
"""
|
||||||
|
cap = float(cap_w)
|
||||||
|
if (reading is not None and getattr(reading, "is_observed", False)
|
||||||
|
and reading.peak_w is not None and float(reading.peak_w) > cap):
|
||||||
|
return float(reading.peak_w), CEILING_MONTH_PEAK
|
||||||
|
return cap, CEILING_CAP
|
||||||
|
|
||||||
|
|
||||||
|
def excess_w(projection, ceiling_w) -> float:
|
||||||
|
"""Watts that must come off the meter NOW to land the quarter on the ceiling.
|
||||||
|
|
||||||
|
The whole control law, and the only place it is written down:
|
||||||
|
|
||||||
|
excess = 900 * (projected - ceiling) / remaining
|
||||||
|
|
||||||
|
Positive means the quarter is projected over; zero or negative means it is
|
||||||
|
not. `remaining <= 0` is not a large number, it is an undefined one - a
|
||||||
|
quarter with no seconds left cannot be influenced - so it answers 0.0 rather
|
||||||
|
than dividing.
|
||||||
|
"""
|
||||||
|
remaining = QUARTER_S - float(projection.elapsed_s)
|
||||||
|
if remaining <= 0.0:
|
||||||
|
return 0.0
|
||||||
|
return QUARTER_S * (float(projection.projected_w) - float(ceiling_w)) / remaining
|
||||||
|
|
||||||
|
|
||||||
|
def uncovered_s(projection) -> float:
|
||||||
|
"""Seconds of the open block the meter did not actually cover.
|
||||||
|
|
||||||
|
Derived from what `Projection` already carries rather than re-measured:
|
||||||
|
`covered_frac` is `elapsed / elapsed_wall`, and `elapsed_s` IS that wall.
|
||||||
|
"""
|
||||||
|
return max(0.0, float(projection.elapsed_s)
|
||||||
|
* (1.0 - float(projection.covered_frac)))
|
||||||
|
|
||||||
|
|
||||||
|
def blindness(projection, max_hold_s, frozen: bool) -> str | None:
|
||||||
|
"""Why this projection may not be graded, or None if it may be.
|
||||||
|
|
||||||
|
Evaluated BEFORE the projection is compared to anything. Each of the three is
|
||||||
|
a way for the projected number to be confident and wrong, and the first - a
|
||||||
|
fresh averager's all-zero `Projection` - is confident, wrong AND under every
|
||||||
|
ceiling there is.
|
||||||
|
"""
|
||||||
|
if frozen:
|
||||||
|
return BLIND_FROZEN
|
||||||
|
if projection.block_start is None:
|
||||||
|
return BLIND_NO_BLOCK
|
||||||
|
gap = uncovered_s(projection)
|
||||||
|
if max_hold_s is not None and gap > float(max_hold_s):
|
||||||
|
return (f"{BLIND_COVERAGE}: {gap:.0f} s of this quarter uncovered, past "
|
||||||
|
f"the {float(max_hold_s):.0f} s the averager will hold a sample")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def decide(projection, *, batt_w, cap_w, reading, achievable_w,
|
||||||
|
max_hold_s=None, frozen: bool = False) -> PeakDecision:
|
||||||
|
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
|
||||||
|
|
||||||
|
`batt_w` MEASURED battery power, positive = discharge. None when the
|
||||||
|
sensor for it is missing - and then there is nothing to size a
|
||||||
|
floor from, so no floor is invented.
|
||||||
|
`achievable_w` the largest discharge the arbiter could actually grant
|
||||||
|
(`min(max_w, device max)`). The floor is clamped to it so this
|
||||||
|
claim can NEVER produce `lo > hi` in the resolver - a
|
||||||
|
contradiction there resolves to 0 W, which would park the
|
||||||
|
battery in the exact minute it should be discharging hardest.
|
||||||
|
"""
|
||||||
|
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
|
||||||
|
ceil_top = abs(float(achievable_w))
|
||||||
|
|
||||||
|
blind = blindness(projection, max_hold_s, frozen)
|
||||||
|
if blind is not None:
|
||||||
|
# 0.0, not a sized floor. There is no trustworthy measurement to size one
|
||||||
|
# from; the claim that remains is "do not add to the offtake you cannot
|
||||||
|
# see", which is one-directional and cannot cost pack energy.
|
||||||
|
return PeakDecision(
|
||||||
|
engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where,
|
||||||
|
blind=blind, projected_w=None,
|
||||||
|
reason=(f"PEAK GUARD BLIND ({blind}) - the battery may not import "
|
||||||
|
f"while the billed quarter cannot be measured; commanding no "
|
||||||
|
f"charging (floor 0 W)"))
|
||||||
|
|
||||||
|
projected = float(projection.projected_w)
|
||||||
|
if batt_w is None:
|
||||||
|
# main.py's P_SAFETY stop already owns a cycle with a missing input, at
|
||||||
|
# 0 W. Filing nothing is the honest answer: a floor sized from a
|
||||||
|
# measurement that is not there is a made-up number.
|
||||||
|
return PeakDecision(
|
||||||
|
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||||
|
ceiling_from=from_where, projected_w=projected,
|
||||||
|
reason="no battery measurement to size a floor from")
|
||||||
|
if QUARTER_S - float(projection.elapsed_s) <= 0.0:
|
||||||
|
# A block with no seconds left cannot be influenced. This is degenerate
|
||||||
|
# rather than urgent - `excess_w` answers 0.0 there and a floor equal to
|
||||||
|
# the battery's own current output would be a claim that says nothing.
|
||||||
|
return PeakDecision(
|
||||||
|
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||||
|
ceiling_from=from_where, projected_w=projected,
|
||||||
|
reason="the quarter has no seconds left to influence")
|
||||||
|
|
||||||
|
ex = excess_w(projection, ceiling)
|
||||||
|
wanted = float(batt_w) + ex
|
||||||
|
if wanted <= 0.0:
|
||||||
|
# ⚠️ THE TEST IS ON THE FLOOR, NOT ON `excess > 0`, AND THE REPLAY IS WHY.
|
||||||
|
# `excess` is the watts that must come off the METER, so it goes to zero
|
||||||
|
# as soon as the battery is already removing them - and a guard that
|
||||||
|
# disengaged there handed the actuator straight back, the offtake sprang
|
||||||
|
# back to the raw load, and the next cycle engaged again. On 2026-08-20's
|
||||||
|
# billed quarter that bang-bang cycled 17 times in 900 s and left the
|
||||||
|
# quarter 49 W over the limit it had just spent a quarter of an hour
|
||||||
|
# holding. §10.2: "an EMS that oscillates is worse than no EMS, both for
|
||||||
|
# hardware wear and - per §9.1 - for the peak it is trying to avoid."
|
||||||
|
#
|
||||||
|
# `batt_w + excess` is the offtake that would exist with the battery at
|
||||||
|
# zero, minus the level the quarter can afford. Testing THAT is the same
|
||||||
|
# arithmetic read as a level rather than as a switch: the claim is filed
|
||||||
|
# for as long as a positive floor is needed and decays continuously to
|
||||||
|
# nothing as the load falls, so there is still no hysteresis constant -
|
||||||
|
# the quantity being thresholded is simply the right one.
|
||||||
|
return PeakDecision(
|
||||||
|
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||||
|
ceiling_from=from_where, projected_w=projected, excess_w=ex,
|
||||||
|
wanted_w=wanted,
|
||||||
|
reason="the load is under the level this quarter can afford")
|
||||||
|
|
||||||
|
floor = min(wanted, ceil_top)
|
||||||
|
saturated = floor < wanted
|
||||||
|
return PeakDecision(
|
||||||
|
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
|
||||||
|
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
|
||||||
|
saturated=saturated,
|
||||||
|
reason=(
|
||||||
|
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
|
||||||
|
f"{ceiling:.0f} W ceiling ({from_where}) with "
|
||||||
|
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
|
||||||
|
f"come off the meter; battery measured {float(batt_w):+.0f} W, "
|
||||||
|
f"floor {floor:+.0f} W"
|
||||||
|
+ (f" - SATURATED, {wanted:+.0f} W was needed and the inverter can "
|
||||||
|
f"only give {ceil_top:.0f} W" if saturated else "")))
|
||||||
|
|
||||||
|
|
||||||
|
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
|
||||||
|
"""Must the maintenance charge-only shaping stand down this cycle?
|
||||||
|
|
||||||
|
THE ONE PLACE THIS CONJUNCTION IS WRITTEN, and it is public for the reason
|
||||||
|
`watchdog.loop_is_following()` and `freeze.looks_frozen()` are: the check
|
||||||
|
that grades it has to assert on THE SHIPPED EXPRESSION. A safety predicate
|
||||||
|
transcribed into its own test is one copy plus a future disagreement about
|
||||||
|
what the check means, and the copy that drifts is always the grading one.
|
||||||
|
It was inline in `main.py` and nothing could reach it; a reviewer mutated it
|
||||||
|
back to its historical broken form and all twelve suites stayed green.
|
||||||
|
|
||||||
|
⚠️ WHY SUPPRESSION AND NOT PRIORITY. The shaping is a LIMIT (`hi <= 0`) and
|
||||||
|
the peak guard's claim is a LIMIT (`lo >= 0`). arbiter.py clause 2 applies
|
||||||
|
EVERY limit at or above the winner's priority and takes the MOST RESTRICTIVE,
|
||||||
|
with no notion of one limit outranking another - which is precisely what
|
||||||
|
makes the safety bounds unconditional and must not be changed. Two crossing
|
||||||
|
limits are therefore a CONTRADICTION, which resolves to 0 W: the battery
|
||||||
|
PARKED, in the one quarter of the month that sets the bill. P_PEAK 50 >
|
||||||
|
P_MAINT_SHAPE 40 does not save it and never could.
|
||||||
|
|
||||||
|
⚠️ AND THE FIRST TERM IS NOT ENOUGH ON ITS OWN. `peak_forecast_entity` is
|
||||||
|
OPTIONAL and is empty on a default install, so `peak_forecast_w` is None
|
||||||
|
there and `peak_at_risk()` answers False forever. Before this card the
|
||||||
|
suspension therefore could not fire on the very site that needed it. The
|
||||||
|
second term is the MEASURED guard, which is always present wherever P1 is.
|
||||||
|
Deleting either term - or re-gating the whole thing on the forecast - parks
|
||||||
|
a default install at 0 W, silently, in the billed quarter. test_peakguard.py
|
||||||
|
part 7 drives the real cycle with each of those mutations in place.
|
||||||
|
"""
|
||||||
|
if peak_at_risk(peak_forecast_w, float(cap_w)):
|
||||||
|
return True
|
||||||
|
return decision is not None and decision.engaged
|
||||||
|
|
||||||
|
|
||||||
|
def claim(decision: PeakDecision) -> Claim | None:
|
||||||
|
"""The decision as one arbiter claim, or None when the guard is idle.
|
||||||
|
|
||||||
|
A LIMIT with only `lo` set. `hi` stays `+inf`, which is 9.1's "discharge and
|
||||||
|
export are never capped by the peak guard" expressed as a fact about the
|
||||||
|
object rather than as a rule somebody has to remember.
|
||||||
|
"""
|
||||||
|
if not decision.engaged or decision.floor_w is None:
|
||||||
|
return None
|
||||||
|
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
|
||||||
|
reason=("blind" if decision.blind else "quarter peak"))
|
||||||
@@ -522,6 +522,7 @@ class Projection:
|
|||||||
covered_frac: float
|
covered_frac: float
|
||||||
complete: bool
|
complete: bool
|
||||||
block_start: datetime | None
|
block_start: datetime | None
|
||||||
|
held_w: float
|
||||||
|
|
||||||
def explain(self) -> str:
|
def explain(self) -> str:
|
||||||
return (f"quarter projects {self.projected_w:.0f} W "
|
return (f"quarter projects {self.projected_w:.0f} W "
|
||||||
@@ -657,19 +658,38 @@ class PeakTracker:
|
|||||||
"""
|
"""
|
||||||
start = averager.block_start
|
start = averager.block_start
|
||||||
if start is None:
|
if start is None:
|
||||||
return Projection(0.0, 0.0, 0.0, 0.0, False, None)
|
# ⚠️ A ZERO PROJECTION, AND IT IS NOT "THE QUARTER IS FINE". It is
|
||||||
|
# "there is no quarter". `block_start is None` is the flag that says
|
||||||
|
# which, and SAFETY-07 grades that flag BEFORE it grades the number -
|
||||||
|
# see peakguard.blindness(). A consumer that reads only
|
||||||
|
# `projected_w` here reads 0 W, which is under every ceiling there
|
||||||
|
# is, on exactly the startup path where nothing else is watching.
|
||||||
|
return Projection(0.0, 0.0, 0.0, 0.0, False, None, 0.0)
|
||||||
elapsed_wall = max(0.0, (now - start).total_seconds())
|
elapsed_wall = max(0.0, (now - start).total_seconds())
|
||||||
elapsed = averager.elapsed_s
|
elapsed = averager.elapsed_s
|
||||||
acc = averager.partial_ws
|
acc = averager.partial_ws
|
||||||
remaining = max(0.0, QUARTER_S - elapsed_wall)
|
remaining = max(0.0, QUARTER_S - elapsed_wall)
|
||||||
held = max(getattr(averager, "_last_net", 0.0), 0.0)
|
# ⚠️ NO `getattr` DEFAULT, AND THAT IS THE FIX, NOT A TIDY-UP. This read
|
||||||
|
# was `getattr(averager, "_last_net", 0.0)`: a private name with a
|
||||||
|
# fallback, so renaming the attribute would have kept every projection
|
||||||
|
# green while crediting 0 W held - a projection that reads LOW, which is
|
||||||
|
# the UNDER-GUARDING direction for SAFETY-07's floor. TEST-13 item 4.
|
||||||
|
# `held_net_w` is now a documented public property on QuarterAverager and
|
||||||
|
# is read without a default, so the same rename raises AttributeError on
|
||||||
|
# the first cycle instead of quietly disarming the money guard.
|
||||||
|
held = max(averager.held_net_w, 0.0)
|
||||||
return Projection(
|
return Projection(
|
||||||
projected_w=(acc + held * remaining) / QUARTER_S,
|
projected_w=(acc + held * remaining) / QUARTER_S,
|
||||||
elapsed_w=(acc / elapsed) if elapsed > 0 else 0.0,
|
elapsed_w=(acc / elapsed) if elapsed > 0 else 0.0,
|
||||||
elapsed_s=elapsed_wall,
|
elapsed_s=elapsed_wall,
|
||||||
covered_frac=(elapsed / elapsed_wall) if elapsed_wall > 0 else 0.0,
|
covered_frac=(elapsed / elapsed_wall) if elapsed_wall > 0 else 0.0,
|
||||||
complete=elapsed_wall <= 0 or elapsed >= elapsed_wall - 1e-9,
|
complete=elapsed_wall <= 0 or elapsed >= elapsed_wall - 1e-9,
|
||||||
block_start=start)
|
block_start=start,
|
||||||
|
# Carried so a consumer can see WHAT was extrapolated, not only the
|
||||||
|
# extrapolation. It is the signed held value; `projected_w` uses the
|
||||||
|
# offtake-only form above, and the two differ whenever the site is
|
||||||
|
# exporting - which is the healthy operating point here.
|
||||||
|
held_w=averager.held_net_w)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -595,12 +595,33 @@ check("...so a block with no reason is a TypeError at the call site, not a "
|
|||||||
and raises(lambda: Gate.block(""), ValueError))
|
and raises(lambda: Gate.block(""), ValueError))
|
||||||
check("...and the same holds for the refusal FCST-03 added to Arbitrage",
|
check("...and the same holds for the refusal FCST-03 added to Arbitrage",
|
||||||
raises(lambda: Arbitrage.refuse(), TypeError))
|
raises(lambda: Arbitrage.refuse(), TypeError))
|
||||||
check("an ALLOWED arbitrage verdict cannot be constructed around a provisional "
|
# ⚠️ ONE CALL PER ROLE, AND THE FIRST DRAFT SHOWED WHY. Passing the provisional
|
||||||
"hour even directly - §8.3 is enforced at the constructor, not only in "
|
# point as BOTH `cheapest` and `dearest` is a stimulus that cannot distinguish
|
||||||
"the ladder above it",
|
# "the constructor guards the cheapest hour" from "the constructor guards both
|
||||||
raises(lambda: Arbitrage.allow(0.1, _prov_pt, _prov_pt, "normal",
|
# hours": a guard on either one alone raises, and the check goes green for an
|
||||||
|
# implementation that leaves the sell hour unguarded. That is the degeneracy
|
||||||
|
# rule this file states at the top, in a new dress - so the two roles are
|
||||||
|
# stimulated SEPARATELY, each with a firm point in the other seat.
|
||||||
|
_firm_pt = TODAY_PART.points[0]
|
||||||
|
check("the two points these constructor checks use really differ in provenance, "
|
||||||
|
"or neither of them grades anything",
|
||||||
|
_prov_pt.provisional is True and _firm_pt.provisional is False)
|
||||||
|
check("an ALLOWED arbitrage verdict cannot be constructed with a provisional "
|
||||||
|
"CHEAPEST hour - the buy hour - even directly: §8.3 is enforced at the "
|
||||||
|
"constructor, not only in the ladder above it",
|
||||||
|
raises(lambda: Arbitrage.allow(0.1, _prov_pt, _firm_pt, "normal",
|
||||||
PriceProduct.DAY_AHEAD, 0.0, frozenset()),
|
PriceProduct.DAY_AHEAD, 0.0, frozenset()),
|
||||||
ValueError))
|
ValueError))
|
||||||
|
check("...nor with a provisional DEAREST hour - the sell hour - which a guard "
|
||||||
|
"written for `cheapest` alone would have let through",
|
||||||
|
raises(lambda: Arbitrage.allow(0.1, _firm_pt, _prov_pt, "normal",
|
||||||
|
PriceProduct.DAY_AHEAD, 0.0, frozenset()),
|
||||||
|
ValueError))
|
||||||
|
check("...while two firm points build the verdict perfectly well, so the two "
|
||||||
|
"refusals above are discriminating rather than universal",
|
||||||
|
Arbitrage.allow(0.1, _firm_pt, _firm_pt, "normal",
|
||||||
|
PriceProduct.DAY_AHEAD, 0.0, frozenset()).kind
|
||||||
|
== "allowed")
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -789,6 +810,17 @@ check("the cheap-window flag is decided per HOUR: a provisional hour is never "
|
|||||||
check("...while a firm hour below the firm mean still is, so carry-forward "
|
check("...while a firm hour below the firm mean still is, so carry-forward "
|
||||||
"filling does not switch the maintenance charge floor off every morning",
|
"filling does not switch the maintenance charge floor off every morning",
|
||||||
_mixed.cheap_now(_firm_hour) is True)
|
_mixed.cheap_now(_firm_hour) is True)
|
||||||
|
# ⚠️ GRADED DIRECTLY, BECAUSE ITS CONSUMERS CANNOT GRADE IT. The §11.4 refusal
|
||||||
|
# and `cheap_now` both guard provenance independently, so `firm_points()`
|
||||||
|
# returning the carried hours TOO still leaves both of them answering correctly
|
||||||
|
# - a stimulus that cannot distinguish an over-reporting accessor from a
|
||||||
|
# correct one. So the tuple itself is compared with the published points, by
|
||||||
|
# identity and in order, rather than only the mean it feeds.
|
||||||
|
check("firm_points() returns EXACTLY the published points, in order - not the "
|
||||||
|
"carried ones as well, which its two consumers would have absorbed "
|
||||||
|
"without either of them changing its answer",
|
||||||
|
list(_mixed.firm_points()) == _firm_rest
|
||||||
|
and all(a is b for a, b in zip(_mixed.firm_points(), _firm_rest)))
|
||||||
check("...and the firm mean it compares against is derived from published "
|
check("...and the firm mean it compares against is derived from published "
|
||||||
"hours only",
|
"hours only",
|
||||||
near(sum(p.eur_per_kwh for p in _mixed.firm_points())
|
near(sum(p.eur_per_kwh for p in _mixed.firm_points())
|
||||||
|
|||||||
Reference in New Issue
Block a user