diff --git a/goodwe_controller/DECISIONS/MAINT-24-charge-first.md b/goodwe_controller/DECISIONS/MAINT-24-charge-first.md index 2aae299..8cdbe82 100644 --- a/goodwe_controller/DECISIONS/MAINT-24-charge-first.md +++ b/goodwe_controller/DECISIONS/MAINT-24-charge-first.md @@ -1,6 +1,6 @@ # MAINT-24 - a started maintenance charge gets the cheap night -**Status: for review.** Tests: `test_maint24.py`. No option added; the MAINT-13 +**Status: for review.** Tests: `test_maint24.py` (`test_opt20.py`'s `ch_ch_maint` digest moved with it). No option added; the MAINT-13 timeout numbers are unchanged. Folds in SAFETY-80. SAFETY-111 stays on its card (section 3). **What happened (live 0.21.0, A, 2026-11-08..11).** A sat in its maintenance @@ -18,7 +18,9 @@ charging time and ended the cycle. Report: `reports/MAINT-24-investigation-repor `modetrade.decide(maint_charging=)`, set in `_mode_roles()` when any pack is in its maintenance CHARGE phase. It is the mirror of `draining` (SAFETY-78): no grid-charge trade for the other packs, why `cycle`, the same Tick. The other -packs keep their solar roles, so they may still take spare solar. B's night +packs keep their solar roles, so they may still take spare solar. A refusal probe +(SAFETY-73, PROBE_W alone, OPT-20) still goes on: it places no charge on the +pack in maintenance, and `test_opt20.py` g7 pins it. The drain still blocks it. B's night charge waits for the nights A needs. At N=1 the only pack is the one in maintenance, so nothing changes. diff --git a/goodwe_controller/app/modetrade.py b/goodwe_controller/app/modetrade.py index 2c07891..75f6c58 100644 --- a/goodwe_controller/app/modetrade.py +++ b/goodwe_controller/app/modetrade.py @@ -199,7 +199,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now, charge from the grid (why "cycle"). `wait` ("wait" or "settle", the why) says no charge may start yet (SAFETY-135). `maint_charging` says a battery is in its maintenance CHARGE phase: no grid charge for the others - either, same why (MAINT-24, DECISIONS/MAINT-24-charge-first.md). + either, same why, but a refusal probe goes on (MAINT-24, + DECISIONS/MAINT-24-charge-first.md). """ plan_on = runs is not None and runs.on in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful() @@ -209,8 +210,12 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now, why = None ch = chargers(packs, ceiling_pct, done_charge, targets, probe=True) \ if in_cheap else [] - if ch and (draining or maint_charging): + if ch and draining: ch, why = [], "cycle" + elif ch and maint_charging: + # MAINT-24: a refusal probe (PROBE_W alone, OPT-20) may go on. + ch = [p for p in ch if p.refused < 0] + why = None if ch else "cycle" charging = False if ch: need = 0.0 if charging_now else START_W diff --git a/goodwe_controller/test_maint24.py b/goodwe_controller/test_maint24.py index 95a3c73..0309fea 100644 --- a/goodwe_controller/test_maint24.py +++ b/goodwe_controller/test_maint24.py @@ -205,6 +205,17 @@ check("with it: no charge, why 'cycle', B keeps a hold role, no ask", _on.action is None and _on.why == "cycle" and _on.ask_w == 0.0 and _on.roles[B] == modetrade.HOLD) check("same answer as the SAFETY-78 drain", _on == _dr) +_pr = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0, + refused=-1, probing=True) +_tp = _dec([_pa, _pr], maint_charging=True) +check("a refusal probe beside the maintenance charge goes on: PROBE_W alone " + "(OPT-20); beside a drain it does not", + _tp.action == modetrade.CHARGE and _tp.ask_w == -modetrade.PROBE_W + and _dec([_pa, _pr], draining=True).action is None) +check("a probe does not carry a normal charger along", + _dec([_pa, _pr, modetrade.Pack("inverter-c", True, packmode.CHEAP_HOURS, + False, 2500.0, 30.0, 10.0)], + maint_charging=True).ask_w == -modetrade.PROBE_W) check("it files nothing outside a cheap run (why None)", _dec([_pa, _pb], maint_charging=True, ).why == "cycle" and modetrade.decide( diff --git a/goodwe_controller/test_opt20.py b/goodwe_controller/test_opt20.py index 889d8fd..39f0816 100644 --- a/goodwe_controller/test_opt20.py +++ b/goodwe_controller/test_opt20.py @@ -248,11 +248,14 @@ print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e") # now counts those seconds, so ch_ch's quarters land on 3450.0 W where they # landed 1.3-1.4 W over it (bf9019e: 15fc25dd...), and ch_ch_maint's first, # partial quarter charges a little less at its end (bf9019e: e46e3c66...). +# MAINT-24 moved ch_ch_maint again: B no longer grid-charges beside A's +# maintenance charge, and A's charge is no longer paused (release/1.0 02eac3b: +# e56eddab...). AT_BASE = { "sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9", "ch_ch": "2b5879ec95810c39a5f0f82c04b9f8e358ea03ddce4ba187cc2b7e0885dfcbfa", "ch_ch_maint": - "e56eddabdb785f81793e7afa525f8e6da237b0b2165860aa8e0f8a6499b275a9", + "df86a451a3b2d365ca5d35b772cd1eb8502d164e6b63e6844b5cfb82f15aeb46", } _got = sc.no_probe_digests() for _k in AT_BASE: @@ -260,8 +263,8 @@ for _k in AT_BASE: f"{_got[_k][:16]}") check("with no refusal every share, grid, trade sentence, role and plant " "power of a scripted cheap run equals bf9019e's (A Solar first, A " - "charging, A in maintenance charge; the last two as SAFETY-125 moved " - "them)", _got == AT_BASE) + "charging, A in maintenance charge; the last two as SAFETY-125 and " + "MAINT-24 moved them)", _got == AT_BASE) print() if fails: