OPT-11 P1: per-battery mode limits, on/off, solar order
A battery's mode is a soft unit limit (source `mode`, P_MODE 35) kept
apart from the hard limits in pre_resolve(). The allocator applies it
only while the site target is decided at or below 35 (new
Resolution.deciding_priority), so the peak floor releases it and a mode
`hi` never meets the site `lo`. At N=1 resolve() applies the same rule
to the site target after stage 2. A mode range without 0 W is dropped
as a software fault, so REFUSE_SIGN stays unreachable from a mode.
compute() gets the loop bound (sum over live units of hard limits with
modes applied), so the loop never asks for watts no battery may give
and the saturation freeze never parks on them. While charging, Leftover
solar weighs 0 so Solar first fills first. Store keys pack_use and
pack_mode per battery, set by two new /action values; Off stops and
skips maintenance with a log line. Minimal Batteries cards (EN/NL).
Off by default (battery_modes_enabled); off, commands are byte-identical
to a5dddad at N=1 and N=2 (test_modes.py part 1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# 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, applies only while the site target is
|
||||
decided at or below 35, and is released by anything above it, such as the peak
|
||||
guard. 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 (design
|
||||
sections 2-3). Whether Off should be absolute is an open owner question.
|
||||
- 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.
|
||||
@@ -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, like every battery.
|
||||
- **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, and released whenever a claim above it (the peak guard) decides the site target. 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
|
||||
|
||||
@@ -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 only when the site target was
|
||||
decided at or below the mode's own priority - `deciding_priority` None
|
||||
means no higher claim was named, so the mode applies. `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))
|
||||
|
||||
@@ -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 and applied only while the site target
|
||||
# is decided at or below this priority. Above arbitrage, so a trade stays inside
|
||||
# each battery's mode; below the peak guard, which releases every 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 stage 3 applies it only when the site target was decided
|
||||
# at or below P_MODE. At N=1 it routes to the site like every source, 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,14 @@ 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`. Stage 3 applies each unit's mode only when
|
||||
# this is at or below that mode's priority (P_MODE), so a peak floor that
|
||||
# binds S (P_PEAK) releases every mode and never meets a mode `hi`.
|
||||
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 +464,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 +473,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 +534,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 +552,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 +589,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 +658,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 +675,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)
|
||||
|
||||
# STAGE 3 - the seam, and since ALLOC-01 the allocator behind it.
|
||||
#
|
||||
@@ -654,5 +714,33 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
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=out.deciding_priority)
|
||||
return out
|
||||
|
||||
|
||||
def _site_mode(out: Resolution, modes: list) -> None:
|
||||
"""OPT-11 at N=1: the one battery's mode bounds the SITE target, after stage 2.
|
||||
|
||||
The unit rule of `allocator.unit_interval()` on the site: the mode applies
|
||||
only when stage 2 decided at or below its priority, and a range without 0 W
|
||||
is a software fault that drops the mode (`out.mode_fault`). A clamp of one
|
||||
number into a range that holds 0 cannot contradict anything, so the peak
|
||||
floor still wins by releasing the mode, as at N>1.
|
||||
"""
|
||||
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.deciding_priority is not None and out.deciding_priority > prio:
|
||||
return
|
||||
target = max(lo, min(hi, out.target_w))
|
||||
if target != out.target_w:
|
||||
c = lims[0]
|
||||
out.bound_by = f"{c.source}({c.reason})" if c.reason else c.source
|
||||
out.target_w = float(target)
|
||||
|
||||
@@ -293,6 +293,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 +479,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 +490,16 @@ 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
|
||||
|
||||
# --- saturation, WITH the duration term -------------------------------
|
||||
# Command and actual diverging means the inverter cannot follow - it is at
|
||||
@@ -589,6 +602,16 @@ def compute(
|
||||
else:
|
||||
i_w = max(moved, i_w)
|
||||
|
||||
# OPT-11: the integrator does not wind past what the modes let the
|
||||
# batteries place. Applied BEFORE the integrator bound below, so a loop
|
||||
# held by a mode reads as `mode-bound` and not as SAFETY-03's winding into
|
||||
# the rail (`i_clamped`). That bound still bites where it is the tighter.
|
||||
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 +819,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 +842,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),
|
||||
|
||||
@@ -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"):
|
||||
|
||||
+176
-26
@@ -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 the allocator applies it only while
|
||||
# the site target is decided at or below P_MODE, so the peak guard's
|
||||
# site floor (P_PEAK) releases it and never meets it in a `lo > hi`.
|
||||
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)
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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/<name>/<key>` (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
|
||||
@@ -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 ---------------- */
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
<section class="screen" id="s-batt" role="tabpanel" aria-labelledby="t-batt" hidden>
|
||||
<div id="bcards"></div>
|
||||
<div class="card">
|
||||
<p class="small muted" style="margin:0 0 10px" data-t="batteries.description"></p>
|
||||
<p class="small muted" style="margin:0 0 10px" id="batt-desc" data-t="batteries.description"></p>
|
||||
<a class="btn" href="#settings" data-t="batteries.settings"></a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("", (
|
||||
|
||||
@@ -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/<lang>.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})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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,156 @@ 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()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -1156,6 +1156,118 @@ 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)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
"""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",
|
||||
}
|
||||
_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, and at N=2 a refusal with probes)",
|
||||
_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)})
|
||||
|
||||
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")),
|
||||
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, 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")
|
||||
@@ -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(<string literal>)`, 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/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.
|
||||
"""
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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}),
|
||||
}
|
||||
|
||||
|
||||
def digests() -> dict:
|
||||
return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
|
||||
|
||||
|
||||
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})}")
|
||||
for k, v in digests().items():
|
||||
print(f"{k} {v}")
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user