Merge pull request 'OPT-15: price-run fixes (quarter edge, charge-end overshoot, refusal probe)' (#157) from opt15-price-run-fixes into release/1.0
infra-pin / fresh (push) Successful in 2s
infra-pin / fresh (push) Successful in 2s
This commit was merged in pull request #157.
This commit is contained in:
@@ -50,7 +50,7 @@ P1 is its section 6 P1, and the pull request lists what was measured.
|
||||
needs its own run to clear the spread (without: a night charge was spent
|
||||
on flat morning prices, then charging went on at them);
|
||||
- a run edge reseeds the loop at the house's level (a 3 kW import spike
|
||||
removed; about 0.1 x rating still overshoots as export).
|
||||
removed) and, since OPT-15, holds it there until the meter shows the step.
|
||||
- Replay (tools/opt11p3_replay.py, 16 recorded days, EPEX hourly prices a
|
||||
year earlier, modelled batteries), net EUR, lower is better: P2 19.33, P3
|
||||
Solar first + Leftover solar 19.57, with a sale 19.60, the old one-period
|
||||
@@ -75,6 +75,63 @@ P1 is its section 6 P1, and the pull request lists what was measured.
|
||||
P2's 19.33; Solar first + Cheap hours only 19.57.
|
||||
- Not done in P3: the forecast (P4), and a headroom too short for the run
|
||||
moving the rest to the next cheap periods.
|
||||
- OPT-15 (TEST-23 findings on 5a4cdc2, merged up to e3e4ba6,
|
||||
`test_price_run_fixes.py`):
|
||||
- F1, a charge stopped at a quarter edge and latched for the quarter. The
|
||||
P1 averager opens a quarter only when a sample arrives, and the HA source
|
||||
builds one 0.35 s after the reading, so the cycle on the boundary still
|
||||
saw the old block: "no seconds left", headroom None, "no trade
|
||||
(headroom)", latched for the new quarter. With modes on the guard now
|
||||
projects the quarter `now` is in (`PeakTracker.project(roll=True)`,
|
||||
crediting the held sample as the averager will), and only a measured
|
||||
stop latches. Modes off still reads the old block (identity).
|
||||
Since SAFETY-91, `next_quarter()` does the same for the guard at two
|
||||
inverters (no stop there without the roll, same banked quarters); the
|
||||
roll is what covers one inverter, where the old projection stops the
|
||||
charge at every edge. With modes on at one inverter the roll also removes
|
||||
the 1 s 0 W command at each quarter edge that SAFETY-81's sim showed, so
|
||||
its modes-on digests are re-pinned (modes off equal to e3e4ba6). A
|
||||
rolled projection is not graded by SAFETY-81's alarm (the old edge tick
|
||||
read "no seconds left"), so a standing overload is still notified once
|
||||
per quarter.
|
||||
- F2, -1120 W export at a charge end against -257 W modelled. The replay
|
||||
acted every 5 s on readings of that instant, and by then the lagged
|
||||
battery had nearly arrived, so it never acted on a reading that still
|
||||
showed the trade. The add-on cycles on each meter update (1 s on the rig)
|
||||
on readings 1-2 s old: the two cycles after the reseed saw the trade's
|
||||
+3300 W and slewed E up 555 W each. The loop now holds E until the meter
|
||||
reaches the target (or `EDGE_HOLD_S`, 18 s; to 36 s while the battery
|
||||
still moves). The replay gained `Lag` (dead time, reading age, the P1
|
||||
debounce, 1 s cycles). Charge end, 800 W house: -10 to -51 W over eight
|
||||
lag variants; without the hold -1370 W (meter 2 s). On e3e4ba6 the same
|
||||
numbers hold. The hold gives the loop the target as its grid, so it
|
||||
yields to SAFETY-95's drain tail, and any other claim that moves the
|
||||
command ends it.
|
||||
- F3, a charge refusal in a cheap run was never probed: the SAFETY-73 pin
|
||||
stands only while the site asks that way, and nothing asked to charge.
|
||||
Probing was chosen over changing the card: the probe is 200 W for 34 s
|
||||
each 15 min under the same peak gates, and it wins back the run. In its
|
||||
probe window the refusing battery is a charger, pinned at 200 W by its
|
||||
own claim, and the others rest. A monthly drain (SAFETY-78) still comes
|
||||
first, and with a day plan a probing Leftover solar battery goes only to
|
||||
its planned level. At one inverter SAFETY-52 already ends a charge
|
||||
refusal as soon as the house asks for power.
|
||||
- Not fixed, same on e3e4ba6: with lagged readings a headroom-sized quarter
|
||||
banks up to 1.4 W over the aim (48.6 W under the cap), and a battery
|
||||
reading older than the meter can make a charge start look like a house
|
||||
over the aim, a real guard stop.
|
||||
- Review: an unmeasured stop that never latched let a headroom missing
|
||||
every other cycle flip the charge 1799 times in 30 min, so the second one
|
||||
in a quarter latches (at most 4 changes a quarter). A house that rises
|
||||
1, 2 or 3 kW as the charge ends, from 30 s into the quarter to 15 s
|
||||
before its end (two inverters, lagged readings): the hold adds at most
|
||||
+33 W to that quarter against the loop without it, and the most any
|
||||
quarter banks is 3431 W with the charge sized to the aim (3450 W; the cap
|
||||
is 3500 W), 3456 W with a slow 8 s battery (the lag's own, the same on
|
||||
e3e4ba6). The import during the hold is the rise, below the charge's
|
||||
own, and a rise past that is the peak floor's, which ends the hold.
|
||||
A maintenance drain, hold or charge that starts during the hold ends it
|
||||
on that tick (replayed at edge + 3 s).
|
||||
|
||||
## P4: the solar forecast and the day plan (2026-09-29)
|
||||
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
|
||||
|
||||
@@ -555,10 +555,10 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
|
||||
**What always comes first.**
|
||||
- The peak limit, the safety stops and the monthly cycle come before a battery's mode.
|
||||
- A grid charge is sized so the 15-minute period stays under the peak limit less its margin (`peak_margin_w`). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period.
|
||||
- A grid charge is sized so the 15-minute period stays under the peak limit less its margin (`peak_margin_w`). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period. The start of a new period does not stop it.
|
||||
- While one battery charges from the grid, the others rest, and the house uses the grid.
|
||||
- While one battery empties for its monthly cycle, no battery charges from the grid.
|
||||
- A battery that refuses to charge or give power takes no part in a run in that direction.
|
||||
- A battery that refuses to charge or give power takes no part in a run in that direction. In a cheap run it is still tested every 15 minutes: it charges at 200 W for about half a minute while the others rest.
|
||||
|
||||
**In Home Assistant.** With MQTT set up, each battery also gets a *Mode* list and a *Use* switch, for example `select.goodwe_inverter_a_mode` and `switch.goodwe_inverter_a_use`. They do the same as the Batteries screen. Turn battery modes off and restart, and they are removed.
|
||||
|
||||
@@ -566,7 +566,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
|
||||
*For installers:* a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`.
|
||||
|
||||
*For installers:* when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts. On the replay model that removes a ~3 kW import spike after a 2.5 kW charge and halves the settling time; about 0.1 × the battery's power still overshoots as export. The *Mode* list gains *Cheap hours only* in Home Assistant, at the end.
|
||||
*For installers:* when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts, and holds there until the meter shows the change (at most 18 s, up to 36 s while the battery is still moving). This removes the ~3 kW import spike after a 2.5 kW charge and the export overshoot that readings a second or two old caused (-1120 W on the test rig); with the hold it stays within 0.15 × the battery's power. The *Mode* list gains *Cheap hours only* in Home Assistant, at the end.
|
||||
|
||||
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
|
||||
|
||||
|
||||
@@ -238,6 +238,9 @@ def load_options() -> dict:
|
||||
# OPT-11 P3: how long the last cheap run stays the price stored energy is
|
||||
# valued at (`priceruns.plan(ref_eur_kwh=)`): the rest of that day.
|
||||
REF_CHEAP_KEEP = timedelta(hours=12)
|
||||
# OPT-15: the longest a run edge holds the loop at E waiting for the meter to
|
||||
# show the step: the plant's measured step time (control.PLANT_SETTLE_S).
|
||||
EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
|
||||
|
||||
# OPT-11 P4: the store key of the house profile (dayplan.HouseProfile), and
|
||||
# how often the forecast sensors are read (they change hourly at most).
|
||||
@@ -968,6 +971,7 @@ class Controller:
|
||||
self.peak_unmeasured_s = 0.0
|
||||
self._peak_alarm_block = None
|
||||
self._peak_high_since = None
|
||||
self._peak_rolled = False # OPT-15
|
||||
self._peak_floor_limited = False
|
||||
# SAFETY-87: the reserve alarm's hold, as SAFETY-81's above.
|
||||
self._reserve_batt_prev = None
|
||||
@@ -990,7 +994,11 @@ class Controller:
|
||||
self._run_done = {}
|
||||
self._mode_trade_was = False
|
||||
self._charge_blocked_q = None
|
||||
self._charge_gap_q = None # OPT-15: an unmeasured stop's quarter
|
||||
self._ref_cheap = None
|
||||
# OPT-15: a run edge the loop holds at E: (E, step sign, until, the
|
||||
# last battery reading).
|
||||
self._edge = None
|
||||
# OPT-11 P4: the solar forecast and the day plan. Only with battery
|
||||
# modes on AND a forecast for today set; otherwise none of this runs
|
||||
# and every mode acts as in P3 option A. OPT-17: each day may name
|
||||
@@ -2216,10 +2224,15 @@ class Controller:
|
||||
or maint_results[i].phase == IDLE)
|
||||
refused = (inv.refused if len(self.inverters) > 1
|
||||
else self.batt_refused) or 0
|
||||
# OPT-15: a charge refusal's probe (SAFETY-73, N>1) as it stood
|
||||
# when last decided; its pin is filed after this, in cycle().
|
||||
probing = (len(self.inverters) > 1 and refused < 0
|
||||
and inv.probe_from is not None)
|
||||
out.append(modetrade.Pack(
|
||||
inv.name, pm.use, pm.mode, pm.sell and pm.can_sell,
|
||||
float(inv.max_w), inv.soc, inv.capacity_kwh,
|
||||
live=inv.name in live, idle=idle, refused=int(refused)))
|
||||
live=inv.name in live, idle=idle, refused=int(refused),
|
||||
probing=probing))
|
||||
return out
|
||||
|
||||
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
|
||||
@@ -2377,9 +2390,17 @@ class Controller:
|
||||
done_charge=done_c, done_sell=done_s, targets=targets,
|
||||
draining=any(r.phase == DRAIN for r in maint_results or ()))
|
||||
# A charge the guard or the headroom stopped stays off this period.
|
||||
if was and self.mode_tick.action != modetrade.CHARGE and \
|
||||
self.mode_tick.why in ("peak", "headroom"):
|
||||
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
|
||||
# (None) in a quarter stops this tick only, so a gap cannot cost the
|
||||
# whole quarter. A second one latches, so gaps cannot flip the charge.
|
||||
if was and self.mode_tick.action != modetrade.CHARGE:
|
||||
why = self.mode_tick.why
|
||||
gap = why == "headroom" and self.arb_headroom_w is None
|
||||
if why == "peak" or (why == "headroom" and not gap) or (
|
||||
gap and self._charge_gap_q == quarter):
|
||||
self._charge_blocked_q = quarter
|
||||
elif gap:
|
||||
self._charge_gap_q = quarter
|
||||
return self.mode_tick
|
||||
|
||||
def _run_mark(self, kind, run, packs, out, extra=None) -> set:
|
||||
@@ -2450,18 +2471,18 @@ class Controller:
|
||||
else "trade_off"))
|
||||
return self.arb.action is not None
|
||||
|
||||
def _reseed_run_edge(self, claims) -> None:
|
||||
def _reseed_run_edge(self, claims, now) -> None:
|
||||
"""OPT-11 P3: a mode trade just ended, so the loop takes over from a
|
||||
command no loop chose. Start it at the house's own point E, the
|
||||
command that brings the grid to its target now (`site battery + grid
|
||||
- target`, inside the loop bound), instead of at the trade's watts
|
||||
(the trade began with the same unslewed step). This tick's error still
|
||||
measures the trade's command (it is exactly E - battery), so the
|
||||
integrator is seeded that much short and lands on E after its step.
|
||||
Measured (tools/opt11p3_replay.py, lagged battery, 2.5 kW charge
|
||||
ending under an 800 W house): no reseed -258 W export then a 2976 W
|
||||
import, settled in 55 s; this -257 W / +615 W, settled in 20 s.
|
||||
- target`, inside the loop bound), instead of at the trade's watts.
|
||||
OPT-15: then HOLD it at E (`_edge_grid()`) until the meter shows the
|
||||
step or `EDGE_HOLD_S` passes. The readings still describe the trade for
|
||||
a second or two (meter and battery lag), and stepping on them sent
|
||||
E 810 -> 1370 -> 1920 W on TEST-23: -1120 W export at a charge end.
|
||||
Measured: DECISIONS/OPT-11-battery-modes.md.
|
||||
"""
|
||||
self._edge = None
|
||||
batt = self.site_batt_w
|
||||
if batt is None or self.grid is None:
|
||||
return
|
||||
@@ -2470,8 +2491,34 @@ class Controller:
|
||||
if bound is not None:
|
||||
want = max(bound[0], min(bound[1], want))
|
||||
want = float(round(max(-self.site_max_w, min(self.site_max_w, want))))
|
||||
self.target = want
|
||||
self.i_w = want - self.tuning.gain * (want - float(batt))
|
||||
self.target = self.i_w = want
|
||||
if want != float(batt):
|
||||
self._edge = (want, 1.0 if want > float(batt) else -1.0,
|
||||
now + timedelta(seconds=EDGE_HOLD_S), float(batt))
|
||||
|
||||
def _edge_grid(self, end, now):
|
||||
"""OPT-15: the grid reading the loop acts on this tick. While a run
|
||||
edge is held, the target itself (no error, the command stays at E).
|
||||
The hold ends when the meter reaches the target in the step's
|
||||
direction (or passes it), on `end` (a trade started, or another claim
|
||||
moved the command), or after `EDGE_HOLD_S` - later, up to twice that,
|
||||
while the battery reading is still moving towards E."""
|
||||
e = self._edge
|
||||
if e is None:
|
||||
return self.grid
|
||||
held_w, sign, until, last_b = e
|
||||
tg, db = self.tuning.target_grid_w, self.tuning.deadband_w
|
||||
b = self.site_batt_w
|
||||
moving = (b is not None and sign * (float(b) - last_b) >= db
|
||||
and sign * (held_w - float(b)) >= db
|
||||
and now < until + timedelta(seconds=EDGE_HOLD_S))
|
||||
if (end or (now >= until and not moving)
|
||||
or sign * (float(self.grid) - tg) < db):
|
||||
self._edge = None
|
||||
return self.grid
|
||||
self._edge = (held_w, sign, until, last_b if b is None else float(b))
|
||||
self.i_w = held_w
|
||||
return tg
|
||||
|
||||
def _charge_track(self, claims, maint_results) -> None:
|
||||
"""SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can
|
||||
@@ -4558,8 +4605,16 @@ class Controller:
|
||||
# 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`.
|
||||
# OPT-15: with battery modes on, the quarter `now` is in, even before
|
||||
# its first sample (DECISIONS/OPT-11-battery-modes.md).
|
||||
self._peak_proj = (None if not self.p1_enabled
|
||||
else self.peak.project(self.p1.averager, now))
|
||||
else self.peak.project(self.p1.averager, now,
|
||||
roll=self.modes_on))
|
||||
# OPT-15 x SAFETY-81: see `check_peak_saturated()`.
|
||||
self._peak_rolled = (self._peak_proj is not None
|
||||
and self._peak_proj.block_start is not None
|
||||
and self._peak_proj.block_start
|
||||
!= self.p1.averager.block_start)
|
||||
_reading = self.peak.peak(now) if self.p1_enabled else None
|
||||
# SAFETY-90: seconds a meter gap left uncounted are counted, so the
|
||||
# guard goes on after the readings return.
|
||||
@@ -5002,11 +5057,15 @@ class Controller:
|
||||
# entering failsafe re-seeds the integrator to zero and the first
|
||||
# cycle after release starts from zero instead of dumping the whole
|
||||
# stale period as power.
|
||||
if self.loop_w is None or self.target != self.loop_w:
|
||||
moved = self.loop_w is None or self.target != self.loop_w
|
||||
if moved:
|
||||
self.i_w = self.target
|
||||
# OPT-11 P3: a run edge (a mode trade ended) reseeds the loop.
|
||||
# OPT-11 P3: a run edge (a mode trade ended) reseeds the loop, and
|
||||
# OPT-15 holds it there until the meter shows the step.
|
||||
if trade_was and not mode_trade:
|
||||
self._reseed_run_edge(claims)
|
||||
self._reseed_run_edge(claims, now)
|
||||
moved = False
|
||||
loop_grid = self._edge_grid(mode_trade or moved, now)
|
||||
# SAFETY-52: another writer overrode the loop, so a refusal learned
|
||||
# against the loop's own ask may no longer describe the wire. A gap
|
||||
# (`loop_w` None: blind or stopped) keeps it: the stale-reading stop
|
||||
@@ -5059,8 +5118,10 @@ class Controller:
|
||||
decision = compute(
|
||||
prev_w=loop_prev,
|
||||
# SAFETY-95: a drain's tail is not the loop's to chase.
|
||||
# OPT-15: `loop_grid` is the grid, or the target while a run
|
||||
# edge is held.
|
||||
grid_w=(self._drain_grid_w(_live96) if self._drain_tail
|
||||
else self.grid),
|
||||
else loop_grid),
|
||||
actual_w=site_batt,
|
||||
tuning=self.tuning,
|
||||
sat_count=self.sat_count,
|
||||
@@ -5807,9 +5868,13 @@ class Controller:
|
||||
if prev is not None:
|
||||
batt = min(float(batt), float(prev))
|
||||
p = self._peak_proj
|
||||
# OPT-15 x SAFETY-81: until the new quarter's first sample the old
|
||||
# projection read "no seconds left" (not high), which restarted the
|
||||
# hold; a rolled one (battery modes on) is not graded then, so a
|
||||
# persistent overload is still notified once per quarter.
|
||||
high = bool(
|
||||
g is not None and g.saturated and p is not None
|
||||
and p.block_start is not None
|
||||
and p.block_start is not None and not self._peak_rolled
|
||||
and peakguard.load_too_high(
|
||||
p, batt_w=batt, ceiling_w=g.ceiling_w,
|
||||
achievable_w=self.site_rating_w))
|
||||
|
||||
@@ -21,7 +21,7 @@ Roles (the page's Why line names them):
|
||||
grid the others cannot give power either; the allocator never
|
||||
splits a target both ways).
|
||||
|
||||
Rules that keep this safe (tests: test_modes_p3.py):
|
||||
Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
* every range holds 0 W (a mode may forbid a direction, never force one);
|
||||
* a Solar first battery never charges from the grid;
|
||||
* P4: with a day plan (`targets`), a Leftover solar battery charges from
|
||||
@@ -31,11 +31,11 @@ Rules that keep this safe (tests: test_modes_p3.py):
|
||||
* grid charging needs a measured peak guard that is clear and a measured
|
||||
headroom (SAFETY-45); the trade is sized to that headroom, whose aim is
|
||||
`peak_cap_w - peak_margin_w` (SAFETY-79). It starts only at START_W or
|
||||
more, and once the guard or the headroom stops it, it stays stopped for
|
||||
the rest of that 15-minute period (`blocked`): a charge makes the other
|
||||
batteries stop giving power, so a few watts of charge flipping on and
|
||||
off would swing the house between them and the grid (measured on the
|
||||
2026-09-09 replay: 13 flips in 4 minutes at a 3.5 kW house);
|
||||
more, and once the guard or a measured headroom stops it, it stays
|
||||
stopped for the rest of that 15-minute period (`blocked`): a charge
|
||||
makes the other batteries stop giving power, so a few watts of charge
|
||||
flipping on and off would swing the house between them and the grid
|
||||
(measured on the 2026-09-09 replay: 13 flips in 4 minutes at 3.5 kW);
|
||||
* selling needs selling on for that battery, the sell run, a price >= 0
|
||||
now, and the battery above `arbitrage_soc_floor`; it stops there. The
|
||||
peak guard never stops a sell (section 9.1: discharge is never capped);
|
||||
@@ -43,7 +43,10 @@ Rules that keep this safe (tests: test_modes_p3.py):
|
||||
* a battery out of a run (full, or at the floor) stays out until the run
|
||||
ends, so the ceiling and the floor do not re-arm within one run;
|
||||
* a battery that refuses a direction (SAFETY-52/73) takes no part in a
|
||||
run in that direction: the refusal wins over the run;
|
||||
run in that direction: the refusal wins over the run. The one exception
|
||||
is its SAFETY-73 probe inside a cheap run (OPT-15): it is a charger then,
|
||||
pinned at PROBE_W by its own claim, so the others rest (one sign) and the
|
||||
card's "tries again every 15 minutes" holds in the run too;
|
||||
* no grid charge while another battery drains for its monthly cycle
|
||||
(`draining`, SAFETY-78): it would charge against the drain.
|
||||
"""
|
||||
@@ -75,6 +78,7 @@ class Pack:
|
||||
live: bool = True
|
||||
idle: bool = True # not in a maintenance phase
|
||||
refused: int = 0 # SAFETY-52/73: +1 refuses to give, -1 to charge
|
||||
probing: bool = False # OPT-15: its SAFETY-73 probe window is open
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -112,15 +116,18 @@ def charge_to(p: Pack, ceiling_pct, targets=None):
|
||||
return None if t is None else min(float(ceiling_pct), float(t))
|
||||
|
||||
|
||||
def chargers(packs, ceiling_pct, done=(), targets=None) -> list:
|
||||
def chargers(packs, ceiling_pct, done=(), targets=None, probe=False) -> list:
|
||||
"""Batteries that may charge from the grid in a cheap run: below the
|
||||
level `charge_to()` allows them (P4: the day plan's)."""
|
||||
level `charge_to()` allows them (P4: the day plan's). With `probe`, also
|
||||
one refusing to charge while its SAFETY-73 probe is open: its own P_SAFETY
|
||||
pin keeps it at PROBE_W (OPT-15)."""
|
||||
out = []
|
||||
for p in packs:
|
||||
to = charge_to(p, ceiling_pct, targets)
|
||||
if (to is not None and p.use and p.idle and p.live
|
||||
and p.soc is not None and p.soc < to
|
||||
and p.refused >= 0 and p.name not in done):
|
||||
and (p.refused >= 0 or (probe and p.probing))
|
||||
and p.name not in done):
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
@@ -172,8 +179,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
in_sell = plan_on and runs.sell is not None and runs.sell.holds(now)
|
||||
|
||||
why = None
|
||||
ch = chargers(packs, ceiling_pct, done_charge, targets) if in_cheap \
|
||||
else []
|
||||
ch = chargers(packs, ceiling_pct, done_charge, targets, probe=True) \
|
||||
if in_cheap else []
|
||||
if ch and draining:
|
||||
ch, why = [], "cycle"
|
||||
charging = False
|
||||
|
||||
@@ -312,6 +312,12 @@ class QuarterAverager:
|
||||
"""
|
||||
return self._last_net
|
||||
|
||||
@property
|
||||
def held_t(self) -> float | None:
|
||||
"""Epoch seconds of the held sample. OPT-15: `PeakTracker.project()`
|
||||
with `roll=True` credits it into the next block as `add()` will."""
|
||||
return self._last_t
|
||||
|
||||
@property
|
||||
def held_since(self) -> datetime | None:
|
||||
"""SAFETY-90: when the held sample arrived (its `ingest_ts`), or None."""
|
||||
|
||||
@@ -776,9 +776,14 @@ class PeakTracker:
|
||||
return r.peak_w / self.cap_w
|
||||
|
||||
@staticmethod
|
||||
def project(averager, now: datetime) -> Projection:
|
||||
def project(averager, now: datetime, roll: bool = False) -> Projection:
|
||||
"""End-of-quarter average if the last sample held for the rest of it.
|
||||
|
||||
`roll` (OPT-15, battery modes only): when `now` is past the open block
|
||||
because no sample has come since the boundary, project the quarter
|
||||
`now` is in, crediting the held sample as `add()` will (`max_hold_s`).
|
||||
Without it that tick reads "no seconds left" and measures nothing.
|
||||
|
||||
⚠️ The two divisors are DIFFERENT and only coincide at the end of the
|
||||
block. `elapsed_w` divides by the seconds actually covered (what the
|
||||
meter has shown so far); `projected_w` divides by the full 900 s and
|
||||
@@ -798,6 +803,14 @@ class PeakTracker:
|
||||
elapsed_wall = max(0.0, (now - start).total_seconds())
|
||||
elapsed = averager.elapsed_s
|
||||
acc = averager.partial_ws
|
||||
if roll and elapsed_wall >= QUARTER_S:
|
||||
t = now.timestamp()
|
||||
block = int(t // QUARTER_S) * QUARTER_S
|
||||
start = datetime.fromtimestamp(block, timezone.utc)
|
||||
held_end = float(averager.held_t) + float(averager.max_hold_s)
|
||||
elapsed = max(0.0, min(t, held_end) - block)
|
||||
elapsed_wall = t - block
|
||||
acc = max(averager.held_net_w, 0.0) * elapsed
|
||||
remaining = max(0.0, QUARTER_S - elapsed_wall)
|
||||
# ⚠️ 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
|
||||
|
||||
@@ -74,7 +74,7 @@ AT_7A00F12 = {
|
||||
"n1_off": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2_off": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
}
|
||||
_rows = {k: ident.run(**v)[0] for k, v in ident.SCENARIOS.items()}
|
||||
_rows = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_7A00F12}
|
||||
_got = {k: ident.digest(v) for k, v in _rows.items()}
|
||||
for k in AT_7A00F12:
|
||||
print(f" {k} {'same' if _got[k] == AT_7A00F12[k] else 'DIFFERS'} "
|
||||
@@ -482,10 +482,16 @@ _ref = [r for r in ROWS_R if present(_first)
|
||||
print(f" B refuses to charge; after 3 min: trades "
|
||||
f"{sorted({str(r['action']) for r in _ref})}, B roles "
|
||||
f"{sorted({r['roles'].get(B) for r in _ref})}")
|
||||
# OPT-15: B's SAFETY-73 probe (every 15 min, 34 s) is the one grid trade
|
||||
# left: PROBE_W on B alone, A resting.
|
||||
_prb = [r for r in _ref if r["action"] is not None]
|
||||
check("B refusing to charge: no grid trade is filed after the refusal is "
|
||||
"learned, B's role is no longer 'charge', and A covers the house",
|
||||
_ref and all(r["action"] is None for r in _ref)
|
||||
and all(r["roles"].get(B) != modetrade.CHARGE for r in _ref)
|
||||
"learned but B's probe (PROBE_W on B, A resting), B charges only then, "
|
||||
"and A covers the house",
|
||||
_ref and all(r["shares"].get(B) == -200 and r["shares"].get(A, 0) == 0
|
||||
for r in _prb) and len(_prb) <= 0.1 * len(_ref)
|
||||
and all(r["roles"].get(B) != modetrade.CHARGE
|
||||
for r in _ref if r["action"] is None)
|
||||
and sum(r["shares"].get(A, 0) > 0 for r in _ref) > len(_ref) / 2)
|
||||
|
||||
# ============================================================================ #
|
||||
@@ -516,7 +522,7 @@ print("\n7. a run edge reseeds the loop")
|
||||
|
||||
|
||||
def _no_reseed(c):
|
||||
c._reseed_run_edge = lambda claims: None
|
||||
c._reseed_run_edge = lambda claims, now: None
|
||||
|
||||
|
||||
def edge(rows, kind):
|
||||
|
||||
@@ -60,37 +60,42 @@ UTC0 = D0.astimezone(timezone.utc)
|
||||
print("1. no usable forecast: byte-identical to 5a4cdc2 (P3 option A)")
|
||||
# Printed by tools/opt11p4_identity.py on release/1.0 5a4cdc2 (2026-09-29),
|
||||
# goodwe-addon-checks:1. 1080 cycles each, battery modes on.
|
||||
# OPT-15: the six scenarios with a grid charge (`_ch`) were re-pinned on the
|
||||
# OPT-15 head: a charge end now holds the loop at the house's level (F2). With
|
||||
# that hold and its reseed switched off they equal 5a4cdc2's again; the others
|
||||
# (no charge) never moved.
|
||||
BASE_DIGESTS = {
|
||||
"n1_ls":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch":
|
||||
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
|
||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
||||
"n2_sf_ls":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls":
|
||||
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
|
||||
"9c54abddf877ddacbb3511d9cbe5231ddbda74986a89f396b83c5b2d22dc16f6",
|
||||
"n2_ls_ls":
|
||||
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
|
||||
"n2_sf_ch":
|
||||
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
|
||||
"d16d3cac0d9f720b68212c5899624d2a02fb6f7ecae258ddfc8dbba560d47bcf",
|
||||
"n1_ls_fc_down":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch_fc_down":
|
||||
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
|
||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
||||
"n2_sf_ls_fc_down":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls_fc_down":
|
||||
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
|
||||
"9c54abddf877ddacbb3511d9cbe5231ddbda74986a89f396b83c5b2d22dc16f6",
|
||||
"n2_ls_ls_fc_down":
|
||||
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
|
||||
"n2_sf_ch_fc_down":
|
||||
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
|
||||
"d16d3cac0d9f720b68212c5899624d2a02fb6f7ecae258ddfc8dbba560d47bcf",
|
||||
}
|
||||
import opt11p4_identity as ident # noqa: E402
|
||||
|
||||
got = ident.digests()
|
||||
for k, v in BASE_DIGESTS.items():
|
||||
check(f"{k}: same digest as 5a4cdc2", got.get(k) == v)
|
||||
check(f"{k}: pinned digest (5a4cdc2; OPT-15 for the _ch ones)",
|
||||
got.get(k) == v)
|
||||
check("every scenario is pinned", set(got) == set(BASE_DIGESTS))
|
||||
for k in [k for k in got if k.endswith("_fc_down")]:
|
||||
check(f"{k}: a set but unavailable forecast equals no forecast",
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
"""Runnable check for OPT-15, the P3 price-run fixes. `python3 test_price_run_fixes.py`
|
||||
|
||||
The OPT-11 P3 post-merge test on TEST-23 (5a4cdc2) found three defects. This
|
||||
file drives the pure modules and the REAL Controller (tools/opt11p3_replay.py)
|
||||
with the lagged model (`Lag`: command dead time, meter and battery reading
|
||||
age, the P1 debounce, 1 s cycles), which reproduces all three on 5a4cdc2.
|
||||
|
||||
1. F1: a quarter edge while charging does not stop the charge (the guard
|
||||
projects the quarter `now` is in), and a real guard stop still latches.
|
||||
At two inverters SAFETY-91's `next_quarter()` already does that for the
|
||||
guard; at one inverter only the rolled projection does.
|
||||
2. F2: the export overshoot at a charge end stays within 0.15 x rating in
|
||||
the model and in the lagged variants (TEST-23 measured -1120 W).
|
||||
3. F3: a charge refusal inside a cheap run is probed every 15 minutes.
|
||||
4. The P3 and SAFETY-79 invariants on the lagged runs.
|
||||
5. Modes off is byte-identical to release/1.0 e3e4ba6, also with a late P1
|
||||
sample on every quarter boundary, and the harness sees a change there.
|
||||
|
||||
DECISIONS/OPT-11-battery-modes.md has the record. Mutations (MUTATION.md,
|
||||
purged copies, graded over this file and test_modes_p3.py) are listed in the
|
||||
pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
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 opt11p3_identity as ident # noqa: E402
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from app import arbitrage, modetrade, packmode, peakguard # noqa: E402
|
||||
from app import tariff # noqa: E402
|
||||
from app import control as ctl # noqa: E402
|
||||
from app import main # noqa: E402
|
||||
from app.p1 import QuarterAverager, make_sample # noqa: E402
|
||||
from app.tariff import PeakTracker # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=2))
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
D0 = datetime(2026, 9, 9, 0, 0, tzinfo=L)
|
||||
UTC0 = D0.astimezone(timezone.utc)
|
||||
# Cheap 02:00-04:00 local, dear 19:00-20:00, flat otherwise (two days).
|
||||
PRICES = [0.30] * 192
|
||||
for _i in range(8, 16):
|
||||
PRICES[_i] = 0.10
|
||||
for _i in range(76, 80):
|
||||
PRICES[_i] = 0.40
|
||||
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p)
|
||||
for i, p in enumerate(PRICES)]
|
||||
RATING = rp.RATING
|
||||
BOUND = 0.15 * RATING
|
||||
AIM = rp.PEAK_CAP - 50
|
||||
CH_SF = {"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.SOLAR_FIRST, False)}
|
||||
|
||||
|
||||
def at(h):
|
||||
return UTC0 + timedelta(hours=h)
|
||||
|
||||
|
||||
def house(steps, base=800.0):
|
||||
"""[(t, W)] for the replay: `base`, then each (local hour, W) from then."""
|
||||
out = [(UTC0.timestamp() - 10, base)]
|
||||
out += [(at(h).timestamp(), float(w)) for h, w in steps]
|
||||
return out
|
||||
|
||||
|
||||
def run(start_h, hours, *, lag=None, soc_a=20.0, steps=(), choice=None,
|
||||
refuse=(), patch=None, check_cb=None, n=2):
|
||||
rows = []
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
sh = dict(c.allocation.shares) if c.allocation is not None and \
|
||||
c.allocation.shares else {}
|
||||
rows.append(dict(
|
||||
now=now, grid=h.grid(), shares=sh,
|
||||
action=getattr(c.arb, "action", None),
|
||||
reason=getattr(c.arb, "reason", None) or "",
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
refused=c.inverters[0].refused,
|
||||
probing=c.inverters[0].probe_from is not None,
|
||||
headroom=c.arb_headroom_w,
|
||||
p={u: p.p for u, p in h.plants.items()}))
|
||||
if check_cb:
|
||||
check_cb(c, h, now, pr)
|
||||
st, c = rp.run("p3_ch_sell", house(steps), WPTS, at(start_h),
|
||||
3600 * hours, check=cb, lag=lag, n=n,
|
||||
soc0={"a": soc_a, "b": 60.0},
|
||||
choice=CH_SF if choice is None else choice,
|
||||
refuse=refuse, patch=patch,
|
||||
extra={"cycle_cost_threshold_eur": 0.10})
|
||||
return rows, c
|
||||
|
||||
|
||||
def stops(rows):
|
||||
"""[(row index, row)] where a grid charge stopped."""
|
||||
return [(i, rows[i]) for i in range(1, len(rows))
|
||||
if rows[i - 1]["action"] == arbitrage.CHARGE
|
||||
and rows[i]["action"] != arbitrage.CHARGE]
|
||||
|
||||
|
||||
def q(when):
|
||||
return int(when.timestamp() // 900)
|
||||
|
||||
|
||||
def banked(c, rows):
|
||||
"""The add-on's own P1 blocks for the quarters that had a grid charge."""
|
||||
chq = {q(r["now"]) for r in rows if r["action"] == arbitrage.CHARGE}
|
||||
return [b.offtake_avg_w for b in c.p1.blocks if q(b.start) in chq]
|
||||
|
||||
|
||||
def mixed(rows):
|
||||
return [r for r in rows if any(v > 0 for v in r["shares"].values())
|
||||
and any(v < 0 for v in r["shares"].values())]
|
||||
|
||||
|
||||
# Close to TEST-23's plant: 1 s cycles, readings 1 s old, a battery that
|
||||
# follows the previous second's command.
|
||||
LAG = rp.Lag(tau_s=0.05, ramp_w_s=1e9)
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. F1: a quarter edge while charging")
|
||||
# 1a. The projection on the edge tick, before the new block's first sample.
|
||||
_avg = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
_edge = datetime(2026, 9, 9, 12, 0, tzinfo=timezone.utc)
|
||||
for _s in range(-900, 1, 1):
|
||||
_t = _edge + timedelta(seconds=_s - 0.35)
|
||||
_avg.add(make_sample("ha_signed", 3300.0, 0.0, phases=1, ingest_ts=_t,
|
||||
ingest_mono=0.0))
|
||||
_now = _edge + timedelta(seconds=0.6)
|
||||
_old = PeakTracker.project(_avg, _now)
|
||||
_new = PeakTracker.project(_avg, _now, roll=True)
|
||||
|
||||
|
||||
def _decide(pr):
|
||||
return peakguard.decide(pr, batt_w=-2500.0, cap_w=3500.0, margin_w=50.0,
|
||||
reading=None, achievable_w=5000.0, soc=50.0,
|
||||
soc_floor=20.0, max_hold_s=30.0)
|
||||
|
||||
|
||||
print(f" edge tick: old block {_old.explain()}; rolled {_new.explain()}")
|
||||
check("on the edge tick the open block is still the old one: the guard reads "
|
||||
"'no seconds left' and the headroom is unmeasured (the F1 cause)",
|
||||
_old.elapsed_s >= 900 and peakguard.charge_headroom_w(
|
||||
_decide(_old)) is None)
|
||||
_later = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
for _s in range(-900, 1, 1):
|
||||
_later.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=_s - 0.35),
|
||||
ingest_mono=0.0))
|
||||
_later.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=0.6),
|
||||
ingest_mono=0.0))
|
||||
_real = PeakTracker.project(_later, _now)
|
||||
_hr = peakguard.charge_headroom_w(_decide(_new))
|
||||
check("rolled, it is the new quarter, measured, and equal to what the "
|
||||
"averager projects once the new block's first sample is in; the "
|
||||
"headroom is the house and the aim (3450 - 800 W)",
|
||||
_new.block_start == _edge == _real.block_start and _new.complete
|
||||
and abs(_new.projected_w - _real.projected_w) < 0.01
|
||||
and abs(_new.elapsed_s - 0.6) < 1e-6
|
||||
and _hr is not None and abs(_hr - 2650.0) < 1.0)
|
||||
_gap = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
_gap.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
|
||||
ingest_ts=_edge - timedelta(seconds=100), ingest_mono=0))
|
||||
_gp = PeakTracker.project(_gap, _edge + timedelta(seconds=40), roll=True)
|
||||
check("...and a sample older than the hold is not credited: past the hold "
|
||||
"the rolled quarter is blind, as the averager would make it",
|
||||
_gp.elapsed_s == 40.0 and _gp.covered_frac == 0.0
|
||||
and peakguard.blindness(_gp, 30.0, False) is not None)
|
||||
check("without `roll` nothing changes (the modes-off path)",
|
||||
PeakTracker.project(_avg, _now, roll=False) == _old)
|
||||
# Review of #157 (X1): a held export is credited as 0 W, as `add()` banks it,
|
||||
# never as a negative that would pull the rolled quarter under the meter.
|
||||
_exp = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
for _s in range(-900, 1, 1):
|
||||
_exp.add(make_sample("ha_signed", 0.0, 1500.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=_s - 0.35),
|
||||
ingest_mono=0.0))
|
||||
_ep = PeakTracker.project(_exp, _now, roll=True)
|
||||
_exp.add(make_sample("ha_signed", 0.0, 1500.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=0.6),
|
||||
ingest_mono=0.0))
|
||||
_er = PeakTracker.project(_exp, _now)
|
||||
check("...and a held export rolls in as 0 W, equal to what the averager "
|
||||
"banks for those seconds",
|
||||
_ep.block_start == _er.block_start and _ep.projected_w == 0.0
|
||||
and _ep.elapsed_w == 0.0 and _er.projected_w == 0.0)
|
||||
|
||||
# 1b. The lagged replay: 02:00-03:30 local, a Cheap hours battery charging
|
||||
# under an 800 W house through five quarter edges.
|
||||
ROWS1, C1 = run(1.9, 1.6, lag=LAG, choice={
|
||||
"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.LEFTOVER_SOLAR, False)})
|
||||
_ch1 = [r for r in ROWS1 if r["action"] == arbitrage.CHARGE]
|
||||
_edges1 = sorted({q(r["now"]) for r in _ch1})
|
||||
_st1 = stops(ROWS1)
|
||||
print(f" {len(_ch1)} charge cycles over {len(_edges1)} quarters; stops "
|
||||
f"{[(r['now'].astimezone(L).strftime('%H:%M:%S'), r['reason'][:40]) for _, r in _st1]}")
|
||||
check("no stop at any quarter edge crossed while charging (two inverters; "
|
||||
"see below for the old projection)",
|
||||
len(_edges1) >= 5 and not _st1)
|
||||
|
||||
|
||||
def _no_roll(c):
|
||||
real = c.peak.project
|
||||
c.peak.project = lambda a, now, roll=False: real(a, now)
|
||||
|
||||
|
||||
_CH1 = {"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.LEFTOVER_SOLAR, False)}
|
||||
# One inverter: SAFETY-91's `next_quarter()` is not applied there, so this is
|
||||
# where the old projection stops the charge at every edge.
|
||||
ROWS1N, C1N = run(1.9, 1.6, lag=LAG, n=1, choice=_CH1)
|
||||
ROWS1M, _ = run(1.9, 0.6, lag=LAG, n=1, patch=_no_roll, choice=_CH1)
|
||||
_m1 = stops(ROWS1M)
|
||||
check("one inverter: no stop at any edge crossed while charging",
|
||||
len({q(r["now"]) for r in ROWS1N if r["action"] == arbitrage.CHARGE})
|
||||
>= 5 and not stops(ROWS1N))
|
||||
check("non-vacuous: with the old projection one inverter stops at every edge "
|
||||
"for want of a measured headroom (5a4cdc2 then latched the quarter; "
|
||||
"the latch fix alone keeps it to that tick)",
|
||||
len(_m1) >= 2 and all("headroom" in r["reason"] and
|
||||
r["now"].astimezone(L).minute % 15 == 0 and
|
||||
r["now"].astimezone(L).second == 0
|
||||
for _, r in _m1))
|
||||
# Two inverters: since SAFETY-91 the guard grades the next quarter past a
|
||||
# block's end (`tariff.next_quarter()`), so the old projection no longer
|
||||
# stops there; the rolled one gives the same quarters.
|
||||
ROWS1O, C1O = run(1.9, 1.6, lag=LAG, patch=_no_roll, choice=_CH1)
|
||||
print(f" two inverters, old projection: {len(stops(ROWS1O))} stops; banked "
|
||||
f"{[round(x) for x in banked(C1O, ROWS1O)]} against "
|
||||
f"{[round(x) for x in banked(C1, ROWS1)]} rolled")
|
||||
check("two inverters: SAFETY-91 already holds the edge (no stop with the old "
|
||||
"projection), and rolling it changes no banked quarter",
|
||||
not stops(ROWS1O) and banked(C1O, ROWS1O) == banked(C1, ROWS1))
|
||||
|
||||
# 1c. A real guard stop still latches, and the 500 W minimum stays.
|
||||
# House: 800 W; 3700 W at 02:20-02:21; 3000 W from 02:26 (headroom 450 W
|
||||
# in the next quarter); 2850 W from 02:40 (600 W).
|
||||
ROWS2, C2 = run(1.9, 0.9, lag=LAG, steps=[
|
||||
(2 + 20 / 60, 3700), (2 + 21 / 60, 800), (2 + 26 / 60, 3000),
|
||||
(2 + 40 / 60, 2850)])
|
||||
_st2 = stops(ROWS2)
|
||||
_first = _st2[0][1] if _st2 else None
|
||||
_q2 = q(_first["now"]) if _first else None
|
||||
_after2 = [r for r in ROWS2 if _first and r["now"] > _first["now"]
|
||||
and q(r["now"]) == _q2]
|
||||
print(f" guard stop at "
|
||||
f"{_first['now'].astimezone(L).strftime('%H:%M:%S') if _first else None}"
|
||||
f" ({_first['reason'][:40] if _first else ''})")
|
||||
check("a mid-quarter 3.7 kW load stops the charge for the peak and it stays "
|
||||
"off for the rest of that quarter, the house back at 800 W",
|
||||
_first is not None and _first["now"].astimezone(L).minute == 20
|
||||
and ("peak" in _first["reason"] or "headroom" in _first["reason"])
|
||||
and _after2 and all(r["action"] is None for r in _after2))
|
||||
_starts = [b for a, b in zip(ROWS1 + ROWS2, (ROWS1 + ROWS2)[1:])
|
||||
if a["action"] != arbitrage.CHARGE
|
||||
and b["action"] == arbitrage.CHARGE]
|
||||
_low = [r for r in ROWS2 if r["headroom"] is not None
|
||||
and 0 < r["headroom"] <= modetrade.START_W and r["action"] is None]
|
||||
print(f" {len(_starts)} charge starts, lowest headroom at a start "
|
||||
f"{min(r['headroom'] for r in _starts):.0f} W; {len(_low)} cycles "
|
||||
f"with 0-500 W of headroom and no charge")
|
||||
check("the 500 W minimum stays: every charge starts with more than 500 W of "
|
||||
"headroom, and a house at 3 kW (450 W) holds it off",
|
||||
_starts and _low and all(r["headroom"] > modetrade.START_W
|
||||
for r in _starts))
|
||||
|
||||
# 1d. An unmeasured headroom stops one tick and does not latch.
|
||||
_c1d = run(1.9, 0.2, soc_a=20.0)[1]
|
||||
_n1d = UTC0 + timedelta(hours=2, minutes=5)
|
||||
_ok1d = False
|
||||
if _c1d.mode_tick is not None and _c1d.mode_tick.action == modetrade.CHARGE:
|
||||
_c1d.arb_headroom_w = None
|
||||
_t1 = _c1d._mode_roles(_n1d, None)
|
||||
_c1d.arb_headroom_w = 3000.0
|
||||
_t2 = _c1d._mode_roles(_n1d + timedelta(seconds=1), None)
|
||||
_c1d.arb_headroom_w = 0.0
|
||||
_t3 = _c1d._mode_roles(_n1d + timedelta(seconds=2), None)
|
||||
_c1d.arb_headroom_w = 3000.0
|
||||
_t4 = _c1d._mode_roles(_n1d + timedelta(seconds=3), None)
|
||||
_ok1d = (_t1.action is None and _t2.action == modetrade.CHARGE
|
||||
and _t3.action is None and _t4.action is None
|
||||
and _c1d._charge_blocked_q == q(_n1d))
|
||||
check("an unmeasured headroom (None) stops the charge that tick only; a "
|
||||
"measured 0 W stops it for the rest of the quarter", _ok1d)
|
||||
|
||||
|
||||
# 1e. Review of #157: gaps cannot flip the charge. A headroom forced to None
|
||||
# every other cycle flipped it 1799 times in 30 min before this; the second
|
||||
# unmeasured stop in a quarter now latches it.
|
||||
def _gaps(c):
|
||||
real, k = c._mode_roles, [0]
|
||||
|
||||
def mr(now, maint):
|
||||
k[0] += 1
|
||||
if k[0] % 2 == 0:
|
||||
c.arb_headroom_w = None
|
||||
return real(now, maint)
|
||||
c._mode_roles = mr
|
||||
|
||||
|
||||
_rows1e = run(2.0, 0.5, lag=LAG, patch=_gaps)[0]
|
||||
_flips = {}
|
||||
_on1e = next((i for i, r in enumerate(_rows1e)
|
||||
if r["action"] == arbitrage.CHARGE), len(_rows1e))
|
||||
for _a, _b in zip(_rows1e[_on1e:], _rows1e[_on1e + 1:]):
|
||||
if (_a["action"] == arbitrage.CHARGE) != (_b["action"] == arbitrage.CHARGE):
|
||||
_flips[q(_b["now"])] = _flips.get(q(_b["now"]), 0) + 1
|
||||
print(f" None every other cycle: charge on/off changes per quarter "
|
||||
f"{sorted(_flips.values())}")
|
||||
check("a headroom unmeasured every other cycle changes the charge at most "
|
||||
"4 times a quarter (restart, stop, restart, stop and latch)",
|
||||
_flips and max(_flips.values()) <= 4)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. F2: the export overshoot at a charge end")
|
||||
# A fills to its 95 % ceiling at about 04:03 under an 800 W house, and
|
||||
# Solar first B takes the house over.
|
||||
VARIANTS = {
|
||||
"model (5 s, instant readings)": None,
|
||||
"model, lagged (1 s cycles)": rp.Lag(),
|
||||
"TEST-23 plant, meter 1 s": rp.Lag(tau_s=0.05, ramp_w_s=1e9),
|
||||
"TEST-23 plant, meter 2 s": rp.Lag(tau_s=0.05, ramp_w_s=1e9,
|
||||
meter_s=2.0),
|
||||
"meter 2 s, battery reading fresh": rp.Lag(tau_s=0.05, ramp_w_s=1e9,
|
||||
meter_s=2.0, batt_s=0.0),
|
||||
"2 s cycles, meter 2 s": rp.Lag(tick=2.0, meter_s=2.0),
|
||||
"5 s meter": rp.Lag(tick=5.0, meter_s=5.0, batt_s=2.0),
|
||||
"slow battery (8 s, 2 s dead), 5 s meter": rp.Lag(
|
||||
tau_s=8.0, dead_s=2.0, tick=5.0, meter_s=5.0, batt_s=2.0),
|
||||
}
|
||||
|
||||
|
||||
def charge_end(rows, lag):
|
||||
"""(min grid, max grid, pre-edge grid, settle s) after the first charge
|
||||
end, over 120 s; settle: from then on within 40 W of the target."""
|
||||
st = stops(rows)
|
||||
if not st:
|
||||
return None
|
||||
i = st[0][0]
|
||||
tick = rp.TICK if lag is None else lag.tick
|
||||
w = [r["grid"] for r in rows[i:i + int(120 / tick)]]
|
||||
settle = next((j * tick for j in range(len(w))
|
||||
if all(abs(x + 10) < 40 for x in w[j:])), None)
|
||||
return min(w), max(w), rows[i - 1]["grid"], settle
|
||||
|
||||
|
||||
F2 = {}
|
||||
for _k, _lag in VARIANTS.items():
|
||||
F2[_k] = charge_end(run(3.9, 0.5, lag=_lag, soc_a=86.0)[0], _lag)
|
||||
print(f" {_k}: {F2[_k] and tuple(round(x) if x is not None else x for x in F2[_k])}")
|
||||
check(f"every variant: the charge ends, the export overshoot stays within "
|
||||
f"0.15 x rating ({BOUND:.0f} W), no import above the charge's own, and "
|
||||
f"the grid settles within 45 s (90 s for the slow battery)",
|
||||
all(v is not None and v[0] >= -BOUND and v[1] <= v[2] + 50
|
||||
and v[3] is not None and v[3] <= (90 if k.startswith("slow")
|
||||
else 45)
|
||||
for k, v in F2.items()))
|
||||
|
||||
|
||||
def _no_hold(c):
|
||||
c._edge_grid = lambda end, now: c.grid
|
||||
|
||||
|
||||
_pre = charge_end(run(3.9, 0.5, lag=VARIANTS["TEST-23 plant, meter 2 s"],
|
||||
soc_a=86.0, patch=_no_hold)[0],
|
||||
VARIANTS["TEST-23 plant, meter 2 s"])
|
||||
print(f" without the hold (5a4cdc2's reseed), meter 2 s: "
|
||||
f"{_pre and tuple(round(x) if x is not None else x for x in _pre)}")
|
||||
check("non-vacuous: without the hold the lagged model shows TEST-23's "
|
||||
"overshoot (over 0.4 x rating), so it cannot hide again",
|
||||
_pre is not None and _pre[0] < -0.4 * RATING)
|
||||
|
||||
# The hold must not hide a real load change at the edge.
|
||||
_down = run(3.9, 0.5, lag=LAG, soc_a=86.0, steps=[])[0]
|
||||
_i_end = stops(_down)[0][0] if stops(_down) else None
|
||||
_edge_t = _down[_i_end]["now"] if _i_end else None
|
||||
|
||||
|
||||
def _edge_step(w):
|
||||
"""The same charge end with the house stepping to `w` at the edge:
|
||||
(seconds until the loop moves B off E, lowest grid, highest grid after
|
||||
the first 3 s)."""
|
||||
if _edge_t is None:
|
||||
return None
|
||||
h = (_edge_t - UTC0).total_seconds() / 3600
|
||||
rows = run(3.9, 0.5, lag=LAG, soc_a=86.0, steps=[(h, w)])[0]
|
||||
after = [r for r in rows if r["now"] >= _edge_t]
|
||||
first = nth(after, 0, "the edge cycle")
|
||||
if not present(first):
|
||||
return None
|
||||
e = first["shares"].get(B)
|
||||
moved = next(((r["now"] - _edge_t).total_seconds() for r in after
|
||||
if r["shares"].get(B) != e), None)
|
||||
return moved, min(r["grid"] for r in after[:60]), max(
|
||||
r["grid"] for r in after[3:60]), [r["grid"] for r in after[:60]]
|
||||
|
||||
|
||||
_fall, _rise = _edge_step(200.0), _edge_step(1800.0)
|
||||
print(f" house 800 -> 200 W at the edge: loop moves after {_fall[0]} s, "
|
||||
f"lowest {_fall[1]:.0f} W; 800 -> 1800 W: after {_rise[0]} s, highest "
|
||||
f"{_rise[2]:.0f} W")
|
||||
check("the hold does not hide a load change: a house that falls at the edge "
|
||||
"ends it within 5 s, with no export beyond the fall and the bound; one "
|
||||
"that rises is followed by EDGE_HOLD_S, with no import beyond the rise",
|
||||
_fall and _rise and _fall[0] is not None and _fall[0] <= 5
|
||||
and _fall[1] >= -600 - BOUND
|
||||
and _rise[0] is not None and _rise[0] <= main.EDGE_HOLD_S + 2
|
||||
and _rise[2] <= 1000 + 50)
|
||||
_peak = _edge_step(4500.0)
|
||||
_g10 = nth(_peak[3], 10, "10 s after the edge") if _peak else None
|
||||
print(f" 800 -> 4500 W at the edge: grid 10 s later {_g10} W")
|
||||
check("...and when the peak floor takes the command during the hold, the "
|
||||
"hold ends: 10 s after the edge B gives its rating (grid 4500 - 2500 W), "
|
||||
"not the aim the floor holds", present(_g10) and _g10 <= 2100)
|
||||
|
||||
# Review of #157: the hold's own rules, on `_reseed_run_edge()` and
|
||||
# `_edge_grid()` directly (target -10 W, deadband 15 W, EDGE_HOLD_S 18 s).
|
||||
_T0 = UTC0
|
||||
|
||||
|
||||
def _held(grid, batt, s, last_b=-2500.0, end=False):
|
||||
"""(what the loop reads, still held) for a charge end E=800 W at +s."""
|
||||
f = SimpleNamespace(
|
||||
_edge=(800.0, 1.0, _T0 + timedelta(seconds=main.EDGE_HOLD_S), last_b),
|
||||
tuning=ctl.Tuning(target_grid_w=-10.0), grid=grid,
|
||||
site_batt_w=batt, i_w=0.0)
|
||||
g = main.Controller._edge_grid(f, end, _T0 + timedelta(seconds=s))
|
||||
return g, f._edge is not None
|
||||
|
||||
|
||||
_hs = main.EDGE_HOLD_S
|
||||
check("the hold: the loop reads the target while the meter still shows the "
|
||||
"step; it ends on a new trade or claim, when the meter comes within the "
|
||||
"deadband of the target, and at EDGE_HOLD_S (later only while the "
|
||||
"battery still moves to E, and never past twice that)",
|
||||
_held(3300.0, -2500.0, 1) == (-10.0, True)
|
||||
and _held(3300.0, -2500.0, 1, end=True) == (3300.0, False)
|
||||
and _held(5.0, -2500.0, 1) == (-10.0, True)
|
||||
and _held(4.0, -2500.0, 1) == (4.0, False)
|
||||
and _held(3300.0, -2500.0, _hs + 1) == (3300.0, False)
|
||||
and _held(3300.0, -2000.0, _hs + 1) == (-10.0, True)
|
||||
and _held(3300.0, -2000.0, 2 * _hs + 1) == (3300.0, False))
|
||||
# Review of #157 (X4): a reading that has passed E is not "still moving".
|
||||
check("...and a battery reading already past E does not extend the hold",
|
||||
_held(3300.0, 900.0, _hs + 1, last_b=700.0) == (3300.0, False))
|
||||
_f = SimpleNamespace(site_batt_w=500.0, grid=90.0, _edge=None,
|
||||
tuning=ctl.Tuning(target_grid_w=-10.0), site_max_w=5000.0,
|
||||
loop_bound=lambda claims: None, target=None, i_w=None)
|
||||
main.Controller._reseed_run_edge(_f, [], _T0)
|
||||
check("...and a small step is held too (E 600 W from 500 W)",
|
||||
_f.target == 600.0 and _f._edge is not None and _f._edge[:2] == (
|
||||
600.0, 1.0))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. F3: a charge refusal inside a cheap run is probed")
|
||||
# A refuses to charge from its first charge; the plant accepts again at
|
||||
# 02:40. The cheap run lasts to 04:45.
|
||||
|
||||
|
||||
def _accept(c, h, now, pr):
|
||||
if now >= at(2 + 40 / 60):
|
||||
h.plants["a"].refuse_charge = False
|
||||
|
||||
|
||||
ROWS3, C3 = run(1.9, 1.2, soc_a=30.0, refuse=("a",), check_cb=_accept)
|
||||
_ref3 = [r for r in ROWS3 if r["refused"]]
|
||||
_pr3 = [r for r in ROWS3 if r["probing"] and r["action"] == arbitrage.CHARGE]
|
||||
_probe_q = sorted({r["now"].astimezone(L).strftime("%H:%M") for r in _pr3})
|
||||
_end3 = next((r for r in ROWS3 if _ref3 and r["now"] > _ref3[-1]["now"]),
|
||||
None)
|
||||
print(f" refused {len(_ref3)} cycles; charge probes at {_probe_q}; "
|
||||
f"after: {_end3 and _end3['now'].astimezone(L).strftime('%H:%M:%S')}"
|
||||
f" {_end3 and _end3['action']}")
|
||||
check("while A refuses, it is probed every 15 minutes: at PROBE_W exactly, "
|
||||
"with B resting (no share on B, one sign)",
|
||||
len(_probe_q) >= 2 and all(
|
||||
r["shares"].get(A) == -ctl.PROBE_W and r["shares"].get(B, 0) == 0
|
||||
for r in _pr3))
|
||||
check("...outside a probe the refusal wins: no grid trade and A never "
|
||||
"charges",
|
||||
all(r["action"] is None and r["shares"].get(A, 0) >= 0
|
||||
for r in _ref3 if not r["probing"]))
|
||||
check("...and the probe after the plant accepts again ends the refusal and "
|
||||
"the charge resumes in the same run",
|
||||
_end3 is not None and _end3["now"] < at(3)
|
||||
and any(r["action"] == arbitrage.CHARGE and r["shares"].get(A, 0)
|
||||
< -1000 for r in ROWS3 if r["now"] > _end3["now"]))
|
||||
# Review of #157 (M15): outside its probe a refuser is no charger, so the
|
||||
# others are not made to rest. With solar to spare, Solar first B takes it.
|
||||
_rows3s = run(1.9, 0.5, soc_a=30.0, refuse=("a",), steps=[(2, -1500)])[0]
|
||||
_pt = [r["now"] for r in _rows3s if r["probing"]]
|
||||
_sun = [r for r in _rows3s if r["refused"] and not r["probing"]
|
||||
and r["now"] >= at(2 + 5 / 60) and not any(
|
||||
timedelta(0) < r["now"] - t <= timedelta(seconds=30) for t in _pt)]
|
||||
_bsun = sorted(r["shares"].get(B, 0) for r in _sun)
|
||||
print(f" solar 1500 W over the house, A refusing: B's share outside probes "
|
||||
f"{_bsun[0] if _bsun else None} .. {_bsun[-1] if _bsun else None} W")
|
||||
check("...outside a probe the others are not held at rest: Solar first B "
|
||||
"takes the spare solar (from 30 s after a probe)",
|
||||
_sun and all(r["shares"].get(B, 0) < -1000 for r in _sun))
|
||||
_pk = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0,
|
||||
10.0, refused=-1, probing=True)
|
||||
_runs = SimpleNamespace(on=True, cheap=SimpleNamespace(holds=lambda n: True),
|
||||
charge_useful=lambda: True, give_at=lambda n: None,
|
||||
sell=None)
|
||||
|
||||
|
||||
def _dec(packs, **kw):
|
||||
args = dict(charge_ok=True, sell_ok=True, trade_why=None,
|
||||
price_now=0.1, headroom_w=3000.0, floor_pct=20.0,
|
||||
ceiling_pct=95.0, house_w=800.0)
|
||||
args.update(kw)
|
||||
return modetrade.decide(_runs, UTC0, packs, **args)
|
||||
|
||||
|
||||
_pb = modetrade.Pack(B, True, packmode.SOLAR_FIRST, False, 2500.0, 60.0,
|
||||
10.0)
|
||||
check("modetrade: a probing charge refuser is a charger, the others rest; "
|
||||
"not while in maintenance, not on Leftover solar or Solar first, not "
|
||||
"latched, not under the 500 W minimum, not with the guard engaged, "
|
||||
"and never in energy() (the plan's sizing)",
|
||||
_dec([_pk, _pb]).action == modetrade.CHARGE
|
||||
and _dec([_pk, _pb]).roles == {A: modetrade.CHARGE, B: modetrade.REST}
|
||||
and _dec([replace(_pk, idle=False), _pb]).action is None
|
||||
and all(_dec([replace(_pk, mode=m), _pb]).action is None
|
||||
for m in (packmode.LEFTOVER_SOLAR, packmode.SOLAR_FIRST))
|
||||
and _dec([_pk, _pb], blocked=True).action is None
|
||||
and _dec([_pk, _pb], headroom_w=450.0).action is None
|
||||
and _dec([_pk, _pb], charge_ok=False, trade_why="peak").action is None
|
||||
and modetrade.energy([_pk], 20.0, 95.0)[1] == 0.0
|
||||
and _dec([replace(_pk, probing=False), _pb]).action is None)
|
||||
|
||||
# On the new base (release/1.0 e3e4ba6): SAFETY-78's drain comes before the
|
||||
# probe, and P4's day plan sets how far a probing Leftover solar battery goes.
|
||||
_ls = replace(_pk, mode=packmode.LEFTOVER_SOLAR)
|
||||
_dr = _dec([_pk, _pb], draining=True)
|
||||
check("a monthly drain comes before the probe: no grid charge while another "
|
||||
"battery drains, the probing refuser included (why 'cycle')",
|
||||
_dr.action is None and _dr.why == "cycle"
|
||||
and _dr.roles.get(A) != modetrade.CHARGE
|
||||
and _dec([_pk, _pb], draining=False).action == modetrade.CHARGE)
|
||||
check("with a day plan a probing Leftover solar battery is a charger up to "
|
||||
"its planned level and no further; without a level for it, it is not",
|
||||
_dec([_ls, _pb], targets={A: 60.0}).action == modetrade.CHARGE
|
||||
and _dec([_ls, _pb], targets={A: 25.0}).action is None
|
||||
and _dec([_ls, _pb], targets={B: 60.0}).action is None
|
||||
and _dec([_ls, _pb]).action is None
|
||||
and modetrade.energy([_ls], 20.0, 95.0, targets={A: 60.0})[1] == 0.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. invariants on these runs (and part 1c's house in the model)")
|
||||
_rows4, _c4 = run(1.9, 0.9, steps=[
|
||||
(2 + 20 / 60, 3700), (2 + 21 / 60, 800), (2 + 26 / 60, 3000),
|
||||
(2 + 40 / 60, 2850)])
|
||||
_all = ROWS1 + ROWS2 + ROWS3 + _rows4
|
||||
check("no opposite-direction split on any cycle", not mixed(_all))
|
||||
_ceil = peakguard.quarter_ceiling_w(rp.PEAK_CAP, None)[0]
|
||||
_bk0 = banked(_c4, _rows4)
|
||||
_bk = banked(C1, ROWS1) + banked(C2, ROWS2)
|
||||
print(f" banked quarters with a grid charge: model {len(_bk0)}, highest "
|
||||
f"{max(_bk0):.2f} W; lagged {len(_bk)}, highest {max(_bk):.2f} W "
|
||||
f"(e3e4ba6: the same 3451.37 W); ceiling - margin {_ceil - 50:.0f} W")
|
||||
check("every banked 15-minute period with a grid charge is at most "
|
||||
"quarter_ceiling_w() - margin (SAFETY-79) in the model; lagged, the "
|
||||
"battery's lag may use 2 W of the 50 W margin, as on e3e4ba6",
|
||||
_bk0 and max(_bk0) <= _ceil - 50 + 0.5
|
||||
and _bk and max(_bk) <= _ceil - 50 + 2.0)
|
||||
check("Solar first and Leftover solar never charge from the grid: no charge "
|
||||
"role and no charge share on B while the site charges",
|
||||
all(r["roles"].get(B) != modetrade.CHARGE for r in _all)
|
||||
and all(r["shares"].get(B, 0) >= 0 for r in _all
|
||||
if r["action"] == arbitrage.CHARGE))
|
||||
check("selling is off: no sale on any cycle",
|
||||
packmode.SELLING_ENABLED is False
|
||||
and all(r["action"] != arbitrage.DISCHARGE for r in _all))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. modes off: byte-identical to release/1.0 e3e4ba6")
|
||||
# Printed by tools/opt11p3_identity.py on release/1.0 e3e4ba6 (2026-10-13),
|
||||
# goodwe-addon-checks:1, with this branch's harness (same API). n1 and n2 are
|
||||
# also 5a4cdc2's; the lag ones moved with SAFETY-90/91 (n2_lag now equals n2:
|
||||
# the next-quarter grading removed the edge tick's blindness at N=2).
|
||||
AT_BASE = {
|
||||
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
"n1_lag": "86ef0f5ad8da1498ff7dcf59262a177bef847a0b201ce804e947c8eab5f07190",
|
||||
"n2_lag": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
}
|
||||
_got = {k: ident.digest(ident.run(**ident.SCENARIOS[k])[0])
|
||||
for k in AT_BASE}
|
||||
for _k in AT_BASE:
|
||||
print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} "
|
||||
f"{_got[_k][:16]}")
|
||||
check("with battery modes off every command, integrator, allocation and "
|
||||
"trade sentence equals e3e4ba6's, also when every quarter boundary's "
|
||||
"cycle still sees the old P1 block", _got == AT_BASE)
|
||||
|
||||
_real_project = tariff.PeakTracker.project
|
||||
tariff.PeakTracker.project = staticmethod(
|
||||
lambda a, now, roll=False: _real_project(a, now, roll=True))
|
||||
try:
|
||||
_moved = ident.digest(ident.run(**ident.SCENARIOS["n1_lag"])[0]) \
|
||||
!= AT_BASE["n1_lag"]
|
||||
finally:
|
||||
tariff.PeakTracker.project = _real_project
|
||||
check("non-vacuous: the rolled projection leaking into modes off moves the "
|
||||
"late-sample scenario", _moved)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -57,6 +57,28 @@ BASE = {
|
||||
"twice7000": ([1001.0, 1502.0, 2699.0], "1cddda8cf35859efb2ad503f1683f744cb812a6325a390c4fa3df19d717af043"),
|
||||
"step6500_gap": ([1201.0, 1801.0], "95e5c90e4a6814526cb4f3bea76286063b449d86a420f888c014531fb6e913a3"),
|
||||
}
|
||||
# OPT-15: with battery modes ON the guard no longer reads "no seconds left" on
|
||||
# a quarter's edge tick (it projects the quarter it is in), so the 1 s 0 W
|
||||
# command at each edge is gone and every run differs from 5a4cdc2's. Printed by
|
||||
# `python3 tools/safety81_sim.py` on the OPT-15 head (BASE above stays as the
|
||||
# reference). Modes OFF stays byte-identical (section 1b).
|
||||
OPT15 = {
|
||||
"const4500": "ed2f4ef9a74172ada2f00707a09ad839743449642f61dc6583ace44bac33caf4",
|
||||
"const4500_m0": "63d4d62643b9d39697b16078603576d180d90090117a497122e77c33f3db2227",
|
||||
"sine4500": "a034af0a896d55552915f1e4816055e26c006d4df0a86d1c9d9c3a625faf747f",
|
||||
"edge4600": "74e03c7182469715c1517765c2f53f1d68d7342aed4a8ad388878eac24964020",
|
||||
"edge4600_m0": "93e4402b6b879a44da5ecc28fb3b51b16c0b0f701396e00a9703803273dbd18e",
|
||||
"burst_end": "8dc4001e06ad8fa562caea02d755227cdfa16396708fa986aef7a5704e22a691",
|
||||
"noise5000_m50": "71e29a970837211835e56636280c29f70d7c2d3f3062cfac2b940ea30fa66424",
|
||||
"noise5000_m0": "a47988b7bbee629a6f8722588f325a4c87d7dcb143bba549b6df452664b30251",
|
||||
"step6500": "a7356fe0f8c00ac4d16dd4f60b054af7336ceb2eedcd76911d30138df4b457b8",
|
||||
"miss5560": "2b0afb600fe1cfd0bfe4b28fbfeeff73fc7e875e3d5f377ae7c2a489e20abf53",
|
||||
"ramp6500": "ae3838aa8f2af9458f73b540eeb76cb5fa25ace0f1ea06df2b4f6d9201907522",
|
||||
"step7500_reserve": "21eb9c07a4582f47f347084d1c75a28a5d767bc068bc3f74f32433d3239e9ccb",
|
||||
"sine4500_fast": "47ed9d02a4f7afbc50905f44519b14364f262ba793aae80f3cd077eb6f600b8b",
|
||||
"twice7000": "b89396022f589bbb90bc592ece121cf2a0bf3a4f04316da36891ac1a2b6ae18e",
|
||||
"step6500_gap": "a7356fe0f8c00ac4d16dd4f60b054af7336ceb2eedcd76911d30138df4b457b8",
|
||||
}
|
||||
OUT = {k: sim.run(**v) for k, v in sim.SCENARIOS.items()}
|
||||
RUN = {k: v[:2] for k, v in OUT.items()}
|
||||
ERR = {k: v[4] for k, v in OUT.items()}
|
||||
@@ -65,7 +87,23 @@ ERR = {k: v[4] for k, v in OUT.items()}
|
||||
print("1. every command byte-identical to release/1.0 5a4cdc2")
|
||||
check("the same scenarios as 5a4cdc2", set(RUN) == set(BASE))
|
||||
for k, (alarms, digest) in RUN.items():
|
||||
check(f"{k}: digest {digest[:12]}", digest == BASE[k][1])
|
||||
check(f"{k}: digest {digest[:12]} (OPT-15 pin; modes off in 1b)",
|
||||
digest == OPT15[k])
|
||||
|
||||
# ============================================================================ #
|
||||
print("1b. battery modes off: every command byte-identical to release/1.0 e3e4ba6")
|
||||
# Printed on e3e4ba6 (this sim copied in, `modes=False`), in the CI image.
|
||||
OFF = {
|
||||
"const4500": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3",
|
||||
"edge4600": "4f8f943f5a4e9b218c2e8bc6a95d20934ec19f1bebfbdc5748ca52e753e5afa0",
|
||||
"step6500": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3",
|
||||
"burst_end": "4ca4ba895afe7dacfe3952743e13d21607e690dfec39029fd5c5745fe03af5d7",
|
||||
"twice7000": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3",
|
||||
"sine4500_fast": "3e41ad417880a4ebb63ae3d6826f413fb779c18dfb677207775b1c980133423f",
|
||||
}
|
||||
for k, want in OFF.items():
|
||||
digest = sim.run(modes=False, **sim.SCENARIOS[k])[1]
|
||||
check(f"{k} (modes off): digest {digest[:12]}", digest == want)
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. no false alarm (5a4cdc2's are listed)")
|
||||
@@ -76,16 +114,17 @@ for k in ("edge4600", "edge4600_m0", "const4500", "const4500_m0", "sine4500",
|
||||
# ============================================================================ #
|
||||
print("3. real overloads still alarm, ALARM_HOLD_S after 5a4cdc2")
|
||||
# ALARM_HOLD_S (10 s) plus up to 2 s of late meter samples; at 1800 s the
|
||||
# quarter-edge tick restarts the hold.
|
||||
for k, want in (("step6500", [1211.0, 1813.0]),
|
||||
("miss5560", [1213.0, 1813.0])):
|
||||
# quarter-edge tick restarts the hold (OPT-15: with modes on, by not grading
|
||||
# a projection that rolled into the new quarter before its first sample).
|
||||
for k, want in (("step6500", [1211.0, 1811.0]),
|
||||
("miss5560", [1213.0, 1811.0])):
|
||||
print(f" {k}: load steps up at 1200 s; alarms at {RUN[k][0]}, "
|
||||
f"5a4cdc2 {BASE[k][0]}")
|
||||
check(f"{k}: after the hold, and again only at the next quarter",
|
||||
RUN[k][0] == want)
|
||||
check("step6500_gap: cycles stopped by a lost battery reading do not "
|
||||
"restart the hold",
|
||||
RUN["step6500_gap"][0] == [1215.0, 1813.0])
|
||||
RUN["step6500_gap"][0] == [1215.0, 1811.0])
|
||||
check("twice7000: notified once in the quarter (5a4cdc2: "
|
||||
f"{BASE['twice7000'][0]})", RUN["twice7000"][0] == [1011.0])
|
||||
check("twice7000: the ERROR line for both overloads",
|
||||
@@ -96,7 +135,7 @@ print(f" ramp6500: alarms at {RUN['ramp6500'][0]}, "
|
||||
f"5a4cdc2 {BASE['ramp6500'][0]}")
|
||||
check("ramp6500: alarms once the quarter will end over the real cap by "
|
||||
"more than ALARM_MISS_W",
|
||||
RUN["ramp6500"][0] == [1316.0, 1813.0])
|
||||
RUN["ramp6500"][0] == [1317.0, 1811.0])
|
||||
check("step7500 under the peak reserve: not this alarm (the guard is not "
|
||||
"allowed to discharge)", RUN["step7500_reserve"][0] == [])
|
||||
|
||||
@@ -150,6 +189,7 @@ stub = SimpleNamespace(
|
||||
reason="PEAK_GUARD: fixture"),
|
||||
site_rating_w=2000.0, shadow=False, events=[],
|
||||
_peak_batt_prev=None, _peak_alarm_block=None, _peak_high_since=None,
|
||||
_peak_rolled=False,
|
||||
_peak_saturated=False, hass=SimpleNamespace(call=_call),
|
||||
log_event=lambda msg, hint=None: None)
|
||||
first = None
|
||||
|
||||
@@ -19,12 +19,14 @@ import hashlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
|
||||
from app.static_tariff import TariffPlan # noqa: E402
|
||||
|
||||
@@ -53,6 +55,21 @@ def _feed(c, t):
|
||||
s79._feed(c, t)
|
||||
|
||||
|
||||
# OPT-15: the P1 sample of each cycle is built this long after the cycle
|
||||
# reads the grid (the HA source's debounce), so the cycle on a quarter
|
||||
# boundary still sees the old block, as on TEST-23.
|
||||
P1_LAG_S = 0.35
|
||||
|
||||
|
||||
def _feed_lag(c, t):
|
||||
c.tariff_plan, c.tariff_at = PLAN, rig.BASE + timedelta(seconds=t)
|
||||
imp, exp = split_signed(c.hass.grid())
|
||||
c.p1.submit(make_sample(
|
||||
"ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=rig.BASE + timedelta(seconds=t - P1_LAG_S),
|
||||
ingest_mono=time.monotonic()))
|
||||
|
||||
|
||||
def load(t):
|
||||
"""Under the limit, over it, a surplus, a big evening load."""
|
||||
k = int(t // 600) % 5
|
||||
@@ -70,10 +87,13 @@ SCENARIOS = {
|
||||
# The option present and false: an install that saved it off.
|
||||
"n1_off": dict(n=1, extra={"battery_modes_enabled": False}),
|
||||
"n2_off": dict(n=2, extra={"battery_modes_enabled": False}),
|
||||
# OPT-15: every quarter boundary with the old block still open.
|
||||
"n1_lag": dict(n=1, lag=True),
|
||||
"n2_lag": dict(n=2, lag=True),
|
||||
}
|
||||
|
||||
|
||||
def run(n, *, extra=None, dur=DUR):
|
||||
def run(n, *, extra=None, dur=DUR, lag=False):
|
||||
opts = dict(s79.P1, **ARB, **(extra or {}))
|
||||
trade = []
|
||||
|
||||
@@ -85,7 +105,8 @@ def run(n, *, extra=None, dur=DUR):
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
try:
|
||||
rows, c = rig.run(n, extra=opts, dur=dur, load=load, soc=soc,
|
||||
cut=lambda u, t: False, before=_feed,
|
||||
cut=lambda u, t: False,
|
||||
before=_feed_lag if lag else _feed,
|
||||
on_cycle=on_cycle)
|
||||
finally:
|
||||
rig.Controller = real
|
||||
|
||||
@@ -18,7 +18,11 @@ earlier (364 days), or the captured quarter-hourly energy-charts bodies for
|
||||
2026-09-09/10), the decision code.
|
||||
MODELLED: the batteries (a first-order lag, 3 s, 800 W/s ramp, SoC
|
||||
integrated, 95 % each way, a reading that jitters by a few watts, and
|
||||
optionally a device limit or a refusal to charge), the all-in price (the EPEX file's own stated
|
||||
optionally a device limit or a refusal to charge). By default the readings
|
||||
are instantaneous and the loop runs every 5 s, which hides what the loop
|
||||
does on readings a second or two old; `lag=Lag(...)` (OPT-15) adds a command
|
||||
dead time, a meter and battery reading age, the P1 sample's debounce and a
|
||||
1 s cycle, as on TEST-23. The all-in price (the EPEX file's own stated
|
||||
assumption: (wholesale + 0.02 + 0.10) x 1.06), export paid at the wholesale
|
||||
price, tomorrow's prices known from 13:00 local. The absolute money is
|
||||
therefore an estimate; the comparison between variants is the point.
|
||||
@@ -104,10 +108,28 @@ def qh_points():
|
||||
if p is not None]
|
||||
|
||||
|
||||
class Lag:
|
||||
"""OPT-15: how old what the add-on reads is, and how often it acts.
|
||||
`dead_s` a command takes effect that late; `meter_s` / `batt_s` the age
|
||||
of the grid and battery readings; `p1_s` a P1 sample is built that long
|
||||
after the reading (the HA source's debounce), so a cycle just after a
|
||||
quarter boundary can still see the old block; `tick` the cycle period;
|
||||
`tau_s` / `ramp_w_s` the battery's own lag (TEST-23's plant: `dead_s` 1,
|
||||
no lag or ramp)."""
|
||||
|
||||
def __init__(self, dead_s=1.0, meter_s=1.0, batt_s=1.0, p1_s=0.35,
|
||||
tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0):
|
||||
self.dead_s, self.meter_s, self.batt_s = dead_s, meter_s, batt_s
|
||||
self.p1_s, self.tick, self.sub = p1_s, tick, sub
|
||||
self.tau_s, self.ramp_w_s = tau_s, ramp_w_s
|
||||
|
||||
|
||||
class Plant:
|
||||
def __init__(self, soc, refuse_charge=False):
|
||||
self.cmd, self.p, self.soc = 0.0, 0.0, float(soc)
|
||||
self.refuse_charge = refuse_charge
|
||||
self.queue = [] # (t, command) not yet in effect
|
||||
self.tau, self.ramp = 3.0, 800.0
|
||||
|
||||
def step(self, dt, floor):
|
||||
c = self.cmd
|
||||
@@ -115,8 +137,8 @@ class Plant:
|
||||
c = 0.0
|
||||
if c < 0 and (self.soc >= 100.0 or self.refuse_charge):
|
||||
c = 0.0
|
||||
want = self.p + (c - self.p) * (1 - math.exp(-dt / 3.0))
|
||||
dp = max(-800.0 * dt, min(800.0 * dt, want - self.p))
|
||||
want = self.p + (c - self.p) * (1 - math.exp(-dt / self.tau))
|
||||
dp = max(-self.ramp * dt, min(self.ramp * dt, want - self.p))
|
||||
self.p += dp
|
||||
e = self.p * dt / 3600.0 / 1000.0 # kWh, + = out
|
||||
eff = 1 / 0.95 if e > 0 else 0.95
|
||||
@@ -125,17 +147,52 @@ class Plant:
|
||||
|
||||
|
||||
class Hass:
|
||||
def __init__(self, units, house_at, soc0=None, refuse=(), limits=None):
|
||||
def __init__(self, units, house_at, soc0=None, refuse=(), limits=None,
|
||||
lag=None):
|
||||
self.units = units
|
||||
self.lim = limits or {}
|
||||
self.plants = {u: Plant((soc0 or {}).get(u, 50.0), u in refuse)
|
||||
for u in units}
|
||||
self.house_at = house_at
|
||||
self.t = 0.0
|
||||
self.lag = lag
|
||||
self.hist = [] # (t, grid, {unit: p}), with `lag`
|
||||
for p in self.plants.values():
|
||||
if lag is not None:
|
||||
p.tau, p.ramp = lag.tau_s, lag.ramp_w_s
|
||||
|
||||
def grid(self):
|
||||
return self.house_at(self.t) - sum(p.p for p in self.plants.values())
|
||||
|
||||
def _past(self, age):
|
||||
"""(grid, {unit: p}) as they were `age` seconds ago."""
|
||||
want = self.t - age
|
||||
for t, g, ps in reversed(self.hist):
|
||||
if t <= want + 1e-9:
|
||||
return g, ps
|
||||
return self.hist[0][1:] if self.hist else (self.grid(), {
|
||||
u: p.p for u, p in self.plants.items()})
|
||||
|
||||
def advance(self, dt, floor):
|
||||
"""Step the plant `dt` seconds (with `lag`: in `sub` steps, commands
|
||||
applied after `dead_s`, history kept for the lagged readings)."""
|
||||
lag = self.lag
|
||||
if lag is None:
|
||||
for p in self.plants.values():
|
||||
p.step(dt, floor)
|
||||
return
|
||||
end = self.t + dt
|
||||
while self.t < end - 1e-9:
|
||||
h = min(lag.sub, end - self.t)
|
||||
for p in self.plants.values():
|
||||
while p.queue and p.queue[0][0] <= self.t + 1e-9:
|
||||
p.cmd = p.queue.pop(0)[1]
|
||||
p.step(h, floor)
|
||||
self.t += h
|
||||
self.hist.append((self.t, self.grid(),
|
||||
{u: p.p for u, p in self.plants.items()}))
|
||||
del self.hist[:-200]
|
||||
|
||||
async def limits(self, e):
|
||||
lo, hi = self.lim.get(e[-1], (-5000.0, 5000.0))
|
||||
return (lo, hi, 1.0)
|
||||
@@ -145,7 +202,8 @@ class Hass:
|
||||
|
||||
async def number(self, e, invert=False):
|
||||
if e == "sensor.grid":
|
||||
return self.grid()
|
||||
return self.grid() if self.lag is None else \
|
||||
self._past(self.lag.meter_s)[0]
|
||||
u = e[-1]
|
||||
if e.startswith("sensor.soc"):
|
||||
return round(self.plants[u].soc, 1)
|
||||
@@ -153,11 +211,17 @@ class Hass:
|
||||
# A real reading jitters by a few watts; a dead-flat one is a
|
||||
# stale register to the add-on (SAFETY-37), which is not modelled.
|
||||
j = (ord(u) * 31 + int(self.t)) % 7 - 3
|
||||
return float(round(self.plants[u].p) + j)
|
||||
p = self.plants[u].p if self.lag is None else \
|
||||
self._past(self.lag.batt_s)[1][u]
|
||||
return float(round(p) + j)
|
||||
return None
|
||||
|
||||
async def set_number(self, e, v):
|
||||
self.plants[e[-1]].cmd = float(v)
|
||||
p = self.plants[e[-1]]
|
||||
if self.lag is None:
|
||||
p.cmd = float(v)
|
||||
else:
|
||||
p.queue.append((self.t + self.lag.dead_s, float(v)))
|
||||
return True
|
||||
|
||||
|
||||
@@ -239,7 +303,7 @@ def plan_at(now, pts):
|
||||
|
||||
def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
soc0=None, refuse=(), extra=None, choice=None, patch=None,
|
||||
limits=None):
|
||||
limits=None, lag=None):
|
||||
modes, arb, var_choice = VARIANTS[variant]
|
||||
choice = var_choice if choice is None else choice
|
||||
units = tuple("ab"[:n])
|
||||
@@ -272,7 +336,8 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
ticks=0, quarters={}, trade_ticks=0)
|
||||
|
||||
async def go():
|
||||
hass = Hass(units, house_at, soc0, refuse, limits)
|
||||
hass = Hass(units, house_at, soc0, refuse, limits, lag)
|
||||
tick = TICK if lag is None else lag.tick
|
||||
import tempfile
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="p3r-"), "tariff.db")
|
||||
c = Controller(dict(options(modes, arb, n), **(extra or {})), hass,
|
||||
@@ -299,6 +364,8 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
now = start + timedelta(seconds=t)
|
||||
hass.t = t
|
||||
clock[0] = now
|
||||
if lag is not None and not hass.hist:
|
||||
hass.advance(0.0, 10.0)
|
||||
if plan_hour != now.replace(minute=0, second=0,
|
||||
microsecond=0):
|
||||
plan_hour = now.replace(minute=0, second=0,
|
||||
@@ -306,17 +373,25 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
c.tariff_plan, c.tariff_at = plan_at(now, pts), now
|
||||
else:
|
||||
c.tariff_at = now
|
||||
if lag is None:
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
elif t >= lag.p1_s:
|
||||
# The sample of the reading one debounce ago.
|
||||
imp, exp = split_signed(hass._past(
|
||||
lag.meter_s + lag.p1_s)[0])
|
||||
c.p1.submit(make_sample(
|
||||
"ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now - timedelta(seconds=lag.p1_s),
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
for p in hass.plants.values():
|
||||
p.step(TICK, 10.0)
|
||||
hass.advance(tick, 10.0)
|
||||
g = hass.grid()
|
||||
pr = price_at(now)
|
||||
dt_h = TICK / 3600.0
|
||||
dt_h = tick / 3600.0
|
||||
if g > 0:
|
||||
stats["imp_kwh"] += g / 1000 * dt_h
|
||||
stats["cost"] += g / 1000 * dt_h * (all_in(pr) if pr
|
||||
@@ -339,7 +414,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
stats["ticks"] += 1
|
||||
if check is not None:
|
||||
check(c, hass, now, pr)
|
||||
t += TICK
|
||||
t += tick
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
return c
|
||||
|
||||
@@ -119,15 +119,15 @@ class _Plant:
|
||||
|
||||
|
||||
def run(load, *, margin=50, dur=2700.0, rating=2000.0, cap=3500.0,
|
||||
late_at=(), soc=60.0, batt_gap=()):
|
||||
late_at=(), soc=60.0, batt_gap=(), modes=True):
|
||||
"""Alarm times (s from the start of the first quarter), a digest of every
|
||||
cycle's target and written command, the Controller, the rows, and the
|
||||
times of the ERROR log line. A sample is also late at each `t % 900` in
|
||||
`late_at`."""
|
||||
`late_at`. `modes=False` (OPT-15) runs with battery modes off."""
|
||||
opts = rig._options(1, {
|
||||
"meter_source": "ha_signed", "p1_net_entity": "sensor.net",
|
||||
"meter_phases": 1, "peak_cap_w": cap, "peak_margin_w": margin,
|
||||
"battery_modes_enabled": True, "max_w": rating})
|
||||
"battery_modes_enabled": modes, "max_w": rating})
|
||||
store = rig._Store()
|
||||
store.data[unit_key("inverter-a", packmode.USE_KEY)] = False
|
||||
store.data[packmode.USE_KEY] = False
|
||||
|
||||
Reference in New Issue
Block a user