SAFETY-95: a maintenance drain that ends hands back smoothly (N>=2)
When a battery's drain ended (done, Off, timeout, abort, stale link, refusal), its drain floor stayed in the site target and landed on the others: B 0 -> +1600 W in one cycle at a 1200 W house, exporting; under a solar surplus B gave into the export. Now, on that cycle, the loop starts from what the others stood at plus the battery's new command, raised to the house's own point when the house needs more (never above last cycle's start). While the battery settles, the loop counts the batteries at its start instead of chasing the drain's tail on the meter. Then one slew step a cycle. N=1 is unchanged. test_safety95.py; DECISIONS/SAFETY-95-drain-end.md; one DOCS line. test_modes.py: opt11 n2_maint re-pinned on purpose (its drain ends by a refusal at 1655 s; B now stays at the house's 1420 W instead of 2500 W). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b2f55732b6
commit
bdec66e97a
@@ -0,0 +1,66 @@
|
|||||||
|
# SAFETY-95 - a maintenance drain that ends hands back smoothly
|
||||||
|
|
||||||
|
**Status: implemented 2026-10-06, for review.** Tests: `test_safety95.py`.
|
||||||
|
|
||||||
|
**The defect** (SAFETY-93 review, O1). At N=2, when A's drain ended (A
|
||||||
|
switched Off, say) with the house at 1200 W, B went 0 -> +1600 W in one
|
||||||
|
cycle and exported. It is the mirror of SAFETY-93 on the drain end.
|
||||||
|
|
||||||
|
**Cause.** During a drain the site target carries A's drain floor
|
||||||
|
(SAFETY-78, `drain`, 2000 W) above what the house needs. At the end A's
|
||||||
|
command drops (pin, Off, abort, stale link, refusal), but the loop slews
|
||||||
|
from last cycle's target (2000 -> 1600) and only B is left to place it.
|
||||||
|
Under a solar surplus B gave 1600 W into the export.
|
||||||
|
|
||||||
|
**The rule** (`main.py`, `_reseed_drain_end()`, `_drain_grid_w()`, N>=2
|
||||||
|
only). On the cycle a drain ends:
|
||||||
|
|
||||||
|
1. The start point is what the others stood at plus A's new command (its
|
||||||
|
pin, else 0 W; a dropped battery is sent 0 W), as SAFETY-93 does.
|
||||||
|
2. If the house needs more, the start is raised to the house's own point
|
||||||
|
(live batteries' readings + grid - target, as `_reseed_run_edge()`),
|
||||||
|
but never above last cycle's start. So the others cover the house at
|
||||||
|
once and never take over the drain's export.
|
||||||
|
3. While A settles (SAFETY-96's window: within the deadband, at most
|
||||||
|
`PLANT_SETTLE_DOWN_S`), the loop's error is the house's point minus its
|
||||||
|
start, not the meter. The meter still shows A's tail and the others'
|
||||||
|
climb; chasing it sent B down and then up again.
|
||||||
|
4. Then the loop moves one slew step a cycle.
|
||||||
|
|
||||||
|
A drain ends when its battery leaves DRAIN, is not live, its claims
|
||||||
|
contradict, or it refuses (SAFETY-73's limit outranks the pin without a
|
||||||
|
contradiction, so the refusal is read from the battery). The end is kept
|
||||||
|
until the loop runs (a missed poll). A dropped draining battery is this
|
||||||
|
rule's, not `_reseed_drop()`'s. Order in one cycle: drop/rejoin
|
||||||
|
(SAFETY-96), charge end (SAFETY-93), drain end (this) last; each moves the
|
||||||
|
start by its own battery, and the drain end's raise reads the start the
|
||||||
|
others left.
|
||||||
|
|
||||||
|
**Why not SAFETY-93's rule alone** (step 1, then slew). Measured on the
|
||||||
|
same plant: at 1200 W the grid imports 849 W and B goes to -465 W
|
||||||
|
(charging) while the loop chases A's tail; at N=3 and 4000 W it imports
|
||||||
|
784 W where base imports 42 W, as the others give up A's share of the house.
|
||||||
|
The house point keeps the grid at its aim; the cap at last cycle's start
|
||||||
|
keeps it from exporting.
|
||||||
|
|
||||||
|
**Before -> after** (`test_safety95.py`, 2500 W batteries, 10 s lag, 2 s
|
||||||
|
meter; base = the same tree with the change taken out):
|
||||||
|
|
||||||
|
| case | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| drain done / Off / timeout, N=2, 1200 W | B 0 -> 1600, +390 W commanded export, grid then +559 W | B 0 -> 1210, grid within 40 W of its aim |
|
||||||
|
| the same, N=3, 1200 W | B, C 0 -> 800, grid +695 W | 0 -> 605, grid +39 W |
|
||||||
|
| the same, solar surplus | B gives 1600 W into the surplus | B never gives; site moves 400 W a cycle |
|
||||||
|
| refusal, N=3, 1200 W | B, C step 1020 W, +2000 W commanded export | step 20 W, +70 W |
|
||||||
|
| N=3, 4000 W | others take A's share at once | the same (grid +1 W, base +42 W) |
|
||||||
|
| abort (site zeroed by the operator) | climbs from 0, grid +669 W | climbs from 0, grid +16 W |
|
||||||
|
| stale link (board watchdog 25 s) | | as base, but N=3 4000 W: grid +676 W (base +786) |
|
||||||
|
| N=1, every drain end | | byte-identical |
|
||||||
|
|
||||||
|
`tools/opt11_identity.py` n2_maint moved on purpose (A's drain refused at
|
||||||
|
1655 s; B stays at the house's 1420 W instead of 2500 W). Re-pinned in
|
||||||
|
`test_modes.py`. The other 45 digests are byte-identical.
|
||||||
|
|
||||||
|
**Not changed.** The peak guard's floor still binds (arbiter, after the
|
||||||
|
loop). Held batteries still give last; the idle clamp and the freeze test
|
||||||
|
are as they were. A charge end (SAFETY-93) and a trade end are unchanged.
|
||||||
@@ -1702,6 +1702,9 @@ When a charge ends, the other batteries take over one slew step a cycle,
|
|||||||
and none starts charging from the grid (SAFETY-93).
|
and none starts charging from the grid (SAFETY-93).
|
||||||
When a battery's link drops, the others take over one slew step a cycle,
|
When a battery's link drops, the others take over one slew step a cycle,
|
||||||
and control does not stall on the dropped battery's power (SAFETY-96).
|
and control does not stall on the dropped battery's power (SAFETY-96).
|
||||||
|
When a drain ends (done, Off, timeout, abort, link, refusal), the others
|
||||||
|
cover the house at once but never take over its export; from there control
|
||||||
|
moves one slew step a cycle (SAFETY-95).
|
||||||
|
|
||||||
#### Capacity-tariff exposure of the grid-following loop
|
#### Capacity-tariff exposure of the grid-following loop
|
||||||
|
|
||||||
|
|||||||
@@ -1025,6 +1025,12 @@ class Controller:
|
|||||||
# cycle, and those whose charge ended since the loop last ran.
|
# cycle, and those whose charge ended since the loop last ran.
|
||||||
self._charging_was = frozenset()
|
self._charging_was = frozenset()
|
||||||
self._charge_ended = frozenset()
|
self._charge_ended = frozenset()
|
||||||
|
# SAFETY-95: the same for a maintenance drain.
|
||||||
|
self._draining_was = frozenset()
|
||||||
|
self._drain_ended = frozenset()
|
||||||
|
# SAFETY-95: batteries settling after their drain ended; meanwhile
|
||||||
|
# the loop's error counts every battery at its command.
|
||||||
|
self._drain_tail = set()
|
||||||
# SAFETY-96: last cycle's commandable batteries, this cycle's start
|
# SAFETY-96: last cycle's commandable batteries, this cycle's start
|
||||||
# point per battery where a reseed moved it, and batteries settling
|
# point per battery where a reseed moved it, and batteries settling
|
||||||
# after a step the loop did not make ({name: since, monotonic}).
|
# after a step the loop did not make ({name: since, monotonic}).
|
||||||
@@ -2440,18 +2446,23 @@ class Controller:
|
|||||||
|
|
||||||
def _charge_track(self, claims, maint_results) -> None:
|
def _charge_track(self, claims, maint_results) -> None:
|
||||||
"""SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can
|
"""SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can
|
||||||
no longer be placed (stale link, refusal). Kept until the loop runs."""
|
no longer be placed (stale link, refusal). Kept until the loop runs.
|
||||||
|
SAFETY-95: a drain likewise; its refusal (SAFETY-73) outranks the pin
|
||||||
|
without a contradiction, so it is read from the battery."""
|
||||||
if len(self.inverters) < 2:
|
if len(self.inverters) < 2:
|
||||||
return
|
return
|
||||||
live = set(self.live_units())
|
live = set(self.live_units())
|
||||||
now = set()
|
now = {CHARGE: set(), DRAIN: set()}
|
||||||
for inv, res in zip(self.inverters, maint_results):
|
for inv, res in zip(self.inverters, maint_results):
|
||||||
u = self.actuator_of("maintenance", inv)
|
u = self.actuator_of("maintenance", inv)
|
||||||
if res.phase == CHARGE and u in live and not pre_resolve(
|
if res.phase in now and u in live and not pre_resolve(
|
||||||
[c for c in claims if c.actuator == u]).contradiction:
|
[c for c in claims if c.actuator == u]).contradiction \
|
||||||
now.add(inv.name)
|
and not (res.phase == DRAIN and inv.refused):
|
||||||
self._charge_ended |= self._charging_was - now
|
now[res.phase].add(inv.name)
|
||||||
self._charging_was = frozenset(now)
|
self._charge_ended |= self._charging_was - now[CHARGE]
|
||||||
|
self._charging_was = frozenset(now[CHARGE])
|
||||||
|
self._drain_ended |= self._draining_was - now[DRAIN]
|
||||||
|
self._draining_was = frozenset(now[DRAIN])
|
||||||
|
|
||||||
def _reseed_charge_end(self, maint_results) -> None:
|
def _reseed_charge_end(self, maint_results) -> None:
|
||||||
"""SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop
|
"""SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop
|
||||||
@@ -2476,6 +2487,53 @@ class Controller:
|
|||||||
self._settle[inv.name] = self._now_mono
|
self._settle[inv.name] = self._now_mono
|
||||||
self.i_w = self.target
|
self.i_w = self.target
|
||||||
|
|
||||||
|
def _reseed_drain_end(self, maint_results, live) -> None:
|
||||||
|
"""SAFETY-95, N>=2: when a battery's maintenance drain ends, its
|
||||||
|
drain leaves the loop's start point, as a charge does (SAFETY-93): the
|
||||||
|
start is what the others stood at plus its new command (its pin, else
|
||||||
|
0 W). Where the house needs more, the start is the house's own point
|
||||||
|
(the live batteries' readings + grid - target), never above last
|
||||||
|
cycle's start. The loop then moves one slew step a cycle. Else the
|
||||||
|
drain's floor stays in the target and lands on the others: B 0 ->
|
||||||
|
+1600 W in one cycle, exporting. DECISIONS/SAFETY-95-drain-end.md.
|
||||||
|
"""
|
||||||
|
ended, self._drain_ended = self._drain_ended, frozenset()
|
||||||
|
if len(self.inverters) < 2 or not ended:
|
||||||
|
return
|
||||||
|
hi = self.target
|
||||||
|
for inv, res in zip(self.inverters, maint_results):
|
||||||
|
if inv.name not in ended:
|
||||||
|
continue
|
||||||
|
now_w = (float(res.setpoint_w or 0.0)
|
||||||
|
if res.owns_setpoint and res.phase != DRAIN
|
||||||
|
else min(float(inv.target), 0.0))
|
||||||
|
if inv.name not in live:
|
||||||
|
now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W
|
||||||
|
else:
|
||||||
|
self._drain_tail.add(inv.name)
|
||||||
|
self.target += now_w - float(inv.target)
|
||||||
|
self._loop_start[inv.name] = now_w
|
||||||
|
self._settle[inv.name] = self._now_mono
|
||||||
|
lo, hi = sorted((self.target, hi))
|
||||||
|
# A dropped battery's power is the meter's, like the house's.
|
||||||
|
here = float(self.grid) - self.tuning.target_grid_w + sum(
|
||||||
|
float(inv.batt) for inv in self.inverters if inv.name in live)
|
||||||
|
self.target = self.i_w = min(hi, max(lo, here))
|
||||||
|
|
||||||
|
def _drain_grid_w(self, live) -> float:
|
||||||
|
"""SAFETY-95: the grid the loop regulates. While a battery settles
|
||||||
|
from its ended drain (`_held_out_w`: until within the deadband, at
|
||||||
|
most PLANT_SETTLE_DOWN_S), the meter still shows the drain's tail and
|
||||||
|
the others' climb, so the loop would chase them (B down, then up
|
||||||
|
again). Meanwhile the batteries count at the site command: the error
|
||||||
|
is the house's own point minus the loop's start."""
|
||||||
|
grid = float(self.grid)
|
||||||
|
self._drain_tail &= set(self._settle) & set(live)
|
||||||
|
if not self._drain_tail:
|
||||||
|
return grid
|
||||||
|
return grid - float(self.target) + sum(
|
||||||
|
float(inv.batt) for inv in self.inverters if inv.name in live)
|
||||||
|
|
||||||
def _reseed_drop(self, live) -> None:
|
def _reseed_drop(self, live) -> None:
|
||||||
"""SAFETY-96, N>=2: a battery that drops out of the split (stale
|
"""SAFETY-96, N>=2: a battery that drops out of the split (stale
|
||||||
link) leaves the loop's start point, so the others take over at one
|
link) leaves the loop's start point, so the others take over at one
|
||||||
@@ -2488,8 +2546,8 @@ class Controller:
|
|||||||
if len(self.inverters) < 2 or was is None or was == live:
|
if len(self.inverters) < 2 or was is None or was == live:
|
||||||
return
|
return
|
||||||
for inv in self.inverters:
|
for inv in self.inverters:
|
||||||
if inv.name in self._charge_ended:
|
if inv.name in self._charge_ended | self._drain_ended:
|
||||||
continue # SAFETY-93's edge: `_reseed_charge_end()`
|
continue # SAFETY-93/95's edge: `_reseed_*_end()`
|
||||||
if inv.name in was and inv.name not in live:
|
if inv.name in was and inv.name not in live:
|
||||||
now_w = 0.0
|
now_w = 0.0
|
||||||
elif inv.name in live and inv.name not in was:
|
elif inv.name in live and inv.name not in was:
|
||||||
@@ -4835,6 +4893,9 @@ class Controller:
|
|||||||
self._reseed_drop(_live96)
|
self._reseed_drop(_live96)
|
||||||
# SAFETY-93: a charge that ended leaves the loop's start point.
|
# SAFETY-93: a charge that ended leaves the loop's start point.
|
||||||
self._reseed_charge_end(maint_results)
|
self._reseed_charge_end(maint_results)
|
||||||
|
# SAFETY-95: then a drain that ended, last (it reads the start
|
||||||
|
# the others left).
|
||||||
|
self._reseed_drain_end(maint_results, _live96)
|
||||||
probe = self._refusal_probe()
|
probe = self._refusal_probe()
|
||||||
if len(self.inverters) > 1:
|
if len(self.inverters) > 1:
|
||||||
probe = any(_probing.values())
|
probe = any(_probing.values())
|
||||||
@@ -4864,7 +4925,9 @@ class Controller:
|
|||||||
loop_prev = self.target
|
loop_prev = self.target
|
||||||
decision = compute(
|
decision = compute(
|
||||||
prev_w=loop_prev,
|
prev_w=loop_prev,
|
||||||
grid_w=self.grid,
|
# SAFETY-95: a drain's tail is not the loop's to chase.
|
||||||
|
grid_w=(self._drain_grid_w(_live96) if self._drain_tail
|
||||||
|
else self.grid),
|
||||||
actual_w=site_batt,
|
actual_w=site_batt,
|
||||||
tuning=self.tuning,
|
tuning=self.tuning,
|
||||||
sat_count=self.sat_count,
|
sat_count=self.sat_count,
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ finally:
|
|||||||
# cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1.
|
# cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1.
|
||||||
SAFETY78_N2_MAINT = \
|
SAFETY78_N2_MAINT = \
|
||||||
"1afcd46b1b987868b110d2b0f4a7555848c9aa4728a836d4a2ce2a2107268497"
|
"1afcd46b1b987868b110d2b0f4a7555848c9aa4728a836d4a2ce2a2107268497"
|
||||||
|
# SAFETY-95 changes it again ON PURPOSE: A's drain ends at t=1655 (its
|
||||||
|
# refusal), and B now stays at the house's 1420 W instead of jumping to
|
||||||
|
# 2500 W (1100 W export) and slewing back. Rows differ only at 1655-2105 s.
|
||||||
|
SAFETY95_N2_MAINT = \
|
||||||
|
"949e7c093dcf065257417de3f2f908e4d57311790abc9fba0a09aa4b5d64c0d2"
|
||||||
# SAFETY-91 changes n2 and n2_off ON PURPOSE: from t=1600 battery A is at its
|
# SAFETY-91 changes n2 and n2_off ON PURPOSE: from t=1600 battery A is at its
|
||||||
# 20 % reserve, so B carries the house first and A gives last. With
|
# 20 % reserve, so B carries the house first and A gives last. With
|
||||||
# `_held_back()` reverted to SAFETY-87's (held only for the peak) both are
|
# `_held_back()` reverted to SAFETY-87's (held only for the peak) both are
|
||||||
@@ -165,8 +170,8 @@ check("...n2 and n2_off are the SAFETY-91 digest (A at its reserve gives "
|
|||||||
and _n2_s87 == SAFETY94_N2_S87)
|
and _n2_s87 == SAFETY94_N2_S87)
|
||||||
check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad "
|
check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad "
|
||||||
"refused on 392 of 540 cycles now runs (test_safety78.py), bounded by "
|
"refused on 392 of 540 cycles now runs (test_safety78.py), bounded by "
|
||||||
"SAFETY-85",
|
"SAFETY-85, its end handed back by SAFETY-95",
|
||||||
_now["n2_maint"] == SAFETY78_N2_MAINT != A5DDDAD["n2_maint"])
|
_now["n2_maint"] == SAFETY95_N2_MAINT != A5DDDAD["n2_maint"])
|
||||||
check("...and no cycle in those runs filed a `mode` claim, and only "
|
check("...and no cycle in those runs filed a `mode` claim, and only "
|
||||||
"n2_maint's (SAFETY-85: maintenance at N=2) handed `compute()` a bound",
|
"n2_maint's (SAFETY-85: maintenance at N=2) handed `compute()` a bound",
|
||||||
_claims and not any(c.source == "mode" for cl in _claims for c in cl)
|
_claims and not any(c.source == "mode" for cl in _claims for c in cl)
|
||||||
|
|||||||
@@ -0,0 +1,528 @@
|
|||||||
|
"""Runnable check for SAFETY-95: a maintenance drain that ends hands back
|
||||||
|
smoothly at N>=2. `python3 test_safety95.py`
|
||||||
|
|
||||||
|
At N=2, when battery A's drain ended (A switched Off, say) with the house at
|
||||||
|
1200 W, the drain's floor stayed in the loop's site target and landed on B:
|
||||||
|
B went 0 -> +1600 W in one cycle and exported. Now the loop starts from what
|
||||||
|
the others stood at plus A's new command, raised to the house's own point
|
||||||
|
when the house needs more (never above last cycle's start), and counts every
|
||||||
|
battery at its command while A settles. Drives the REAL Controller on a
|
||||||
|
lagging battery (10 s, 2 s meter, as on the rig), at N=2 and N=3.
|
||||||
|
DECISIONS/SAFETY-95-drain-end.md.
|
||||||
|
|
||||||
|
1. Every drain end (complete -> charge, Off, timeout, operator abort, stale
|
||||||
|
link, refusal) at houses of 1200 W and 4000 W and under a solar surplus.
|
||||||
|
2. The peak guard still binds across a drain end.
|
||||||
|
3. A drain end in the same cycle as another battery's drop; a missed poll.
|
||||||
|
4. N=1 is untouched; the rule at its boundaries.
|
||||||
|
|
||||||
|
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import functools
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, HERE)
|
||||||
|
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||||
|
logging.disable(logging.WARNING)
|
||||||
|
|
||||||
|
import opt11_identity as rig # noqa: E402
|
||||||
|
import safety79_identity as s79 # noqa: E402
|
||||||
|
from app import main as app_main, packmode # noqa: E402
|
||||||
|
from app.main import Controller # noqa: E402
|
||||||
|
from app.maintenance import (CHARGE, DRAIN, DRAIN_TIMEOUT_MIN, # noqa: E402
|
||||||
|
HOLD, IDLE, MaintResult)
|
||||||
|
from app.store import unit_key # noqa: E402
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
SLEW = 400.0 # the rig's slew_w
|
||||||
|
TOL = SLEW / 4 # the loop's own overshoot on a lagging battery
|
||||||
|
AIM = -10.0 # the rig's target_grid_w
|
||||||
|
T = 300.0 # the drain ends here
|
||||||
|
M = {"maintenance_enabled": True, "maintenance_start_hour": 11}
|
||||||
|
A = "inverter-a"
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
# None on a tree without the fix, where this file prints the base numbers
|
||||||
|
# and runs red.
|
||||||
|
fixed = getattr(Controller, "_reseed_drain_end", None)
|
||||||
|
real_grid = getattr(Controller, "_drain_grid_w", None)
|
||||||
|
real_drop = Controller._reseed_drop
|
||||||
|
|
||||||
|
|
||||||
|
def _base_drop(self, live):
|
||||||
|
"""SAFETY-96's drop reseed as it was: a draining battery is not
|
||||||
|
skipped."""
|
||||||
|
keep, self._drain_ended = self._drain_ended, frozenset()
|
||||||
|
try:
|
||||||
|
real_drop(self, live)
|
||||||
|
finally:
|
||||||
|
self._drain_ended = keep
|
||||||
|
|
||||||
|
|
||||||
|
def without(fn):
|
||||||
|
"""Run `fn` with SAFETY-95 taken out (the base behaviour)."""
|
||||||
|
if fixed is None:
|
||||||
|
return fn()
|
||||||
|
Controller._reseed_drain_end = lambda self, r, live: setattr(
|
||||||
|
self, "_drain_ended", frozenset())
|
||||||
|
Controller._drain_grid_w = lambda self, live: float(self.grid)
|
||||||
|
Controller._reseed_drop = _base_drop
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
finally:
|
||||||
|
Controller._reseed_drain_end = fixed
|
||||||
|
Controller._drain_grid_w = real_grid
|
||||||
|
Controller._reseed_drop = real_drop
|
||||||
|
|
||||||
|
|
||||||
|
def options(n, extra):
|
||||||
|
o = rig._options(n, extra)
|
||||||
|
if n > 1:
|
||||||
|
o["inverters"] = [
|
||||||
|
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
|
||||||
|
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
|
||||||
|
"max_w": 2500, "bms_soc_floor": 10} for u in "abc"[:n]]
|
||||||
|
return o
|
||||||
|
|
||||||
|
|
||||||
|
def run(n, house, *, store, extra=None, soc=None, reject=None, dead=None,
|
||||||
|
before=None, dur=T + 120.0, lag=10.0, p1=False, back=None):
|
||||||
|
"""Rows of one run at N=`n` on plantsim's battery (800 W/s ramp, `lag`
|
||||||
|
s first-order lag) at a 2 s meter tick. Writes to the units in `reject`
|
||||||
|
(name -> from s) are refused, until `back` s; `dead` (u, from s[, to
|
||||||
|
s]): u gives nothing from then on (until `to`). Each row: (t, the
|
||||||
|
add-on's commands, site target, grid, phases, loop reason, peak floor).
|
||||||
|
opt11_identity's cycle, with a third battery."""
|
||||||
|
units = tuple("abc"[:n])
|
||||||
|
soc = soc or (lambda u, t: 50.0)
|
||||||
|
|
||||||
|
def refused(u, t):
|
||||||
|
return bool(reject) and u in reject and t >= reject[u] and (
|
||||||
|
back is None or t < back)
|
||||||
|
|
||||||
|
class Hass(rig._Hass):
|
||||||
|
def power(self, u):
|
||||||
|
st = self.__dict__.setdefault("_plant", {})
|
||||||
|
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
|
||||||
|
for k in range(int(round((self.t - tl) / 0.1))):
|
||||||
|
now = tl + (k + 1) * 0.1
|
||||||
|
c = self.cmd[u]
|
||||||
|
if dead is not None and u == dead[0] and dead[1] <= now < (
|
||||||
|
dead[2] if len(dead) > 2 else math.inf):
|
||||||
|
c = 0.0
|
||||||
|
d = c - tgt
|
||||||
|
tgt += math.copysign(min(abs(d), 80.0), d)
|
||||||
|
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
|
||||||
|
st[u] = (a, tgt, self.t)
|
||||||
|
return float(round(a))
|
||||||
|
|
||||||
|
async def set_number(self, e, v):
|
||||||
|
if refused(e[-1], self.t):
|
||||||
|
return False
|
||||||
|
self.cmd[e[-1]] = float(v)
|
||||||
|
return True
|
||||||
|
|
||||||
|
opts = options(n, dict(M, **(s79.P1 if p1 else {}), **(extra or {})))
|
||||||
|
db = os.path.join(tempfile.mkdtemp(prefix="s95-"), "tariff.db")
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
hass = Hass(units, soc, lambda u, t: False,
|
||||||
|
lambda t: house(t) if callable(house) else house)
|
||||||
|
c = Controller(opts, hass, store, rig._Pub(),
|
||||||
|
stop=asyncio.Event(), peak_db_path=db)
|
||||||
|
for i in c.inverters:
|
||||||
|
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||||
|
c.auto = True
|
||||||
|
real_dt, clock = app_main.datetime, [0.0]
|
||||||
|
|
||||||
|
class _DT(real_dt):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return rig.BASE + timedelta(seconds=clock[0])
|
||||||
|
app_main.datetime = _DT
|
||||||
|
rows = []
|
||||||
|
try:
|
||||||
|
t = 0.0
|
||||||
|
while t < dur:
|
||||||
|
hass.t = clock[0] = t
|
||||||
|
for inv in c.inverters:
|
||||||
|
u = inv.name[-1]
|
||||||
|
if refused(u, t) and inv.rejected_since is None:
|
||||||
|
inv.rejected_since = rig.BASE + timedelta(seconds=t)
|
||||||
|
if before is not None:
|
||||||
|
r = before(c, t)
|
||||||
|
if asyncio.iscoroutine(r):
|
||||||
|
await r
|
||||||
|
if p1:
|
||||||
|
s79._feed(c, t)
|
||||||
|
await c.read_inputs(now_mono=t)
|
||||||
|
await c.cycle()
|
||||||
|
g = c.peak_guard
|
||||||
|
rows.append((t, tuple(i.target for i in c.inverters),
|
||||||
|
c.target,
|
||||||
|
hass.grid(), tuple(m.phase for m in c.maints),
|
||||||
|
c.loop_reason,
|
||||||
|
None if g is None or not g.engaged
|
||||||
|
else g.floor_w))
|
||||||
|
t += 2.0
|
||||||
|
finally:
|
||||||
|
app_main.datetime = real_dt
|
||||||
|
return rows, c
|
||||||
|
return asyncio.run(go())
|
||||||
|
|
||||||
|
|
||||||
|
def store(n, **a):
|
||||||
|
"""A due for its monthly cycle (it drains from the first cycle); the
|
||||||
|
others finished theirs, so none starts here."""
|
||||||
|
s = rig._Store()
|
||||||
|
if n == 1:
|
||||||
|
for k, v in a.items():
|
||||||
|
s.data[k] = v
|
||||||
|
return s
|
||||||
|
for u in "bc"[:n - 1]:
|
||||||
|
s.data[unit_key(f"inverter-{u}", "last_completed")] = \
|
||||||
|
rig.BASE.isoformat()
|
||||||
|
for k, v in a.items():
|
||||||
|
s.data[unit_key(A, k)] = v
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def timed_store(n):
|
||||||
|
"""A's drain passes DRAIN_TIMEOUT_MIN at T; its last start was at 0 s,
|
||||||
|
so the abort's cooldown keeps it from starting again."""
|
||||||
|
return store(n, phase=DRAIN, phase_started=(
|
||||||
|
rig.BASE + timedelta(seconds=T - DRAIN_TIMEOUT_MIN * 60 - 1)
|
||||||
|
).isoformat(), last_start_attempt=rig.BASE.isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
def off(c, t):
|
||||||
|
if t >= T:
|
||||||
|
c.store.data[unit_key(A, packmode.USE_KEY)] = False
|
||||||
|
|
||||||
|
|
||||||
|
async def abort(c, t):
|
||||||
|
if t == T:
|
||||||
|
await c.handle_action("maint_abort")
|
||||||
|
|
||||||
|
|
||||||
|
ENDS = {
|
||||||
|
"complete": lambda n, h, **k: run(
|
||||||
|
n, h, store=store(n), soc=lambda u, t:
|
||||||
|
5.0 if (u == "a" and t >= T) else 50.0, **k),
|
||||||
|
"Off": lambda n, h, **k: run(n, h, store=store(n), before=off,
|
||||||
|
extra={"battery_modes_enabled": True}, **k),
|
||||||
|
"timeout": lambda n, h, **k: run(n, h, store=timed_store(n), **k),
|
||||||
|
"abort": lambda n, h, **k: run(n, h, store=store(n), before=abort, **k),
|
||||||
|
# A's link goes stale; its board's watchdog sends it to 0 W after 25 s.
|
||||||
|
"stale link": lambda n, h, **k: run(n, h, store=store(n),
|
||||||
|
reject={"a": T}, dead=("a", T + 25.0),
|
||||||
|
dur=T + 200.0, **k),
|
||||||
|
# A gives nothing from T; SAFETY-73 learns it ~55 s later.
|
||||||
|
"refusal": lambda n, h, **k: run(n, h, store=store(n), dead=("a", T),
|
||||||
|
dur=T + 300.0, **k),
|
||||||
|
}
|
||||||
|
HOUSES = (1200.0, 4000.0, -1500.0)
|
||||||
|
|
||||||
|
|
||||||
|
def end_at(rows):
|
||||||
|
"""The first cycle A is not draining, or its command left the drain."""
|
||||||
|
return next(r[0] for r in rows if r[0] >= T and (
|
||||||
|
r[4][0] != DRAIN or r[1][0] < 2000.0))
|
||||||
|
|
||||||
|
|
||||||
|
def measure(rows, n, house):
|
||||||
|
"""From the end on: the site target's largest step after the end
|
||||||
|
cycle (the slew's subject); the commanded export in the 20 s after it (the site command over
|
||||||
|
what the house needs, or over what the others stood at); the largest
|
||||||
|
grid import; the export beyond what the site exported before the end;
|
||||||
|
the largest grid step; the others' largest command step."""
|
||||||
|
te = end_at(rows)
|
||||||
|
pre = next(r for r in reversed(rows) if r[0] < te)
|
||||||
|
seg = [r for r in rows if r[0] >= te - 2.0]
|
||||||
|
aft = [r for r in rows if te <= r[0] <= te + 120.0]
|
||||||
|
others0 = sum(pre[1][1:])
|
||||||
|
site = [r[2] for r in seg]
|
||||||
|
return dict(
|
||||||
|
end=te,
|
||||||
|
site_step=max(abs(b - a) for a, b in zip(site[1:], site[2:])),
|
||||||
|
cmd_export=max(sum(r[1]) - max(house - AIM, others0) for r in aft
|
||||||
|
if r[0] <= te + 20.0),
|
||||||
|
imp=max(r[3] for r in aft),
|
||||||
|
exp=min(min(pre[3], AIM) - r[3] for r in aft),
|
||||||
|
gstep=max(abs(b[3] - a[3]) for a, b in zip(seg, seg[1:])),
|
||||||
|
ostep=max(abs(b[1][i] - a[1][i]) for a, b in zip(seg, seg[1:])
|
||||||
|
for i in range(1, n)),
|
||||||
|
rows=rows)
|
||||||
|
|
||||||
|
|
||||||
|
TABLE = []
|
||||||
|
|
||||||
|
|
||||||
|
def case(kind, n, house):
|
||||||
|
now = measure(ENDS[kind](n, house)[0], n, house)
|
||||||
|
old = measure(without(lambda: ENDS[kind](n, house))[0], n, house)
|
||||||
|
TABLE.append((kind, n, house, now, old))
|
||||||
|
return now, old
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================ #
|
||||||
|
print("1. every drain end, N=2 and N=3, houses 1200 W, 4000 W and -1500 W")
|
||||||
|
for kind in ENDS:
|
||||||
|
for n in (2, 3):
|
||||||
|
for house in HOUSES:
|
||||||
|
now, old = case(kind, n, house)
|
||||||
|
tag = f"{kind}, N={n}, house {house:.0f} W"
|
||||||
|
# Abort zeroes the site (operator); a stale link's start is
|
||||||
|
# SAFETY-96's drop. Those are graded against base.
|
||||||
|
own = kind not in ("abort", "stale link")
|
||||||
|
check(f"{'⭐' if own else ' '} {tag}: no battery gives into an "
|
||||||
|
f"export after the end (commanded export "
|
||||||
|
f"{now['cmd_export']:.0f} W, base {old['cmd_export']:.0f}) "
|
||||||
|
f"and the site target moves at most one slew step a cycle "
|
||||||
|
f"after it ({now['site_step']:.0f} W, base "
|
||||||
|
f"{old['site_step']:.0f})",
|
||||||
|
now["cmd_export"] <= (TOL if own else max(
|
||||||
|
TOL, old["cmd_export"] + TOL))
|
||||||
|
and now["site_step"] <= SLEW)
|
||||||
|
check(f" {tag}: the grid imports no more than base (+50 W) "
|
||||||
|
f"({now['imp']:.0f} W, base {old['imp']:.0f}) and exports "
|
||||||
|
f"no more than it did before the end, or base (+50 W) "
|
||||||
|
f"({now['exp']:.0f} W more, base {old['exp']:.0f})",
|
||||||
|
now["imp"] <= old["imp"] + 50.0
|
||||||
|
and now["exp"] <= max(0.0, old["exp"]) + 50.0)
|
||||||
|
|
||||||
|
_by = {(k, n, h): (a, b) for k, n, h, a, b in TABLE}
|
||||||
|
for kind in ("complete", "Off", "timeout", "refusal"):
|
||||||
|
_now, _old = _by[(kind, 2, 1200.0)]
|
||||||
|
check(f"non-vacuous ({kind}, N=2, 1200 W): without the fix B steps "
|
||||||
|
f"more than a slew step into an export",
|
||||||
|
_old["cmd_export"] >= 300.0 and _old["ostep"] > SLEW
|
||||||
|
and _now["ostep"] < _old["ostep"])
|
||||||
|
_now, _old = _by[(kind, 2, -1500.0)]
|
||||||
|
check(f"non-vacuous ({kind}, solar surplus): without the fix B gives "
|
||||||
|
f"into the surplus", _old["cmd_export"] >= 1000.0
|
||||||
|
and _now["cmd_export"] <= TOL)
|
||||||
|
_now, _old = _by[("complete", 2, 1200.0)]
|
||||||
|
check("⭐ the rig case (A's drain completes at 1200 W): B covers the house "
|
||||||
|
"at once (0 -> 1210 W, not 1600), and the grid stays within 50 W of "
|
||||||
|
"its aim while A settles (base: +559 W import, B down to 230 W)",
|
||||||
|
[r[1][1] for r in _now["rows"] if r[0] == _now["end"]] == [1210.0]
|
||||||
|
and _now["imp"] <= 50.0 and _old["imp"] > 400.0)
|
||||||
|
_now, _old = _by[("complete", 3, 4000.0)]
|
||||||
|
check("N=3 at 4000 W: the others take A's share at once, as before (the "
|
||||||
|
"site does not drop), so the grid does not import",
|
||||||
|
_now["imp"] <= 50.0 and abs(_now["imp"] - _old["imp"]) <= 50.0)
|
||||||
|
|
||||||
|
# ============================================================================ #
|
||||||
|
print("\n2. the peak guard still binds across a drain end")
|
||||||
|
_g = ENDS["Off"](2, 4000.0, p1=True, dur=T + 120.0)[0]
|
||||||
|
_gs = [r for r in _g if r[0] >= T - 20 and r[6] is not None]
|
||||||
|
print(f" guard engaged on {len(_gs)} cycles around the end; site "
|
||||||
|
f"{[sum(r[1]) for r in _g if T - 4 <= r[0] <= T + 8]}")
|
||||||
|
check("⭐ house 4000 W over a 2500 W limit, A switched Off mid-drain: on "
|
||||||
|
"every cycle the guard is engaged, the site gives at least its floor "
|
||||||
|
"(or every battery is at its rating)",
|
||||||
|
_gs and all(sum(r[1]) >= min(r[6], 2500.0 * 1) - 1.0 for r in _gs))
|
||||||
|
|
||||||
|
# ============================================================================ #
|
||||||
|
print("\n3. a drain end with another edge in the same cycle; a missed poll")
|
||||||
|
|
||||||
|
|
||||||
|
def c_drops(c, t):
|
||||||
|
"""C's writes are refused from T - 30 s, so it drops at T, the cycle A
|
||||||
|
is switched Off; its board goes to 0 W at once (T - 30 s)."""
|
||||||
|
off(c, t)
|
||||||
|
|
||||||
|
|
||||||
|
_e3 = run(3, 4000.0, store=store(3), before=c_drops, reject={"c": T - 30.0},
|
||||||
|
dead=("c", T - 30.0), extra={"battery_modes_enabled": True})[0]
|
||||||
|
_e3m = measure(_e3, 3, 4000.0)
|
||||||
|
print(f" A Off as C drops, house 4000 W: B "
|
||||||
|
f"{[r[1][1] for r in _e3 if T - 4 <= r[0] <= T + 10]}")
|
||||||
|
check("⭐ A switched Off on the cycle C drops (N=3, 4000 W): B takes over "
|
||||||
|
"with no export and the site moves one slew step a cycle after",
|
||||||
|
_e3m["cmd_export"] <= TOL and _e3m["site_step"] <= SLEW
|
||||||
|
and _e3m["end"] == T)
|
||||||
|
|
||||||
|
|
||||||
|
def c_back(c, t):
|
||||||
|
"""C's link (stale since T - 60 s, its board holding 0 W) answers again
|
||||||
|
at T, the cycle A is switched Off."""
|
||||||
|
off(c, t)
|
||||||
|
if t == T:
|
||||||
|
c.inverters[2].note_write(True, rig.BASE + timedelta(seconds=t))
|
||||||
|
|
||||||
|
|
||||||
|
_r3 = run(3, 1200.0, store=store(3), before=c_back, reject={"c": T - 60.0},
|
||||||
|
back=T, dead=("c", T - 60.0, T), extra={"battery_modes_enabled": True})[0]
|
||||||
|
_r3m = measure(_r3, 3, 1200.0)
|
||||||
|
print(f" A Off as C comes back, house 1200 W: B, C "
|
||||||
|
f"{[r[1][1:] for r in _r3 if T - 4 <= r[0] <= T + 6]}")
|
||||||
|
check("⭐ A switched Off on the cycle C comes back (N=3, 1200 W): no "
|
||||||
|
"export, one slew step a cycle after",
|
||||||
|
_r3m["cmd_export"] <= TOL and _r3m["site_step"] <= SLEW
|
||||||
|
and _r3m["end"] == T)
|
||||||
|
|
||||||
|
|
||||||
|
def missed_poll(at_t):
|
||||||
|
"""Battery B's power reading is missing on the cycle at `at_t` s."""
|
||||||
|
def before(c, t):
|
||||||
|
off(c, t)
|
||||||
|
if not getattr(c.hass, "_s95", False):
|
||||||
|
read = c.hass.number
|
||||||
|
|
||||||
|
async def number(e, invert=False):
|
||||||
|
if e == "sensor.batt_b" and c.hass.t == at_t:
|
||||||
|
return None
|
||||||
|
return await read(e, invert)
|
||||||
|
c.hass.number, c.hass._s95 = number, True
|
||||||
|
return before
|
||||||
|
|
||||||
|
|
||||||
|
_mp = run(2, 1200.0, store=store(2), before=missed_poll(T),
|
||||||
|
extra={"battery_modes_enabled": True})[0]
|
||||||
|
_mpo = without(lambda: run(2, 1200.0, store=store(2), before=missed_poll(T),
|
||||||
|
extra={"battery_modes_enabled": True}))[0]
|
||||||
|
print(f" a missed poll on the end cycle: B "
|
||||||
|
f"{[r[1][1] for r in _mp if T - 2 <= r[0] <= T + 8]}, without the fix "
|
||||||
|
f"{[r[1][1] for r in _mpo if T - 2 <= r[0] <= T + 8]}")
|
||||||
|
check("⭐ a missed poll on the cycle A is switched Off: the end still "
|
||||||
|
"reaches the loop on the next cycle (B covers the house, no export)",
|
||||||
|
max(sum(r[1]) for r in _mp if r[0] > T) <= 1200.0 - AIM + TOL
|
||||||
|
and max(sum(r[1]) for r in _mpo if r[0] > T) >= 1500.0)
|
||||||
|
|
||||||
|
# ============================================================================ #
|
||||||
|
print("\n4. N=1 is untouched; the rule at its boundaries")
|
||||||
|
|
||||||
|
|
||||||
|
def digest(rows):
|
||||||
|
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
_n1 = [functools.partial(ENDS[k], 1, h) for k in ("complete", "timeout",
|
||||||
|
"abort", "refusal")
|
||||||
|
for h in (1200.0, -1500.0)]
|
||||||
|
check("⭐ N=1, a drain that completes, times out, is aborted or refused, at "
|
||||||
|
"1200 W and under a surplus: every row byte-identical with and "
|
||||||
|
"without SAFETY-95",
|
||||||
|
[digest(f()[0]) for f in _n1] == [digest(without(f)[0]) for f in _n1])
|
||||||
|
_wave = functools.partial(run, 2, lambda t: 2400.0 + 1500 * math.sin(t / 40),
|
||||||
|
store=store(2, last_completed=rig.BASE.isoformat()),
|
||||||
|
dur=600.0)
|
||||||
|
check("N=2 healthy (a moving house, no maintenance): every row "
|
||||||
|
"byte-identical with and without SAFETY-95",
|
||||||
|
digest(_wave()[0]) == digest(without(_wave)[0]))
|
||||||
|
|
||||||
|
if fixed is None:
|
||||||
|
print(f"\n{len(fails)} FAILED (no _reseed_drain_end): " + ", ".join(fails))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
_c = run(2, 1200.0, store=store(2), dur=4.0)[1]
|
||||||
|
_pin = MaintResult(IDLE, True, 0.0, False, [])
|
||||||
|
_free = MaintResult(CHARGE, False, None, True, [])
|
||||||
|
_b = MaintResult(IDLE, False, None, False, [])
|
||||||
|
AB = {"inverter-a", "inverter-b"}
|
||||||
|
|
||||||
|
|
||||||
|
def reseeded(target, shares, batts, grid, res=_pin, live=AB, ended=(A,)):
|
||||||
|
"""`_reseed_drain_end` on a 2-battery controller: (target, i_w,
|
||||||
|
A's start)."""
|
||||||
|
_c.target, _c.i_w, _c._drain_ended = target, 123.0, frozenset(ended)
|
||||||
|
_c._loop_start, _c._settle, _c._drain_tail = {}, {}, set()
|
||||||
|
_c._now_mono, _c.grid = 50.0, grid
|
||||||
|
for inv in _c.inverters:
|
||||||
|
inv.target, inv.batt = shares[inv.name[-1]], batts[inv.name[-1]]
|
||||||
|
_c._reseed_drain_end([res, _b], set(live))
|
||||||
|
return _c.target, round(_c.i_w, 6), _c._loop_start.get(A)
|
||||||
|
|
||||||
|
|
||||||
|
# House 1200 W: A drains 2000, B 0, grid -800.
|
||||||
|
check("the house needs more than the others stand at: the start is the "
|
||||||
|
"house's own point (1210 W), the integrator with it",
|
||||||
|
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
|
||||||
|
-800.0) == (1210.0, 1210.0, 0.0)
|
||||||
|
and _c._settle == {A: 50.0} and _c._drain_tail == {A}
|
||||||
|
and _c._drain_ended == frozenset())
|
||||||
|
check("...and while A settles the loop's error is the house's point minus "
|
||||||
|
"its start (0 here: it holds), not the meter's -790 W",
|
||||||
|
_c._drain_grid_w(AB) - AIM == 0.0)
|
||||||
|
check("a solar surplus (house -1500 W): the start is what the others stood "
|
||||||
|
"at plus A's new command (0 W), and the loop moves down from there",
|
||||||
|
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
|
||||||
|
-3500.0)[0] == 0.0)
|
||||||
|
check("the house needs more than stood placed (4000 W, B at 2000): never "
|
||||||
|
"above last cycle's start",
|
||||||
|
reseeded(4000.0, {"a": 2000.0, "b": 2000.0},
|
||||||
|
{"a": 2000.0, "b": 2000.0}, 0.0)[0] == 4000.0)
|
||||||
|
check("a free battery (drain complete -> charge): its drain leaves the "
|
||||||
|
"start; a pin's value is its new command",
|
||||||
|
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
|
||||||
|
-3500.0, res=_free)[2] == 0.0
|
||||||
|
and reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
|
||||||
|
-3500.0, res=MaintResult(HOLD, True, 0.0, False, []))[2]
|
||||||
|
== 0.0)
|
||||||
|
check("a dropped battery's power is the meter's: a board stuck at 2000 W "
|
||||||
|
"keeps B where it stood, and there is no tail to count",
|
||||||
|
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
|
||||||
|
-800.0, live={"inverter-b"})[0] == 0.0
|
||||||
|
and _c._drain_tail == set())
|
||||||
|
check("no drain ended: nothing moves (the integrator either)",
|
||||||
|
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
|
||||||
|
-800.0, ended=()) == (2000.0, 123.0, None))
|
||||||
|
_c._drain_tail, _c._settle, _c.grid = {A}, {}, -800.0
|
||||||
|
check("the tail ends with A's settling (SAFETY-96's window)",
|
||||||
|
_c._drain_grid_w(AB) == -800.0 and _c._drain_tail == set())
|
||||||
|
_n1c = run(1, 1200.0, store=store(1), dur=4.0)[1]
|
||||||
|
_n1c.target, _n1c.i_w, _n1c._drain_ended = 2000.0, 5.0, frozenset({A})
|
||||||
|
_n1c._reseed_drain_end([_pin], {A})
|
||||||
|
check("N=1: nothing moves", (_n1c.target, _n1c.i_w) == (2000.0, 5.0))
|
||||||
|
|
||||||
|
_seen = []
|
||||||
|
|
||||||
|
|
||||||
|
def _spy(self, maint_results, live):
|
||||||
|
_seen.append(sorted(self._drain_ended))
|
||||||
|
return fixed(self, maint_results, live)
|
||||||
|
|
||||||
|
|
||||||
|
Controller._reseed_drain_end = _spy
|
||||||
|
try:
|
||||||
|
run(2, 1200.0, store=store(2), soc=lambda u, t:
|
||||||
|
5.0 if (u == "a" and T <= t < T + 200) else 99.5 if u == "a" and
|
||||||
|
t >= T + 200 else 50.0, dur=T + 400.0,
|
||||||
|
extra={"maintenance_hold_min": 2})
|
||||||
|
finally:
|
||||||
|
Controller._reseed_drain_end = fixed
|
||||||
|
check("it fires once, at the drain end: not at the charge end, the HOLD "
|
||||||
|
"end, or on any other cycle", [x for x in _seen if x] == [[A]]
|
||||||
|
and len(_seen) > 300)
|
||||||
|
|
||||||
|
print("\n drain end | N | house W | site step W head/base | commanded "
|
||||||
|
"export W head/base | others' step W head/base | grid import W "
|
||||||
|
"head/base | grid step W head/base | export beyond the end's W head/base")
|
||||||
|
for k, n, h, a, b in TABLE:
|
||||||
|
print(f" {k} | {n} | {h:.0f} | {a['site_step']:.0f}/"
|
||||||
|
f"{b['site_step']:.0f} | {a['cmd_export']:.0f}/{b['cmd_export']:.0f}"
|
||||||
|
f" | {a['ostep']:.0f}/{b['ostep']:.0f} | {a['imp']:.0f}/"
|
||||||
|
f"{b['imp']:.0f} | {a['gstep']:.0f}/{b['gstep']:.0f} | "
|
||||||
|
f"{a['exp']:.0f}/{b['exp']:.0f}")
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||||
|
sys.exit(1)
|
||||||
|
print("all SAFETY-95 checks passed")
|
||||||
Reference in New Issue
Block a user