diff --git a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md new file mode 100644 index 0000000..0f7bfba --- /dev/null +++ b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md @@ -0,0 +1,21 @@ +# OPT-11 - battery modes, per battery + +A battery's mode (on/off, Solar first, Leftover solar) is a soft unit limit at +`P_MODE` = 35 that always holds 0 W. A SET above 35 (a safety stop) overrides +it; a limit above 35 (the peak guard) makes it give way only as far as that +limit needs, and a battery its mode holds back then carries only what the +others cannot (`arbiter._unit_modes()`, `_site_mode()`). Design and owner answers: `goodwe-session/OPT-11-design.md` (2026-09-27); +P1 is its section 6 P1, and the pull request lists what was measured. + +- Off by default (`battery_modes_enabled`); off, commands are byte-identical to + a5dddad (`test_modes.py` part 1). +- Off is a mode range, so the peak guard can still use an Off battery when the + others cannot cover the floor (design sections 2-3). Whether Off should be + absolute is an open owner question. +- Review (PR #149): releasing only when the peak floor bound S let a trade won + above the floor keep the modes and place less than the floor, and let a + capped grid charge release an Off battery. Both are graded in + `test_arbiter.py` part 11. The loop bound leaves a side that reaches the + rail to the rail, so SAFETY-03 still flags a loop winding into it. +- In P1 only Off closes a direction; Leftover solar differs by charging last. + Price runs, selling (`pack_sell`, reserved) and the forecast are P3/P4. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 72bb441..4f36859 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -183,6 +183,7 @@ The label and description each setting shows on the Configuration tab, in tab or | Hardware · Inverter list | Battery power sign is reversed (optional) | `inverters.batt_invert` | Switch this on if this inverter's sensor shows giving power as a negative number. | | Hardware · Inverter list | Usable battery capacity (kWh) (optional) | `inverters.capacity_kwh` | This battery's usable capacity from its datasheet. Fill it in on every inverter or on none. The add-on then shares the work by the energy each battery has left. | | Hardware | RS485 emergency stop is fitted | `estop_fitted` | Switch this on if the emergency stop is installed. It only changes a warning on the status page. | +| Hardware | Battery modes | `battery_modes_enabled` | Give each battery an on and off switch and a mode, set on the Batteries screen. | | Hardware | Solar production entity (W) | `pv_entity` | Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control. | | Hardware | Solar production entities, per inverter (W) | `pv_entities` | Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded. | | Hardware | Reversed-sign solar entities | `pv_invert_entities` | Optional. Add each solar entity above that reads negative while producing, spelled the same way. Its sign is flipped before adding up. | @@ -512,6 +513,17 @@ two of a thing there is one of, and `P1 sample age` is a safety input. Same for status, the maintenance phase, the capacity-tariff peak and the three loop rows below. +### Battery modes (`battery_modes_enabled`) + +Off by default. Turn it on, restart the add-on, and the Batteries screen gives each battery two controls. They apply at once, with no restart. + +- **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover. +- **Mode.** *Solar first* charges from spare solar before the other batteries. *Leftover solar* charges from the solar they cannot take. Both cover the house at any hour. Charging from the grid in cheap hours, and selling, come in a later update. + +The peak limit, the safety stops and the monthly cycle always come before a battery's mode. + +*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`. + ### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason` The control law is a bounded integrator: an accumulator that is **not** the diff --git a/goodwe_controller/app/allocator.py b/goodwe_controller/app/allocator.py index 38872a2..9ddef8b 100644 --- a/goodwe_controller/app/allocator.py +++ b/goodwe_controller/app/allocator.py @@ -98,6 +98,12 @@ DROP_STALE = "stale link" DROP_CONTRADICTION = "contradicts itself" DROP_NO_INTEGER = "no integer setpoint in its interval" +# OPT-11: why a unit's MODE layer was not applied this tick. Only the first is +# a fault; the other two are the rule working. +MODE_FAULT = "mode range leaves out 0 W" +MODE_RELEASED = "mode released by a higher claim" +MODE_OUTRANKED = "hard limit leaves no room for the mode" + REFUSE_SCOPE = "mis-routed claim" REFUSE_EMPTY = "no unit available" REFUSE_SIGN = "opposite directions" @@ -213,6 +219,10 @@ class Allocation: # that calls `plan()` for its arithmetic gets None and does not care; # main.py stamps every record it puts on the write path. tick: int | None = None + # OPT-11: ((unit, why), ...) for each unit whose mode range left out 0 W + # this tick. That mode was dropped (see `unit_interval()`); main.py logs it + # as a software fault. Empty on every tick without a mode. + mode_faults: tuple = () @property def ok(self) -> bool: @@ -290,6 +300,87 @@ class Allocation: return out +def unit_interval(b, deciding_priority=None): + """OPT-11. `(lo, hi, note)`: the interval a unit is allocated into. + + The hard limits (`b.lo`, `b.hi`) always apply. The unit's MODE range + (`b.mode_lo`, `b.mode_hi`) narrows them unless `deciding_priority` is + above the mode's own priority (arbiter._unit_modes() passes None when the + modes hold, and the yielding limit's priority when they give way). `note` says why the + mode did not apply, or is None when it did or there is none: + + * MODE_FAULT: the mode range leaves out 0 W. A mode may forbid a direction + and never force one, so this is a software fault and the mode is dropped. + It is what keeps REFUSE_SIGN unreachable from a mode: the fill below + moves every free unit away from 0 in ONE direction, and it starts from 0 + only while every interval holds 0. + * MODE_RELEASED: a higher claim (the peak floor) decided the target. + * MODE_OUTRANKED: the hard limits and the mode do not overlap. The hard + limits win, as they always do. + + A unit without mode fields (`mode_priority` absent or None) gets its hard + limits, unchanged: that is every unit when battery modes are off. + """ + lo, hi = b.lo, b.hi + prio = getattr(b, "mode_priority", None) + if prio is None: + return lo, hi, None + mlo, mhi = b.mode_lo, b.mode_hi + if not (mlo <= 0.0 <= mhi): + return lo, hi, MODE_FAULT + if deciding_priority is not None and deciding_priority > prio: + return lo, hi, MODE_RELEASED + nlo, nhi = max(lo, mlo), min(hi, mhi) + if nlo > nhi: + return lo, hi, MODE_OUTRANKED + return nlo, nhi, None + + +class _Narrowed: + """A unit's bounds with its interval replaced by `unit_interval()`'s.""" + __slots__ = ("lo", "hi") + + def __init__(self, lo, hi): + self.lo, self.hi = lo, hi + + +def loop_bound(bounds, live=None): + """OPT-11. `(lo, hi)`: what the units can place when the LOOP decides. + + The sum, over live units that do not contradict themselves, of each unit's + interval with its mode applied (`unit_interval()` with no higher claim). A + pinned unit adds the span from 0 to its pin, so a probe or a maintenance + pin stays reachable. Every term holds 0, so the sum does too. + + `compute()` keeps the site target inside this, so the loop never asks for + watts no battery may give: the allocator would report them as unmet and the + saturation freeze would park the target on them (SAFETY-73 section 1). + Pure, like `plan()`: it reads numbers and intervals and nothing else. + """ + lo_sum, hi_sum = 0.0, 0.0 + for u in sorted(bounds): + b = bounds[u] + if live is not None and u not in live: + continue + if getattr(b, "contradiction", False): + continue + pv = getattr(b, "pinned_w", None) + if getattr(b, "gate", None) is not None and pv is not None: + lo, hi = _int_envelope(b) + p = float(pv) if lo > hi else float(_clamp_int(round(float(pv)), + lo, hi)) + lo_sum += min(0.0, p) + hi_sum += max(0.0, p) + continue + lo, hi, _ = unit_interval(b) + lo, hi = _int_envelope(_Narrowed(lo, hi)) + if lo > hi: + continue + lo_sum += min(0.0, lo) + hi_sum += max(0.0, hi) + return lo_sum, hi_sum + + def _int_envelope(b): """A unit's interval, narrowed to the integers a 1 W register can hold. @@ -385,7 +476,8 @@ def _fill(order, weights, cap, amount): def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(), - site_priority=None, site_key="site", tick=None) -> Allocation: + site_priority=None, site_key="site", tick=None, + deciding_priority=None) -> Allocation: """Divide the site grid target `target_w` across the units in `bounds`. `bounds` unit name -> that unit's interval for THIS TICK (arbiter stage 1, @@ -411,6 +503,9 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(), `site_priority` the priority of the SITE claim that chose `target_w`. It is what a unit's own winning SET is weighed against - see PINS. `site_key` the name to use when there are no units at all. + `deciding_priority` OPT-11: the priority that decided `target_w` + (`Resolution.deciding_priority`). Each unit's mode range applies + only at or below it - see `unit_interval()`. None applies them. `tick` an opaque token identifying the tick this record is FOR, handed straight back on the record. SAFETY-28's write gate compares it against the tick it is writing on, so that a record cannot be @@ -497,6 +592,7 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(), dropped = [] ilo, ihi = {}, {} considered = [] + mode_faults = [] for u in units: b = bounds[u] if live is not None and u not in live: @@ -512,7 +608,12 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(), # has no interval to allocate into. dropped.append((u, DROP_CONTRADICTION)) continue - lo, hi = _int_envelope(b) + # OPT-11: the mode layer, when it applies, narrows the hard limits. + # A unit with no mode gets exactly its hard limits, as before. + nlo, nhi, note = unit_interval(b, deciding_priority) + if note == MODE_FAULT: + mode_faults.append((u, MODE_FAULT)) + lo, hi = _int_envelope(_Narrowed(nlo, nhi)) if lo > hi: # An interval that contains no integer. The register is 1 W, so # there is no number this unit could be commanded that both bounds @@ -526,7 +627,7 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(), return Allocation( target_w=float(target_w), sum_w=sum_w, shares={}, plans=(), dropped=tuple(dropped), unmet_w=sum_w, quantisation_w=quant, - refused=why, tick=tick) + refused=why, tick=tick, mode_faults=tuple(mode_faults)) # --- REFUSAL 2: nobody is available ------------------------------------ if not considered: @@ -659,4 +760,5 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(), return Allocation(target_w=float(target_w), sum_w=sum_w, shares=shares, plans=tuple(plans), dropped=tuple(dropped), - unmet_w=int(unmet), quantisation_w=quant, tick=tick) + unmet_w=int(unmet), quantisation_w=quant, tick=tick, + mode_faults=tuple(mode_faults)) diff --git a/goodwe_controller/app/arbiter.py b/goodwe_controller/app/arbiter.py index ab5b36d..5d69d1d 100644 --- a/goodwe_controller/app/arbiter.py +++ b/goodwe_controller/app/arbiter.py @@ -86,8 +86,17 @@ P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only) # because trading against the meter is the entire point - the grid-following # loop would otherwise cancel every charge the moment it created import. P_ARBITRAGE = 30 # price arbitrage - trades against the meter on purpose +# OPT-11. A battery's mode (Solar first, Leftover solar, Off): a SOFT unit +# limit, kept apart from the hard ones. Above arbitrage, so a trade stays inside +# each battery's mode; below the peak guard, to which a mode gives way only as +# far as the floor needs (`_unit_modes()`, `_site_mode()`). +# DECISIONS/OPT-11-battery-modes.md. +P_MODE = 35 P_LOOP = 10 # ordinary grid-following +# The one source filed at P_MODE. pre_resolve() keeps it out of the hard limits. +MODE_SOURCE = "mode" + SET = "set" LIMIT = "limit" @@ -238,6 +247,13 @@ CLAIM_SCOPE = { # this pack to its floor, charge this pack to full, recalibrate this pack's # coulomb counter. Two packs recalibrate on their own schedules. "maintenance": SCOPE_UNIT, + # main.py: `Claim.limit("mode", P_MODE, lo, hi, ...)`, OPT-11. THIS + # battery's mode, chosen per battery by its owner. A unit claim that never + # enters a `lo > hi` test with a site limit: stage 1 keeps it apart from the + # hard limits, and it gives way to a site limit above P_MODE only as far as + # that limit needs (`_unit_modes()`). At N=1 it routes to the site, and + # `resolve()` takes it out of stage 2 for the same reason. + "mode": SCOPE_UNIT, } @@ -365,6 +381,13 @@ class UnitBounds: pinned_w: float | None = None pinned_by: str | None = None contradiction: bool = False + # OPT-11: the mode layer, APART from `lo`/`hi` above, which stay the hard + # limits. `mode_priority` is None when no mode claim applies. Whether the + # mode binds is decided at stage 3 (`allocator.unit_interval()`), because it + # depends on the priority that decided the SITE target. + mode_lo: float = -inf + mode_hi: float = inf + mode_priority: int | None = None def explain(self) -> str: if self.contradiction: @@ -410,6 +433,15 @@ class Resolution: # rules drifting apart: this is set by the same line that picks the winner # `explain()` names. winner_priority: int | None = None + # OPT-11. The higher of the winning set's priority and the priority of the + # limit that bound `target_w`. Whether the modes hold is decided from the + # limits themselves (`_site_mode()`, `_unit_modes()`): a mode gives way + # only as far as a limit above it (the peak floor) needs, so a mode `hi` + # never meets a site `lo`. + deciding_priority: int | None = None + # OPT-11 at N=1: why the site's mode layer was dropped this tick, or None. + # At N>1 the same fact is on `allocation.mode_faults`, per unit. + mode_fault: str | None = None def explain(self) -> str: """One line an operator can act on - this is the point of the module.""" @@ -433,7 +465,8 @@ def pre_resolve(unit_claims: list) -> UnitBounds: of the fault this card removes, and the reason `resolve()` partitions rather than filters twice. """ - sets = sorted((c for c in unit_claims if c.kind == SET), + sets = sorted((c for c in unit_claims + if c.kind == SET and c.source != MODE_SOURCE), key=lambda c: c.priority, reverse=True) gate = pinned_w = pinned_by = None if sets: @@ -441,19 +474,30 @@ def pre_resolve(unit_claims: list) -> UnitBounds: lo, hi = -inf, inf lo_src = hi_src = None + # OPT-11: a mode claim never joins the hard limits, so a mode cannot make a + # unit contradict itself or narrow a pin. A mode SET is not a thing: only + # limits are read. The lowest mode priority is kept (the first to release). + mode_lo, mode_hi, mode_priority = -inf, inf, None for c in unit_claims: if c.kind != LIMIT or (gate is not None and c.priority < gate): continue + if c.source == MODE_SOURCE: + mode_lo, mode_hi = max(mode_lo, c.lo), min(mode_hi, c.hi) + mode_priority = (c.priority if mode_priority is None + else min(mode_priority, c.priority)) + continue if c.lo > lo: lo, lo_src = c.lo, c.source if c.hi < hi: hi, hi_src = c.hi, c.source return UnitBounds(lo, hi, lo_src, hi_src, gate, pinned_w, pinned_by, - contradiction=lo > hi) + contradiction=lo > hi, mode_lo=mode_lo, mode_hi=mode_hi, + mode_priority=mode_priority) def allocate(target_w: float, bounds: dict, *, weights=None, live=None, - scope_errors=(), site_priority=None) -> tuple: + scope_errors=(), site_priority=None, + deciding_priority=None) -> tuple: """STAGE 3. THE SEAM, AND SINCE ALLOC-01 IT IS OCCUPIED. `(shares, allocated)`. ⚠️ THE ALLOCATOR ITSELF IS `app/allocator.py` AND THE REASONING LIVES THERE. @@ -491,11 +535,13 @@ def allocate(target_w: float, bounds: dict, *, weights=None, live=None, """ return allocate_record(target_w, bounds, weights=weights, live=live, scope_errors=scope_errors, - site_priority=site_priority)[:2] + site_priority=site_priority, + deciding_priority=deciding_priority)[:2] def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None, - scope_errors=(), site_priority=None, tick=None) -> tuple: + scope_errors=(), site_priority=None, tick=None, + deciding_priority=None) -> tuple: """`allocate()` plus the record - `(shares, allocated, allocation)`. `tick` is SAFETY-28's freshness token: it identifies the tick the record is @@ -507,7 +553,7 @@ def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None, rec = allocator.plan(target_w, bounds, weights=weights, live=live, scope_errors=scope_errors, site_priority=site_priority, site_key=ACTUATOR_SITE, - tick=tick) + tick=tick, deciding_priority=deciding_priority) return rec.shares, rec.ok, rec @@ -544,19 +590,26 @@ def site_resolve(claims: list) -> Resolution: considered = [f"{c.source}:{c.kind}" for c in claims] if lo > hi: + # OPT-11: 0 W here, and every limit that took part counts as deciding. return Resolution(0.0, winner.source, bound_by=None, contradiction=True, considered=considered, - winner_priority=winner.priority) + winner_priority=winner.priority, + deciding_priority=max(winner.priority, + lo_src.priority, + hi_src.priority)) target = max(lo, min(hi, winner.value)) bound_by = None + deciding = winner.priority if target != winner.value: binder = lo_src if target == lo else hi_src if binder is not None: bound_by = f"{binder.source}({binder.reason})" if binder.reason else binder.source + deciding = max(deciding, binder.priority) return Resolution(float(target), winner.source, bound_by, False, considered, - winner_priority=winner.priority) + winner_priority=winner.priority, + deciding_priority=deciding) def resolve(claims: list, *, units=None, weights=None, live=None, @@ -606,6 +659,12 @@ def resolve(claims: list, *, units=None, weights=None, live=None, written down. """ site_claims = [c for c in claims if c.actuator == ACTUATOR_SITE] + # OPT-11: a mode claim on the site (N=1, where every source routes there) + # never enters stage 2's `lo > hi` test. It is applied after it, below, by + # the same release rule stage 3 uses. + site_modes = [c for c in site_claims if c.source == MODE_SOURCE] + if site_modes: + site_claims = [c for c in site_claims if c.source != MODE_SOURCE] if units is None: units = {c.actuator for c in claims if c.actuator != ACTUATOR_SITE} units = tuple(sorted(units)) @@ -617,6 +676,8 @@ def resolve(claims: list, *, units=None, weights=None, live=None, # STAGE 2 - the site claims become the grid target. At N=1 `site_claims` IS # `claims`, so this is the shipped arbiter on the shipped list. out = site_resolve(site_claims) + if site_modes: + _site_mode(out, site_modes, site_claims) # STAGE 3 - the seam, and since ALLOC-01 the allocator behind it. # @@ -649,10 +710,125 @@ def resolve(claims: list, *, units=None, weights=None, live=None, # nothing. See `packshare.weights()` on the residual this leaves (the fill's # own direction can differ from the target's sign under a pin, in which case # the weighting is a worse preference and still cannot be unsafe). + # OPT-11: whether each unit's mode holds this tick (`_unit_modes()`). + # Before the weights, because a limit above the modes may move the target. + gate, held = _unit_modes(out, site_claims, bounds, live) if callable(weights): weights = weights(out.target_w) + if held: + weights = {u: (0.0 if u in held else + (1.0 if weights is None else weights.get(u, 1.0))) + for u in bounds} out.shares, out.allocated, out.allocation = allocate_record( out.target_w, bounds, weights=weights, live=live, scope_errors=out.scope_errors, site_priority=out.winner_priority, - tick=tick) + tick=tick, deciding_priority=gate) return out + + +def _limits_above(site_claims: list, prio: int) -> tuple: + """OPT-11: `(lo, lo_src, hi, hi_src)` over the site LIMITs above `prio`. + + These are what a mode may never take the site target outside of: the peak + guard (P_PEAK), maintenance shaping (P_MAINT_SHAPE) and the hard limits. + """ + lo, hi = -inf, inf + lo_src = hi_src = None + for c in site_claims: + if c.kind != LIMIT or c.priority <= prio: + continue + if c.lo > lo: + lo, lo_src = c.lo, c + if c.hi < hi: + hi, hi_src = c.hi, c + return lo, lo_src, hi, hi_src + + +def _yield_to(out: Resolution, t1: float, above: tuple): + """OPT-11: the target `t1` the modes allow, moved only as far as the limits + above the mode need (`_limits_above()`). Returns the claim that moved it, + or None when `t1` already satisfies them, and sets `out.target_w`.""" + lo, lo_src, hi, hi_src = above + t2 = max(lo, min(hi, t1)) + out.target_w = float(t2) + if t2 == t1: + return None + binder = lo_src if t2 == lo else hi_src + out.bound_by = (f"{binder.source}({binder.reason})" if binder.reason + else binder.source) + out.deciding_priority = max(out.deciding_priority or binder.priority, + binder.priority) + return binder + + +def _site_mode(out: Resolution, modes: list, site_claims: list) -> None: + """OPT-11 at N=1: the one battery's mode bounds the SITE target, after stage 2. + + A SET above the mode (a safety stop) decides alone. Otherwise the target is + clamped into the mode range and then back into the site limits above the + mode, so the mode gives way only as far as those limits need: a peak floor + of 2000 W over an Off battery asks it for 2000 W, whether a trade or the + loop won stage 2, and a charge the peak guard merely caps stays at 0 W. A + range without 0 W is a software fault that drops the mode + (`out.mode_fault`). Clamping one number cannot contradict anything. + """ + out.considered = out.considered + [f"{c.source}:{c.kind}" for c in modes] + lims = [c for c in modes if c.kind == LIMIT] + if not lims or out.contradiction: + return + lo = max(c.lo for c in lims) + hi = min(c.hi for c in lims) + prio = min(c.priority for c in lims) + if not (lo <= 0.0 <= hi): + out.mode_fault = f"mode range [{lo:.0f}, {hi:.0f}] leaves out 0 W" + return + if out.winner_priority is not None and out.winner_priority > prio: + return + before = out.target_w + t1 = max(lo, min(hi, before)) + if _yield_to(out, t1, _limits_above(site_claims, prio)) is None \ + and t1 != before: + c = lims[0] + out.bound_by = f"{c.source}({c.reason})" if c.reason else c.source + + +def _unit_modes(out: Resolution, site_claims: list, bounds: dict, live): + """OPT-11 at N>1: `(gate, held)` for stage 3. + + `gate` is the `deciding_priority` handed to the allocator: None applies + every unit's mode, a number above P_MODE releases them. `held` is the set + of units whose mode holds them back in the target's direction; while the + modes are released they weigh 0, so they carry only what the other units + cannot (the allocator's last-resort pass). + + The rule matches N=1: a SET above the mode decides alone. Otherwise the + target is clamped into what the units can place with their modes + (`allocator.loop_bound()`) and back into the site limits above the mode. + If that point is still placeable with the modes, they hold and the target + is unchanged (what they cannot place is reported unmet, as before). If a + limit above the mode needs more, the target moves only that far, and the + modes are released for it. + """ + mode_units = [u for u, b in bounds.items() + if getattr(b, "mode_priority", None) is not None] + if not mode_units or out.contradiction: + return out.deciding_priority, set() + prio = min(bounds[u].mode_priority for u in mode_units) + if out.winner_priority is not None and out.winner_priority > prio: + return out.deciding_priority, set() + clo, chi = allocator.loop_bound(bounds, live) + t1 = max(clo, min(chi, out.target_w)) + before = out.target_w + binder = _yield_to(out, t1, _limits_above(site_claims, prio)) + if binder is None: + out.target_w = float(before) + return None, set() + held = set() + for u in mode_units: + b = bounds[u] + nlo, nhi, note = allocator.unit_interval(b) + if note is not None: + continue + if (nhi < b.hi) if out.target_w > 0 else (nlo > b.lo): + held.add(u) + return binder.priority, held diff --git a/goodwe_controller/app/control.py b/goodwe_controller/app/control.py index 89d4ee5..8e01509 100644 --- a/goodwe_controller/app/control.py +++ b/goodwe_controller/app/control.py @@ -17,6 +17,7 @@ exists because its absence produced a specific, observed failure. """ import math +from math import inf from dataclasses import dataclass # --------------------------------------------------------------------------- @@ -293,6 +294,10 @@ REFUSED_BELOW_W = PROBE_ANSWER_W PROBE_EVERY_S = 900.0 PROBE_S = 34.0 +# OPT-11. The loop's reason when the battery modes, not the meter, set the +# command: every battery that may move this way is at its mode's limit. +MODE_BOUND = "mode-bound" + @dataclass(frozen=True) class Tuning: @@ -475,6 +480,7 @@ def compute( i_w: float | None = None, refused: int | None = None, probe: bool = False, + bound: tuple | None = None, ) -> Decision: """One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1). @@ -485,8 +491,24 @@ def compute( `refused` SAFETY-52's carried refused direction, or None to leave it out (the `i_w=None` equivalence path does) `probe` the caller's clock says a refused direction may try PROBE_W now + `bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with + their modes applied (`allocator.loop_bound()`), or None. See + MODE_BOUND below. None is the law as it was. """ reason = "tracking" + # OPT-11: a bound that leaves out 0 W would FORCE a direction, which no + # mode may do. It is dropped rather than obeyed (a software fault, and + # main.py never builds one: every term of `loop_bound()` holds 0). + if bound is not None and not (bound[0] <= 0.0 <= bound[1]): + bound = None + # ...and a side of it that reaches the rail is left to the rail, so that + # SAFETY-03's `i_clamped` still fires when the loop winds into it. With + # every battery on, the bound IS the rail, and the law is exactly as it was. + if bound is not None: + _rail = min(tuning.max_w, tuning.max_w if tuning.integrator_max_w is None + else tuning.integrator_max_w) + bound = (float(bound[0]) if bound[0] > -_rail else -inf, + float(bound[1]) if bound[1] < _rail else inf) # --- saturation, WITH the duration term ------------------------------- # Command and actual diverging means the inverter cannot follow - it is at @@ -589,6 +611,15 @@ def compute( else: i_w = max(moved, i_w) + # OPT-11: the integrator does not wind past what the modes let the + # batteries place. Only a side tighter than the rail is left in `bound` + # (above), so this never takes SAFETY-03's `i_clamped` away from the rail. + if bound is not None: + held = max(float(bound[0]), min(float(bound[1]), i_w)) + if held != i_w: + i_w = held + reason = MODE_BOUND + # ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this # bound is not. It is what makes the worst-case unwind time finite and # knowable instead of a function of how long the error happened to stand. @@ -796,6 +827,22 @@ def compute( target = refused * PROBE_W if probe and wants else 0.0 reason = PROBING if probe and wants else REFUSED + # --- OPT-11: the command stays inside what the modes allow ------------- + # Applied after the slew limit and the freeze, so a mode that closes drops + # the ask at once rather than one slew step per cycle. That moves no + # battery further than the allocator would: it places at most this bound + # when the loop decides. What it removes is the unmet part, which would + # otherwise hold `prev_w` away from `actual_w` and trip the saturation + # freeze on watts no battery was allowed to give. + if bound is not None: + inside = max(float(bound[0]), min(float(bound[1]), target)) + if inside != target: + target = inside + reason = MODE_BOUND + # SAFETY-48's lead cap above may pull the integrator back toward the + # slewed command; what is CARRIED stays inside the bound too. + i_w = max(float(bound[0]), min(float(bound[1]), i_w)) + # --- quantise ---------------------------------------------------------- # 10 W. The register is 1 W and the inverter reports at 1 W, but its # response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent @@ -803,6 +850,13 @@ def compute( # 10 W simply avoids a visible staircase on dashboards. step = max(1, int(tuning.step_w)) target = round(target / step) * step + if bound is not None: + # OPT-11: rounding must not step outside the bound. The bound holds + # 0 W, so one step toward 0 is always inside it. + if target > bound[1]: + target -= step + elif target < bound[0]: + target += step return Decision(float(target), sat_count, frozen, reason, float(i_w), i_clamped, clamped, float(i_bound_w), float(want), diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index 1361661..01afa62 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -9,8 +9,9 @@ note, section 3.3. The plan shows only what the add-on does today: trading on price (the arbitrage verdict, the same call the control cycle makes), the monthly battery -cycle, the peak limit and "follows the house". Battery modes (OPT-11) are not -built, so nothing here invents a mode plan. Every sentence is in the words +cycle, the peak limit and "follows the house". OPT-11 P1 adds each battery's +switch and mode to its row; the mode plan arrives with P3/P4, so nothing here +invents one. Every sentence is in the words section at the bottom and is held to STYLE.md by test_messages.py. """ @@ -530,9 +531,17 @@ def battery_rows(c, shadow, running, held=False): running=running, held=held, would_w=shares.get(inv.name), settled=at_zero(inv)), - # Per-battery reasons arrive with battery modes (OPT-11). + # Per-battery reasons arrive with the plan (OPT-11 P3/P4). "why": None, }) + if getattr(c, "modes_on", False): + # OPT-11 P1: the battery's switch and mode, for the Batteries + # screen. Only with battery modes on, so the page is unchanged + # without them. UX-13 P3 builds the real screen on these keys. + pm = c.pack_mode(inv) + rows[-1].update({"use": pm.use, "mode_key": pm.key, + "mode_choice": pm.mode, + "mode": mode_word(pm.key)}) return rows @@ -723,6 +732,11 @@ def mode_house() -> str: return _t("mode_house") +def mode_word(key) -> str: + """OPT-11: a battery's mode, or Off, in the page's language.""" + return _t("modes." + key) + + def attn_shadow() -> str: return _t("attn_shadow") @@ -964,6 +978,7 @@ def _samples() -> list: out = [("title", t) for t in words["verdict"].values()] out += [("title", _t("zero_going")), ("title", _t("zero_held"))] out += [("title", t) for t in words["attn"].values()] + out += [("title", mode_word(k)) for k in words["modes"]] out += [("title", mode_house()), ("title", attn_shadow()), ("line", attn_shadow_body())] for k in ("running", "shadow", "stopped", "alarm", "warning"): diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index d92ceb7..4cdaaf2 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -37,9 +37,11 @@ import aiohttp from . import arbitrage from .arbiter import ( - P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, - Claim, Resolution, actuator_for, resolve, + P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_MODE, P_PEAK, P_SAFETY, + Claim, Resolution, actuator_for, pre_resolve, resolve, ) +from . import allocator +from . import packmode from . import control as _ctl from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk from .delivery import ( @@ -67,7 +69,8 @@ from .hass import HomeAssistant from .inverter import (LINK_STALE_S, UNIT_A, build_roster, roster_entries, roster_names) from .maintenance import ( - IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate, + IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult, + arbitrate as maint_arbitrate, ) from .mqtt import (MqttPublisher, announced_units, optional_keys, unit_slugs, unit_state_key) @@ -894,6 +897,13 @@ class Controller: self._peak_saturated = False self._peak_floor_limited = False self._maint_shape_suspended = False + # OPT-11. Battery modes, off by default so an install that has not + # turned them on behaves exactly as before (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` actions). + self.modes_on = opts.get("battery_modes_enabled") is True + # The units whose mode fault is logged already (edge-logged). + self._mode_faults = set() # [(pack name, why)] for packs that are due and waiting their turn. # Empty at a roster of one, where nothing can defer anything. self._maint_deferred = [] @@ -1953,7 +1963,103 @@ class Controller: that have nothing to do with the allocator, and a weighting held from construction prefers a pack that has since emptied. """ - return packshare.weights(self.pack_energy, target_w) + base = packshare.weights(self.pack_energy, target_w) + if not self.modes_on: + return base + # OPT-11: the solar order. While the site charges, Leftover solar + # batteries weigh 0, so Solar first batteries fill first. A comfort + # choice like the rest of this method: permission is the mode claim. + return packmode.charge_weights(base, self.pack_modes(), target_w) + + def pack_mode(self, inv) -> "packmode.PackMode": + """OPT-11. This battery's on/off switch and mode, read from the store.""" + return packmode.read(getattr(self.store, "data", None) or {}, inv.name) + + def pack_modes(self) -> dict: + return {inv.name: self.pack_mode(inv) for inv in self.inverters} + + def loop_bound(self, claims): + """OPT-11. `(lo, hi)`: what the batteries may place if the loop decides. + + The sum over live units of each unit's hard limits with its mode + applied (`allocator.loop_bound()`), from THIS cycle's unit claims. At + one inverter every claim is on the site, so it is the mode range + itself, or None when no mode is filed (maintenance) or the range + leaves out 0 W (a fault `resolve()` reports). + """ + if len(self.inverters) <= 1: + modes = [c for c in claims if c.source == "mode"] + if not modes: + return None + lo = max(c.lo for c in modes) + hi = min(c.hi for c in modes) + return (lo, hi) if lo <= 0.0 <= hi else None + bounds = {u: pre_resolve([c for c in claims if c.actuator == u]) + for u in self.units} + return allocator.loop_bound(bounds, live=self.live_units()) + + def _mode_fault_log(self, resolution) -> None: + """OPT-11: say once, per battery, that its mode was dropped as a fault. + + A mode range without 0 W would force a direction, which no mode may do, + so it is never applied (allocator.unit_interval()). main.py never + builds one; this is the line that says so if it ever does. + """ + alloc = resolution.allocation + now = {u for u, _ in getattr(alloc, "mode_faults", ())} + if resolution.mode_fault is not None and self.inverters: + now.add(self.inverters[0].name) + for inv in self.inverters: + if inv.name in now and inv.name not in self._mode_faults: + _LOG.error("Battery mode ignored for %s. Software fault " + "MODE_RANGE.", inv.name) + self.log_event(f"battery mode ignored on {inv.name}, a " + f"software fault", hint=messages.hint( + "mode_fault", unit=inv.name)) + self._mode_faults = now + + def _pack_action(self, what, unit, value, now) -> dict: + """OPT-11: switch one battery on or off, or set its mode. + + Store state, not an option, so it applies on the next cycle with no + restart. Written to the action log. Switching a battery off also stops + its maintenance cycle (the owner's 2026-09-27 answer). Refused, with a + plain reason, while battery modes are off or for an unknown battery or + value. + """ + if not self.modes_on: + return {"error": messages.pack_refused("off")} + inv = next((i for i in self.inverters if i.name == unit), None) + if inv is None: + return {"error": messages.pack_refused("unit")} + if what == "pack_use": + if not isinstance(value, bool): + return {"error": messages.pack_refused("value")} + inv.store_set(packmode.USE_KEY, value) + self.log_event(f"{inv.name} switched {'on' if value else 'off'} " + f"by operator", + hint=messages.hint("pack_on" if value + else "pack_off", unit=inv.name)) + if not value: + self._maint_stop_off(self.maints[self.inverters.index(inv)], + now) + else: + if value not in packmode.MODES: + return {"error": messages.pack_refused("value")} + inv.store_set(packmode.MODE_KEY, value) + self.log_event(f"{inv.name} mode set to {value.replace('_', ' ')} " + f"by operator", + hint=messages.hint("pack_mode", unit=inv.name)) + pm = self.pack_mode(inv) + return {"unit": inv.name, "use": pm.use, "mode": pm.mode} + + def _maint_stop_off(self, machine, now) -> None: + """OPT-11: stop this battery's maintenance cycle because it is off.""" + if machine.phase == IDLE: + return + for msg in machine.abort(now, "battery switched off"): + self.log_event(msg, hint=messages.hint("maint_off", + unit=machine.name)) @property def why_words(self) -> str: @@ -2696,9 +2802,27 @@ class Controller: # Calling `tick()` without the answer this produced is the # two-packs-draining-at-once defect with the interlock present but not # wired, which is why the plan is built here and not inside the loop. - maint_plan = maint_arbitrate(self.maints, now) + # OPT-11: a battery switched off starts no cycle and is left out of the + # slot, so it cannot hold the slot from a battery that is on. One that + # is mid-cycle is stopped, with a log line (the owner's 2026-09-27 + # answer). The switch itself stops it at once (`pack_use`); this is + # the same rule for a store that says Off at start-up. + _off = ({inv.name for inv in self.inverters + if not self.pack_mode(inv).use} if self.modes_on else set()) + for inv, machine in zip(self.inverters, self.maints): + if inv.name in _off: + self._maint_stop_off(machine, now) + _on = [m for inv, m in zip(self.inverters, self.maints) + if inv.name not in _off] + _plan_on = iter(maint_arbitrate(_on, now) if _off + else maint_arbitrate(self.maints, now)) + maint_plan = [None if inv.name in _off else next(_plan_on) + for inv, _m in zip(self.inverters, self.maints)] maint_results = [] for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan): + if blocked is None: + maint_results.append(MaintResult(IDLE, False, None, False, [])) + continue res = machine.tick(now, inv.soc, blocked_by=blocked) for msg in res.events: self.log_event(msg, hint=messages.hint("maintenance", @@ -3564,6 +3688,23 @@ class Controller: actuator=self.actuator_of("maintenance", inv))) + # --- OPT-11: each battery's mode, a soft unit limit at P_MODE --------- + # Filed only with battery modes on, and never on a battery in any + # maintenance phase: maintenance at 60/40 outranks the mode. The range + # always holds 0 W (packmode.mode_range()). pre_resolve() keeps it + # apart from the hard limits, and it gives way to the peak guard's + # site floor (P_PEAK) only as far as the floor needs, so the two never + # meet in a `lo > hi` (arbiter._unit_modes()). + if self.modes_on: + for inv, result in zip(self.inverters, maint_results): + if result.phase != IDLE: + continue + pm = self.pack_mode(inv) + lo, hi = packmode.mode_range(pm, inv.max_w) + claims.append(Claim.limit("mode", P_MODE, lo, hi, pm.key, + actuator=self.actuator_of("mode", + inv))) + # --- the grid-following loop ----------------------------------------- # ⚠️ SAFETY-10: `or self.shadow`. The loop is the leg a shadow run most # needs and the one that is switched off by `auto_start: false`, so @@ -3588,6 +3729,28 @@ class Controller: self._unit_refusals() _probing = {inv.name: self._unit_probe(inv) for inv in self.inverters if inv.refused} + # --- SAFETY-73: each refusing unit's own P_SAFETY claim --------------- + # A hard unit limit that always intersects (OPT-11's P_MODE layer sits + # inside it): closed in the refused direction, so the allocator hands + # the share to the others within their own limits. During its probe it + # is a pin at PROBE_W and the others carry the rest. Filed here, before + # the loop, so OPT-11's loop bound sees it. Unit claims only, and still + # the last unit claims filed, so every unit's claim order is as before. + for inv in self.inverters: + if not inv.refused: + continue + if _probing.get(inv.name): + claims.append(Claim.set("refused", P_SAFETY, + inv.refused * _ctl.PROBE_W, + _ctl.PROBING, + actuator=self.actuator_of("refused", + inv))) + else: + claims.append(Claim.limit("refused", P_SAFETY, + *_refused_interval(inv.refused), + _ctl.REFUSED, + actuator=self.actuator_of("refused", + inv))) if (self.auto or self.shadow) and self.inputs_ok: # ⚠️ prev is the ARBITER's last output, not the loop's own last # wish. If something outranked the loop, that is what the hardware @@ -3649,6 +3812,9 @@ class Controller: refused=self.batt_refused if len(self.inverters) == 1 else self._site_refused(), probe=probe, + # OPT-11: None unless battery modes are on, which is the law + # exactly as it was. + bound=self.loop_bound(claims) if self.modes_on else None, ) if decision.frozen and self.sat_count < self.tuning.saturation_cycles: self.log_event(f"saturation freeze ({self.target:.0f} W vs " @@ -3757,26 +3923,6 @@ class Controller: # ability to DELIVER rests on this argument - a weight cannot stop a # unit being commanded and cannot move a failing unit's load to its # sibling. The `pack` claim filed above is the half that constrains. - # --- SAFETY-73: each refusing unit's own P_SAFETY claim --------------- - # A hard unit limit that always intersects (OPT-11's P_MODE layer sits - # inside it): closed in the refused direction, so the allocator hands - # the share to the others within their own limits. During its probe it - # is a pin at PROBE_W and the others carry the rest. - for inv in self.inverters: - if not inv.refused: - continue - if _probing.get(inv.name): - claims.append(Claim.set("refused", P_SAFETY, - inv.refused * _ctl.PROBE_W, - _ctl.PROBING, - actuator=self.actuator_of("refused", - inv))) - else: - claims.append(Claim.limit("refused", P_SAFETY, - *_refused_interval(inv.refused), - _ctl.REFUSED, - actuator=self.actuator_of("refused", - inv))) _live = self.live_units() while True: resolution = resolve(claims, units=self.units, live=_live, @@ -3796,6 +3942,8 @@ class Controller: *_refused_interval(1 if claims[k].value > 0 else -1), _ctl.REFUSED, actuator=claims[k].actuator) self.allocation = resolution.allocation + if self.modes_on: + self._mode_fault_log(resolution) # --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL -------------- # Both do the same thing - REPLACE the resolution with a 0 W failsafe # which is then written like any other cycle at @@ -5743,8 +5891,10 @@ class Controller: out["home_error"] = True return out - async def handle_action(self, what: str): + async def handle_action(self, what: str, *, unit=None, value=None): now = datetime.now(timezone.utc) + if what in ("pack_use", "pack_mode"): + return self._pack_action(what, unit, value, now) if what == "auto_toggle": self.auto = not self.auto self.store.set("auto", self.auto) diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 32bb31d..1481a54 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -919,9 +919,26 @@ HINTS = { "not_delivering_cleared": "{unit}: not-delivering alarm cleared", "maintenance": "Battery maintenance: {phase}", "maint_paused": "Maintenance charging paused to stay under the peak limit", + "pack_on": "{unit}: battery switched on", + "pack_off": "{unit}: battery switched off, it is asked for 0 W", + "pack_mode": "{unit}: battery mode changed", + "maint_off": "{unit}: maintenance stopped, the battery is switched off", + "mode_fault": "{unit}: battery mode ignored because of a software fault", } +# OPT-11: why a battery switch or mode change was refused (the page shows it). +PACK_REFUSED = { + "off": "Battery modes are off. Turn on “Battery modes” first.", + "unit": "There is no battery with that name.", + "value": "That choice is not possible for this battery.", +} + + +def pack_refused(why: str) -> str: + return PACK_REFUSED[why] + + def hint(key: str, **kw) -> str: """The plain words for one event. `w` is formatted as watts, `sw` as watts with their sign (positive gives power, negative charges).""" diff --git a/goodwe_controller/app/packmode.py b/goodwe_controller/app/packmode.py new file mode 100644 index 0000000..df2641d --- /dev/null +++ b/goodwe_controller/app/packmode.py @@ -0,0 +1,103 @@ +"""OPT-11 P1: each battery's mode and on/off switch, as limits and weights. + +Pure: no I/O, no clock. main.py reads the store, calls these, and files the +result as a `mode` claim (arbiter.P_MODE) and as the charge weights. +DECISIONS/OPT-11-battery-modes.md has the record; OPT-11-design section 1 has +the modes. + +Two rules hold for every mode, and they are what keeps modes safe: + +1. A mode range always holds 0 W. A mode may forbid a direction, never force + one, so batteries never move power in opposite directions because of a mode + (allocator.unit_interval() drops a range without 0 as a software fault). +2. A mode is a limit, not a weight. A weight of 0 does not keep a battery out: + allocator._fill()'s last-resort pass still uses it. So "may not" is a range, + and weights only set the order among batteries that may. + +P1 has no price runs and no forecast (P3, P4). So both modes give power to the +house at any hour, and the only mode that closes a direction is Off. What P1 +changes is the solar order: while the site charges, Leftover solar batteries +weigh 0, so they get solar only once the Solar first batteries are full or at +their power limit. Selling (the owner's 2026-09-27 answer) is a per-battery +choice that arrives with the price runs in P3; `PackMode.sell` and the +`pack_sell` store key are reserved for it and read by nothing yet. +""" + +from dataclasses import dataclass + +from .store import unit_key + +SOLAR_FIRST = "solar_first" +LEFTOVER_SOLAR = "leftover_solar" +CHEAP_HOURS = "cheap_hours" # reserved for P3; not accepted in P1 + +# The modes a battery may be set to in this release, in the order the page +# lists them. The first is the default when the store has no value. +MODES = (SOLAR_FIRST, LEFTOVER_SOLAR) +DEFAULT_MODE = SOLAR_FIRST + +# Store keys, per battery: `unit//` (store.unit_key()). +USE_KEY = "pack_use" +MODE_KEY = "pack_mode" +SELL_KEY = "pack_sell" # reserved for P3 + + +@dataclass(frozen=True) +class PackMode: + """One battery's choice: on or off, its mode, and (P3) selling.""" + use: bool = True + mode: str = DEFAULT_MODE + sell: bool = False # reserved for P3, never read in P1 + + @property + def key(self) -> str: + """The page's word for this state: a mode, or `off`.""" + return self.mode if self.use else "off" + + +def read(data: dict, name: str) -> PackMode: + """Battery `name`'s choice from the store's data. A missing or unknown + value falls back to the default (on, Solar first), never to Off: a store + written by a later version must not switch a battery off here.""" + use = data.get(unit_key(name, USE_KEY), True) + mode = data.get(unit_key(name, MODE_KEY), DEFAULT_MODE) + return PackMode(use=use is not False, + mode=mode if mode in MODES else DEFAULT_MODE, + sell=data.get(unit_key(name, SELL_KEY)) is True) + + +def mode_range(pm: PackMode, rating_w: float) -> tuple: + """`(lo, hi)` in W for this battery's mode claim. Always holds 0 W. + + Off asks for 0 W both ways. In P1 both modes may charge and give power up + to the battery's rating: Leftover solar holds back its power only in the + planned expensive hours, and there are none before P3. + """ + if not pm.use: + return 0.0, 0.0 + r = abs(float(rating_w)) + return -r, r + + +def charge_weights(base, modes: dict, target_w: float): + """The allocator's weights with the solar order applied (design 3.5). + + `base` is packshare.weights()' answer (None for "all 1.0"). While the site + charges (`target_w < 0`), a Leftover solar battery weighs 0 and a Solar + first battery keeps its weight, so the fill gives the Solar first batteries + everything they can take before the last-resort pass reaches the others. + Giving power keeps `base` (P3 adds the expensive-run order). + + When no battery that is on would keep a positive weight (all Leftover + solar, or every Solar first one at 0), `base` is returned unchanged, so the + order among equals stays OPT-09's rather than an equal split. + """ + if float(target_w) >= 0.0 or not modes: + return base + names = sorted(modes) + w = {n: (1.0 if base is None else float(base.get(n, 1.0))) for n in names} + out = {n: (0.0 if modes[n].mode == LEFTOVER_SOLAR else w[n]) + for n in names} + if not any(out[n] > 0.0 for n in names if modes[n].use): + return base + return out diff --git a/goodwe_controller/app/static/app.js b/goodwe_controller/app/static/app.js index f872379..5606652 100644 --- a/goodwe_controller/app/static/app.js +++ b/goodwe_controller/app/static/app.js @@ -308,9 +308,41 @@ if (open) $("#c-details").open = true; } - /* ---------------- Batteries (placeholder until P3) ---------------- */ + /* ---------------- Batteries (placeholder until UX-13 P3) ---------------- */ + // OPT-11 P1: with battery modes on, each card gets an On/Off switch and a + // mode choice (`mode_key` in /status). UX-13 P3 replaces these cards. + const MODES = ["solar_first", "leftover_solar"]; + let packBusy = false; + async function packAct(what, unit, value) { + if (packBusy) return; + packBusy = true; + try { + const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: what, unit: unit, value: value }) }); + const j = await r.json().catch(() => ({})); + toast(r.ok ? t("toast.done") : (j.error || t("toast.failed"))); + } catch (e) { toast(t("toast.failed")); } + finally { packBusy = false; } + refresh(); + } + function modeControls(b) { + const sw = h("button", { class: "btn" + (b.use ? " primary" : ""), type: "button", role: "switch", + "aria-checked": String(b.use), "aria-label": b.name + ": " + t("batteries.use"), + onclick: () => packAct("pack_use", b.name, !b.use) }, + b.use ? t("batteries.on") : t("batteries.off")); + const sel = h("select", { class: "sel", "aria-label": b.name + ": " + t("batteries.mode"), disabled: !b.use, + onchange: (e) => packAct("pack_mode", b.name, e.target.value) }, + ...MODES.map((k) => h("option", { value: k, selected: b.mode_choice === k }, t("batteries.choice." + k + ".name")))); + return [h("div", { class: "row spread", style: "margin-top:8px" }, h("span", { class: "small" }, t("batteries.use")), sw), + h("div", { class: "row spread", style: "margin-top:8px" }, h("span", { class: "small" }, t("batteries.mode")), sel), + h("p", { class: "tiny muted", style: "margin:6px 0 0" }, t("batteries.choice." + b.mode_key + ".description"))]; + } function renderBatteries() { if (!S.batteries) { $("#bcards").replaceChildren(); return; } + // Do not rebuild a card under an open mode list or a request in flight. + if (packBusy || ($("#bcards").contains(document.activeElement) && document.activeElement.tagName === "SELECT")) return; + const modes = S.batteries.some((b) => b.mode_key != null); + $("#batt-desc").textContent = t(modes ? "batteries.with_modes.description" : "batteries.description"); $("#bcards").replaceChildren(...S.batteries.map((b) => h("div", { class: "card" }, h("div", { class: "row spread" }, h("strong", {}, b.name), h("span", { class: "small muted" }, b.capacity_kwh ? b.capacity_kwh + " kWh" : "")), levelBar(b.level_pct, b.name), @@ -318,7 +350,8 @@ h("dt", {}, t("batteries.level")), h("dd", {}, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"), b.stored_kwh != null ? [h("dt", {}, t("batteries.stored")), h("dd", {}, dec(b.stored_kwh.toFixed(1)) + " kWh")] : null, h("dt", {}, t("batteries.now")), h("dd", {}, b.now), - h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode))))); + b.mode_key == null ? [h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode)] : null), + b.mode_key != null ? modeControls(b) : null))); } /* ---------------- control buttons ---------------- */ diff --git a/goodwe_controller/app/static/index.html b/goodwe_controller/app/static/index.html index 14577db..7518ac8 100644 --- a/goodwe_controller/app/static/index.html +++ b/goodwe_controller/app/static/index.html @@ -96,7 +96,7 @@ diff --git a/goodwe_controller/app/ui_text/en.json b/goodwe_controller/app/ui_text/en.json index 8903e83..4b36fe2 100644 --- a/goodwe_controller/app/ui_text/en.json +++ b/goodwe_controller/app/ui_text/en.json @@ -10,6 +10,7 @@ "parts": { "meter.timing": {"name": "Timing"}, "inverters.solar": {"name": "Solar"}, + "inverters.modes": {"name": "Battery modes"}, "tariff.selling": {"name": "Charging and selling on price"}, "tariff.feed": {"name": "Price feed details"}, "safety.peak": {"name": "Peak limit"}, @@ -82,7 +83,14 @@ "toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."}, "offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."}, "setup_only": {"name": "Setup: nothing is controlled yet", "description": "Nothing is sent to any inverter until setup is saved and the add-on restarts.", "control": "Control starts after setup.", "open": "Open Setup"}, - "batteries": {"name": "Batteries", "description": "Battery modes and on and off switches arrive in a later update.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings"}, + "batteries": {"name": "Batteries", "description": "To choose a mode for each battery, turn on battery modes in the settings.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings", + "with_modes": {"description": "Each battery follows its mode, after the peak limit and safety stops."}, + "use": "Use this battery", "on": "On", "off": "Off", + "choice": { + "solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries and covers the house."}, + "leftover_solar": {"name": "Leftover solar", "description": "Charges from solar the other batteries cannot take, and covers the house."}, + "off": {"name": "Off", "description": "The add-on asks this battery for 0 W, unless the peak limit needs it."} + }}, "settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"} }, "choices_later": { @@ -228,6 +236,7 @@ }, "home_words": { "mode_house": "Follows the house", + "modes": {"solar_first": "Solar first", "leftover_solar": "Leftover solar", "off": "Off"}, "attn_shadow": "Shadow mode is on", "attn_shadow_body": "The add-on decides everything, but every inverter stays at 0 W.", "zero_held": "Held at 0 W", diff --git a/goodwe_controller/app/ui_text/nl.json b/goodwe_controller/app/ui_text/nl.json index be52870..11b2ec3 100644 --- a/goodwe_controller/app/ui_text/nl.json +++ b/goodwe_controller/app/ui_text/nl.json @@ -10,6 +10,7 @@ "parts": { "meter.timing": {"name": "Timing"}, "inverters.solar": {"name": "Zonnepanelen"}, + "inverters.modes": {"name": "Batterijmodi"}, "tariff.selling": {"name": "Laden en verkopen op prijs"}, "tariff.feed": {"name": "Details van de prijsfeed"}, "safety.peak": {"name": "Piekgrens"}, @@ -79,7 +80,14 @@ "toast": {"started": "Regeling gestart.", "stopped": "Regeling gestopt.", "done": "Klaar.", "failed": "Dat lukte niet. Probeer opnieuw."}, "offline": {"name": "Add-on niet bereikbaar", "description": "De pagina probeert het elke 3 seconden opnieuw."}, "setup_only": {"name": "Installatie: nog niets wordt geregeld", "description": "Er gaat niets naar een omvormer tot de installatie bewaard is en de add-on herstart.", "control": "De regeling start na de installatie.", "open": "Installatie openen"}, - "batteries": {"name": "Batterijen", "description": "Batterijmodi en aan- en uitschakelaars komen in een latere update.", "level": "Laadniveau", "now": "Nu", "stored": "Opgeslagen", "mode": "Modus", "settings": "Instellingen van omvormers wijzigen"}, + "batteries": {"name": "Batterijen", "description": "Zet batterijmodi aan in de instellingen om per batterij een modus te kiezen.", "level": "Laadniveau", "now": "Nu", "stored": "Opgeslagen", "mode": "Modus", "settings": "Instellingen van omvormers wijzigen", + "with_modes": {"description": "Elke batterij volgt haar modus, na de piekgrens en de veiligheidsstops."}, + "use": "Deze batterij gebruiken", "on": "Aan", "off": "Uit", + "choice": { + "solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen en dekt het huis."}, + "leftover_solar": {"name": "Overschot zonne-energie", "description": "Laadt met zonne-energie die de andere batterijen niet opnemen, en dekt het huis."}, + "off": {"name": "Uit", "description": "De add-on vraagt deze batterij 0 W, behalve als de piekgrens haar nodig heeft."} + }}, "settings": {"name": "Instellingen", "description": "De instellingen staan hieronder per taak.", "open": "Instellingen openen"} }, "choices_later": {"region": {"wallonia": "Wallonië, nog geen standaardwaarde", "brussels": "Brussel, nog geen standaardwaarde"}}, @@ -202,6 +210,7 @@ }, "home_words": { "mode_house": "Volgt het huis", + "modes": {"solar_first": "Eerst zonne-energie", "leftover_solar": "Overschot zonne-energie", "off": "Uit"}, "attn_shadow": "Schaduwmodus staat aan", "attn_shadow_body": "De add-on beslist alles, maar elke omvormer blijft op 0 W.", "zero_held": "Op 0 W gehouden", diff --git a/goodwe_controller/app/uilayout.py b/goodwe_controller/app/uilayout.py index 0c5b30e..9c7f00a 100644 --- a/goodwe_controller/app/uilayout.py +++ b/goodwe_controller/app/uilayout.py @@ -126,6 +126,9 @@ GROUPS = ( # pv.py reads each entity's own unit, so a kW sensor is fine here. Part("solar", (Field("ui.solar", SOLAR_LIST, kind="power", wizard=3),)), + # OPT-11: the switch that turns battery modes on. Each battery's mode + # and on/off switch are runtime state on the Batteries screen. + Part("modes", (Field("battery_modes_enabled", TOGGLE),)), )), Group("tariff", ( Part("", ( diff --git a/goodwe_controller/app/web.py b/goodwe_controller/app/web.py index 007fbdb..93d1a49 100644 --- a/goodwe_controller/app/web.py +++ b/goodwe_controller/app/web.py @@ -33,6 +33,10 @@ not options is a Store key, `setup_done`, written when the wizard's save succeeds, so the page stops opening the wizard by itself (uipage.setup_needed). UX-15's setup-only mode serves these routes from `setuponly.SetupOnly`, which commands nothing; there `/action` answers 409. +OPT-11 P1 adds two `/action` values, `pack_use` and `pack_mode`, which set one +battery's on/off switch or mode in the store (Controller._pack_action). They +are the only actions that name a battery; the configuration routes still reach +none of them. """ import io @@ -53,6 +57,9 @@ from .uimodel import DeriveError, options_to_ui _LOG = logging.getLogger("goodwe.web") +# OPT-11: the per-battery actions, which name a battery and a value. +PACK_ACTIONS = ("pack_use", "pack_mode") + # UX-13 P1: the page lives in app/static/ as three files - index.html, # app.css, app.js - plus the page words from app/ui_text/.json (English # and, since P5, Dutch; the page picks by the browser's language). They are @@ -146,7 +153,17 @@ def build_app(controller, selfcfg=None) -> web.Application: # UX-15: nothing to start or stop until setup is saved. return web.json_response( {"ok": False, "error": messages.row_setup_only()}, status=409) - result = await controller.handle_action(what) + if what in PACK_ACTIONS: + # OPT-11: one battery's switch or mode. Store state for the named + # battery, applied on the next cycle; nothing is written here. + result = await controller.handle_action( + what, unit=body.get("unit"), value=body.get("value")) + if isinstance(result, dict) and "error" in result: + return web.json_response({"ok": False, + "error": result["error"]}, + status=400) + else: + result = await controller.handle_action(what) if result is None: raise web.HTTPBadRequest(text=f"unknown action {what!r}") return web.json_response({"ok": True, "result": result}) diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index 3f560ae..6744977 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -146,6 +146,11 @@ options: # options blob, and the upgrade leaves an add-on that cannot start. inverters: [] estop_fitted: false + # OPT-11: battery modes. Off by default, so an install that has not turned + # them on behaves exactly as before. When on, each battery gets an on/off + # switch and a mode on the Batteries screen, kept in the add-on's store. + # DECISIONS/OPT-11-battery-modes.md. + battery_modes_enabled: false # TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT # DECIDES ANYTHING. Optional, empty on every install that does not set it, and @@ -943,6 +948,7 @@ schema: capacity_kwh: float(0.1,200)? estop_fitted: bool + battery_modes_enabled: bool # TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike # `price_now_entity`/`price_avg_entity`, which are legal only while # `price_provider` is `off` and make the add-on REFUSE TO START otherwise. diff --git a/goodwe_controller/test_alloc.py b/goodwe_controller/test_alloc.py index fcda789..a04c55a 100644 --- a/goodwe_controller/test_alloc.py +++ b/goodwe_controller/test_alloc.py @@ -1041,6 +1041,133 @@ check("...and the three that are NOT caught are reported as such, with the " all(b is False for _n, b, _w in MUT[-3:])) + +print() +print("13. OPT-11: the mode layer - applied at or below 35, released above it") +# A mode is a SOFT unit limit beside the hard ones (UnitBounds.mode_*). The +# allocator applies it only when the site target was decided at or below the +# mode's own priority (P_MODE, 35), drops it when its range leaves out 0 W, +# and never lets it narrow a hard limit into nothing. +from app.allocator import (MODE_FAULT, MODE_OUTRANKED, MODE_RELEASED, # noqa: E402 + loop_bound, unit_interval) +from app.arbiter import P_MODE # noqa: E402 + + +def M(lo=-RATING, hi=RATING, mlo=-inf, mhi=inf, prio=P_MODE, **kw): + """A unit's bounds with a mode range beside its hard limits.""" + b = B(lo, hi, **kw) + return UnitBounds(lo=b.lo, hi=b.hi, gate=b.gate, pinned_w=b.pinned_w, + pinned_by=b.pinned_by, contradiction=b.contradiction, + mode_lo=mlo, mode_hi=mhi, mode_priority=prio) + + +_off = {A: M(), Bee: M(mlo=0.0, mhi=0.0)} +_at35 = plan(3000, _off, deciding_priority=P_MODE) +_at50 = plan(3000, _off, deciding_priority=P_PEAK) +_at10 = plan(3000, _off, deciding_priority=P_LOOP) +print(f" B off, 3000 W: decided at 10 {_at10.explain()} | at 35 " + f"{_at35.explain()} | at 50 {_at50.explain()}") +check("⭐ RELEASE AT 35: a target decided at P_MODE itself keeps the mode - " + "B (Off) is held at 0 W and A carries all 3000 W", + _at35.shares == {A: 3000, Bee: 0} and _at35.unmet_w == 0) +check("...and below it too (the loop, 10): the same split", + _at10.shares == _at35.shares) +check("⭐ RELEASE AT 50: a target decided by the peak floor releases the mode " + "and B takes its weighted half", + _at50.shares == {A: 1500, Bee: 1500} and _at50.unmet_w == 0) +check("...and the boundary is exactly 35: at 36 the mode is released", + plan(3000, _off, deciding_priority=P_MODE + 1).shares + == {A: 1500, Bee: 1500}) +check("...and no deciding priority named means the mode applies (the safe " + "reading for a direct caller)", + plan(3000, _off).shares == {A: 3000, Bee: 0}) +check("unit_interval() reports why: None when applied, MODE_RELEASED above " + "35, and the hard limits always", + unit_interval(_off[Bee], P_MODE) == (0.0, 0.0, None) + and unit_interval(_off[Bee], P_PEAK) == (-RATING, RATING, MODE_RELEASED)) + +# CONTAINS 0: a mode that would FORCE a direction is a software fault. +_force = {A: M(mlo=500.0, mhi=RATING), Bee: M()} +_f = plan(-1000, _force, deciding_priority=P_LOOP) +print(f" A's mode is [500, 5000] (leaves out 0 W), site charges 1000 W: " + f"{_f.explain()}, faults {_f.mode_faults}") +check("⭐ CONTAINS 0: a mode range that leaves out 0 W is dropped and named " + "as a fault, so REFUSE_SIGN stays unreachable: both units charge and " + "the split is allowed", + _f.ok and _f.mode_faults == ((A, MODE_FAULT),) + and all(v <= 0 for v in _f.shares.values()) + and sum(_f.shares.values()) == -1000) +_f_obeyed = plan(-1000, {A: B(500.0, RATING), Bee: B(-RATING, RATING)}) +check("...and that is not a hypothetical: the same range obeyed as a hard " + "limit forces A to give 500 W while B charges 1500 W, which the " + "allocator REFUSES as circulating power", + not _f_obeyed.ok and REFUSE_SIGN in _f_obeyed.refused) +check("...and a mode whose own lo is above its own hi is the same fault", + unit_interval(M(mlo=300.0, mhi=-300.0))[2] == MODE_FAULT) +_out = unit_interval(M(lo=-2000.0, hi=-500.0, mlo=0.0, mhi=0.0)) +check("a hard limit that leaves the mode no room wins outright " + "(MODE_OUTRANKED): the unit keeps its hard interval", + _out == (-2000.0, -500.0, MODE_OUTRANKED)) +check("a unit with no mode (every unit with battery modes off) gets exactly " + "its hard limits, and plan() is the same record as before the mode " + "fields existed", + unit_interval(B(-700.0, 900.0)) == (-700.0, 900.0, None) + and plan(1234, TWO, deciding_priority=P_PEAK) == plan(1234, TWO)) + +# The sweep: sum and sign kept whatever the modes, the target and the +# deciding priority; every share inside its effective interval. +_rng = random.Random(1135) +_bad, _nm = [], 0 +for _ in range(20000): + _units = {} + for u in (A, Bee, "inverter-c")[:_rng.choice((2, 3))]: + r = _rng.choice((2000.0, 3600.0, 5000.0)) + mlo = _rng.choice((-r, -700.0, 0.0)) + mhi = _rng.choice((0.0, 450.0, r)) + _units[u] = M(-r, r, mlo, mhi) + _d = _rng.choice((P_LOOP, P_ARBITRAGE, P_MODE, P_MODE + 1, P_PEAK, None)) + _t = _rng.randint(-15000, 15000) + _p = plan(_t, _units, deciding_priority=_d) + _nm += 1 + _eff = {u: unit_interval(b, _d)[:2] for u, b in _units.items()} + _sh = _p.shares + if (not _p.ok or sum(_sh.values()) + _p.unmet_w != _p.sum_w + or (any(v > 0 for v in _sh.values()) + and any(v < 0 for v in _sh.values())) + or any(not (_eff[u][0] <= _sh[u] <= _eff[u][1]) for u in _sh) + or (_d is not None and _d > P_MODE + and any(_eff[u] != (b.lo, b.hi) for u, b in _units.items()))): + _bad.append((_t, _d, _p.explain())) +for x in _bad[:5]: + print(f" BAD {x}") +check(f"over {_nm} random ticks (2-3 units, mode ranges holding 0 W, targets " + f"+-15 kW, deciding at 10/30/35/36/50/None) the shares sum to S with " + f"`unmet`, never split the sign, and each stays inside its interval - " + f"the mode's above 35 released, applied at or below", + not _bad and _nm == 20000) + +# loop_bound(): what the loop may ask when it decides. +_lb = loop_bound({A: M(mlo=-3000.0, mhi=0.0), Bee: M(mlo=0.0, mhi=0.0)}) +check("loop_bound() sums each unit's interval with its mode applied: A may " + "only charge 3000 W, B is off, so the loop may ask [-3000, 0]", + _lb == (-3000.0, 0.0)) +check("...a unit that is not live adds nothing", + loop_bound({A: M(), Bee: M()}, live=(A,)) == (-RATING, RATING)) +check("...a PINNED unit adds the span from 0 to its pin, so a 200 W probe " + "(SAFETY-73: the closed limit becomes a pin) stays reachable", + loop_bound({A: M(gate=P_SAFETY, pin=200.0), + Bee: M(mlo=0.0, mhi=0.0)}) == (0.0, 200.0)) +check("...and the pin is clamped into the hard limits first, as plan() " + "clamps it", + loop_bound({A: M(lo=-RATING, hi=0.0, gate=P_SAFETY, pin=200.0), + Bee: M(mlo=0.0, mhi=0.0)}) == (0.0, 0.0)) +check("...a mode that leaves out 0 W adds the hard interval (dropped, as in " + "plan()), so every term holds 0 and so does the sum", + loop_bound({A: M(mlo=500.0, mhi=900.0), Bee: M(mlo=0.0, mhi=0.0)}) + == (-RATING, RATING)) +check("...and a unit contradicting itself adds nothing", + loop_bound({A: M(contra=True), Bee: M(mlo=0.0, mhi=0.0)}) == (0.0, 0.0)) + print() if fails: print(f"{len(fails)} FAILED: {', '.join(fails)}") diff --git a/goodwe_controller/test_arbiter.py b/goodwe_controller/test_arbiter.py index 57fa525..071fb64 100644 --- a/goodwe_controller/test_arbiter.py +++ b/goodwe_controller/test_arbiter.py @@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what " Claim.limit("device", P_SAFETY, -1, 1).source == "device") print() -print("ARB-03 2. the split: twelve sources, each classified where it is BUILT") +print("ARB-03 2. the split: thirteen sources, each classified where it is BUILT") # The classification is graded against what each claim is ABOUT, restated here # so that the table in arbiter.py and the expectation in a test are not the same # sentence read twice. test_peakguard.py part 10f pins this set against main.py's @@ -193,16 +193,20 @@ UNIT_SOURCES = { "refused": "THIS battery's own reading gave nothing against THIS unit's " "own standing command, so that direction is closed on THIS " "unit and its share goes to the others. Filed at N>1 only", + # OPT-11, added on purpose (test_peakguard.py part 10f says why). + "mode": "THIS battery's mode, chosen by its owner. A soft unit limit " + "that stage 1 keeps apart from the hard ones and stage 3 " + "releases when a claim above P_MODE decides the site target", } for _src, _why in sorted(SITE_SOURCES.items()): print(f" site {_src:<11} {_why}") for _src, _why in sorted(UNIT_SOURCES.items()): print(f" unit {_src:<11} {_why}") -check("all TWELVE claim sources are classified, and only those twelve", +check("all THIRTEEN claim sources are classified, and only those thirteen", set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES)) check("the six that are about the GRID CONNECTION are site claims", {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES)) -check("the six that are about a PACK, a DEVICE or one write are per-inverter", +check("the seven that are about a PACK, a DEVICE or one write are per-inverter", {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES)) check("a source nobody classified cannot be routed at all - actuator_for() " "raises rather than defaulting to the site, because defaulting to the " @@ -220,7 +224,7 @@ check("with one inverter every source routes to the site, per-inverter ones " all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE)) check("...and with none configured at all, likewise", all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE)) -check("with TWO the identity breaks and the split becomes real: the six " +check("with TWO the identity breaks and the split becomes real: the seven " "per-inverter sources route to the unit this controller drives, the six " "site sources stay on the grid connection", {s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE} @@ -540,6 +544,252 @@ check("...and a correctly routed cycle is unaffected, which is what says this " resolve(SITE_SIDE, units=TWO).allocated is True) + +print() +print("OPT-11 9. the mode layer: a unit `hi` never meets the site `lo`") +# A battery's mode is a SOFT unit limit at P_MODE (35). Stage 1 keeps it apart +# from the hard limits; stage 3 applies it only while the site target was +# decided at or below P_MODE. So the peak guard's site floor (P_PEAK) releases +# every mode and SAFETY-25 section 0a's cross-inverter contradiction - 0 W in a +# billed period - still cannot be written down. +from app.arbiter import MODE_SOURCE, P_MODE # noqa: E402 +from app.allocator import MODE_FAULT, MODE_RELEASED # noqa: E402 + + +def _two(loop_w, *, peak_lo=None, modes=None, rating=2000.0, arb=None): + """A two-inverter cycle's claims: ratings, each unit's mode, the loop, + optionally the peak floor and an arbitrage set.""" + cl = [] + for u in TWO: + cl.append(Claim.limit("supervised", P_SAFETY, -rating, rating, + "max_w", actuator=u)) + for u, (lo, hi) in (modes or {}).items(): + cl.append(Claim.limit(MODE_SOURCE, P_MODE, lo, hi, "mode", actuator=u)) + cl.append(Claim.set("loop", P_LOOP, loop_w)) + if arb is not None: + cl.append(Claim.set("arbitrage", P_ARBITRAGE, arb)) + if peak_lo is not None: + cl.append(Claim.limit("peak", P_PEAK, lo=peak_lo)) + return cl + + +check("P_MODE sits between arbitrage and the peak guard, so a trade stays " + "inside each mode and the peak floor releases them all", + P_ARBITRAGE < P_MODE < P_PEAK and P_MODE < P_MAINT_SHAPE) +_off_b = {"inverter-b": (0.0, 0.0)} +r = resolve(_two(-500.0, peak_lo=3000.0, modes=_off_b), units=TWO) +print(f" peak floor 3000 W, loop asks -500 W, B off: {r.explain()}, " + f"deciding {r.deciding_priority}, shares {r.shares}") +check("⭐ at N=2 a mode `hi` of 0 W on B and the peak guard's site `lo` of " + "3000 W do NOT contradict: no 0 W stop, the floor decides S (priority " + "50), the mode is released, and the shares still sum to S", + not r.contradiction and r.allocated and r.target_w == 3000.0 + and r.deciding_priority == P_PEAK + and sum(r.shares.values()) == 3000 and r.allocation.unmet_w == 0) +_forbidden = [c if c.source != MODE_SOURCE + else Claim.limit(MODE_SOURCE, P_MODE, c.lo, c.hi, + actuator=ACTUATOR_SITE) + for c in _two(-500.0, peak_lo=3000.0, modes=_off_b)] +_rf = site_resolve([c for c in _forbidden if c.actuator == ACTUATOR_SITE]) +check("...and the SAME mode filed as a SITE limit is exactly the fault the " + "layer exists to keep out: stage 2 then reads `lo` 3000 > `hi` 0 and " + "stops the site at 0 W (so the check above sees the difference)", + _rf.contradiction and _rf.target_w == 0.0) +check("...and resolve() refuses that mis-filed mode at N=2 (a UNIT claim on " + "the site), rather than dividing a number that may be wrong", + resolve(_forbidden, units=TWO).allocated is False) + +# The sweep: every mode range that holds 0 W, every loop ask, every floor. +_bad = [] +_n = 0 +_spans = [(0.0, 0.0), (-2000.0, 0.0), (0.0, 2000.0), (-2000.0, 2000.0), + (-700.0, 300.0)] +for _ma in _spans: + for _mb in _spans: + for _loop in (-5000.0, -1200.0, 0.0, 800.0, 3900.0): + for _peak in (None, 0.0, 1500.0, 3000.0, 4000.0): + _n += 1 + _r = resolve(_two(_loop, peak_lo=_peak, + modes={"inverter-a": _ma, + "inverter-b": _mb}), units=TWO) + _al = _r.allocation + _sh = list(_r.shares.values()) + if (_r.contradiction or not _r.allocated + or sum(_sh) + _al.unmet_w != _al.sum_w + or (any(x > 0 for x in _sh) and any(x < 0 for x in _sh)) + or (_peak is not None and _r.target_w < _peak)): + _bad.append((_ma, _mb, _loop, _peak, _r.explain(), + _al.explain())) +for x in _bad[:5]: + print(f" BAD {x}") +check(f"over {_n} two-inverter cycles (5x5 mode ranges holding 0 W, 5 loop " + f"asks, 5 peak floors) no cycle contradicts, refuses, loses a watt of " + f"the sum, splits the sign, or goes under the peak floor", + not _bad and _n == 625) + +check("deciding_priority: the loop alone decides at 10", + resolve(_two(800.0), units=TWO).deciding_priority == P_LOOP) +check("...a trade decides at 30, so the modes still apply to it", + resolve(_two(800.0, arb=-1500.0), units=TWO).deciding_priority + == P_ARBITRAGE) +check("...a peak floor that does not bind leaves the loop deciding at 10", + resolve(_two(3500.0, peak_lo=1000.0), units=TWO).deciding_priority + == P_LOOP) +check("...and a peak floor that binds decides at 50", + resolve(_two(500.0, peak_lo=1000.0), units=TWO).deciding_priority + == P_PEAK) +_r35 = resolve(_two(3000.0, modes=_off_b), units=TWO) +check("with the loop deciding, B's Off mode holds: B gets 0 W and A carries " + "what it can, the rest reported as unmet", + _r35.shares == {"inverter-a": 2000, "inverter-b": 0} + and _r35.allocation.unmet_w == 1000) + +_ub = pre_resolve([Claim.limit("supervised", P_SAFETY, -2000, 2000), + Claim.limit(MODE_SOURCE, P_MODE, 0.0, 0.0)]) +check("stage 1 keeps the mode APART from the hard limits: the unit's own " + "interval is still its rating and the mode travels beside it", + (_ub.lo, _ub.hi, _ub.mode_lo, _ub.mode_hi, _ub.mode_priority) + == (-2000, 2000, 0.0, 0.0, P_MODE) and not _ub.contradiction) +_ub2 = pre_resolve([Claim.limit("supervised", P_SAFETY, -2000, 2000), + Claim.limit(MODE_SOURCE, P_MODE, 500.0, 2000.0)]) +check("...so a mode that does not hold 0 W cannot make the unit contradict " + "itself at stage 1; stage 3 drops it as a software fault instead", + not _ub2.contradiction + and allocate(1000.0, {"inverter-a": _ub2, "inverter-b": UnitBounds()})[1] + and resolve(_two(1000.0, modes={"inverter-a": (500.0, 2000.0)}), + units=TWO).allocation.mode_faults + == (("inverter-a", MODE_FAULT),)) +_ubm = pre_resolve([Claim.limit("supervised", P_SAFETY, -2000, 2000), + Claim.set("maintenance", P_MAINTENANCE, 2500.0, "drain"), + Claim.limit(MODE_SOURCE, P_MODE, 0.0, 0.0)]) +check("...and a unit's own maintenance SET (60) gates the mode out, as it " + "gates every lower limit: maintenance outranks the mode", + _ubm.mode_priority is None and _ubm.pinned_by == "maintenance") + +print() +print("OPT-11 10. at N=1 the mode bounds the site target, by the same rule") +_one = [Claim.limit("supervised", P_SAFETY, -5000, 5000, "max_w"), + Claim.limit(MODE_SOURCE, P_MODE, 0.0, 0.0, "off")] +_r1 = resolve(_one + [Claim.set("loop", P_LOOP, 1200.0)], units=ONE) +check("the loop deciding: an Off battery's site target is 0 W, named as the " + "mode's doing, with no contradiction", + _r1.target_w == 0.0 and not _r1.contradiction + and _r1.bound_by == "mode(off)" and _r1.allocated) +_r1p = resolve(_one + [Claim.set("loop", P_LOOP, 1200.0), + Claim.limit("peak", P_PEAK, lo=2000.0)], units=ONE) +check("...a binding peak floor releases it (2000 W, no contradiction), " + "which at N=1 would otherwise be `lo` 2000 > `hi` 0 and a 0 W stop", + _r1p.target_w == 2000.0 and not _r1p.contradiction + and _r1p.deciding_priority == P_PEAK) +_r1a = resolve(_one + [Claim.set("arbitrage", P_ARBITRAGE, -3000.0)], + units=ONE) +check("...a trade (30) stays inside it", _r1a.target_w == 0.0) +_r1f = resolve([Claim.limit(MODE_SOURCE, P_MODE, 100.0, 900.0), + Claim.set("loop", P_LOOP, -1200.0)], units=ONE) +check("...and a mode range without 0 W is dropped and reported, never " + "obeyed: the target stays the loop's -1200 W", + _r1f.target_w == -1200.0 and _r1f.mode_fault is not None) +check("...and with no mode claim at all, stage 2's answer is untouched " + "(the N=1 path with battery modes off)", + resolve([Claim.set("loop", P_LOOP, 1200.0)], units=ONE).target_w + == 1200.0) + +print() +print("OPT-11 11. a mode gives way only as far as the peak limit needs") +# Review of PR #149. The first cut released the modes only when a limit above +# P_MODE bound S. A trade (30) that won above the peak floor then kept the +# modes, and the units placed less than the floor: the mode beat the peak +# guard (a billed peak). And a peak limit that merely capped a charge released +# the modes, so an Off battery was charged from the grid. The rule now: clamp +# into what the modes can place, then back into the limits above the mode. +_offb = {"inverter-b": (0.0, 0.0)} + + +def _placed(r): + return sum(r.shares.values()) + + +_ra = resolve(_two(0.0, arb=3000.0, peak_lo=2000.0, rating=1500.0, + modes=_offb)[:-1] + [ + Claim.set("arbitrage", P_ARBITRAGE, 3000.0), + Claim.limit("peak", P_PEAK, lo=2000.0)], units=TWO) +print(f" trade 3000 W over a 2000 W floor, A 1500 W, B off: {_ra.shares}") +check("⭐ a trade above the peak floor with B Off: the units still place the " + "floor (2000 W), A to its rating and B only the 500 W A cannot give", + _placed(_ra) == 2000 and _ra.shares == {"inverter-a": 1500, + "inverter-b": 500}) +_r1t = resolve(_one + [Claim.set("arbitrage", P_ARBITRAGE, 3000.0), + Claim.limit("peak", P_PEAK, lo=2000.0)], units=ONE) +check("⭐ ...and at N=1 an Off battery under a trade gives the peak floor " + "(2000 W), not 0 W", _r1t.target_w == 2000.0 + and _r1t.bound_by == "peak") +_rc = resolve(_two(0.0, arb=-3000.0, peak_lo=-1000.0, modes=_offb), + units=TWO) +check("⭐ a grid charge the peak limit caps at 1000 W does not release B's " + "Off: A takes it all and B stays at 0 W", + _rc.shares == {"inverter-a": -1000, "inverter-b": 0}) +_r1c = resolve(_one + [Claim.set("arbitrage", P_ARBITRAGE, -3000.0), + Claim.limit("peak", P_PEAK, lo=-1000.0)], units=ONE) +check("...and at N=1 an Off battery is not charged from the grid under a " + "capped trade", _r1c.target_w == 0.0) +_rp = resolve(_two(500.0, peak_lo=1500.0, modes=_offb), units=TWO) +check("a peak floor A can carry alone leaves B Off at 0 W", + _rp.shares == {"inverter-a": 1500, "inverter-b": 0} + and _rp.target_w == 1500.0) +_rall = resolve(_two(0.0, peak_lo=3000.0, + modes={"inverter-a": (0.0, 0.0), + "inverter-b": (0.0, 0.0)}), units=TWO) +check("every battery Off and a 3000 W peak floor: the floor is placed, " + "split between them, and nothing contradicts", + _placed(_rall) == 3000 and not _rall.contradiction + and _rall.allocation.refused is None) + +import random as _rnd # noqa: E402 +_g = _rnd.Random(149) +_worse, _needless, _sign, _nn = [], [], [], 0 +for _ in range(20000): + _units = ("inverter-a", "inverter-b", "inverter-c")[:_g.choice((2, 3))] + _cl = [] + for _u in _units: + _rt = _g.choice((1000.0, 2500.0)) + _cl.append(Claim.limit("supervised", P_SAFETY, -_rt, _rt, + actuator=_u)) + if _g.random() < 0.2: + _cl.append(Claim.limit("pack", P_SAFETY, + *_g.choice(((0.0, inf), (-inf, 0.0))), + actuator=_u)) + _cl.append(Claim.limit(MODE_SOURCE, P_MODE, + _g.choice((0.0, -_rt)), _g.choice((0.0, _rt)), + actuator=_u)) + _src = _g.choice(("loop", "arbitrage")) + _cl.append(Claim.set(_src, P_LOOP if _src == "loop" else P_ARBITRAGE, + _g.uniform(-7000.0, 7000.0))) + _pk = _g.uniform(-4000.0, 6000.0) + _cl.append(Claim.limit("peak", P_PEAK, lo=_pk)) + _r = resolve(_cl, units=_units) + _r0 = resolve([c for c in _cl if c.source != MODE_SOURCE], units=_units) + _nn += 1 + if _r.allocation.refused is not None: + _sign.append(_cl) + continue + if _placed(_r) < _pk - 1 and _placed(_r0) >= _pk - 1: + _worse.append(_cl) + # An Off unit gives power only when the peak floor is short without it. + _off = [c.actuator for c in _cl if c.source == MODE_SOURCE + and c.lo == 0.0 and c.hi == 0.0] + # What the units whose mode lets them give power can give, within it. + _on_cap = sum(max(0.0, min(_r.unit_bounds[u].hi, _r.unit_bounds[u].mode_hi)) + for u in _units if _r.unit_bounds[u].mode_hi > 0.0 + and _r.unit_bounds[u].lo <= 0.0) + if any(_r.shares.get(u, 0) > 0 for u in _off) and _pk <= _on_cap - 1: + _needless.append(_cl) +check(f"over {_nn} random N=2/3 cycles (modes, packs, a trade or the loop, a " + f"peak floor): no split is refused, the placed power never falls under " + f"the floor where the same cycle without modes met it, and an Off " + f"battery gives power only when the others cannot carry the floor " + f"({len(_sign)}, {len(_worse)}, {len(_needless)})", + not _sign and not _worse and not _needless) + print() if fails: print(f"{len(fails)} FAILED: {', '.join(fails)}") diff --git a/goodwe_controller/test_control.py b/goodwe_controller/test_control.py index 14cec8e..9db66c2 100644 --- a/goodwe_controller/test_control.py +++ b/goodwe_controller/test_control.py @@ -1156,6 +1156,142 @@ check("...while the GAIN does own it: 0.6 -> 0.3 -> 0.2 falls monotonically, at _R["gain03"]["over"] < _R["rail"]["over"] and _R["gain02"]["over"] < _R["gain03"]["over"]) +print() +print("OPT-11: the loop is told what the battery modes allow") +# compute(bound=(lo, hi)) is the site output bound: the sum over live units of +# (hard limits with the mode applied), allocator.loop_bound(). Without it the +# loop asks for watts no battery may give, the allocator reports them unmet, +# and the saturation freeze parks the target on them (SAFETY-73 section 1). +import random as _random # noqa: E402 +from app.control import MODE_BOUND # noqa: E402 +from named_absence import present # noqa: E402 + +TB = Tuning(gain=0.6, max_w=5000.0, slew_w=400.0, deadband_w=15.0, + step_w=10, target_grid_w=-10.0) + + +def closed_loop(cycles, load, *, bound=None, deliver=None, change=None): + """A plant that follows 94 % of the command each cycle, capped at + `deliver` (what the allocator can place). `change(i)` may return a new + bound. Rows: (command, actual, sat_count, frozen, reason, i_w).""" + prev, actual, sat, i_w, rows = 0.0, 0.0, 0, None, [] + for i in range(cycles): + if change is not None: + bound = change(i, bound) + d = compute(prev, load - actual, actual, TB, sat, i_w, bound=bound) + prev, sat, i_w = d.target_w, d.sat_count, d.i_w + cmd = prev if deliver is None else max(deliver[0], + min(deliver[1], prev)) + actual = actual + 0.94 * (cmd - actual) + rows.append((prev, actual, sat, d.frozen, d.reason, d.i_w)) + return rows + + +# (a) One battery may give 800 W (the other is Off), the house takes 3000 W. +_with = closed_loop(40, 3000.0, bound=(-800.0, 800.0)) +_without = closed_loop(40, 3000.0, deliver=(-800.0, 800.0)) +print(f" house 3000 W, modes allow 800 W: with the bound the command ends " + f"at {nth(_with, -1)[0]:.0f} W (sat {max(r[2] for r in _with)}, reason " + f"{nth(_with, -1)[4]}); without it {nth(_without, -1)[0]:.0f} W with " + f"{sum(r[3] for r in _without)} frozen cycles") +check("⭐ THE LOOP BOUND HOLDS: every command and every integrator value " + "stays inside [-800, 800], and the command settles at the bound", + all(-800.0 <= r[0] <= 800.0 and -800.0 <= r[5] <= 800.0 for r in _with) + and present(nth(_with, -1)) and nth(_with, -1)[0] == 800.0 + and nth(_with, -1)[4] == MODE_BOUND) +_big = [] +_pv, _iw, _sat = 0.0, None, 0 +for _i in range(30): + _d = compute(_pv, 12000.0, _pv, TB, _sat, _iw, bound=(-800.0, 800.0)) + _pv, _iw, _sat = _d.target_w, _d.i_w, _d.sat_count + _big.append(_d) +check("...and a house far beyond what the modes allow (12 kW against 800 W) " + "does not read as the loop winding into its rail: SAFETY-03's flags " + "stay false on every cycle", + _big and not any(d.i_clamped or d.clamped for d in _big) + and all(d.target_w <= 800.0 for d in _big)) +check("⭐ NO SATURATION FREEZE from unmet watts: sat_count never reaches the " + "freeze and no cycle is frozen", + not any(r[3] for r in _with) + and max(r[2] for r in _with) < TB.saturation_cycles) +check("...and the same site WITHOUT the bound is the failure it removes: the " + "loop asks past 800 W, the plant cannot follow and the freeze engages", + any(r[3] for r in _without) and max(r[0] for r in _without) > 800.0) + +# (b) Every battery switched off: the bound is [0, 0]. +_off = closed_loop(20, 2500.0, bound=(0.0, 0.0)) +check("all batteries off: the loop asks 0 W on every cycle, with the " + "integrator at 0, and never freezes", + all(r[0] == 0.0 and r[5] == 0.0 and not r[3] for r in _off)) + +# (c) A mode closes while the battery gives 2000 W: the ask drops at once. +_close = closed_loop(30, 2000.0, bound=(-5000.0, 5000.0), + change=lambda i, b: (0.0, 0.0) if i >= 20 else b) +check("a mode that closes mid-run drops the ask to 0 W on the same cycle " + "(no slew steps of unmet watts) and carries no integrator past it", + present(nth(_close, 20)) and nth(_close, 19)[0] > 1500.0 + and nth(_close, 20)[0] == 0.0 + and nth(_close, 20)[5] == 0.0 and not any(r[3] for r in _close[20:])) + +# (d) Rounding never steps outside the bound. +_q = compute(1000.0, 3000.0, 1000.0, TB, 0, 1236.0, bound=(-5000.0, 1236.0)) +check("quantising to 10 W does not step outside the bound: 1236 W becomes " + "1230 W, not 1240 W", _q.target_w == 1230.0) +_qn = compute(-1000.0, -3000.0, -1000.0, TB, 0, -1236.0, + bound=(-1236.0, 5000.0)) +check("...on the charge side too: -1236 W becomes -1230 W", + _qn.target_w == -1230.0) + +# (e) A bound that leaves out 0 W is a fault and is not obeyed. +_rngc = _random.Random(1131) +_same, _nb = True, 0 +for _ in range(4000): + args = (_rngc.uniform(-5000, 5000), _rngc.uniform(-4000, 4000), + _rngc.uniform(-5000, 5000), TB, _rngc.randint(0, 5), + _rngc.choice((None, _rngc.uniform(-6000, 6000)))) + _nb += 1 + if (compute(*args, bound=(200.0, 900.0)) != compute(*args) + or compute(*args, bound=None) != compute(*args)): + _same = False +check(f"a bound that leaves out 0 W is ignored, and bound=None is the law as " + f"it was: {_nb} random cycles give the identical Decision", + _same and _nb == 4000) + +# (f) Inside the bound nothing changes: a bound wider than every command. +_wide = True +for _ in range(4000): + args = (_rngc.uniform(-4000, 4000), _rngc.uniform(-3000, 3000), + _rngc.uniform(-4000, 4000), TB, _rngc.randint(0, 5), + _rngc.choice((None, _rngc.uniform(-4000, 4000)))) + if compute(*args, bound=(-9000.0, 9000.0)) != compute(*args): + _wide = False +check("a bound wider than the rail changes no Decision (4000 random cycles)", + _wide) + +# (g) Review of PR #149: a bound EQUAL to the rail - every battery on, the +# default once modes are on - must leave SAFETY-03 exactly as it was. Before +# the fix the mode clamp ran first and took `i_clamped` away from the rail. +_rail_same, _rail_hits = True, 0 +for _tb in (TB, Tuning(gain=0.6, max_w=5000.0, slew_w=400.0, deadband_w=15.0, + step_w=10, target_grid_w=-10.0, + integrator_max_w=7000.0)): + _lim = _tb.max_w + for _ in range(4000): + args = (_rngc.uniform(-5000, 5000), _rngc.uniform(-12000, 12000), + _rngc.uniform(-5000, 5000), _tb, _rngc.randint(0, 5), + _rngc.choice((None, _rngc.uniform(-8000, 8000)))) + _d0 = compute(*args) + _rail_hits += _d0.i_clamped + if compute(*args, bound=(-_lim, _lim)) != _d0: + _rail_same = False +check(f"a bound equal to the rail changes no Decision, SAFETY-03's i_clamped " + f"included ({_rail_hits} of 8000 random cycles wind into the rail), and " + f"with integrator_max_w above max_w too", _rail_same and _rail_hits > 100) +_one_side = compute(4000.0, 12000.0, 4000.0, TB, 0, 4990.0, + bound=(0.0, 5000.0)) +check("...and a bound that closes only the other side still lets the rail " + "flag a loop winding into it", _one_side.i_clamped) + print() if fails: print(f"{len(fails)} FAILED: {', '.join(fails)}") diff --git a/goodwe_controller/test_modes.py b/goodwe_controller/test_modes.py new file mode 100644 index 0000000..2f47c4f --- /dev/null +++ b/goodwe_controller/test_modes.py @@ -0,0 +1,488 @@ +"""Runnable check for OPT-11 P1, battery modes. `python3 test_modes.py` + +Each battery gets an on/off switch and a mode (Solar first, Leftover solar), +kept in the store and filed as a SOFT unit limit at P_MODE (35). Drives the +REAL `Controller` (read_inputs + cycle) through tools/opt11_identity.py's +harness, in simulated time. DECISIONS/OPT-11-battery-modes.md has the record. + +1. With battery modes off, every command is byte-identical to release/1.0 + a5dddad, at one inverter and at two (digests measured on that tree). +2. Solar order: surplus solar fills the Solar first battery, then spills to the + Leftover solar one; a full Solar first battery hands all of it on. +3. Off: the battery is asked for 0 W, the others carry the site, the loop is + bounded and never freezes on watts no battery may give; all off at N=1 and + N=2 asks 0 W. +4. Off stops and skips maintenance, with a log line; a battery in maintenance + gets no mode claim. +5. The actions: `pack_use` and `pack_mode`, what they store, log and refuse. +6. A mode range without 0 W is dropped as a software fault, logged once. +7. The Batteries screen's data. + +Mutations (MUTATION.md, purged copies, graded over this file and the named +tests) are listed in the pull request and DECISIONS/OPT-11-battery-modes.md. +""" + +import asyncio +import logging +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "tools")) + +import opt11_identity as rig # noqa: E402 +from named_absence import nth, present # noqa: E402 +import app.main as app_main # noqa: E402 +from app import homeview, messages, packmode # noqa: E402 +from app.control import MODE_BOUND # noqa: E402 +from app.maintenance import DRAIN, IDLE # noqa: E402 +from app.store import unit_key # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +ON = {"battery_modes_enabled": True} +A, B = "inverter-a", "inverter-b" + + +def store_with(**modes): + """A store where each named battery has (use, mode).""" + s = rig._Store() + for name, (use, mode) in modes.items(): + s.data[unit_key(name, packmode.USE_KEY)] = use + s.data[unit_key(name, packmode.MODE_KEY)] = mode + return s + + +def spy_claims(): + """Record every claim list main.py's own `resolve` is handed.""" + seen, real = [], app_main.resolve + + def _spy(claims, **kw): + seen.append(list(claims)) + return real(claims, **kw) + app_main.resolve = _spy + return seen, lambda: setattr(app_main, "resolve", real) + + +def spy_bounds(): + """Record the `bound` main.py hands `compute()` on every cycle.""" + seen, real = [], app_main.compute + + def _spy(*a, **kw): + seen.append(kw.get("bound", "absent")) + return real(*a, **kw) + app_main.compute = _spy + return seen, lambda: setattr(app_main, "compute", real) + + +def flat(t): + return 0.0 + + +# ============================================================================ # +print("1. battery modes off: byte-identical to release/1.0 a5dddad") +# The digests below were printed by `python3 tools/opt11_identity.py` run on +# the a5dddad tree (the same file copied in, nothing else changed), in the CI +# image, on 2026-09-28. Each hashes 540 cycles: site target, every inverter's +# written value, the loop's reason, integrator and saturation count, the +# arbiter's sentence and the allocation record. +A5DDDAD = { + "n1": "fc3bca170c46920f08d86baee62ea883954a89ea6222d6bbf7d6652877226262", + "n2": "a60003b1930fa3cba21f3220670d9172b9ff438347d77ba9bc3383fca1ceb15a", + "n1_off": "fc3bca170c46920f08d86baee62ea883954a89ea6222d6bbf7d6652877226262", + "n2_off": "a60003b1930fa3cba21f3220670d9172b9ff438347d77ba9bc3383fca1ceb15a", + # Added in review (PR #149), printed on a5dddad the same way: both + # batteries due for maintenance, and the arbiter alone on 6000 seeded + # claim lists with the peak floor, trades, maintenance and refusals. + "n1_maint": "5a21d43ec7cbf95a224bbb9bca13a8e62a00161101b586ae9fa68ab565992977", + "n2_maint": "48a1ad96121592e207a4664fb2da9f5208a49b88e5802a358873a5d30cb8c18e", + "resolve": "43a874216711936e14b9185bcc8d26c58fe4f3cfc3c1c8eaf89bffd95d6e81ef", +} +_claims, _undo_c = spy_claims() +_bounds, _undo_b = spy_bounds() +try: + _now = rig.digests() +finally: + _undo_c() + _undo_b() +for k in A5DDDAD: + print(f" {k:7} {'same' if _now[k] == A5DDDAD[k] else 'DIFFERS'} " + f"{_now[k][:16]}") +check("⭐ at N=1 and N=2, with the option absent and with it false, every " + "command, reason and allocation record over 540 cycles each is " + "byte-identical to a5dddad (house steps, a solar surplus, a battery " + "at its cut-out level, at N=2 a refusal with probes, both batteries " + "due for maintenance), and so is the arbiter on 6000 claim lists", + _now == A5DDDAD) +check("...and no cycle in those runs filed a `mode` claim or handed " + "`compute()` a bound", + _claims and not any(c.source == "mode" for cl in _claims for c in cl) + and _bounds and set(_bounds) == {None}) + + +# ============================================================================ # +print("\n2. the solar order: Solar first fills, the rest spills to Leftover") +_surplus = (lambda t: -3200.0) # 3200 W more solar than the house uses +_full_a = (lambda u, t: 100.0 if (u == "a" and t >= 300) else 50.0) +_rows, _c = rig.run(2, extra=ON, dur=600.0, load=_surplus, soc=_full_a, + cut=lambda u, t: False, + store=store_with(**{A: (True, packmode.SOLAR_FIRST), + B: (True, packmode.LEFTOVER_SOLAR)})) +_room = [r for r in _rows if 200 <= r[0] < 300] +_full = [r for r in _rows if r[0] >= 500] + + +def _cmd(r): + """One row's written values, by battery; ABSENTs if the row is missing.""" + return (dict(r[2]) if present(r) + else {"a": nth((), 0, "a"), "b": nth((), 0, "b")}) + + +print(f" A has room: A {_cmd(nth(_room, -1))['a']:+.0f} W, B " + f"{_cmd(nth(_room, -1))['b']:+.0f} W | A full: A {_cmd(nth(_full, -1))['a']:+.0f} " + f"W, B {_cmd(nth(_full, -1))['b']:+.0f} W") +check("⭐ while A (Solar first) has room it takes all it can, 2500 W, and B " + "(Leftover solar) gets only the spill, about 700 W", + present(nth(_room, -1)) + and all(_cmd(r)["a"] == -2500.0 and -800.0 <= _cmd(r)["b"] <= -600.0 + for r in _room)) +check("⭐ once A is full, the surplus spills to B, up to B's rating", + present(nth(_full, -1)) + and all(_cmd(r)["a"] == 0.0 and _cmd(r)["b"] == -2500.0 for r in _full)) +_rows_off, _ = rig.run(2, dur=300.0, load=_surplus, + soc=lambda u, t: 50.0, cut=lambda u, t: False) +check("...and the same site with battery modes off splits 50/50, which is " + "what says the order above comes from the modes", + _cmd(_rows_off[-1])["a"] == _cmd(_rows_off[-1])["b"] < -1500.0) +_give = rig.run(2, extra=ON, dur=300.0, load=lambda t: 3000.0, + soc=lambda u, t: 50.0, cut=lambda u, t: False, + store=store_with(**{A: (True, packmode.SOLAR_FIRST), + B: (True, packmode.LEFTOVER_SOLAR)}))[0] +check("while giving power the modes keep OPT-09's weights: A Solar first and " + "B Leftover solar, both half full, share a 3000 W house equally", + _cmd(_give[-1])["a"] == _cmd(_give[-1])["b"] > 1400.0) + + +# ============================================================================ # +print("\n3. Off: 0 W on that battery, the loop bounded, no freeze") +_rows, _c = rig.run(2, extra=ON, dur=600.0, load=lambda t: 4000.0, + soc=lambda u, t: 50.0, cut=lambda u, t: False, + store=store_with(**{B: (False, packmode.SOLAR_FIRST)})) +_tail = _rows[40:] +print(f" house 4000 W, B off: A {_cmd(_tail[-1])['a']:+.0f} W, B " + f"{_cmd(_tail[-1])['b']:+.0f} W, site {_tail[-1][1]:+.0f} W, reason " + f"{_tail[-1][3]}, {_tail[-1][7]}") +check("⭐ B (Off) is asked for 0 W on every cycle and A carries the house up " + "to its rating", + all(_cmd(r)["b"] == 0.0 for r in _rows) + and all(_cmd(r)["a"] == 2500.0 for r in _tail)) +check("⭐ the loop stays inside what the modes allow (2500 W) and never " + "freezes: no saturated-freeze reason, and the saturation count stays " + "below the freeze", + all(r[1] <= 2500.0 for r in _rows) + and not any(r[3] == "saturated-freeze" for r in _rows) + and max(r[5] for r in _rows) < _c.tuning.saturation_cycles + and _tail[-1][3] == MODE_BOUND) +check("...and nothing is left unmet: the allocation places the whole target", + all("UNMET" not in (r[7] or "") for r in _tail)) +_bounds, _undo_b = spy_bounds() +try: + rig.run(2, extra=ON, dur=100.0, load=lambda t: 4000.0, + soc=lambda u, t: 50.0, cut=lambda u, t: False, + store=store_with(**{B: (False, packmode.SOLAR_FIRST)})) +finally: + _undo_b() +check("...because main.py hands compute() the loop bound, B's 0 W included: " + "[-2500, 2500]", _bounds and set(_bounds) == {(-2500.0, 2500.0)}) +# Review (PR #149): a unit the allocator drops (stale link) must not count in +# the loop bound either, or the loop asks for watts nobody live can place. +_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat, soc=lambda u, t: 50.0, + cut=lambda u, t: False) +_both = _c.loop_bound([app_main.Claim.limit( + "supervised", app_main.P_SAFETY, -2500.0, 2500.0, + actuator=_c.actuator_of("supervised", i)) for i in _c.inverters]) +_c.live_units = lambda: (A,) +_one_live = _c.loop_bound([app_main.Claim.limit( + "supervised", app_main.P_SAFETY, -2500.0, 2500.0, + actuator=_c.actuator_of("supervised", i)) for i in _c.inverters]) +check("the loop bound counts only live units: both live [-5000, 5000], B's " + "link stale [-2500, 2500]", + _both == (-5000.0, 5000.0) and _one_live == (-2500.0, 2500.0)) + +for _n in (1, 2): + _st = store_with(**{A: (False, packmode.SOLAR_FIRST), + B: (False, packmode.LEFTOVER_SOLAR)}) + if _n == 1: + _st = rig._Store() + _st.data[unit_key(app_main.UNIT_A, packmode.USE_KEY)] = False + _rows, _c = rig.run(_n, extra=ON, dur=300.0, load=lambda t: 3000.0, + soc=lambda u, t: 50.0, cut=lambda u, t: False, + store=_st) + check(f"every battery off at N={_n}: every command is 0 W, the site " + f"target is 0 W, the integrator stays at 0 and nothing freezes", + all(all(v == 0.0 for _u, v in r[2]) and r[1] == 0.0 and r[4] == 0.0 + and r[3] != "saturated-freeze" for r in _rows)) +_rows1, _ = rig.run(1, extra=ON, dur=300.0, load=lambda t: 1800.0, + soc=lambda u, t: 50.0) +_rows1o, _ = rig.run(1, dur=300.0, load=lambda t: 1800.0, + soc=lambda u, t: 50.0) +check("at N=1 a battery that is on (Solar first by default) follows the " + "house exactly as with battery modes off", + [r[:6] for r in _rows1] == [r[:6] for r in _rows1o] + and _cmd(_rows1[-1])["a"] > 1700.0) + + +# ============================================================================ # +print("\n4. Off stops and skips maintenance; maintenance outranks the mode") +MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11} +_st = store_with(**{A: (False, packmode.SOLAR_FIRST)}) +_rows, _c = rig.run(2, extra={**ON, **MAINT}, dur=60.0, + load=lambda t: 500.0, soc=lambda u, t: 60.0, + cut=lambda u, t: False, store=_st) +print(f" both due at 11:00, A off: A {_c.maints[0].phase}, " + f"B {_c.maints[1].phase}") +check("⭐ a battery that is off starts no cycle, and does not hold the slot: " + "both are due, A (first in the roster) is off, so B starts", + _c.maints[0].phase == IDLE and _c.maints[1].phase == DRAIN) +_st2 = store_with() +_seen, _undo_c = spy_claims() +_msgs = [] + + +async def _switch_b_off(c, t): + if t == 30.0: + _msgs.append(await c.handle_action("pack_use", unit=B, value=False)) + + +try: + _rows, _c = rig.run(2, extra={**ON, **MAINT}, dur=60.0, + load=lambda t: 500.0, soc=lambda u, t: 60.0, + cut=lambda u, t: False, store=_st2, + before=_switch_b_off) +finally: + _undo_c() +_during = [cl for cl in _seen[:6]] +print(f" B off at 30 s while A drains: A {_c.maints[0].phase}, " + f"B {_c.maints[1].phase}; events: " + f"{[e for e in _c.events if 'switched' in str(e) or 'ABORT' in str(e)]}") +check("while A runs its cycle it gets no mode claim (maintenance at 60/40 " + "outranks the mode) and B, idle, gets one", + all({c.actuator for c in cl if c.source == "mode"} == {B} + for cl in _during)) +_st3 = store_with() +_st3.data[unit_key(B, "phase")] = DRAIN +_st3.data["maint_schema_version"] = 1 + + +async def _b_off_now(c, t): + if t == 10.0: + _msgs.append(await c.handle_action("pack_use", unit=B, value=False)) + + +_rows, _c = rig.run(2, extra=ON, dur=30.0, load=lambda t: 500.0, + soc=lambda u, t: 60.0, cut=lambda u, t: False, + store=_st3, before=_b_off_now) +_ev = [str(e) for e in _c.events] +print(f" B mid-drain, switched off at 10 s: B {_c.maints[1].phase}; " + f"{[e for e in _ev if 'switched off' in e or 'ABORT' in e]}") +check("⭐ switching off a battery that is mid-cycle stops its cycle at once, " + "with a log line saying why", + _c.maints[1].phase == IDLE + and any("battery switched off" in e for e in _ev) + and _cmd(_rows[-1])["b"] == 0.0) +_st4 = store_with(**{B: (False, packmode.SOLAR_FIRST)}) +_st4.data[unit_key(B, "phase")] = DRAIN +_st4.data["maint_schema_version"] = 1 +_rows, _c = rig.run(2, extra=ON, dur=20.0, load=lambda t: 500.0, + soc=lambda u, t: 60.0, cut=lambda u, t: False, store=_st4) +check("...and a store that says Off for a battery mid-cycle (a restart) stops " + "it on the first cycle, and it is never asked to drain", + _c.maints[1].phase == IDLE + and all(_cmd(r)["b"] == 0.0 for r in _rows)) + + +# ============================================================================ # +print("\n5. the actions: pack_use and pack_mode") +_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat, + soc=lambda u, t: 50.0, cut=lambda u, t: False) +_r1 = asyncio.run(_c.handle_action("pack_mode", unit=B, + value=packmode.LEFTOVER_SOLAR)) +_r2 = asyncio.run(_c.handle_action("pack_use", unit=A, value=False)) +print(f" {_r1} {_r2}") +check("pack_mode stores the mode under the battery's own key and says what " + "it now is", + _r1 == {"unit": B, "use": True, "mode": packmode.LEFTOVER_SOLAR} + and _c.store.data[unit_key(B, packmode.MODE_KEY)] + == packmode.LEFTOVER_SOLAR) +check("pack_use stores the switch, and both are written to the action log", + _r2 == {"unit": A, "use": False, "mode": packmode.SOLAR_FIRST} + and _c.store.data[unit_key(A, packmode.USE_KEY)] is False + and any("inverter-a switched off by operator" in str(e) + for e in _c.events) + and any("inverter-b mode set to leftover solar" in str(e) + for e in _c.events)) +_bad = [asyncio.run(_c.handle_action("pack_use", unit="inverter-z", + value=True)), + asyncio.run(_c.handle_action("pack_use", unit=A, value="yes")), + # 0 == False in Python: a stored 0 would read back as ON. + asyncio.run(_c.handle_action("pack_use", unit=A, value=0)), + asyncio.run(_c.handle_action("pack_use", unit=A, value=1)), + asyncio.run(_c.handle_action("pack_mode", unit=A, + value=packmode.CHEAP_HOURS)), + asyncio.run(_c.handle_action("pack_mode", unit=A, value=None))] +check("an unknown battery, a switch that is not true or false (0 and 1 " + "included), and a mode " + "this release does not offer (Cheap hours arrives in P3) are refused, " + "with a plain reason, and store nothing", + all(isinstance(x, dict) and set(x) == {"error"} for x in _bad) + and unit_key(A, packmode.MODE_KEY) not in _c.store.data + and _c.store.data[unit_key(A, packmode.USE_KEY)] is False) +_rows, _c0 = rig.run(2, dur=10.0, load=flat, soc=lambda u, t: 50.0, + cut=lambda u, t: False) +_r0 = asyncio.run(_c0.handle_action("pack_use", unit=A, value=False)) +check("with battery modes off the actions are refused and store nothing", + set(_r0) == {"error"} + and unit_key(A, packmode.USE_KEY) not in _c0.store.data) +check("the old actions still take no battery and answer as before", + asyncio.run(_c0.handle_action("nonsense")) is None) +check("a store value from a later version (an unknown mode) reads as the " + "default, Solar first and on, never as Off", + packmode.read({unit_key(A, packmode.MODE_KEY): "grid_only", + unit_key(A, packmode.USE_KEY): "maybe"}, A) + == packmode.PackMode(True, packmode.SOLAR_FIRST)) + + +# ============================================================================ # +print("\n6. a mode range without 0 W is a software fault, never obeyed") +_real_range = packmode.mode_range +packmode.mode_range = lambda pm, r: (100.0, 900.0) +_log = [] + + +class _H(logging.Handler): + def emit(self, rec): + _log.append((rec.levelno, rec.getMessage())) + + +_h = _H() +logging.getLogger("goodwe").addHandler(_h) +try: + _rows, _c = rig.run(2, extra=ON, dur=60.0, load=lambda t: -2000.0, + soc=lambda u, t: 50.0, cut=lambda u, t: False) +finally: + packmode.mode_range = _real_range + logging.getLogger("goodwe").removeHandler(_h) +_errs = [msg for lv, msg in _log if lv >= logging.ERROR and "MODE_RANGE" in msg] +print(f" {_errs}") +check("⭐ both batteries still charge from the surplus (the forced range is " + "dropped, so no battery is made to give power) and no split is refused", + all(v <= 0.0 for _u, v in _rows[-1][2]) + and _cmd(_rows[-1])["a"] < 0.0 + and not any("REFUSED" in (r[7] or "") for r in _rows)) +check("...and it is logged once per battery, as an ERROR naming the fault " + "code, not once per cycle", + len(_errs) == 2 and any(A in e for e in _errs) and any(B in e for e in _errs)) + + +# ============================================================================ # +print("\n7. the Batteries screen's data") +_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat, soc=lambda u, t: 50.0, + cut=lambda u, t: False, + store=store_with(**{B: (False, packmode.LEFTOVER_SOLAR)})) +_bat = homeview.battery_rows(_c, False, True) +_bat_nl = homeview.build(_c, level="ok", checks=[], lang="nl")["batteries"] +print(f" {[(b['name'], b['use'], b['mode_key'], b['mode']) for b in _bat]}") +check("with battery modes on each row carries its switch, its state and its " + "stored mode, in the page's words", + [(b["use"], b["mode_key"], b["mode_choice"], b["mode"]) for b in _bat] + == [(True, "solar_first", "solar_first", "Solar first"), + (False, "off", "leftover_solar", "Off")] + and _bat_nl[1]["mode"] == "Uit") +_rows, _c0 = rig.run(2, dur=10.0, load=flat, soc=lambda u, t: 50.0, + cut=lambda u, t: False) +check("...and with them off the rows are what they were: no new keys, the " + "mode reads as before", + all("mode_key" not in b and b["mode"] == homeview.mode_house() + for b in homeview.battery_rows(_c0, False, True))) + + +# ============================================================================ # +print("\n8. the page's route: /action and /status over the real web app") +import socket # noqa: E402 + +import aiohttp # noqa: E402 +from aiohttp import web as aioweb # noqa: E402 + +from app import web as app_web # noqa: E402 + +_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat, soc=lambda u, t: 50.0, + cut=lambda u, t: False) + + +async def _page(): + # The configuration routes are not used here; a stand-in keeps the app + # from building a Supervisor client. + app = app_web.build_app(_c, selfcfg=object()) + runner = aioweb.AppRunner(app) + await runner.setup() + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + await aioweb.TCPSite(runner, "127.0.0.1", port).start() + out = {} + try: + async with aiohttp.ClientSession() as s: + root = f"http://127.0.0.1:{port}/" + for key, body in ( + ("mode", {"action": "pack_mode", "unit": B, + "value": packmode.LEFTOVER_SOLAR}), + ("off", {"action": "pack_use", "unit": A, + "value": False}), + ("bad", {"action": "pack_mode", "unit": A, + "value": "grid_only"})): + async with s.post(root + "action", json=body) as r: + out[key] = (r.status, await r.json(content_type=None)) + async with s.get(root + "status") as r: + out["status"] = (r.status, await r.json(content_type=None)) + async with s.get(root) as r: + out["page"] = (r.status, await r.text()) + finally: + await runner.cleanup() + return out + + +_web = asyncio.run(_page()) +print(f" {_web['mode']} {_web['off']} {_web['bad']}") +check("POST /action with pack_mode and pack_use names a battery and a value, " + "and answers what it stored", + _web["mode"] == (200, {"ok": True, "result": { + "unit": B, "use": True, "mode": packmode.LEFTOVER_SOLAR}}) + and _web["off"][0] == 200 and _web["off"][1]["result"]["use"] is False) +check("...a value this release does not offer is a 400 with the plain reason " + "the page shows", + _web["bad"][0] == 400 and _web["bad"][1]["ok"] is False + and _web["bad"][1]["error"] == messages.pack_refused("value")) +_sb = {b["name"]: b for b in _web["status"][1].get("batteries", [])} +check("...and /status then shows both batteries' switch and mode for the " + "Batteries screen", + _web["status"][0] == 200 + and (_sb.get(A, {}).get("mode_key"), _sb.get(B, {}).get("mode_key")) + == ("off", packmode.LEFTOVER_SOLAR)) +check("the page carries the mode names from app/ui_text for its cards", + _web["page"][0] == 200 and "Leftover solar" in _web["page"][1] + and "Overschot zonne-energie" in _web["page"][1]) + + +print() +if fails: + print(f"{len(fails)} FAILED: {', '.join(fails)}") + sys.exit(1) +print("all checks passed") diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index 59c1076..53ae9b0 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -2653,9 +2653,26 @@ check("...and so does the persistent notification, which outlives the page and " # # If you are here because you added a TWELFTH, that argument is what this line # wants from you, not an edit. +# ⚠️ SAFETY-73 ADDED `refused` (a P_SAFETY unit limit on a measured refusal). +# ⚠️ OPT-11 ADDED `mode`, ON PURPOSE, AND IT IS NOT THE DEFERRED RESERVE EITHER. +# `mode` is `Claim.limit("mode", P_MODE, lo, hi)` on ONE inverter's actuator: +# the owner's per-battery choice (on/off, Solar first, Leftover solar), filed +# only with `battery_modes_enabled` on (default off). It is not conditioned on +# a state of charge, and it cannot bound the site against the peak floor: +# 1. stage 1 keeps it APART from the hard limits (UnitBounds.mode_*), so it +# never enters a `lo > hi` test, not even the unit's own; +# 2. stage 3 applies it only while the site target was decided at or below +# P_MODE (35), so the guard's floor (P_PEAK, 50) RELEASES it, and at N=1 +# `resolve()` applies it to the site by the same rule, after stage 2; +# 3. its range always holds 0 W, so it can forbid a direction and never +# force one (a range without 0 is dropped as a software fault). +# test_arbiter.py part 9 grades the no-contradiction claim at N=2, over 625 +# cycles with the floor, and part 10 at N=1. The sweeps below never reach it: +# their fixtures leave battery modes off, so they still see {device, +# supervised, loop}. DECISIONS/OPT-11-battery-modes.md. CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak", "arbitrage", "maintenance", "loop", "circulation", - "sign_site", "pack", "refused"} + "sign_site", "pack", "refused", "mode"} # The two appends that file a claim BUILT ELSEWHERE in the method. Named here so # that a THIRD such indirection reads as an unrecognised append rather than as a # silent gap in the scan. @@ -2697,7 +2714,7 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ " "- a claim filed through a shape it cannot parse has to fail here rather " "than be counted as nothing, which is how a scan like this goes quietly " "vacuous", len(_appends) >= 10 and None not in _filed) -check("...and the sources that cycle can file are EXACTLY these TWELVE. THIS " +check("...and the sources that cycle can file are EXACTLY these THIRTEEN. THIS " "is what makes part 10 a tripwire: replay_loop() hand-builds the claim " "list, so it cannot see a claim main.py adds - and a loop-side SoC " "reserve is a new source. Filing one takes this line red on the line it " @@ -2849,7 +2866,7 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and " == {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"} and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == {"device", "supervised", "failsafe", "maintenance", "pack", - "refused"}) + "refused", "mode"}) # The shipped roster is ONE inverter, so §6.2's identity holds and every claim a # real cycle files is a site claim. Observed on the claims main.py's own resolve @@ -2947,6 +2964,7 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second " _routed == {"device": "inverter-a", "supervised": "inverter-a", "failsafe": "inverter-a", "maintenance": "inverter-a", "pack": "inverter-a", "refused": "inverter-a", + "mode": "inverter-a", "loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE, "arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE, "circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE}) @@ -3028,13 +3046,13 @@ _unit_sources_unrouted = [ if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]] print(f" {len(_direct)} direct appends, each read at its own line: " + ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing)) -check("all THIRTEEN of `Controller.cycle()`'s DIRECT appends route through " +check("all FOURTEEN of `Controller.cycle()`'s DIRECT appends route through " "`self.actuator_of()`, so this scan can read the routing " "of EVERY one of them. An append that computed its actuator, or omitted " "the argument, or hand-wrote a string, fails HERE rather than being " "silently skipped - which is how a scan like this goes quietly vacuous, " "and 10f is in this file because that already happened once", - len(_direct) == 13 + len(_direct) == 14 and all(a is not None and a.startswith("self.actuator_of('") for _s, a, _ln in _routing)) check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE " @@ -3051,15 +3069,15 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. " "site-scoped sources and is the whole defect for a per-inverter one: a " "`device` limit built inside the roster loop and routed without `inv` " "files inverter B's rating on inverter A, where it binds the wrong " - "share. Eight of the thirteen appends are SCOPE_UNIT - `device`, " - "`supervised`, `failsafe`, maintenance's two, OPT-09's `pack` and " - "SAFETY-73's two `refused` - and all eight pass one. " + "share. Nine of the fourteen appends are SCOPE_UNIT - `device`, " + "`supervised`, `failsafe`, maintenance's two, OPT-09's `pack`, " + "SAFETY-73's two `refused` and OPT-11's `mode` - and all nine pass one. " "SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on " "the site, beside `circulation`, because the number that tripped it is " "the site target", _unit_sources_unrouted == [] and len([r for r in _routing - if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 8) + if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 9) check("...and the only appends this pin does NOT reach are the two that file a " "claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no " "`actuator=` at the call site to mis-type. Their routing is pinned where " @@ -3068,7 +3086,8 @@ check("...and the only appends this pin does NOT reach are the two that file a " "arbitrage.claim() by test_arbitrage.py", sorted(_indirect) == sorted(CLAIM_BUILDERS) and len(_direct) + len(_indirect) == len(_appends)) -check("...and between them the fifteen appends account for exactly the twelve " +check("...and between them the sixteen appends account for exactly the " + "thirteen " "sources 10f pins, so the routing pin and the source set are one scan " "and neither can drift from the other", {s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values()) @@ -3129,7 +3148,7 @@ check("...and naming those three is not an accident of this fixture but the " {c.source for c in _cl2} == CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak", "arbitrage", "circulation", "sign_site", - "refused"}) + "refused", "mode"}) # ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three # checks above grade WHICH ACTUATOR each claim lands on. They say nothing about # WHOSE NUMBERS the bound is built from - and those are two different defects diff --git a/goodwe_controller/tools/alloc_sim.py b/goodwe_controller/tools/alloc_sim.py index f257d6d..9618c8d 100644 --- a/goodwe_controller/tools/alloc_sim.py +++ b/goodwe_controller/tools/alloc_sim.py @@ -5,6 +5,7 @@ python3 tools/alloc_sim.py --splits # AC 2: split-invariance at the meter python3 tools/alloc_sim.py --sweep # AC 3: gain to 1.0 at every split python3 tools/alloc_sim.py --all + python3 tools/alloc_sim.py --spill # OPT-11: surplus solar, by mode Exit 0 only if every split-invariance run HOLDS; 1 if one FAILS; 2 if no rig. @@ -245,12 +246,98 @@ def sweep(sitesim, control, watchdog, cycles, load_w): return worst +def spill(sitesim, control, arbiter, allocator, packmode, gain=0.6, + cycles=30, load_w=1000.0, pv_w=9000.0): + """OPT-11 P1: surplus solar spills from Solar first to Leftover solar. + + The rig's two plants and the add-on's own `compute()`, `resolve()`, + `loop_bound()` and `packmode.charge_weights()`, with the claims main.py + files for this state: each unit's rating, each unit's mode, the loop, and + OPT-09's `pack` limit (`lo = 0`) once battery A is full. A is Solar first + and B Leftover solar. Three phases of `cycles` each: A has room; A is full; + then B is switched off. The loop is fed the site total (DEV-02). + Returns (rows, verdicts). + """ + from plantsim import INVERTER_A, INVERTER_B, Inverter, Site + names = ("inverter-a", "inverter-b") + specs = [("A", INVERTER_A), ("B", INVERTER_B)] + invs = [Inverter.from_spec(n, dict(sp, **sitesim.INSTANT)) + for n, sp in specs] + site = Site(invs, load=load_w, pv=pv_w) + tun = control.Tuning(gain=gain, max_w=sum(sp["rating_w"] for _n, sp in specs), + slew_w=sitesim.EXPERIMENT_SLEW_W) + C = arbiter.Claim + prev, i_w, sat = 0.0, None, 0 + rows = [] + phases = (("A has room", False, True), ("A full", True, True), + ("A full, B off", True, False)) + for label, a_full, b_on in phases: + modes = {names[0]: packmode.PackMode(True, packmode.SOLAR_FIRST), + names[1]: packmode.PackMode(b_on, packmode.LEFTOVER_SOLAR)} + for _c in range(cycles): + claims = [] + for u, (_n, sp) in zip(names, specs): + r = sp["rating_w"] + claims.append(C.limit("supervised", arbiter.P_SAFETY, -r, r, + actuator=u)) + lo, hi = packmode.mode_range(modes[u], r) + claims.append(C.limit("mode", arbiter.P_MODE, lo, hi, + actuator=u)) + if a_full: + claims.append(C.limit("pack", arbiter.P_SAFETY, lo=0.0, + actuator=names[0])) + bound = allocator.loop_bound( + {u: arbiter.pre_resolve([c for c in claims if c.actuator == u]) + for u in names}) + d = control.compute(prev, site.grid, site.battery, tun, sat, i_w, + bound=bound) + prev, i_w, sat = d.target_w, d.i_w, d.sat_count + claims.append(C.set("loop", arbiter.P_LOOP, d.target_w)) + res = arbiter.resolve( + claims, units=names, + weights=lambda t: packmode.charge_weights(None, modes, t)) + for inv, u in zip(invs, names): + inv.command(float(res.shares[u])) + site.settle(sitesim.CYCLE_S, dt=0.05) + rows.append(dict(phase=label, target=d.target_w, grid=site.grid, + a=res.shares[names[0]], b=res.shares[names[1]], + unmet=res.allocation.unmet_w, frozen=d.frozen, + reason=d.reason, bound=bound)) + return rows + + +def spill_report(rows, cycles): + print("=== OPT-11 P1: surplus solar, A Solar first, B Leftover solar ===") + ok = True + for label in dict.fromkeys(r["phase"] for r in rows): + ph = [r for r in rows if r["phase"] == label] + last = ph[-1] + frozen = sum(r["frozen"] for r in ph) + print(f" {label:15} A {last['a']:+6.0f} W B {last['b']:+6.0f} W " + f"grid {last['grid']:+7.0f} W loop bound " + f"[{last['bound'][0]:.0f}, {last['bound'][1]:.0f}] " + f"unmet {last['unmet']} W frozen cycles {frozen} " + f"({last['reason']})") + ok &= frozen == 0 and all(r["a"] <= 0 and r["b"] <= 0 for r in ph) + if label == "A has room": + ok &= last["a"] == -5000 and last["b"] < 0 + elif label == "A full": + ok &= last["a"] == 0 and last["b"] == -5000 + else: + ok &= last["a"] == 0 and last["b"] == 0 and last["unmet"] == 0 + print(f" -> spill {'HOLDS' if ok else 'FAILS'}: A fills first, the rest " + f"spills to B, a full A and an Off B send the surplus to the grid, " + f"and the loop never freezes on watts no battery may take") + return ok + + def main(): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--table", action="store_true") ap.add_argument("--splits", action="store_true") ap.add_argument("--sweep", action="store_true") ap.add_argument("--all", action="store_true") + ap.add_argument("--spill", action="store_true") ap.add_argument("--cycles", type=int, default=40) ap.add_argument("--load", type=float, default=2000.0) ap.add_argument("--gain", type=float, default=1.0) @@ -261,7 +348,7 @@ def main(): "or one inverter's power (the PRE-DEV-02 sensing, " "kept because the comparison is the evidence)") a = ap.parse_args() - if not (a.table or a.splits or a.sweep): + if not (a.table or a.splits or a.sweep or a.spill): a.all = True sim = find_sim() @@ -288,6 +375,11 @@ def main(): print() if a.sweep or a.all: sweep(sitesim, control, watchdog, a.cycles, a.load) + print() + if a.spill or a.all: + packmode = sitesim.load_addon("app.packmode", "alloc_sim") + held &= spill_report(spill(sitesim, control, arbiter, allocator, + packmode), 30) return 0 if held else 1 diff --git a/goodwe_controller/tools/opt11_identity.py b/goodwe_controller/tools/opt11_identity.py new file mode 100644 index 0000000..3339a97 --- /dev/null +++ b/goodwe_controller/tools/opt11_identity.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""OPT-11: with battery modes off, is every command byte-identical to before? + + python3 tools/opt11_identity.py # prints one digest per scenario + +Drives the REAL `Controller` (read_inputs + cycle: the freshness gate, the +arbiter, the allocator, OPT-09's pack claim, SAFETY-73's refusal claims) in +simulated time, and hashes, per cycle, everything a command depends on: the +site target, each inverter's written value, the loop's reason, integrator and +saturation count, and the allocation record. It uses only the Controller API +that release/1.0 a5dddad already had, so the SAME file runs on the old tree and +the new one. test_modes.py part 1 holds the new tree to the digests this file +printed on a5dddad (they are written there, with that provenance). + +Scenarios: N=1 and N=2, a house that steps, a surplus of solar, a battery +reaching its cut-out level and, at N=2, a battery that refuses (SAFETY-73) and +is probed; the same with both batteries due for maintenance; and the arbiter +alone on 6000 seeded claim lists (peak floor, trades, maintenance, refusals). +""" + +import asyncio +import hashlib +import os +import sys +from datetime import datetime, timedelta, timezone + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import app.main as m # noqa: E402 +from app.main import Controller # noqa: E402 + +TICK = 5.0 +BASE = datetime(2026, 9, 20, 11, 0, tzinfo=timezone.utc) + + +class _Store: + def __init__(self): + from app.store import DEFAULTS + self.data = dict(DEFAULTS) + + def set(self, k, v): + self.data[k] = v + + def get_time(self, k): + v = self.data.get(k) + return datetime.fromisoformat(v) if v else None + + def set_time(self, k, when): + self.data[k] = when.isoformat() if when else None + + +class _Pub: + enabled = False + + def publish(self, *a, **k): + pass + + def close(self): + pass + + +def house(t): + """A house that steps, then a solar surplus, then an evening load.""" + if t < 300: + return 800.0 + if t < 900: + return 3200.0 + if t < 1500: + return -2600.0 # more solar than the house uses + if t < 2100: + return 1400.0 + return 4200.0 + + +class _Hass: + """One meter, `units` batteries that follow their command unless `cut`.""" + + def __init__(self, units, soc, cut, load=house): + self.units, self.soc, self.cut, self.load = units, soc, cut, load + self.cmd = {u: 0.0 for u in units} + self.t = 0.0 + + def power(self, u): + c = self.cmd[u] + if self.cut(u, self.t) and c >= -4.0: + return -4.0 + return float(round(c)) + + def grid(self): + return self.load(self.t) - sum(self.power(u) for u in self.units) + + async def limits(self, e): + return (-5000.0, 5000.0, 1.0) + + async def call(self, *a): + return True + + async def number(self, e, invert=False): + if e == "sensor.grid": + return self.grid() + u = e[-1] + if e.startswith("sensor.soc"): + return self.soc(u, self.t) + if e.startswith("sensor.batt"): + return self.power(u) + return None + + async def set_number(self, e, v): + self.cmd[e[-1]] = float(v) + return True + + +def _options(n, extra): + common = {"meter_entity": "sensor.grid", "meter_source": "off", + "gain": 0.6, "slew_w": 400, "target_grid_w": -10, + "deadband_w": 15, "step_w": 10, "heartbeat_s": 10, + "stale_input_s": 15, "auto_start": True} + if n == 1: + common.update({"setpoint_entity": "number.sp_a", + "soc_entity": "sensor.soc_a", + "batt_entity": "sensor.batt_a", "max_w": 5000, + "bms_soc_floor": 10}) + else: + common["inverters"] = [ + {"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}", + "soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}", + "max_w": 2500, "bms_soc_floor": 10} for u in "ab"[:n]] + common.update(extra or {}) + return common + + +def _soc(u, t): + # A empties toward its cut-out level over the run; B stays half full. + return max(8.0, 60.0 - t / 40.0) if u == "a" else 50.0 + + +def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house, + soc=_soc, cut=None, before=None): + """Cycle a real Controller; returns (rows, controller). `before(c, t)` + runs before each cycle, `on_cycle(c, t)` after it.""" + units = tuple("ab"[:n]) + if cut is None: + def cut(u, t): + return n > 1 and u == "a" and 1600.0 <= t < 2300.0 + + async def go(): + hass = _Hass(units, soc, cut, load) + c = Controller(_options(n, extra), hass, store or _Store(), _Pub(), + stop=asyncio.Event()) + for i in c.inverters: + i.dev_min, i.dev_max = -5000.0, 5000.0 + c.auto = True + real_dt = m.datetime + clock = [0.0] + + class _DT(real_dt): + @classmethod + def now(cls, tz=None): + return BASE + timedelta(seconds=clock[0]) + + m.datetime = _DT + rows = [] + try: + t = 0.0 + while t < dur: + hass.t = clock[0] = t + if before is not None: + r = before(c, t) + if asyncio.iscoroutine(r): + await r + await c.read_inputs(now_mono=t) + await c.cycle() + alloc = getattr(c, "allocation", None) + rows.append((t, c.target, tuple(sorted(hass.cmd.items())), + c.loop_reason, round(float(c.i_w), 6), + c.sat_count, c.reason, + alloc.explain() if alloc is not None else None)) + if on_cycle is not None: + on_cycle(c, t) + t += TICK + finally: + m.datetime = real_dt + return rows, c + return asyncio.run(go()) + + +def digest(rows) -> str: + return hashlib.sha256(repr(rows).encode()).hexdigest() + + +# Both batteries due for their monthly cycle at the run's first cycle, so the +# maintenance slot, its pins and its shaping are in every row (PR #149 review: +# the scenarios above never reach maintenance). +_MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11} + +SCENARIOS = { + "n1": dict(n=1), + "n2": dict(n=2), + # The option present and false: what an install that saved the new + # option without turning it on runs. The old tree ignores the key. + "n1_off": dict(n=1, extra={"battery_modes_enabled": False}), + "n2_off": dict(n=2, extra={"battery_modes_enabled": False}), + "n1_maint": dict(n=1, extra=_MAINT), + "n2_maint": dict(n=2, extra=_MAINT), +} + + +def resolve_rows(cases=6000, seed=1149): + """The arbiter alone, on seeded random claim lists with no mode claim: + the peak floor, a trade, the loop, maintenance pins and shaping, SAFETY-73 + refusal limits and probe pins, packs, ratings and safety stops, at one to + three inverters. The peak guard needs a P1 meter the cycle runs above do + not have, so its claim is graded here. Uses only a5dddad's API.""" + import random + from math import inf + from app import arbiter as a + rnd = random.Random(seed) + rows = [] + for _ in range(cases): + n = rnd.choice((1, 2, 3)) + units = ("inverter-a", "inverter-b", "inverter-c")[:n] + + def act(src, u): + return a.actuator_for(src, u, units) + cl = [] + for u in units: + r = rnd.choice((1000.0, 2500.0, 5000.0)) + cl.append(a.Claim.limit("supervised", a.P_SAFETY, -r, r, "max_w", + actuator=act("supervised", u))) + x = rnd.random() + if x < 0.15: + cl.append(a.Claim.limit("pack", a.P_SAFETY, *rnd.choice( + ((0.0, inf), (-inf, 0.0))), actuator=act("pack", u))) + elif x < 0.25: + cl.append(a.Claim.limit("refused", a.P_SAFETY, *rnd.choice( + ((-inf, 0.0), (0.0, inf))), "refused", + actuator=act("refused", u))) + elif x < 0.32: + cl.append(a.Claim.set("refused", a.P_SAFETY, + rnd.choice((200.0, -200.0)), "probing", + actuator=act("refused", u))) + elif x < 0.42: + cl.append(a.Claim.set("maintenance", a.P_MAINTENANCE, + rnd.choice((2000.0, -2000.0)), "drain", + actuator=act("maintenance", u))) + elif x < 0.5: + cl.append(a.Claim.limit("maintenance", a.P_MAINT_SHAPE, + hi=-rnd.choice((500.0, 2000.0)), + actuator=act("maintenance", u))) + y = rnd.random() + if y < 0.8: + cl.append(a.Claim.set("loop", a.P_LOOP, rnd.uniform(-8000, 8000))) + if 0.5 < y < 0.9: + cl.append(a.Claim.set("arbitrage", a.P_ARBITRAGE, + rnd.uniform(-6000, 6000))) + if rnd.random() < 0.6: + cl.append(a.Claim.limit("peak", a.P_PEAK, + lo=rnd.uniform(-4000, 6000))) + if rnd.random() < 0.05: + cl.append(a.Claim.set("safety", a.P_SAFETY, 0.0, "stopped")) + rnd.shuffle(cl) + r = a.resolve(cl, units=units if n > 1 else None, + weights=(None if rnd.random() < 0.5 else + {u: rnd.choice((0.0, 0.5, 1.0)) for u in units})) + rows.append((r.target_w, r.winner, r.bound_by, r.contradiction, + tuple(sorted((r.shares or {}).items())), r.allocated, + r.allocation.explain() if r.allocation else None)) + return rows + + +def digests() -> dict: + out = {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()} + out["resolve"] = digest(resolve_rows()) + return out + + +if __name__ == "__main__": + if "--summary" in sys.argv: + for k, v in SCENARIOS.items(): + rows = run(**v)[0] + print(f"{k}: {len(rows)} cycles, loop reasons " + f"{sorted({r[3] for r in rows})}, arbiter " + f"{sorted({r[6].split(' ->')[0] for r in rows})}") + rr = resolve_rows() + print(f"resolve: {len(rr)} claim lists, winners " + f"{sorted({r[1] for r in rr})}, bound by " + f"{sorted({str(r[2]).split('(')[0] for r in rr})}") + for k, v in digests().items(): + print(f"{k} {v}") diff --git a/goodwe_controller/translations/en.yaml b/goodwe_controller/translations/en.yaml index 4598deb..7fdf628 100644 --- a/goodwe_controller/translations/en.yaml +++ b/goodwe_controller/translations/en.yaml @@ -82,6 +82,9 @@ configuration: estop_fitted: name: "Hardware · RS485 emergency stop is fitted" description: "Switch this on if the emergency stop is installed. It only changes a warning on the status page." + battery_modes_enabled: + name: "Hardware · Battery modes" + description: "Give each battery an on and off switch and a mode, set on the Batteries screen." pv_entity: name: "Hardware · Solar production entity (W)" description: "Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control." diff --git a/goodwe_controller/translations/nl.yaml b/goodwe_controller/translations/nl.yaml index 49476cd..65e7881 100644 --- a/goodwe_controller/translations/nl.yaml +++ b/goodwe_controller/translations/nl.yaml @@ -63,6 +63,9 @@ configuration: estop_fitted: name: "Hardware · RS485-noodstop is geplaatst" description: "Zet dit aan als de noodstop geïnstalleerd is. Het verandert alleen een waarschuwing op de statuspagina." + battery_modes_enabled: + name: "Hardware · Batterijmodi" + description: "Geef elke batterij een aan- en uitschakelaar en een modus, in te stellen op het scherm Batterijen." pv_entity: name: "Hardware · Entiteit zonneproductie (W)" description: "Optioneel. De vermogenssensor van uw zonne-omvormer, positief tijdens productie. De add-on houdt ze bij en regelt er niet mee."