Files
glenn schrooyenandClaude Opus 5.5 63dda4b0d3
checks / unit (pull_request) Successful in 19m51s
infra-pin / fresh (pull_request) Successful in 2s
MAINT-20 part 1 review: restore three lost lines, rewrap ragged comments
Comment, docstring and DECISIONS only (same-code script OK; python -OO
bytecode now identical for all six files).
- peakguard.charge_room_w: one-line docstring + SAFETY-125 pointer (cut whole).
- peakguard.shaping_suspended: keep 'the one place this conjunction is
  written; do not inline it'.
- price_forecast module note: an hour with no price and no yesterday is left
  out, never filled (test_price.py 14d cites the module note for it).
- Refill comment blocks the cut left ragged; DECISIONS pointers on one line.
- FCST-04 note: _yesterday_of(), not _previous_day(); FCST-03 note: the 49
  stronger cases are why the rule is a real change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-11-01 03:02:51 +01:00

462 lines
21 KiB
Python

"""SAFETY-07 - the quarter-peak projection guard: one arbiter `lo` limit at
`P_PEAK` from tariff.py's measured month peak. Pure: no I/O, clock or state.
Why a limit, the floor's derivation, blindness, the SoC reserve and the
options: DECISIONS/SAFETY-07-peak-guard.md."""
from dataclasses import dataclass, replace
from . import messages
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
from .control import peak_at_risk
from .tariff import QUARTER_S
# Why the projection may not be graded; the status page and log say which.
BLIND_NO_BLOCK = "no open 15-minute period, the meter has given no reading yet"
BLIND_COVERAGE = "meter gap"
BLIND_FROZEN = "the meter is answering without changing"
# Where the ceiling came from. Only an OBSERVED reading may relax it.
CEILING_CAP = "peak_cap_w"
CEILING_MONTH_PEAK = "this month's peak"
# SAFETY-24. Why a forced discharge may not be sized.
FLOOR_SOC_UNREADABLE = ("the battery level was not read this cycle, and "
"unread is not full")
# The words are "the peak reserve", not "the SoC floor": `peak_soc_floor`
# bounds this guard only (SAFETY-25).
FLOOR_AT_SOC_FLOOR = "the battery level is at or below the peak reserve"
SOURCE = "peak"
# Mirror of config.yaml's peak options, pinned to the file by
# test_peakguard.py. Not a read: `decide()` takes them as arguments.
PEAK_OPTION_DEFAULTS = {
# Percent a forced discharge may not go below. 20 is derived from the
# reference pack, 300 Ah at 51.2 V with DoD 90 - 13.8 kWh usable (a test
# pins this wording). See DECISIONS/SAFETY-07-peak-guard.md.
"peak_soc_floor": 20,
# The pack's own cut-out in percent; rule 8 of validate.py refuses a
# `peak_soc_floor` at or below it.
"bms_soc_floor": 10,
# SAFETY-79. Watts the guard aims under `peak_cap_w`.
"peak_margin_w": 50,
}
# SAFETY-79. Range of `peak_margin_w` in config.yaml; test_peakguard.py pins it.
PEAK_MARGIN_RANGE_W = (0, 500)
@dataclass(frozen=True)
class PeakDecision:
"""What the guard concluded this cycle. No `floor_w_or(default)`: turning "not
measured" into a number is the defect `PeakReading` removes."""
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
target_w: float | None = None # SAFETY-79: what the guard aims at
saturated: bool = False
# SAFETY-24: a forced discharge was wanted and the peak reserve refused it;
# excludes `saturated`. SAFETY-87: at N>=2 the others may be asked up to
# `able_w`.
floor_limited: bool = False
soc: float | None = None
soc_floor: float | None = None
soc_floor_reason: str | None = None
reason: str = ""
# SAFETY-87, N>=2: batteries at or under their reserve, and what the others
# can give.
held: tuple = ()
able_w: float | None = None
# SAFETY-126: power is wanted but the quarter still ends within
# ALARM_MISS_W of the aim with the batteries at 0 W (`misses_aim()`). Set
# by main.py.
within_aim: bool = False
def explain(self) -> str:
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
technical record with every figure, for the log and the action log."""
if self.floor_limited:
return self.reason
if not self.engaged:
if self.projected_w is None:
return messages.row_peak_guard_quiet()
return messages.row_peak_guard_idle(
self.projected_w, self.ceiling_w,
month_peak=self.ceiling_from == CEILING_MONTH_PEAK)
return self.reason
def blind_kind(blind: str) -> str:
"""Which of the three kinds of blindness, as a key of
`messages.BLIND_WORDS`, for the status row."""
if blind == BLIND_FROZEN:
return "frozen"
if blind == BLIND_NO_BLOCK:
return "no_block"
return "gap"
def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
"""The average this quarter may not exceed, and where it came from.
Only an OBSERVED reading raises it; a failed read keeps the cap (stricter)."""
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 quarter_target_w(cap_w, margin_w, reading) -> float:
"""SAFETY-79. The average the guard AIMS at: `margin_w` under
`quarter_ceiling_w()`. Reports and the bill keep the real limit (`ceiling_w`)."""
return quarter_ceiling_w(cap_w, reading)[0] - float(margin_w)
def excess_w(projection, ceiling_w) -> float:
"""Watts that must come off the meter now to land the quarter on the ceiling:
`900 * (projected - ceiling) / remaining`; 0.0 when `remaining <= 0`."""
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 cover (`covered_frac` of `elapsed_s`)."""
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. Checked first: a fresh
averager's all-zero `Projection` sits under every ceiling."""
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 15-minute period not "
f"covered, past the {float(max_hold_s):.0f} s a reading is "
f"held")
return None
# SAFETY-90. Uncounted seconds below this are float noise, never a gap.
GAP_TOL_S = 1e-3
def gap_w(ceiling_w, *seen_w) -> float:
"""SAFETY-90: the watts a meter gap is counted at. The highest of the
limit and `seen_w`, the import with the batteries at 0 W (grid + their
discharge) just before and just after the gap; None entries are skipped.
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None])
def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w,
held_ws=0.0):
"""SAFETY-90: `projection` with the meter-gap seconds the averager did not
count filled at `gap_w` (those before the first reading, `unseen_s`, at
`unseen_w`), plus `held_ws`. Returns `(projection, gap s, unseen s)`.
`tail_s`: up to `max_hold_s` of the held reading is left as before; past
that nothing is filled. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
if projection.block_start is None or max_hold_s is None:
return projection, 0.0, 0.0
wall = float(projection.elapsed_s)
tail = min(max(0.0, float(tail_s)), wall)
if tail > float(max_hold_s):
return projection, 0.0, 0.0
covered = wall - uncovered_s(projection)
missing = wall - covered - tail
if missing <= GAP_TOL_S:
missing = 0.0
if held_ws <= 0.0:
return projection, 0.0, 0.0
unseen = min(missing, max(0.0, float(unseen_s)))
gap = missing - unseen
add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws)
done = covered + missing
return (replace(
projection,
projected_w=float(projection.projected_w) + add_ws / QUARTER_S,
elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done
if done > 0.0 else float(projection.elapsed_w)),
covered_frac=(done / wall if wall > 0.0
else float(projection.covered_frac))), gap, unseen)
def discharge_permitted(soc, soc_floor) -> str | None:
"""SAFETY-24. May the guard size a forced discharge? None if so, else the reason.
`None` for `soc` or `soc_floor` is NOT permission, and neither has a default.
`<=`: a pack exactly on the floor has spent what the guard may spend."""
if soc is None:
return FLOOR_SOC_UNREADABLE
if soc_floor is None:
return ("no peak guard SoC floor is configured, so there is nothing to "
"bound a forced discharge with")
if float(soc) <= float(soc_floor):
return FLOOR_AT_SOC_FLOOR
return None
@dataclass(frozen=True)
class Pack:
"""SAFETY-32. One pack's numbers for the floor question.
`bms_floor` is its own cut-out percent; None when the roster states none."""
name: str
soc: float | None
bms_floor: float | None
def pack_floor_pct(pack, soc_floor):
"""The percent THIS pack may not be spent below, or None if unknown:
`max(site EMS floor, its own cut-out)`. An unstated cut-out is None, not 0
(`discharge_permitted()` reads None as no permission). The reduction is
written out because it must not depend on validate.py rule 8."""
if soc_floor is None or pack.bms_floor is None:
return None
return max(float(soc_floor), float(pack.bms_floor))
def pack_permissions(packs, soc_floor) -> list[tuple[str, str | None]]:
"""SAFETY-32. Per pack, in roster order: None if it may be spent, else why not.
Unlike the `min()` over SoC, it keeps which pack and which floor."""
return [(p.name, discharge_permitted(p.soc, pack_floor_pct(p, soc_floor)))
for p in packs]
def binding_soc_floor(packs, soc_floor):
"""The one floor `decide()` grades `pack_floor_soc` against: the MAXIMUM of the
packs' floors, so never more permissive than grading each pack. None if any
floor is unknown. At N=1 it is `soc_floor` (test_peakguard.py part 12)."""
if not packs:
return None if soc_floor is None else float(soc_floor)
floors = [pack_floor_pct(p, soc_floor) for p in packs]
if any(f is None for f in floors):
return None
return max(floors)
def reserve_split(packs, soc_floor) -> tuple[list, tuple]:
"""SAFETY-87: `(packs the guard may spend, names of those it may not)`,
each pack graded on its own floor by `pack_permissions()`."""
perms = pack_permissions(packs, soc_floor)
return ([p for p, (_n, why) in zip(packs, perms) if why is None],
tuple(n for n, why in perms if why is not None))
def place_on_able(decision, held, *, able_w, soc, soc_floor) -> PeakDecision:
"""SAFETY-87: `decision` with the batteries in `held` (at or under their
own reserve) giving nothing for the peak. `able_w`, `soc` and `soc_floor`
describe the others, as `decide()`'s arguments do for the roster. A
floor-limited decision is re-taken on them: they are asked for the need
up to `able_w`, and it stays floor-limited only if that is not enough.
`soc` and `soc_floor` keep the roster's verdict, as `decide()` echoed it.
DECISIONS/SAFETY-87-guard-uses-able-batteries.md."""
held = tuple(held)
if not decision.floor_limited or decision.wanted_w is None \
or discharge_permitted(soc, soc_floor) is not None:
return replace(decision, held=held)
wanted = float(decision.wanted_w)
floor = min(wanted, float(able_w))
head = (f"this 15-minute period heads for {decision.projected_w:.0f} W "
f"against a {decision.ceiling_w:.0f} W limit "
f"({decision.ceiling_from}), aiming at {decision.target_w:.0f} W. "
f"At the peak reserve, so giving nothing for the peak: "
f"{messages.names(held)}. The others are asked for {floor:+.0f} W")
if floor >= wanted:
return replace(decision, floor_limited=False, floor_w=floor,
soc_floor_reason=None, held=held, able_w=float(able_w),
reason=f"PEAK_GUARD: {head}")
return replace(decision, floor_w=floor, held=held, able_w=float(able_w),
reason=(f"PEAK_GUARD_FLOOR_LIMITED: {head}, all they can "
f"give, but {wanted:+.0f} W was needed, so this "
f"period may go over the limit"))
def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc,
soc_floor, 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 invents no floor.
`achievable_w` largest discharge the arbiter could grant; the floor is
clamped to it so the claim never produces `lo > hi` (resolves to 0 W).
`soc` percent; None is floor-limited. `soc_floor`, `margin_w`: required."""
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
target = quarter_target_w(cap_w, margin_w, reading)
aim = "" if target == ceiling else f", aiming at {target:.0f} W"
ceil_top = abs(float(achievable_w))
blind = blindness(projection, max_hold_s, frozen)
if blind is not None:
# 0.0: nothing trustworthy to size a floor from; forbid charging, which
# costs no pack energy.
return PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where,
target_w=target, blind=blind, projected_w=None,
reason=(f"PEAK_GUARD_BLIND: {blind}. The battery may not charge "
f"while this 15-minute period cannot be measured, so it "
f"is asked for no charging, floor 0 W"))
projected = float(projection.projected_w)
if batt_w is None:
# main.py's P_SAFETY stop already owns a missing input; file nothing.
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, target_w=target, 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 (`excess_w` answers
# 0.0).
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, target_w=target, projected_w=projected,
reason="the quarter has no seconds left to influence")
ex = excess_w(projection, target)
wanted = float(batt_w) + ex
if wanted <= 0.0:
# The test is on the floor (`batt_w + excess`), not on `excess > 0`:
# excess falls to zero once the battery is removing it, and releasing
# there cycled the guard 17 times in one quarter (2026-08-20).
# DECISIONS/SAFETY-07-peak-guard.md.
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, target_w=target, projected_w=projected,
excess_w=ex,
wanted_w=wanted,
reason="the load is under the level this quarter can afford")
# SAFETY-24, only here: `wanted > 0`, so a forced discharge is really asked
# for.
denied = discharge_permitted(soc, soc_floor)
if denied is not None:
# 0.0, never negative: forbids charging only. It bounds the guard, not
# the loop.
return PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=ceiling,
ceiling_from=from_where, target_w=target, projected_w=projected,
excess_w=ex,
wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor,
soc_floor_reason=denied,
reason=(
f"PEAK_GUARD_FLOOR_LIMITED: this 15-minute period heads for "
f"{projected:.0f} W against a {ceiling:.0f} W limit "
f"({from_where}){aim}, and {wanted:+.0f} W from the battery "
f"would hold it. But {denied}"
+ ("" if soc is None else f", at {float(soc):.0f} %")
+ ("" if soc is None or soc_floor is None
else f" against {float(soc_floor):.0f} %")
+ ", so no forced discharge and no charging, and this period "
"will go over the limit"))
floor = min(wanted, ceil_top)
saturated = floor < wanted
return PeakDecision(
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
target_w=target, blind=None, projected_w=projected, excess_w=ex,
wanted_w=wanted, saturated=saturated, soc=soc, soc_floor=soc_floor,
reason=(
f"PEAK_GUARD: this 15-minute period heads for {projected:.0f} W "
f"against a {ceiling:.0f} W limit ({from_where}){aim}, with "
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
f"come off the meter. The battery measured {float(batt_w):+.0f} W "
f"and is asked for {floor:+.0f} W"
+ (f", the most the inverters give, but {wanted:+.0f} W was needed"
if saturated else "")))
# SAFETY-81. A quarter must end this many W over the real limit (and for
# ALARM_HOLD_S, so a hob cycling does not count) before "Load too high" fires.
ALARM_MISS_W = 10.0
ALARM_HOLD_S = 10.0
def load_too_high(projection, *, batt_w, ceiling_w, achievable_w) -> bool:
"""SAFETY-81. May "Load too high for battery" fire? Only if, with the battery at
full power, the quarter still ends over the real limit by `ALARM_MISS_W`.
No `decide()` gain; `batt_w` is the lower of two cycles' readings.
DECISIONS/SAFETY-81-load-too-high.md."""
remaining = QUARTER_S - float(projection.elapsed_s)
if batt_w is None or remaining <= 0.0:
return False
rest = float(projection.held_w) + float(batt_w) - float(achievable_w)
done_ws = (QUARTER_S * float(projection.projected_w)
- max(float(projection.held_w), 0.0) * remaining)
ends_w = (done_ws + max(rest, 0.0) * remaining) / QUARTER_S
return ends_w - float(ceiling_w) > ALARM_MISS_W
def misses_aim(projection, *, batt_w, target_w) -> bool:
"""SAFETY-126. With the batteries at 0 W from now on, does the quarter end more
than `ALARM_MISS_W` over the aim?
DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md."""
return load_too_high(projection, batt_w=batt_w, ceiling_w=target_w,
achievable_w=0.0)
def charge_headroom_w(decision) -> float | None:
"""SAFETY-45. The hardest this SITE may charge without putting the billed
quarter over the ceiling, in W; None when nothing measured it (not unlimited).
It is `wanted_w` read the other way, independent of the battery, so sizing a
trade to it cannot oscillate. 0.0 when blind or holding. A SIZING, NOT A
CLAIM: a negative `lo` would cross the maintenance shaping's negative `hi`
(0 W, battery parked). DECISIONS/SAFETY-45-sizing-the-trade.md section 2b."""
if decision is None:
return None
if decision.blind:
return 0.0
if decision.wanted_w is None:
return None
return max(0.0, -float(decision.wanted_w))
def charge_room_w(decision, projection) -> float | None:
"""SAFETY-125: `charge_headroom_w()` less the unread seconds counted at the
last reading, so a late reading is not room for a grid charge.
DECISIONS/SAFETY-125-grid-charge-under-the-month-peak.md."""
room = charge_headroom_w(decision)
if not room or projection is None or projection.block_start is None:
return room
left = QUARTER_S - float(projection.elapsed_s)
if left <= 0.0:
return room
late_ws = max(0.0, float(projection.held_w)) * uncovered_s(projection)
return max(0.0, room - late_ws / left)
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
"""Must the maintenance charge-only shaping stand down this cycle?
Crossing limits resolve to 0 W, so the rule is suppression, not priority.
Both terms are needed: the forecast entity is empty on a default install,
where the measured guard fires. Dropping either parks the battery in the
billed quarter (test_peakguard.py part 7). The one place this conjunction is
written, public so the test grades the shipped expression: do not inline it.
DECISIONS/SAFETY-07-peak-guard.md."""
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 (`lo` only, `hi` stays `+inf`), or None."""
if not decision.engaged or decision.floor_w is None:
return None
# Blind, floor-limited and a genuine zero floor all file `lo = 0 W`; each
# gets its own reason. On the SITE actuator: the billed quantity is one per
# site.
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
reason=("blind" if decision.blind
else "SoC floor" if decision.floor_limited
and not decision.floor_w
else "quarter peak"),
actuator=ACTUATOR_SITE)