diff --git a/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md b/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md index 5cbf248..ea464a8 100644 --- a/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md +++ b/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md @@ -1,6 +1,6 @@ # MAINT-28 - during a real peak the maintenance charge stays paused -**Status: for review.** Tests: `test_maint28.py`. No option added; the peak aim, the month peak and the MAINT-13 numbers are unchanged. MAINT-24 follow-up (rig test finding F-1, `reports/MAINT-24-test-report.md`). +**Status: fix-forward after the rig FAIL (TEST-23, f3f9483).** Tests: `test_maint28.py`, `test_maint28b.py`. No option added; the peak aim, the month peak and the MAINT-13 numbers are unchanged. MAINT-24 follow-up (rig test finding F-1, `reports/MAINT-24-test-report.md`). ## What happened (rig x10, house 6.5 kW, month aim 5.95 kW, A in maintenance CHARGE) `peakguard.shaping_suspended()` pauses the charge while the forecast is over the aim or the guard is engaged. MAINT-24 moved the aim from 3450 W to the month aim (SAFETY-80), so the pause no longer lasts the whole peak: the guard gives, its projection drops under 5950 W and it idles ("heading for 5 6xx W, under this month's peak"), the pause ends, the floor charges A 150-460 W, the grid goes to 6.3-6.9 kW, the guard trips again. A flipped charge/give 468 times in 45 min (base 02eac3b 11). The quarters stayed at the aim; the cost was a battery working against itself and a grid that swung 5.4-6.9 kW. @@ -8,7 +8,7 @@ ## The change `peakguard.shaping_held(suspended, held, forecast, aim, house)`: `suspended` is the old `shaping_suspended()`, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. The hold is for N=2 and up, and only where MAINT-24 moved the aim (a banked month peak above `peak_cap_w`, so the aim is above cap minus margin). Elsewhere the old rule stays, byte for byte: SAFETY-87's N=1 digests (`n1_charge_low`) and SAFETY-119's N=2 charge at 5050 W over a 5000 W cap with no banked peak are pinned, and 1b5f7c9 behaves as 02eac3b there (a chase that was not a MAINT-24 regression). -Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house is the meter reading plus the batteries' power (`grid + site_batt_w`): the net demand the meter would see with no battery at work. Solar is already in it, and that is right: the aim is a limit on what the meter takes, so a sunny hour that lowers the net demand is real room, and a cloud that raises it brings the hold back at once. It is not the gross load, and it would be wrong to add PV back (that would hold the pause on room the meter really has). It stays put when A switches, except for the age of the battery reading (up to 10 s on the rig); the 300 W line is far larger than that lag moves it. +Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house is the meter reading plus the batteries' power (`grid + site_batt_w`): the net demand the meter would see with no battery at work. Solar is already in it, and that is right: the aim is a limit on what the meter takes, so a sunny hour that lowers the net demand is real room, and a cloud that raises it brings the hold back at once. It is not the gross load, and it would be wrong to add PV back (that would hold the pause on room the meter really has). It does not stay put when a battery steps: see "Fix-forward" below. Why 300 W: the house wanders +-50 W; once the charge resumes it settles at (aim - house) / 2, so 300 W gives at least 150 W, three times the noise, and charging less than that is not worth a restart. @@ -34,3 +34,12 @@ Rig (MAINT-24 test report, F-1): 468 reversals in 45 min on 1b5f7c9, 11 on 02eac ## Not changed MAINT-24's rate and its tests, N=1, the drain, modes off (digests of 1b5f7c9). The pause for a late or missing forecast. + +## Fix-forward (rig FAIL F-1, F-2; `reports/MAINT-28-test-report.md`) +The sim had no battery-reading lag, and the first version said the 300 W line is far larger than the lag moves the estimate. That was wrong. The rig publishes a battery's power every 10 s; the meter is newer. After each guard step charge -> give (+500-1000 W) the next meter reading shows the give and the battery reading still shows the charge, so `grid + site_batt_w` drops by the step, under the 5650 W line: 69/69, 132/133, 215/215, 51/51 releases at a median 5430 W with the house at 6450 W or more. Test: `test_maint28b.py`, sim `Lag.batt_period_s`. + +**F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 13 s after the load (7 s in the test), not 0: the first settled estimate. No cut-back is touched. + +**F-2: under its peak reserve the pack gives nothing for the house.** While the pause holds (N>=2, month aim above the cap), a pack in maintenance CHARGE that `peakguard.reserve_split()` holds gets `hi` = 0 W (P_MAINT_SHAPE). The guard's own claim for the peak is on the other packs (`place_on_able()`), so it is unchanged. Above the reserve nothing changes. Sim, B charging at 18 %: f3f9483 gave 1.89 kWh and went to 10.0 % (the log said "giving nothing for the peak"); now 0.00 kWh, 18.2 %, 0 give/idle flips in 45 min (the full cap measured by the reviewer: 32 in 40 min), quarters 5948 and 5952 W (aim 5950). + +Mutants, each red in `test_maint28b.py`: the freshness check dropped (always settled); the reserve claim dropped. diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index a8928a7..228d495 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -574,10 +574,11 @@ class CycleMixin: else float(self.grid) + float(self.site_batt_w)) _susp = peakguard.shaping_suspended( self.peak_guard, self.peak_fc, maint_aim) + _m28 = (len(self.inverters) > 1 + and maint_aim > self.peak_aim_w()) self._maint_peak_hold = peakguard.shaping_held( - _susp, _peak_held and len(self.inverters) > 1 - and maint_aim > self.peak_aim_w(), - self.peak_fc, maint_aim, house) + _susp, _peak_held and _m28, self.peak_fc, maint_aim, + house, self._batts_settled()) if self._maint_peak_hold: # Edge-logged: level-logging here would write one line per # cycle for as long as the event lasts and flush the 40-line @@ -588,6 +589,11 @@ class CycleMixin: "suspended so the peak claim is not " "contradicted", hint=messages.hint("maint_paused")) + # MAINT-28: under its peak reserve this pack gives only + # what the guard claims. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md + shape = ((0.0, "peak reserve") + if _m28 and inv.name in self._under_reserve() + else None) else: self._maint_shape_suspended = False hi = 0.0 @@ -604,10 +610,12 @@ class CycleMixin: floor = self._charge_headroom(floor, claims) if floor > 0: hi, reason = -floor, "cheap-window charge" + shape = (hi, reason) + if shape is not None: # PER-INVERTER, like the SET above and for the same reason - # the shaping is about the pack being charged, which is `inv`. - claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, - reason=reason, + claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, + hi=shape[0], reason=shape[1], actuator=self.actuator_of("maintenance", inv))) diff --git a/goodwe_controller/app/ctl_trade.py b/goodwe_controller/app/ctl_trade.py index 09b92f6..096333e 100644 --- a/goodwe_controller/app/ctl_trade.py +++ b/goodwe_controller/app/ctl_trade.py @@ -19,6 +19,7 @@ from . import packmode from . import priceruns from . import solarfc from . import control as _ctl +from . import freshness from . import messages from .maintenance import CHARGE, DRAIN, HOLD, IDLE, MaintConfig, POWER_MOVING from .mqtt import MODE_LABELS, USE_OFF, USE_ON, unit_slugs, unit_state_key @@ -49,6 +50,11 @@ EDGE_HOLD_S = _ctl.PLANT_SETTLE_S HOUSE_PROFILE_KEY = "house_profile" +# MAINT-28: a step of a battery's command, in W, after which its reading is not +# current until a changed one arrives (`Controller._batts_settled()`). +MAINT_STEP_W = 100.0 + + # OPT-34: the level of a night grid charge that has started, so it does not # drop when the forecast goes (`Controller._latch_charge()`). CHARGE_LATCH_KEY = "night_charge_latch" @@ -711,6 +717,36 @@ class TradeMixin: lo = hi = float(inv.batt) self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since) + def _batts_settled(self) -> bool: + """MAINT-28: False while a battery reading may pre-date a step UP of + its command, so `grid + site_batt_w` is a new grid and an old battery. + DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md""" + now, ok = self._now_mono, True + if now is None: + return True + for inv in self.inverters: + cmd = float(inv.standing_w()) + seen = self._m28_step.get(inv.name) + if seen is None: + seen = self._m28_step[inv.name] = (cmd, None) + elif abs(cmd - seen[0]) >= MAINT_STEP_W: + seen = self._m28_step[inv.name] = ( + cmd, now if cmd > seen[0] else seen[1]) + at = seen[1] + if at is None or now - at >= freshness.BATT_OBLIGED_S: + continue + got = inv.batt_fresh.changed_mono + if got is None or got < at + freshness.PLANT_SETTLE_S: + ok = False + return ok + + def _under_reserve(self) -> frozenset: + """MAINT-28: the batteries at or under their peak reserve now + (`peakguard.reserve_split()`). DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md""" + floor = float(self.o.get( + "peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])) + return frozenset(peakguard.reserve_split(self.packs, floor)[1]) + def _reseed_drop(self, live) -> None: """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 slew step diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 2d6cece..dc4f884 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -506,6 +506,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, self._peak_needed = False self._maint_shape_suspended = False self._maint_peak_hold = False # MAINT-28 + self._m28_step = {} # MAINT-28: unit -> (command, step at) # OPT-11: battery modes, off by default (SAFETY-10 section 1). Each # battery's mode and on/off switch live in the store, not in options, # so a change applies at once (`pack_use`, `pack_mode`). diff --git a/goodwe_controller/app/peakguard.py b/goodwe_controller/app/peakguard.py index 6f052aa..9a14c79 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -450,18 +450,23 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool: return decision is not None and decision.engaged -def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w) -> bool: +def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w, + settled=True) -> bool: """MAINT-28. `suspended` (`shaping_suspended()`'s answer), kept on while `held` until the house is `MAINT_RESUME_UNDER_W` under `aim_w` (`peak_forecast_w` stands in for an unread house; neither read: not held, - as SAFETY-63). Only ever pauses the maintenance charge, never a cut-back. + as SAFETY-63) and `settled` (no battery reading older than its last + command step, so `house_w` is not a new grid plus an old battery). Only + ever pauses the maintenance charge, never a cut-back. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md.""" if suspended: return True if not held: return False level = house_w if house_w is not None else peak_forecast_w - return level is not None and level > float(aim_w) - MAINT_RESUME_UNDER_W + if level is None: + return False + return level > float(aim_w) - MAINT_RESUME_UNDER_W or not settled def cap_floor(decision, cap_w): diff --git a/goodwe_controller/test_maint28b.py b/goodwe_controller/test_maint28b.py new file mode 100644 index 0000000..b409b72 --- /dev/null +++ b/goodwe_controller/test_maint28b.py @@ -0,0 +1,314 @@ +"""Runnable check for MAINT-28 fix-forward. `python3 test_maint28b.py` + +The rig failed MAINT-28 (TEST-23, f3f9483): the hold of the maintenance pause +ended at almost every guard step (F-1), and while it stuck, the pack in +maintenance CHARGE gave its full rating for the house below its peak reserve +(F-2). test_maint28.py missed both because its battery reading has no lag. Here +the battery register is published every 10 s and held (`Lag.batt_period_s`), +the meter reading is 2 s old, as on the rig; the rating is the rig's A (5 kW). + +All on the real Controller through `cycle()` (tools/opt20_scen.py): cheap +window, cap 3500 / margin 50, a month peak of 6000 W banked (aim 5950 W), a +peak forecast like the live sensor, house +-50 W every 5 s, 3000 W then +6500 W for 13 min (over a quarter boundary), then 3000 W. + +1. A real peak, A in CHARGE above its peak reserve: A flips charge/give a few + times (base 1b5f7c9: dozens), no release of the pause on an estimate that + mixes a new grid with an old battery reading, quarters at or under the aim, + A's charge back after the load. +2. F-2: A starts just over its reserve: while the pause holds it gives for the + house only down to the reserve, never below. +3. The mirror: B in CHARGE, under its reserve at the load: it gives nothing + for the house, the other pack carries what the guard claims. +4. Unchanged: the MAINT-24 live shape (A 1475 W, B no grid trade), with the + same lag on. +5. `peakguard.shaping_held()` with `settled`, and `Controller._batts_settled()`. +Mutations: DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md. +Read MUTATION.md before you report a survivor. +""" + +import logging +import os +import random +import sys + +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_replay as rp # noqa: E402 +import opt20_scen as sc # noqa: E402 +from app import ctl_cycle, ctl_trade, freshness, peakguard, tariff # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +rp.RATING, rp.CAP_KWH = 5000.0, 15.36 # the rig's A +LAG = rp.Lag(dead_s=1.0, meter_s=2.0, batt_s=1.0, p1_s=0.35, tick=1.0, + tau_s=2.0, ramp_w_s=1e9, batt_period_s=10.0) +CH = sc.MAINT_CHARGE +MONTH_PEAK = 6000.0 +AIM = MONTH_PEAK - 50.0 +LINE = AIM - getattr(peakguard, "MAINT_RESUME_UNDER_W", 300.0) +T0 = sc.at(2.0) # a quarter boundary +PRE, LOAD, POST = 120.0, 780.0, 120.0 # s (CI time: short runs) +RESERVE = 20.0 # peak_soc_floor default + + +def stepped_house(base_w, hi_w): + rnd = random.Random(28) + out = [(sc.UTC0.timestamp() - 10, base_w)] + for t in range(0, int(PRE + LOAD + POST), 5): + w = hi_w if PRE <= t < PRE + LOAD else base_w + out.append((T0.timestamp() + t, w + rnd.uniform(-50.0, 50.0))) + return out + + +def scen(*, unit="a", soc=49.0, other=30.0, house=None, house_w=3000.0, + peak=MONTH_PEAK, dur=PRE + LOAD + POST, fc="quarter", n=2, + choice=None): + """`unit` is in maintenance CHARGE (forced result), at `soc`, the other at + `other`. Rows: the plants' power and SoC, the pause flag, the add-on's house + estimate and the real house.""" + rows = [] + q = dict(q=None, s=0.0, last=None, start=None) + idx = 0 if unit == "a" else 1 + o = "b" if unit == "a" else "a" + + def patch(c): + c.maints[idx].tick = ( + lambda now, soc_, blocked_by=None, gate_open=True: CH) + if peak is not None: + orig_peak = c.peak.peak + + def peak_(now): + r = orig_peak(now) + return tariff.PeakReading(tariff.OBSERVED, + getattr(r, "month", ""), peak) + c.peak.peak = peak_ + orig = c.cycle + + async def cyc(): + c.cheap = True + if fc and c.grid is not None: + g = float(c.grid) + now = ctl_cycle.datetime.now() # the replay's clock + qi = int(now.timestamp() // 900) + if q["q"] != qi: + q.update(q=qi, s=0.0, last=now, start=now) + el = (now - q["start"]).total_seconds() + q["s"] += g * (now - q["last"]).total_seconds() + q["last"] = now + c.peak_fc = (q["s"] + g * (900.0 - el)) / 900.0 + await orig() + c.cycle = cyc + + def cb(c, h, now, pr): + est = (None if c.grid is None or c.site_batt_w is None + else float(c.grid) + float(c.site_batt_w)) + rows.append(dict( + t=(now - T0).total_seconds(), grid=h.grid(), u=h.plants[unit].p, + o=h.plants[o].p, soc=h.plants[unit].soc, soc_o=h.plants[o].soc, + hold=bool(getattr(c, "_maint_peak_hold", False)), est=est, + house=h.grid() + sum(p.p for p in h.plants.values()))) + + src = house if house is not None else [(sc.UTC0.timestamp() - 10, house_w)] + ch = dict(sc.CH_CH if choice is None else choice) + stats, c = rp.run("p3_ch_sell", src, sc.WPTS, T0, dur, check=cb, lag=LAG, + n=n, soc0={unit: soc, o: other}, choice=ch, + refuse=(), patch=patch, + extra={"cycle_cost_threshold_eur": 0.10}) + return rows, stats, c + + +def flips(rs, k="u"): + n, s = 0, 0 + for r in rs: + v = 1 if r[k] > 50 else -1 if r[k] < -50 else 0 + if v and s and v != s: + n += 1 + if v: + s = v + return n + + +def releases(rows): + """Cycles in the load where the pause ended: (row, estimate under the + release line while the real house was over it).""" + out = [] + for p, r in zip(rows, rows[1:]): + if PRE <= r["t"] < PRE + LOAD and p["hold"] and not r["hold"]: + out.append((r, r["est"] is not None and r["est"] < LINE + and r["house"] >= LINE)) + return out + + +def quarters_ok(st): + # SAFETY-126: a landing 0-7 W over the aim is not an overrun. + return (max(a[0] / a[1] for a in st["quarters"].values()) + <= AIM + peakguard.ALARM_MISS_W) + + +def show(tag, rows, st): + load = [r for r in rows if PRE <= r["t"] < PRE + LOAD] + rel = releases(rows) + print(f" {tag}: flips {flips(load)}, charge s " + f"{sum(1 for r in load if r['u'] < -50)}, releases {len(rel)} " + f"(stale {sum(1 for _r, s in rel if s)}), lowest SoC " + f"{min(r['soc'] for r in rows):.1f} %, give kWh " + f"{sum(r['u'] for r in load if r['u'] > 0) / 3.6e6:.2f}, quarters " + f"{[round(a[0] / a[1], 1) for a in st['quarters'].values()]}") + + +# ============================================================================ # +print("1. a real peak, A in CHARGE at 49 % (above its reserve), the battery " + "reading lagging") +rows, st, c = scen(house=stepped_house(3000.0, 6500.0)) +show("A 49 %", rows, st) +load = [r for r in rows if PRE <= r["t"] < PRE + LOAD] +rel = releases(rows) +after = [r for r in rows if r["t"] >= PRE + LOAD] +back = next((i for i, r in enumerate(after) if r["u"] < -50), None) +print(f" A's charge back {back} s after the load") +check("A flips charge/give 3 times or fewer in the 13 min (1b5f7c9: 60, " + "f3f9483: 11)", flips(load) <= 3) +check("the pause never ends on an estimate under the release line while the " + "real house is over it", not any(s for _r, s in rel)) +check("every quarter at or under the month aim", + quarters_ok(st)) +check("after the load A's charge is back within 20 cycles (1 s each: a 10 s " + "register period plus its settle time, 16 s)", + back is not None and back <= 20) + +# ============================================================================ # +print("\n2. F-2: A starts just over its peak reserve (21 %), the pause holds") +rows, st, c = scen(soc=RESERVE + 1.0, house=stepped_house(3000.0, 6500.0)) +show("A 21 %", rows, st) +check("A gave for the house while above the reserve (as before)", + any(r["u"] > 500 for r in rows if r["soc"] > RESERVE + 0.5)) +check("A never goes under its peak reserve in the peak (rig: to 10 %)", + min(r["soc"] for r in rows) >= RESERVE - 0.5) +check("every quarter at or under the month aim", quarters_ok(st)) + +# ============================================================================ # +print("\n3. mirror: B in CHARGE at 18 % (under its reserve), A on Cheap hours") +rows, st, c = scen(unit="b", soc=18.2, other=30.0, + house=stepped_house(3000.0, 6500.0)) +show("B 18 %", rows, st) +check("B gives nothing for the house: its SoC never goes under 18 %", + min(r["soc"] for r in rows) >= 18.0) +check("every quarter at or under the month aim", quarters_ok(st)) + +# ============================================================================ # +print("\n4. unchanged: the MAINT-24 live shape with the lagging reading") +rl, sl, cl = scen(house_w=3000.0, dur=600.0) +body = rl[len(rl) // 6:] +mean_a = sum(r["u"] for r in body) / len(body) +mean_b = sum(r["o"] for r in body) / len(body) +print(f" A {mean_a:+.0f} W, B {mean_b:+.0f} W, pause " + f"{sum(r['hold'] for r in rl)} cycles") +check("A charges about 1475 W (-1500..-1440) and the pause never comes", + -1500.0 <= mean_a <= -1440.0 and not any(r["hold"] for r in rl)) +check("B does not trade with the grid (Cheap hours, nothing to charge it)", + abs(mean_b) < 100.0) + +# ============================================================================ # +print("\n5. peakguard.shaping_held() with `settled`; Controller._batts_settled()") +check("the rule exists (MAINT_STEP_W, _batts_settled)", + hasattr(ctl_trade, "MAINT_STEP_W") and hasattr(ctl_trade.TradeMixin, + "_batts_settled")) +if not hasattr(ctl_trade, "MAINT_STEP_W"): + print(f"\n{len(fails)} FAILED:") + for f in fails: + print(f" - {f}") + sys.exit(1) +U = peakguard.MAINT_RESUME_UNDER_W +idle = peakguard.PeakDecision(engaged=False, floor_w=None, ceiling_w=6000.0, + ceiling_from="x", target_w=AIM) + + +def held(was, fc, house, settled): + return peakguard.shaping_held( + peakguard.shaping_suspended(idle, fc, AIM), was, fc, AIM, house, + settled) + + +check("held, house under the line but not settled: stays paused", + held(True, AIM - 1, AIM - U - 100.0, False)) +check("held, house under the line and settled: resumes", + not held(True, AIM - 1, AIM - U - 100.0, True)) +check("not held: settled makes no difference", + not held(False, AIM - 1, 6500.0, False)) +check("neither the house nor the forecast read: no pause, settled or not " + "(SAFETY-63)", + not held(True, None, None, False)) + + +class _Fresh: + def __init__(self, changed): + self.changed_mono = changed + + +class _Inv: + def __init__(self, name, cmd, changed): + self.name, self.cmd = name, cmd + self.batt_fresh = _Fresh(changed) + + def standing_w(self): + return self.cmd + + +class _Ctl: + _batts_settled = getattr(ctl_trade.TradeMixin, "_batts_settled", None) + + def __init__(self, invs): + self.inverters, self._m28_step, self._now_mono = invs, {}, 0.0 + + +S = freshness.PLANT_SETTLE_S +a = _Inv("a", -1500.0, 0.0) +k = _Ctl([a, _Inv("b", 0.0, 0.0)]) +check("no step seen yet: settled", k._batts_settled()) +a.cmd = 500.0 +k._now_mono = 100.0 +check("a step up of the command: not settled", not k._batts_settled()) +k._now_mono = 100.0 + S - 0.5 +a.batt_fresh.changed_mono = 100.0 + S - 1.0 +check("...a reading that arrived before step + settle does not count", + not k._batts_settled()) +a.batt_fresh.changed_mono = 100.0 + S +k._now_mono = 100.0 + S +check("...one that arrived at step + settle does", k._batts_settled()) +a.cmd = 3000.0 +k._now_mono = 200.0 +check("a second step up; the old reading is not current", + not k._batts_settled()) +k._now_mono = 200.0 + freshness.BATT_OBLIGED_S +check("...and a reading that never changes is waited for no longer than " + "BATT_OBLIGED_S", k._batts_settled()) +a.cmd = -1500.0 +k._now_mono = 400.0 +check("a step down (less give: the estimate errs high) is no step", + k._batts_settled()) +a2 = _Inv("a", -1500.0, 0.0) +k2 = _Ctl([a2]) +k2._batts_settled() +a2.cmd, k2._now_mono = -1500.0 + ctl_trade.MAINT_STEP_W - 1.0, 1.0 +check("a move under ctl_trade.MAINT_STEP_W is no step", k2._batts_settled()) +a2.cmd = -1500.0 + ctl_trade.MAINT_STEP_W +check("...one of ctl_trade.MAINT_STEP_W is", not k2._batts_settled()) + +print() +if fails: + print(f"{len(fails)} FAILED:") + for f in fails: + print(f" - {f}") + sys.exit(1) +print("all checks passed") diff --git a/goodwe_controller/tools/opt11p3_replay.py b/goodwe_controller/tools/opt11p3_replay.py index 589c22f..c3bbad0 100644 --- a/goodwe_controller/tools/opt11p3_replay.py +++ b/goodwe_controller/tools/opt11p3_replay.py @@ -116,10 +116,15 @@ class Lag: 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).""" + no lag or ramp). `batt_period_s` (MAINT-28): the battery register is + published every that many seconds and held between (the rig: 10 s), so its + reading is `batt_s` plus up to a period older than the meter's; None keeps + the constant age.""" 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): + tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0, + batt_period_s=None): + self.batt_period_s = batt_period_s 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 @@ -211,6 +216,11 @@ class Hass: if e.startswith("sensor.batt"): # 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. + if self.lag is not None and self.lag.batt_period_s: + per = self.lag.batt_period_s + ts = math.floor((self.t - self.lag.batt_s) / per) * per + j = (ord(u) * 31 + int(ts)) % 7 - 3 + return float(round(self._past(self.t - ts)[1][u]) + j) j = (ord(u) * 31 + int(self.t)) % 7 - 3 p = self.plants[u].p if self.lag is None else \ self._past(self.lag.batt_s)[1][u]