diff --git a/goodwe_controller/DECISIONS/SAFETY-52-a-battery-that-refuses.md b/goodwe_controller/DECISIONS/SAFETY-52-a-battery-that-refuses.md index 0bb06af..07c4a58 100644 --- a/goodwe_controller/DECISIONS/SAFETY-52-a-battery-that-refuses.md +++ b/goodwe_controller/DECISIONS/SAFETY-52-a-battery-that-refuses.md @@ -19,12 +19,13 @@ instead and ran SAFETY-59's ~70 s trip cycle. 1. **A refused direction is held at 0 W**, reason `battery refuses`. It engages in `compute()` when the freeze has held `SETTLE_CYCLES` and the reading is - within `saturation_w` of 0 against an ask beyond it. The drop to 0 skips + below `REFUSED_BELOW_W` (100 W, the probe's own answer) against an ask beyond + `saturation_w`. The drop to 0 skips the slew limit (it only removes an ask nobody meets). The direction is carried like `sat_count` (`refused=`, `Decision.refused`). 2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`, - `PROBE_S`; twice `BATT_OBLIGED_S`). An answer of at least 100 W ends the - refusal. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm + `PROBE_S`; twice `BATT_OBLIGED_S`). A reading of at least 100 W in the held + direction ends the refusal, during a probe or not. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm the gate. The clock is main.py's `now_mono`; `compute()` stays clockless. 3. **A gate trip while the loop is frozen on a ~0 W reading is a refusal** for control (`Controller._refusal_from_stale`). This is what stops the ~70 s @@ -52,18 +53,43 @@ shapes, recovery with no sign change, the probe cadence, every exit). | plant | law | gate trips | request >1 kW above delivery | longest held | import / export kWh | worst 15-min | |---|---|---|---|---|---|---| | fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W | -| fitted | new | 130 | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W | +| fitted | new | 130 (133 after review) | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W | | offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W | | offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W | -The +22 W worst quarter and the +45 trips on the fitted plant follow the fit's --22 W offset, which slowly charges the modelled pack above its floor while the -hold stands. With the offset at 0 both vanish. That is inferred from these two -rows and was not traced further. Rejected alternatives and their figures are in +**Traced in review (#141).** The +22 W worst quarter is the old law's worst +quarter too. There the old law stands at +2060 W on a pack giving 0 W, and the +new law asks 0 W, which the fitted plant answers with its -22 W offset: 22 W +charged from the grid. The fit above the floor reads `batt = cmd - 28 W`, so a +real inverter asked 0 W may do the same. Of the 71 new-law trips with no +old-law trip within 2 min, 59 come 85-97 s after an answered probe. The offset has lifted the modelled pack +just above its floor, the probe is answered, control ramps, the pack empties +again mid-ramp, and the gate trips, which re-seeds the hold. The other 12 +follow an other-direction exit the same way. On a site this is one +`STALE_READING` ERROR line per answered probe at most, and only on a battery +that answers a probe and then cuts out again. Catching the re-cut before the +gate needs a faster engage than `SETTLE_CYCLES`, which accuses a healthy slow +battery, so it is left to SAFETY-58/59. After the review changes below the +fitted-plant row reads 133 trips (the delivery exit now also catches answers +that arrive after the probe window); the other figures are unchanged. Rejected alternatives and their figures are in the phase A plan: bounding the integrator by demand made the worst quarter +541 W worse or cycled the gate 3368 times, and an SoC envelope needs the configured floor to be right and needs SoC. +## Review changes (#141) + +- **"Gives nothing" is below 100 W, not below 500 W.** At `saturation_w` a + battery derated to 100-499 W (cold, a tapering battery) was held at 0 W for + ~93 % of the time: 300 W derated gave 86 Wh in 4 h against 1 200 Wh on the + old law. The capture has 0.11 h in that band, so the replay does not move. +- **Any delivery ends the hold, not only an answered probe.** With + `meter_poll_s: 1` the 12 cycles last 12 s, less than a healthy battery's + reading lag (up to `BATT_OBLIGED_S`, 17 s). That engaged on a healthy step and + held it for 15 min. Now the late reading ends it within seconds (2 s held in + `test_refusal.py` part 5b). +- **N>1 is stated as a limit in DOCS.** The pack limit there covers an empty + battery only when its cut-out level is configured right. + ## Not covered A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 735f1a9..192f9af 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -538,17 +538,23 @@ accumulator is doing: | `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise | | `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again | | `not-running` | control is stopped **and** shadow mode is off, so the loop did not run | -| `battery refuses` | the battery gave nothing for a minute while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) | +| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) | | `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again | **When the battery refuses.** At its cut-out level the battery gives nothing, whatever it is asked. The add-on then asks 0 W instead of repeating its last request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every -15 minutes it asks 200 W for about half a minute; if the battery answers, -control resumes. It also resumes when the grid needs charging instead, and when +15 minutes it asks 200 W for about half a minute. Control resumes as soon as +the battery gives 100 W or more, when the grid needs charging instead, and when control is stopped or started. 200 W is below the 250 W the stale-reading check -needs, so a test never trips it. One inverter only: with several, the battery -level limit already moves an empty battery's share to the others. +needs, so a test never trips it. A battery that gives some power, even 100 W, +is not refusing and keeps its request. + +⚠️ **One inverter only.** With several inverters this hold is off. There, the +battery level limit moves an empty battery's share to the others, but only if +its “Battery cut-out level” matches where the battery really stops. A battery +that refuses at another level, or for another reason (cold, a battery fault), +can still hold its last request until the grid starts to export. *For installers:* SAFETY-52. The event log reads `BATTERY REFUSES …` and `BATTERY REFUSES ENDED: …`, and the INFO line ends in `BATTERY_REFUSES`. diff --git a/goodwe_controller/app/control.py b/goodwe_controller/app/control.py index fbd0d1c..89d4ee5 100644 --- a/goodwe_controller/app/control.py +++ b/goodwe_controller/app/control.py @@ -286,6 +286,10 @@ REFUSED = "battery refuses" PROBING = "battery probe" PROBE_W = 200.0 PROBE_ANSWER_W = PROBE_W / 2 +# "Gives nothing" is below the probe's own answer, not below `saturation_w`: a +# pack derated to 100-499 W is a limit, and at 500 W it kept ~7 % of its power +# (review of #141). The capture's refusals read -4 W. +REFUSED_BELOW_W = PROBE_ANSWER_W PROBE_EVERY_S = 900.0 PROBE_S = 34.0 @@ -773,18 +777,19 @@ def compute( # --- SAFETY-52: a refused direction is held at 0 W ---------------------- # Engages only where the freeze has held for SETTLE_CYCLES and the unit - # delivers ~nothing: a reading within `saturation_w` of 0 against an ask - # beyond it is a refusal, not a slow plant. The drop to 0 skips the slew + # delivers ~nothing: a reading below REFUSED_BELOW_W against an ask beyond + # `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew # limit because it only removes an ask the battery is not meeting. if refused is not None: if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES and abs(prev_w) > tuning.saturation_w - and abs(actual_w) < tuning.saturation_w): + and abs(actual_w) < REFUSED_BELOW_W): refused = 1 if prev_w > 0 else -1 if refused and refused * i_w < 0: refused = 0 # the loop wants the other way - elif refused and probe and refused * actual_w >= PROBE_ANSWER_W: - refused = 0 # the probe was answered + elif refused and refused * actual_w >= PROBE_ANSWER_W: + refused = 0 # it delivers: a probe answered, + # or a slow reading arriving elif refused: wants = refused * i_w > 0 i_w = 0.0 diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 2d0847d..03a008f 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -1108,8 +1108,9 @@ class Controller: f"gave {batt_w:+.0f} W for " f"{decision.sat_count} cycles") elif self.batt_refused and not now: - answered = probe and self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W - self._refusal_end("the probe was answered" if answered + gave = self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W + self._refusal_end("the probe was answered" if gave and probe + else "the battery gives power" if gave else "the grid now asks the other direction") def _refusal_from_stale(self, inv, now_mono) -> None: @@ -1124,7 +1125,7 @@ class Controller: and not self.batt_refused and self.sat_count >= t.saturation_cycles and inv.batt_fresh.held is not None - and abs(inv.batt_fresh.held) < t.saturation_w + and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W and abs(standing) > t.saturation_w): self._now_mono = now_mono self._refusal_start(1 if standing > 0 else -1, diff --git a/goodwe_controller/test_delivery.py b/goodwe_controller/test_delivery.py index a266a38..f356a2f 100644 --- a/goodwe_controller/test_delivery.py +++ b/goodwe_controller/test_delivery.py @@ -1147,8 +1147,10 @@ async def _one(): "soc_entity": "sensor.soc", "batt_entity": "sensor.batt", "meter_source": "off", "max_w": 2000, "gain": 0.6, # ⚠️ THE SAME 1 s AS THE TWO-INVERTER FIXTURE, AND THAT IS WHAT - # MAKES THIS SECTION NON-VACUOUS. The run below is 2 s of wall clock - # against a 1 s window, so a hold is not what stops this firing - + # MAKES THIS SECTION NON-VACUOUS. The run below is 1.1 s of wall + # clock against a 1 s window (SAFETY-52 shortened it from 2 s, and + # "the window was never even opened" below is what still makes it + # non-vacuous), so a hold is not what stops this firing - # the reduction is. With a shipped-length hold "nothing was # reported" would be true of a fixture that simply ran out of time. "bms_soc_floor": _FLOOR_PCT, "delivery_hold_s": 1, diff --git a/goodwe_controller/test_refusal.py b/goodwe_controller/test_refusal.py index e2bf87b..a396727 100644 --- a/goodwe_controller/test_refusal.py +++ b/goodwe_controller/test_refusal.py @@ -34,6 +34,12 @@ test_messages (MUTATION.md). Reds are failing checks, on this tree: R7 main.py: another writer no longer ends the refusal 1 R8 main.py: stop/start no longer ends the refusal 1 R9 main.py: the probe window never opens 4 + R10 main.py: seed from a stale stop below `saturation_w` again, 1 + not below REFUSED_BELOW_W (review of #141). The same mutation + in control.py's engage clause SURVIVES, and that is expected: + the delivery exit (R11) ends such a refusal in the same cycle. + R11 control.py: only an answered PROBE ends the refusal 2 + (`and probe` back in the delivery exit; review of #141) """ import asyncio @@ -366,6 +372,89 @@ rows, _, c = run(load906, cut906, 900, cap906, events={600.0: other_writer}) check("another writer on the wire ends the refusal (the arbiter reseed)", at(rows, 590)[6] == 1 and at(rows, 605)[6] == 0) +# ============================================================================== +print("5b. review of #141: a pack that gives SOME power, and a reading that is " + "only late, are not a refusal") +# ============================================================================== +# A pack derated to 100-499 W (cold, BMS taper) gives what it can against a +# larger ask. Engaging below `saturation_w` (500 W) held it at 0 W for ~93 % of +# the time; "gives nothing" is below the probe's own answer. +# `compute()` alone: through the Controller the stale-reading gate trips on the +# flat 300 W first, and that fixture then passes whatever this threshold is. +def derated(carry, cap_w=300.0, n=1800): + prev, i_w, sat, ref, gave, held = 0.0, 0.0, 0, carry, 0.0, 0 + b = 0.0 + for _ in range(n): # 2.5 h at 5 s + d = control.compute(prev, 2000.0 - b, b, T, sat, i_w, refused=ref) + prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused + held += bool(ref) + b = max(0.0, min(prev, cap_w)) # one tick late + gave += b + return gave / n, held + + +T = control.Tuning(**LIVE) +gave_old, _ = derated(None) +gave_new, held_new = derated(0) +print(f" pack derated to 300 W: old law {gave_old:.0f} W average, new " + f"{gave_new:.0f} W, held as refusing on {held_new} cycles") +check("a pack that gives a steady 300 W against a 2000 W ask is never held at " + "0 W as refusing, and keeps giving what the old law got from it", + held_new == 0 and gave_new == gave_old and gave_old > 290.0) + + +def lagging(tick, dead_s=6.0, publish_s=10.0, phase_s=5.0, dur=600.0): + """`compute()` on a HEALTHY pack whose reading lags: it follows the command + of `dead_s` ago, published every `publish_s`. A 1 s meter (`meter_poll_s` + 1) makes SETTLE_CYCLES only 12 s, shorter than that lag.""" + prev, i_w, sat, ref, hist, reg, pub = 0.0, 0.0, 0, 0, [], 0.0, -phase_s + held = engaged = 0 + t = 0.0 + while t < dur: + then = [c for tc, c in hist if tc <= t - dead_s] + b = then[-1] if then else 0.0 + if t - pub >= publish_s: + reg, pub = b, t + d = control.compute(prev, (3000.0 if t >= 10 else 0.0) - b, reg, T, + sat, i_w, refused=ref) + engaged += bool(d.refused) and not ref + prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused + hist.append((t, prev)) + held += tick * bool(ref) + t += tick + return engaged, held + + +lag1 = lagging(1.0) +print(f" 1 s meter, healthy pack with a late reading: engaged {lag1[0]} " + f"time(s), held {lag1[1]:.0f} s") +check("a healthy pack whose reading is merely late is let go as soon as the " + "reading shows it delivering - not held until the next probe", + lag1[1] <= 10.0) + + +async def _seed(held_w): + """`_refusal_from_stale()` on a unit standing at +2000 W, frozen.""" + c = Controller(dict( + {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid", + "soc_entity": "sensor.soc", "batt_entity": "sensor.batt", + "meter_source": "off"}, **LIVE), + _Hass(lambda t: 0.0, lambda t: False), _Store(), _Pub(), + stop=asyncio.Event()) + c.auto, c.sat_count = True, c.tuning.saturation_cycles + inv = c.inverters[0] + inv.last_write, inv.batt_fresh.held = 2000.0, held_w + c._refusal_from_stale(inv, 100.0) + return c.batt_refused + + +check("a stale-reading stop on a register standing at 300 W is not a refusal " + "(it gives something), and one at -4 W is", + asyncio.run(_seed(300.0)) == 0 and asyncio.run(_seed(CUT_W)) == 1) +d = control.compute(0.0, 1500.0, 1400.0, T, 0, 0.0, refused=1, probe=False) +check("...i.e. a held direction that reads 100 W or more ends the refusal " + "without waiting for a probe", d.refused == 0) + # ============================================================================== print("6. words") # ============================================================================== diff --git a/goodwe_controller/tools/replay_refusal.py b/goodwe_controller/tools/replay_refusal.py index b739f1f..6b53173 100644 --- a/goodwe_controller/tools/replay_refusal.py +++ b/goodwe_controller/tools/replay_refusal.py @@ -43,7 +43,7 @@ def go(which, rows, fit, publish_s=10.0, stale_input_s=15.0): if fr.stale and not was: trips += 1 if (which == "new" and not st["ref"] and st["sat"] >= tun.saturation_cycles - and abs(reg) < tun.saturation_w and abs(st["target"]) > tun.saturation_w): + and abs(reg) < NEW.REFUSED_BELOW_W and abs(st["target"]) > tun.saturation_w): st["ref"], st["anchor"] = (1 if st["target"] > 0 else -1), t was = fr.stale if batt is None: