SAFETY-86 review: hold the site at the peak floor while a HOLD gives
checks / unit (pull_request) Successful in 24m23s
infra-pin / fresh (pull_request) Successful in 1s

At N>=2 the holding pack's pin follows the guard's floor while the loop
still asks for +5000 W, so the ALLOCATOR "could not be placed" row was
re-logged on every 1 W change and filled the 40-row action log. While the
pin is raised, cycle() now files a site `hold` ceiling at the floor
(P_MAINTENANCE), as SAFETY-84 does for a charge. Filed only when the
holding pack gives; hi == the floor, so it never clashes with it.

Tests: a noisy N=2 house logs no ALLOCATOR row and at most one clamp
row (20 and 20 without the ceiling); the ceiling is the floor itself;
a CHARGE is not refiled at N=1. The DECISIONS entry records the owner's
2026-10-01 decision: the HOLD ends on its clock, no top-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-01 02:00:20 +02:00
co-authored by Claude Opus 5.5
parent ba31e7c8b1
commit 1c41a1e412
7 changed files with 108 additions and 31 deletions
@@ -20,6 +20,10 @@ battery gave: 4500 W against a 3950 W aim (SAFETY-84 rig test, check 6).
(owner order), and the owner rule for an Off battery is the same, "give way (owner order), and the owner rule for an Off battery is the same, "give way
only for the shortfall". A battery Off by its mode (P_MODE, below only for the shortfall". A battery Off by its mode (P_MODE, below
maintenance) therefore still gives before the holding one. maintenance) therefore still gives before the holding one.
- N>=2, while the holding battery gives: a site `hold` ceiling at the floor
(P_MAINTENANCE), so the loop does not ask for watts the hold keeps back.
Without it the moving pin re-logged the ALLOCATOR "could not be placed"
row each cycle and filled the action log (review, PR #161).
- Below the aim nothing changes: the floor is 0 W, the hold is 0 W, the rows - Below the aim nothing changes: the floor is 0 W, the hold is 0 W, the rows
are identical to before (test_safety86.py part 2). are identical to before (test_safety86.py part 2).
- A site stop (a SET above `P_PEAK`) decides alone: nothing is raised at N>=2, - A site stop (a SET above `P_PEAK`) decides alone: nothing is raised at N>=2,
@@ -29,14 +33,11 @@ battery gave: 4500 W against a 3950 W aim (SAFETY-84 rig test, check 6).
- The hold resumes by itself: the rule is evaluated each cycle, so when the - The hold resumes by itself: the rule is evaluated each cycle, so when the
guard lets go the command is 0 W again. guard lets go the command is 0 W again.
**The level dips below the target: the hold finishes anyway.** The HOLD does **The level dips below the target: the hold finishes anyway.** Owner
not read the level; it ends on its clock (`maintenance_hold_min`). Topping up decision 2026-10-01: the HOLD ends on its clock (`maintenance_hold_min`) even
would mean going back to CHARGE, which waits for sun or a cheap window (up to when the peak guard pulls the level down; no top-up. A top-up would re-enter
36 h, `CHARGE_TIMEOUT_MIN`) while the pack keeps the site's one maintenance CHARGE (up to 36 h, `CHARGE_TIMEOUT_MIN`) and keep the site's one maintenance
slot, so the other battery's cycle waits, and then a new full hold that the slot meanwhile.
guard can interrupt again. The guard only gives while the quarter is at risk,
and the next monthly cycle balances again. If the owner wants a top-up
instead, it is a change to `maintenance.py`'s HOLD branch, not to this rule.
**Not changed.** **Not changed.**
- The peak guard still grades the emptiest battery (SAFETY-32): at N=2 with - The peak guard still grades the emptiest battery (SAFETY-32): at N=2 with
+4
View File
@@ -202,6 +202,10 @@ CLAIM_SCOPE = {
# sibling would have to give against it. Never filed at N=1. # sibling would have to give against it. Never filed at N=1.
# DECISIONS/SAFETY-84-charge-at-two.md. # DECISIONS/SAFETY-84-charge-at-two.md.
"charge": SCOPE_SITE, "charge": SCOPE_SITE,
# main.py: `Claim.limit("hold", P_MAINTENANCE, hi=...)`, SAFETY-86. While a
# holding pack gives the peak shortfall at N>=2, the site target stays at
# the peak floor, what the pin and the others place. Never filed at N=1.
"hold": SCOPE_SITE,
# --- UNIT: about one pack, one inverter, one device ------------------- # --- UNIT: about one pack, one inverter, one device -------------------
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max, # main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
+14 -5
View File
@@ -2533,13 +2533,14 @@ class Controller:
if c.source == peakguard.SOURCE and c.lo > rate: if c.source == peakguard.SOURCE and c.lo > rate:
claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE) claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE)
def _peak_over_hold(self, claims, maint_results) -> None: def _peak_over_hold(self, claims, maint_results):
"""SAFETY-86: a peak floor outranks a maintenance HOLD (0 W). """SAFETY-86: a peak floor outranks a maintenance HOLD (0 W).
At N=1 the floor is refiled at P_MAINTENANCE, as for a drain. At N>=2 At N=1 the floor is refiled at P_MAINTENANCE, as for a drain. At N>=2
the holding pack gives only what the others cannot: its pin is raised the holding pack gives only what the others cannot: its pin is raised
to the floor minus what they may place. A site stop still decides to the floor minus what they may place, and the floor is returned as
alone. DECISIONS/SAFETY-86-peak-over-hold.md. the site `hold` ceiling (else None). A site stop still decides alone.
DECISIONS/SAFETY-86-peak-over-hold.md.
""" """
hold = {self.actuator_of("maintenance", inv) hold = {self.actuator_of("maintenance", inv)
for inv, res in zip(self.inverters, maint_results) for inv, res in zip(self.inverters, maint_results)
@@ -2569,13 +2570,15 @@ class Controller:
if p is not None and p < 0.0: if p is not None and p < 0.0:
return # a pack pinned to charge: never split both ways return # a pack pinned to charge: never split both ways
others += allocator.hi_w(b) if p is None else p others += allocator.hi_w(b) if p is None else p
give = max(claims[i].lo for i in peak) - others floor = max(claims[i].lo for i in peak)
give = floor - others
if give <= 0.0: if give <= 0.0:
return return
for i, c in enumerate(claims): for i, c in enumerate(claims):
if c.source == "maintenance" and c.kind == SET \ if c.source == "maintenance" and c.kind == SET \
and c.actuator in hold: and c.actuator in hold:
claims[i] = dataclasses.replace(c, value=give) claims[i] = dataclasses.replace(c, value=give)
return floor
def _charge_headroom(self, floor: float, claims) -> float: def _charge_headroom(self, floor: float, claims) -> float:
"""SAFETY-84: at N>=2 a cheap-window charge pulls no more than the """SAFETY-84: at N>=2 a cheap-window charge pulls no more than the
@@ -4352,7 +4355,13 @@ class Controller:
reason="maintenance drain", reason="maintenance drain",
actuator=self.actuator_of("drain"))) actuator=self.actuator_of("drain")))
self._peak_over_drain(claims, maint_results) self._peak_over_drain(claims, maint_results)
self._peak_over_hold(claims, maint_results) # --- SAFETY-86: while a HOLD gives, the site stays at the peak floor --
# So the loop never asks for watts the hold keeps back.
hold_hi = self._peak_over_hold(claims, maint_results)
if hold_hi is not None:
claims.append(Claim.limit("hold", P_MAINTENANCE, hi=hold_hi,
reason="maintenance hold",
actuator=self.actuator_of("hold")))
# --- SAFETY-84: a cheap-window charge is a ceiling over the site ---- # --- SAFETY-84: a cheap-window charge is a ceiling over the site ----
charge = self._charge_ceiling(claims) charge = self._charge_ceiling(claims)
if charge is not None: if charge is not None:
+1
View File
@@ -836,6 +836,7 @@ WHY_LIMIT = {
"reserve": "the battery reserve", "reserve": "the battery reserve",
"maintenance": "battery maintenance", "maintenance": "battery maintenance",
"charge": "battery maintenance", "charge": "battery maintenance",
"hold": "battery maintenance",
"refused": "a battery that refuses power", "refused": "a battery that refuses power",
} }
+8 -5
View File
@@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what "
Claim.limit("device", P_SAFETY, -1, 1).source == "device") Claim.limit("device", P_SAFETY, -1, 1).source == "device")
print() print()
print("ARB-03 2. the split: fifteen sources, each classified where it is BUILT") print("ARB-03 2. the split: sixteen sources, each classified where it is BUILT")
# The classification is graded against what each claim is ABOUT, restated here # The classification is graded against what each claim is ABOUT, restated here
# so that the table in arbiter.py and the expectation in a test are not the same # so that the table in arbiter.py and the expectation in a test are not the same
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's # sentence read twice. test_peakguard.py part 10f pins this set against main.py's
@@ -179,6 +179,9 @@ SITE_SOURCES = {
"charge": "the site target may not go above a cheap-window maintenance " "charge": "the site target may not go above a cheap-window maintenance "
"charge, or a sibling would give against it. Filed at N>1 " "charge, or a sibling would give against it. Filed at N>1 "
"only", "only",
# SAFETY-86.
"hold": "the site target stays at the peak floor while a holding pack "
"gives the shortfall. Filed at N>1 only",
} }
UNIT_SOURCES = { UNIT_SOURCES = {
"device": "one inverter's own reported min/max rating", "device": "one inverter's own reported min/max rating",
@@ -210,9 +213,9 @@ for _src, _why in sorted(SITE_SOURCES.items()):
print(f" site {_src:<11} {_why}") print(f" site {_src:<11} {_why}")
for _src, _why in sorted(UNIT_SOURCES.items()): for _src, _why in sorted(UNIT_SOURCES.items()):
print(f" unit {_src:<11} {_why}") print(f" unit {_src:<11} {_why}")
check("all FIFTEEN claim sources are classified, and only those fifteen", check("all SIXTEEN claim sources are classified, and only those sixteen",
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES)) set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
check("the eight that are about the GRID CONNECTION are site claims", check("the nine that are about the GRID CONNECTION are site claims",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES)) {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
check("the seven that are about a PACK, a DEVICE or one write are per-inverter", check("the seven that are about a PACK, a DEVICE or one write are per-inverter",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES)) {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
@@ -234,7 +237,7 @@ check("...and with none configured at all, likewise",
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE)) all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("with TWO the identity breaks and the split becomes real: the seven " check("with TWO the identity breaks and the split becomes real: the seven "
"per-inverter sources route to the unit this controller drives, the " "per-inverter sources route to the unit this controller drives, the "
"eight site sources stay on the grid connection", "nine site sources stay on the grid connection",
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE} {s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
== {**{s: "inverter-a" for s in UNIT_SOURCES}, == {**{s: "inverter-a" for s in UNIT_SOURCES},
**{s: ACTUATOR_SITE for s in SITE_SOURCES}}) **{s: ACTUATOR_SITE for s in SITE_SOURCES}})
@@ -515,7 +518,7 @@ check("...and at N=1, where every source legitimately routes to the site, the "
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0)]) == []) Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0)]) == [])
check("an unclassified ninth source is a violation wherever it is filed", check("an unclassified ninth source is a violation wherever it is filed",
scope_violations([Claim.limit("reserve", P_LOOP, hi=0.0)]) != []) scope_violations([Claim.limit("reserve", P_LOOP, hi=0.0)]) != [])
check("and the eight, routed by actuator_for() at a two-inverter site, are " check("and the sixteen, routed by actuator_for() at a two-inverter site, are "
"clean - which is what says the router and the checker agree", "clean - which is what says the router and the checker agree",
scope_violations( scope_violations(
[Claim.limit(s, P_SAFETY, -1, 1, [Claim.limit(s, P_SAFETY, -1, 1,
+14 -10
View File
@@ -2691,9 +2691,12 @@ check("...and so does the persistent notification, which outlives the page and "
# site `hi` at P_MAINT_SHAPE, filed only at N>=2 while a pack's cheap-window # site `hi` at P_MAINT_SHAPE, filed only at N>=2 while a pack's cheap-window
# maintenance charge makes it charge, equal to that pack's own `hi`. Never at # maintenance charge makes it charge, equal to that pack's own `hi`. Never at
# N=1. DECISIONS/SAFETY-84-charge-at-two.md. # N=1. DECISIONS/SAFETY-84-charge-at-two.md.
# SAFETY-86 added `hold`: a site `hi` at the peak floor while a holding pack
# gives at N>=2. Not a reserve either. DECISIONS/SAFETY-86-peak-over-hold.md.
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak", CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
"arbitrage", "maintenance", "loop", "circulation", "arbitrage", "maintenance", "loop", "circulation",
"sign_site", "pack", "refused", "mode", "drain", "charge"} "sign_site", "pack", "refused", "mode", "drain", "charge",
"hold"}
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so # The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
# that a THIRD such indirection reads as an unrecognised append rather than as a # that a THIRD such indirection reads as an unrecognised append rather than as a
# silent gap in the scan. # silent gap in the scan.
@@ -2735,7 +2738,7 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
"- a claim filed through a shape it cannot parse has to fail here rather " "- a claim filed through a shape it cannot parse has to fail here rather "
"than be counted as nothing, which is how a scan like this goes quietly " "than be counted as nothing, which is how a scan like this goes quietly "
"vacuous", len(_appends) >= 10 and None not in _filed) "vacuous", len(_appends) >= 10 and None not in _filed)
check("...and the sources that cycle can file are EXACTLY these FIFTEEN. THIS " check("...and the sources that cycle can file are EXACTLY these SIXTEEN. THIS "
"is what makes part 10 a tripwire: replay_loop() hand-builds the claim " "is what makes part 10 a tripwire: replay_loop() hand-builds the claim "
"list, so it cannot see a claim main.py adds - and a loop-side SoC " "list, so it cannot see a claim main.py adds - and a loop-side SoC "
"reserve is a new source. Filing one takes this line red on the line it " "reserve is a new source. Filing one takes this line red on the line it "
@@ -2875,7 +2878,7 @@ print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
+ " | per-inverter: " + " | per-inverter: "
+ ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT))) + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT)))
check("...and the split is the one ARB-03 argued, with SAFETY-31's and " check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
"SAFETY-30's, OPT-09's, SAFETY-78's and SAFETY-84's additions: the eight about the GRID CONNECTION " "SAFETY-30's, OPT-09's, SAFETY-78's, SAFETY-84's and SAFETY-86's additions: the nine about the GRID CONNECTION "
"are site claims and the five about a pack, a device or one write are " "are site claims and the five about a pack, a device or one write are "
"per-inverter. `circulation` is a site claim because its quantity is " "per-inverter. `circulation` is a site claim because its quantity is "
"defined over the roster and is identically zero for any one unit; " "defined over the roster and is identically zero for any one unit; "
@@ -2885,7 +2888,7 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
"against that unit's OWN measured power", "against that unit's OWN measured power",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site", == {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site",
"drain", "charge"} "drain", "charge", "hold"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance", "pack", == {"device", "supervised", "failsafe", "maintenance", "pack",
"refused", "mode"}) "refused", "mode"})
@@ -2990,7 +2993,8 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE, "loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE, "arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE, "circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE,
"drain": ACTUATOR_SITE, "charge": ACTUATOR_SITE}) "drain": ACTUATOR_SITE, "charge": ACTUATOR_SITE,
"hold": ACTUATOR_SITE})
check("...and the roster it was restored to is the shipped one, so nothing " check("...and the roster it was restored to is the shipped one, so nothing "
"below inherits a two-inverter Controller", "below inherits a two-inverter Controller",
c_src.units == (app_main.UNIT_A,) and len(c_src.inverters) == 1) c_src.units == (app_main.UNIT_A,) and len(c_src.inverters) == 1)
@@ -3069,13 +3073,13 @@ _unit_sources_unrouted = [
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]] if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
print(f" {len(_direct)} direct appends, each read at its own line: " print(f" {len(_direct)} direct appends, each read at its own line: "
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing)) + ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
check("all SIXTEEN of `Controller.cycle()`'s DIRECT appends route through " check("all SEVENTEEN of `Controller.cycle()`'s DIRECT appends route through "
"`self.actuator_of(<string literal>)`, so this scan can read the routing " "`self.actuator_of(<string literal>)`, so this scan can read the routing "
"of EVERY one of them. An append that computed its actuator, or omitted " "of EVERY one of them. An append that computed its actuator, or omitted "
"the argument, or hand-wrote a string, fails HERE rather than being " "the argument, or hand-wrote a string, fails HERE rather than being "
"silently skipped - which is how a scan like this goes quietly vacuous, " "silently skipped - which is how a scan like this goes quietly vacuous, "
"and 10f is in this file because that already happened once", "and 10f is in this file because that already happened once",
len(_direct) == 16 len(_direct) == 17
and all(a is not None and a.startswith("self.actuator_of('") and all(a is not None and a.startswith("self.actuator_of('")
for _s, a, _ln in _routing)) for _s, a, _ln in _routing))
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE " check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
@@ -3109,8 +3113,8 @@ check("...and the only appends this pin does NOT reach are the two that file a "
"arbitrage.claim() by test_arbitrage.py", "arbitrage.claim() by test_arbitrage.py",
sorted(_indirect) == sorted(CLAIM_BUILDERS) sorted(_indirect) == sorted(CLAIM_BUILDERS)
and len(_direct) + len(_indirect) == len(_appends)) and len(_direct) + len(_indirect) == len(_appends))
check("...and between them the eighteen appends account for exactly the " check("...and between them the nineteen appends account for exactly the "
"fifteen " "sixteen "
"sources 10f pins, so the routing pin and the source set are one scan " "sources 10f pins, so the routing pin and the source set are one scan "
"and neither can drift from the other", "and neither can drift from the other",
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values()) {s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
@@ -3171,7 +3175,7 @@ check("...and naming those three is not an accident of this fixture but the "
{c.source for c in _cl2} {c.source for c in _cl2}
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak", == CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
"arbitrage", "circulation", "sign_site", "arbitrage", "circulation", "sign_site",
"refused", "mode", "drain", "charge"}) "refused", "mode", "drain", "charge", "hold"})
# ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three # ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three
# checks above grade WHICH ACTUATOR each claim lands on. They say nothing about # checks above grade WHICH ACTUATOR each claim lands on. They say nothing about
# WHOSE NUMBERS the bound is built from - and those are two different defects # WHOSE NUMBERS the bound is built from - and those are two different defects
+58 -3
View File
@@ -38,7 +38,8 @@ from app import packmode # noqa: E402
from app.arbiter import (Claim, P_MAINTENANCE, P_PEAK, P_SAFETY, # noqa from app.arbiter import (Claim, P_MAINTENANCE, P_PEAK, P_SAFETY, # noqa
resolve) resolve)
from app.main import Controller # noqa: E402 from app.main import Controller # noqa: E402
from app.maintenance import DRAIN, HOLD, IDLE, MaintResult # noqa: E402 from app.maintenance import (CHARGE, DRAIN, HOLD, IDLE, # noqa: E402
MaintResult)
from app.store import unit_key # noqa: E402 from app.store import unit_key # noqa: E402
fails = [] fails = []
@@ -206,6 +207,47 @@ check("⭐ battery modes on, B Off: the Off battery still gives before the "
and 540.0 <= cmd(_off_rows[-1])["a"] <= 560.0 and 540.0 <= cmd(_off_rows[-1])["a"] <= 560.0
and refused(_off_rows) == 0 and not alarmed(_off)) and refused(_off_rows) == 0 and not alarmed(_off))
def noisy(t):
"""A 6500 W house that moves up to 150 W from one second to the next."""
return 6500.0 + ((int(t) * 7919) % 301) - 150.0
def no_ceiling(fn):
"""Run with the pin raised but no `hold` site ceiling (review check)."""
real = Controller._peak_over_hold
def pin_only(self, claims, results):
real(self, claims, results)
Controller._peak_over_hold = pin_only
try:
return fn()
finally:
Controller._peak_over_hold = real
def rows_logged(c, word):
return sum(1 for e in c.events if word in e)
_nz_rows, _nz = run(noisy, 2)
_nz_old = no_ceiling(lambda: run(noisy, 2))[1]
print(f" noisy house: ALLOCATOR rows {rows_logged(_nz, 'ALLOCATOR')}, "
f"clamp rows {rows_logged(_nz, 'OUTPUT_CLAMP')} (without the site "
f"ceiling {rows_logged(_nz_old, 'ALLOCATOR')} and "
f"{rows_logged(_nz_old, 'OUTPUT_CLAMP')})")
check("⭐ while A gives, the site target is the guard's floor (the `hold` "
"ceiling), so a noisy house does not fill the action log: no ALLOCATOR "
"row and at most one clamp row",
on_aim(_nz) and all(abs(r[1] - r[-1][1][1]) < 1e-6 for r in _nz_rows
if cmd(r)["a"] > 0.0)
and rows_logged(_nz, "ALLOCATOR") == 0
and rows_logged(_nz, "OUTPUT_CLAMP") <= 1)
check("non-vacuous: without the ceiling the loop asks 5000 W and the "
"ALLOCATOR and clamp rows fill the 40-row log",
rows_logged(_nz_old, "ALLOCATOR") >= 10 and len(_nz_old.events) == 40)
# ============================================================================ # # ============================================================================ #
print("\n4. a month peak banked above the limit") print("\n4. a month peak banked above the limit")
BANK = {n: (run(6500.0, n, month_peak=4000.0), BANK = {n: (run(6500.0, n, month_peak=4000.0),
@@ -301,7 +343,8 @@ def pin_after(floor, *extra, live=(A, B), results=HOLDING, c=_c):
Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD, actuator=A), Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD, actuator=A),
Claim.limit("peak", P_PEAK, lo=floor, reason="quarter peak"), Claim.limit("peak", P_PEAK, lo=floor, reason="quarter peak"),
*extra] *extra]
Controller._peak_over_hold(fake, cl, results) ceiling = Controller._peak_over_hold(fake, cl, results)
pin_after.ceiling = ceiling
return next((x.value for x in cl return next((x.value for x in cl
if x.source == "maintenance" and x.kind == "set"), None), cl if x.source == "maintenance" and x.kind == "set"), None), cl
@@ -331,6 +374,15 @@ check("⭐ a site safety SET: A stays at 0 W and the site resolves to 0 W; "
_p == 0.0 and resolve(_cl, units=(A, B), live={A, B}).target_w == 0.0 _p == 0.0 and resolve(_cl, units=(A, B), live={A, B}).target_w == 0.0
and pin_after(3000.0, Claim.set("failsafe", P_SAFETY, 0.0, and pin_after(3000.0, Claim.set("failsafe", P_SAFETY, 0.0,
actuator=B))[0] == 3000.0) actuator=B))[0] == 3000.0)
_p, _cl = pin_after(2600.0)
_hi = pin_after.ceiling
_cl += [Claim.limit("hold", P_MAINTENANCE, hi=_hi, actuator="site"),
Claim.set("loop", 10, 5000.0, "the loop asks for the whole house")]
pin_after(2500.0)
check("⭐ at N=2 the `hold` site ceiling is the floor itself, only when A "
"gives, and a 5000 W loop ask resolves to that floor",
_hi == 2600.0 and pin_after.ceiling is None
and resolve(_cl, units=(A, B), live={A, B}).target_w == 2600.0)
check("only a HOLD: a DRAIN pin or an idle pack is not raised", check("only a HOLD: a DRAIN pin or an idle pack is not raised",
pin_after(4000.0, results=[MaintResult(DRAIN, True, 0.0, False, []), pin_after(4000.0, results=[MaintResult(DRAIN, True, 0.0, False, []),
HOLDING[1]])[0] == 0.0 HOLDING[1]])[0] == 0.0
@@ -355,8 +407,11 @@ _stop = refile(3000.0) + [Claim.set("safety", P_SAFETY, 0.0,
"inputs missing")] "inputs missing")]
check("⭐ at N=1 a refiled floor stays under a safety SET: 0 W", check("⭐ at N=1 a refiled floor stays under a safety SET: 0 W",
resolve(_stop).target_w == 0.0) resolve(_stop).target_w == 0.0)
check("at N=1 outside a HOLD the claim is not touched by this rule", check("at N=1 outside a HOLD (idle, or a CHARGE) the claim is not touched "
"by this rule",
refile(3000.0, (MaintResult(IDLE, False, None, False, []),))[1] refile(3000.0, (MaintResult(IDLE, False, None, False, []),))[1]
.priority == P_PEAK
and refile(3000.0, (MaintResult(CHARGE, False, None, True, []),))[1]
.priority == P_PEAK) .priority == P_PEAK)
print() print()