SAFETY-83: peak guard outranks the maintenance drain at N=1
checks / unit (pull_request) Successful in 16m54s
infra-pin / fresh (pull_request) Successful in 1s

At N=1 the drain is the site SET at P_MAINTENANCE (60), so the peak floor
(P_PEAK, 50) was not applied and the house imported what the drain left.
Where the floor asks for more than the drain rate, main.py now refiles the
peak claim at P_MAINTENANCE, so it wins; safety SETs still park everything.
Nothing else changes: safety78_identity --diff against 35cdffe changes only
cycles where the guard wanted more than the drain; N=2 is byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 22:40:14 +02:00
co-authored by Claude Opus 5.5
parent 35cdffe766
commit d77f1b1fdf
8 changed files with 402 additions and 27 deletions
+25 -6
View File
@@ -25,6 +25,7 @@ battery to zero on its own. Never "hold the last value to be safe".
import asyncio
import contextlib
import dataclasses
import functools
import json
import logging
@@ -2428,6 +2429,25 @@ class Controller:
self._drain_waits = waits
return floor if floor > 0.0 else None
def _peak_over_drain(self, claims, maint_results) -> None:
"""SAFETY-83: at N=1 a peak floor above the drain rate outranks it.
The drain is the site SET at P_MAINTENANCE, above the peak floor
(P_PEAK), so the floor did not bind and the house imported what the
drain left. Where the floor asks for more than the drain rate, it is
refiled at P_MAINTENANCE and wins; nothing else changes. Safety claims
(P_SAFETY) still outrank it. At N>=2 the `drain` floor does this.
"""
if len(self.inverters) != 1:
return
res = maint_results[0]
if res.phase != DRAIN or not res.owns_setpoint:
return
rate = float(res.setpoint_w or 0.0)
for i, c in enumerate(claims):
if c.source == peakguard.SOURCE and c.lo > rate:
claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE)
def _maint_stop_off(self, machine, now) -> None:
"""OPT-11: stop this battery's maintenance cycle because it is off."""
if machine.phase == IDLE:
@@ -3740,12 +3760,10 @@ class Controller:
# 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.
# business, not this block's: a maintenance DRAIN or HOLD owns the
# setpoint at P_MAINTENANCE, and a maintenance CHARGE is only ever a
# P_MAINT_SHAPE limit (40), bound by this floor. A floor above the drain
# rate still wins (SAFETY-78 at N>=2, `_peak_over_drain()` at N=1).
#
# ⚠️ AND IT IS NOT RUN AT ALL WITHOUT P1. `meter_source: off` is the
# legacy single-entity path: there is no telegram stream, so
@@ -4145,6 +4163,7 @@ class Controller:
claims.append(Claim.limit("drain", P_MAINTENANCE, lo=drain,
reason="maintenance drain",
actuator=self.actuator_of("drain")))
self._peak_over_drain(claims, maint_results)
# --- OPT-11 P3: the site trade, after every unit claim --------------
# Sized from what the batteries may place with their modes and hard
# limits (the loop bound), so none is asked for watts it may not give.