OPT-11 P1: per-battery mode limits, on/off, solar order (#149)
infra-pin / fresh (push) Successful in 2s
infra-pin / fresh (push) Successful in 2s
Reviewed: a mode gives way to the peak floor only as far as it needs; SAFETY-03 kept at the rail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #149.
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
# OPT-11 - battery modes, per battery
|
||||||
|
|
||||||
|
A battery's mode (on/off, Solar first, Leftover solar) is a soft unit limit at
|
||||||
|
`P_MODE` = 35 that always holds 0 W. A SET above 35 (a safety stop) overrides
|
||||||
|
it; a limit above 35 (the peak guard) makes it give way only as far as that
|
||||||
|
limit needs, and a battery its mode holds back then carries only what the
|
||||||
|
others cannot (`arbiter._unit_modes()`, `_site_mode()`). Design and owner answers: `goodwe-session/OPT-11-design.md` (2026-09-27);
|
||||||
|
P1 is its section 6 P1, and the pull request lists what was measured.
|
||||||
|
|
||||||
|
- Off by default (`battery_modes_enabled`); off, commands are byte-identical to
|
||||||
|
a5dddad (`test_modes.py` part 1).
|
||||||
|
- Off is a mode range, so the peak guard can still use an Off battery when the
|
||||||
|
others cannot cover the floor (design sections 2-3). Whether Off should be
|
||||||
|
absolute is an open owner question.
|
||||||
|
- Review (PR #149): releasing only when the peak floor bound S let a trade won
|
||||||
|
above the floor keep the modes and place less than the floor, and let a
|
||||||
|
capped grid charge release an Off battery. Both are graded in
|
||||||
|
`test_arbiter.py` part 11. The loop bound leaves a side that reaches the
|
||||||
|
rail to the rail, so SAFETY-03 still flags a loop winding into it.
|
||||||
|
- In P1 only Off closes a direction; Leftover solar differs by charging last.
|
||||||
|
Price runs, selling (`pack_sell`, reserved) and the forecast are P3/P4.
|
||||||
@@ -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 | 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 · 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 | 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 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 | 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. |
|
| 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
|
status, the maintenance phase, the capacity-tariff peak and the three loop rows
|
||||||
below.
|
below.
|
||||||
|
|
||||||
|
### Battery modes (`battery_modes_enabled`)
|
||||||
|
|
||||||
|
Off by default. Turn it on, restart the add-on, and the Batteries screen gives each battery two controls. They apply at once, with no restart.
|
||||||
|
|
||||||
|
- **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover.
|
||||||
|
- **Mode.** *Solar first* charges from spare solar before the other batteries. *Leftover solar* charges from the solar they cannot take. Both cover the house at any hour. Charging from the grid in cheap hours, and selling, come in a later update.
|
||||||
|
|
||||||
|
The peak limit, the safety stops and the monthly cycle always come before a battery's mode.
|
||||||
|
|
||||||
|
*For installers:* a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`.
|
||||||
|
|
||||||
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
|
### 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
|
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_CONTRADICTION = "contradicts itself"
|
||||||
DROP_NO_INTEGER = "no integer setpoint in its interval"
|
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_SCOPE = "mis-routed claim"
|
||||||
REFUSE_EMPTY = "no unit available"
|
REFUSE_EMPTY = "no unit available"
|
||||||
REFUSE_SIGN = "opposite directions"
|
REFUSE_SIGN = "opposite directions"
|
||||||
@@ -213,6 +219,10 @@ class Allocation:
|
|||||||
# that calls `plan()` for its arithmetic gets None and does not care;
|
# that calls `plan()` for its arithmetic gets None and does not care;
|
||||||
# main.py stamps every record it puts on the write path.
|
# main.py stamps every record it puts on the write path.
|
||||||
tick: int | None = None
|
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
|
@property
|
||||||
def ok(self) -> bool:
|
def ok(self) -> bool:
|
||||||
@@ -290,6 +300,87 @@ class Allocation:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def unit_interval(b, deciding_priority=None):
|
||||||
|
"""OPT-11. `(lo, hi, note)`: the interval a unit is allocated into.
|
||||||
|
|
||||||
|
The hard limits (`b.lo`, `b.hi`) always apply. The unit's MODE range
|
||||||
|
(`b.mode_lo`, `b.mode_hi`) narrows them unless `deciding_priority` is
|
||||||
|
above the mode's own priority (arbiter._unit_modes() passes None when the
|
||||||
|
modes hold, and the yielding limit's priority when they give way). `note` says why the
|
||||||
|
mode did not apply, or is None when it did or there is none:
|
||||||
|
|
||||||
|
* MODE_FAULT: the mode range leaves out 0 W. A mode may forbid a direction
|
||||||
|
and never force one, so this is a software fault and the mode is dropped.
|
||||||
|
It is what keeps REFUSE_SIGN unreachable from a mode: the fill below
|
||||||
|
moves every free unit away from 0 in ONE direction, and it starts from 0
|
||||||
|
only while every interval holds 0.
|
||||||
|
* MODE_RELEASED: a higher claim (the peak floor) decided the target.
|
||||||
|
* MODE_OUTRANKED: the hard limits and the mode do not overlap. The hard
|
||||||
|
limits win, as they always do.
|
||||||
|
|
||||||
|
A unit without mode fields (`mode_priority` absent or None) gets its hard
|
||||||
|
limits, unchanged: that is every unit when battery modes are off.
|
||||||
|
"""
|
||||||
|
lo, hi = b.lo, b.hi
|
||||||
|
prio = getattr(b, "mode_priority", None)
|
||||||
|
if prio is None:
|
||||||
|
return lo, hi, None
|
||||||
|
mlo, mhi = b.mode_lo, b.mode_hi
|
||||||
|
if not (mlo <= 0.0 <= mhi):
|
||||||
|
return lo, hi, MODE_FAULT
|
||||||
|
if deciding_priority is not None and deciding_priority > prio:
|
||||||
|
return lo, hi, MODE_RELEASED
|
||||||
|
nlo, nhi = max(lo, mlo), min(hi, mhi)
|
||||||
|
if nlo > nhi:
|
||||||
|
return lo, hi, MODE_OUTRANKED
|
||||||
|
return nlo, nhi, None
|
||||||
|
|
||||||
|
|
||||||
|
class _Narrowed:
|
||||||
|
"""A unit's bounds with its interval replaced by `unit_interval()`'s."""
|
||||||
|
__slots__ = ("lo", "hi")
|
||||||
|
|
||||||
|
def __init__(self, lo, hi):
|
||||||
|
self.lo, self.hi = lo, hi
|
||||||
|
|
||||||
|
|
||||||
|
def loop_bound(bounds, live=None):
|
||||||
|
"""OPT-11. `(lo, hi)`: what the units can place when the LOOP decides.
|
||||||
|
|
||||||
|
The sum, over live units that do not contradict themselves, of each unit's
|
||||||
|
interval with its mode applied (`unit_interval()` with no higher claim). A
|
||||||
|
pinned unit adds the span from 0 to its pin, so a probe or a maintenance
|
||||||
|
pin stays reachable. Every term holds 0, so the sum does too.
|
||||||
|
|
||||||
|
`compute()` keeps the site target inside this, so the loop never asks for
|
||||||
|
watts no battery may give: the allocator would report them as unmet and the
|
||||||
|
saturation freeze would park the target on them (SAFETY-73 section 1).
|
||||||
|
Pure, like `plan()`: it reads numbers and intervals and nothing else.
|
||||||
|
"""
|
||||||
|
lo_sum, hi_sum = 0.0, 0.0
|
||||||
|
for u in sorted(bounds):
|
||||||
|
b = bounds[u]
|
||||||
|
if live is not None and u not in live:
|
||||||
|
continue
|
||||||
|
if getattr(b, "contradiction", False):
|
||||||
|
continue
|
||||||
|
pv = getattr(b, "pinned_w", None)
|
||||||
|
if getattr(b, "gate", None) is not None and pv is not None:
|
||||||
|
lo, hi = _int_envelope(b)
|
||||||
|
p = float(pv) if lo > hi else float(_clamp_int(round(float(pv)),
|
||||||
|
lo, hi))
|
||||||
|
lo_sum += min(0.0, p)
|
||||||
|
hi_sum += max(0.0, p)
|
||||||
|
continue
|
||||||
|
lo, hi, _ = unit_interval(b)
|
||||||
|
lo, hi = _int_envelope(_Narrowed(lo, hi))
|
||||||
|
if lo > hi:
|
||||||
|
continue
|
||||||
|
lo_sum += min(0.0, lo)
|
||||||
|
hi_sum += max(0.0, hi)
|
||||||
|
return lo_sum, hi_sum
|
||||||
|
|
||||||
|
|
||||||
def _int_envelope(b):
|
def _int_envelope(b):
|
||||||
"""A unit's interval, narrowed to the integers a 1 W register can hold.
|
"""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=(),
|
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`.
|
"""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,
|
`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
|
`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.
|
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.
|
`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
|
`tick` an opaque token identifying the tick this record is FOR, handed
|
||||||
straight back on the record. SAFETY-28's write gate compares it
|
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
|
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 = []
|
dropped = []
|
||||||
ilo, ihi = {}, {}
|
ilo, ihi = {}, {}
|
||||||
considered = []
|
considered = []
|
||||||
|
mode_faults = []
|
||||||
for u in units:
|
for u in units:
|
||||||
b = bounds[u]
|
b = bounds[u]
|
||||||
if live is not None and u not in live:
|
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.
|
# has no interval to allocate into.
|
||||||
dropped.append((u, DROP_CONTRADICTION))
|
dropped.append((u, DROP_CONTRADICTION))
|
||||||
continue
|
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:
|
if lo > hi:
|
||||||
# An interval that contains no integer. The register is 1 W, so
|
# An interval that contains no integer. The register is 1 W, so
|
||||||
# there is no number this unit could be commanded that both bounds
|
# 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(
|
return Allocation(
|
||||||
target_w=float(target_w), sum_w=sum_w, shares={}, plans=(),
|
target_w=float(target_w), sum_w=sum_w, shares={}, plans=(),
|
||||||
dropped=tuple(dropped), unmet_w=sum_w, quantisation_w=quant,
|
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 ------------------------------------
|
# --- REFUSAL 2: nobody is available ------------------------------------
|
||||||
if not considered:
|
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,
|
return Allocation(target_w=float(target_w), sum_w=sum_w, shares=shares,
|
||||||
plans=tuple(plans), dropped=tuple(dropped),
|
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
|
# because trading against the meter is the entire point - the grid-following
|
||||||
# loop would otherwise cancel every charge the moment it created import.
|
# loop would otherwise cancel every charge the moment it created import.
|
||||||
P_ARBITRAGE = 30 # price arbitrage - trades against the meter on purpose
|
P_ARBITRAGE = 30 # price arbitrage - trades against the meter on purpose
|
||||||
|
# OPT-11. A battery's mode (Solar first, Leftover solar, Off): a SOFT unit
|
||||||
|
# limit, kept apart from the hard ones. Above arbitrage, so a trade stays inside
|
||||||
|
# each battery's mode; below the peak guard, to which a mode gives way only as
|
||||||
|
# far as the floor needs (`_unit_modes()`, `_site_mode()`).
|
||||||
|
# DECISIONS/OPT-11-battery-modes.md.
|
||||||
|
P_MODE = 35
|
||||||
P_LOOP = 10 # ordinary grid-following
|
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"
|
SET = "set"
|
||||||
LIMIT = "limit"
|
LIMIT = "limit"
|
||||||
|
|
||||||
@@ -238,6 +247,13 @@ CLAIM_SCOPE = {
|
|||||||
# this pack to its floor, charge this pack to full, recalibrate this pack's
|
# this pack to its floor, charge this pack to full, recalibrate this pack's
|
||||||
# coulomb counter. Two packs recalibrate on their own schedules.
|
# coulomb counter. Two packs recalibrate on their own schedules.
|
||||||
"maintenance": SCOPE_UNIT,
|
"maintenance": SCOPE_UNIT,
|
||||||
|
# main.py: `Claim.limit("mode", P_MODE, lo, hi, ...)`, OPT-11. THIS
|
||||||
|
# battery's mode, chosen per battery by its owner. A unit claim that never
|
||||||
|
# enters a `lo > hi` test with a site limit: stage 1 keeps it apart from the
|
||||||
|
# hard limits, and it gives way to a site limit above P_MODE only as far as
|
||||||
|
# that limit needs (`_unit_modes()`). At N=1 it routes to the site, and
|
||||||
|
# `resolve()` takes it out of stage 2 for the same reason.
|
||||||
|
"mode": SCOPE_UNIT,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -365,6 +381,13 @@ class UnitBounds:
|
|||||||
pinned_w: float | None = None
|
pinned_w: float | None = None
|
||||||
pinned_by: str | None = None
|
pinned_by: str | None = None
|
||||||
contradiction: bool = False
|
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:
|
def explain(self) -> str:
|
||||||
if self.contradiction:
|
if self.contradiction:
|
||||||
@@ -410,6 +433,15 @@ class Resolution:
|
|||||||
# rules drifting apart: this is set by the same line that picks the winner
|
# rules drifting apart: this is set by the same line that picks the winner
|
||||||
# `explain()` names.
|
# `explain()` names.
|
||||||
winner_priority: int | None = None
|
winner_priority: int | None = None
|
||||||
|
# OPT-11. The higher of the winning set's priority and the priority of the
|
||||||
|
# limit that bound `target_w`. Whether the modes hold is decided from the
|
||||||
|
# limits themselves (`_site_mode()`, `_unit_modes()`): a mode gives way
|
||||||
|
# only as far as a limit above it (the peak floor) needs, so a mode `hi`
|
||||||
|
# never meets a site `lo`.
|
||||||
|
deciding_priority: int | None = None
|
||||||
|
# OPT-11 at N=1: why the site's mode layer was dropped this tick, or None.
|
||||||
|
# At N>1 the same fact is on `allocation.mode_faults`, per unit.
|
||||||
|
mode_fault: str | None = None
|
||||||
|
|
||||||
def explain(self) -> str:
|
def explain(self) -> str:
|
||||||
"""One line an operator can act on - this is the point of the module."""
|
"""One line an operator can act on - this is the point of the module."""
|
||||||
@@ -433,7 +465,8 @@ def pre_resolve(unit_claims: list) -> UnitBounds:
|
|||||||
of the fault this card removes, and the reason `resolve()` partitions rather
|
of the fault this card removes, and the reason `resolve()` partitions rather
|
||||||
than filters twice.
|
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)
|
key=lambda c: c.priority, reverse=True)
|
||||||
gate = pinned_w = pinned_by = None
|
gate = pinned_w = pinned_by = None
|
||||||
if sets:
|
if sets:
|
||||||
@@ -441,19 +474,30 @@ def pre_resolve(unit_claims: list) -> UnitBounds:
|
|||||||
|
|
||||||
lo, hi = -inf, inf
|
lo, hi = -inf, inf
|
||||||
lo_src = hi_src = None
|
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:
|
for c in unit_claims:
|
||||||
if c.kind != LIMIT or (gate is not None and c.priority < gate):
|
if c.kind != LIMIT or (gate is not None and c.priority < gate):
|
||||||
continue
|
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:
|
if c.lo > lo:
|
||||||
lo, lo_src = c.lo, c.source
|
lo, lo_src = c.lo, c.source
|
||||||
if c.hi < hi:
|
if c.hi < hi:
|
||||||
hi, hi_src = c.hi, c.source
|
hi, hi_src = c.hi, c.source
|
||||||
return UnitBounds(lo, hi, lo_src, hi_src, gate, pinned_w, pinned_by,
|
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,
|
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)`.
|
"""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.
|
⚠️ THE ALLOCATOR ITSELF IS `app/allocator.py` AND THE REASONING LIVES THERE.
|
||||||
@@ -491,11 +535,13 @@ def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
|
|||||||
"""
|
"""
|
||||||
return allocate_record(target_w, bounds, weights=weights, live=live,
|
return allocate_record(target_w, bounds, weights=weights, live=live,
|
||||||
scope_errors=scope_errors,
|
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,
|
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)`.
|
"""`allocate()` plus the record - `(shares, allocated, allocation)`.
|
||||||
|
|
||||||
`tick` is SAFETY-28's freshness token: it identifies the tick the record is
|
`tick` is SAFETY-28's freshness token: it identifies the tick the record is
|
||||||
@@ -507,7 +553,7 @@ def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
|
|||||||
rec = allocator.plan(target_w, bounds, weights=weights, live=live,
|
rec = allocator.plan(target_w, bounds, weights=weights, live=live,
|
||||||
scope_errors=scope_errors,
|
scope_errors=scope_errors,
|
||||||
site_priority=site_priority, site_key=ACTUATOR_SITE,
|
site_priority=site_priority, site_key=ACTUATOR_SITE,
|
||||||
tick=tick)
|
tick=tick, deciding_priority=deciding_priority)
|
||||||
return rec.shares, rec.ok, rec
|
return rec.shares, rec.ok, rec
|
||||||
|
|
||||||
|
|
||||||
@@ -544,19 +590,26 @@ def site_resolve(claims: list) -> Resolution:
|
|||||||
considered = [f"{c.source}:{c.kind}" for c in claims]
|
considered = [f"{c.source}:{c.kind}" for c in claims]
|
||||||
|
|
||||||
if lo > hi:
|
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,
|
return Resolution(0.0, winner.source, bound_by=None,
|
||||||
contradiction=True, considered=considered,
|
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))
|
target = max(lo, min(hi, winner.value))
|
||||||
bound_by = None
|
bound_by = None
|
||||||
|
deciding = winner.priority
|
||||||
if target != winner.value:
|
if target != winner.value:
|
||||||
binder = lo_src if target == lo else hi_src
|
binder = lo_src if target == lo else hi_src
|
||||||
if binder is not None:
|
if binder is not None:
|
||||||
bound_by = f"{binder.source}({binder.reason})" if binder.reason else binder.source
|
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,
|
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,
|
def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||||
@@ -606,6 +659,12 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
|||||||
written down.
|
written down.
|
||||||
"""
|
"""
|
||||||
site_claims = [c for c in claims if c.actuator == ACTUATOR_SITE]
|
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:
|
if units is None:
|
||||||
units = {c.actuator for c in claims if c.actuator != ACTUATOR_SITE}
|
units = {c.actuator for c in claims if c.actuator != ACTUATOR_SITE}
|
||||||
units = tuple(sorted(units))
|
units = tuple(sorted(units))
|
||||||
@@ -617,6 +676,8 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
|||||||
# STAGE 2 - the site claims become the grid target. At N=1 `site_claims` IS
|
# 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.
|
# `claims`, so this is the shipped arbiter on the shipped list.
|
||||||
out = site_resolve(site_claims)
|
out = site_resolve(site_claims)
|
||||||
|
if site_modes:
|
||||||
|
_site_mode(out, site_modes, site_claims)
|
||||||
|
|
||||||
# STAGE 3 - the seam, and since ALLOC-01 the allocator behind it.
|
# STAGE 3 - the seam, and since ALLOC-01 the allocator behind it.
|
||||||
#
|
#
|
||||||
@@ -649,10 +710,125 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
|||||||
# nothing. See `packshare.weights()` on the residual this leaves (the fill's
|
# nothing. See `packshare.weights()` on the residual this leaves (the fill's
|
||||||
# own direction can differ from the target's sign under a pin, in which case
|
# own direction can differ from the target's sign under a pin, in which case
|
||||||
# the weighting is a worse preference and still cannot be unsafe).
|
# the weighting is a worse preference and still cannot be unsafe).
|
||||||
|
# OPT-11: whether each unit's mode holds this tick (`_unit_modes()`).
|
||||||
|
# Before the weights, because a limit above the modes may move the target.
|
||||||
|
gate, held = _unit_modes(out, site_claims, bounds, live)
|
||||||
if callable(weights):
|
if callable(weights):
|
||||||
weights = weights(out.target_w)
|
weights = weights(out.target_w)
|
||||||
|
if held:
|
||||||
|
weights = {u: (0.0 if u in held else
|
||||||
|
(1.0 if weights is None else weights.get(u, 1.0)))
|
||||||
|
for u in bounds}
|
||||||
out.shares, out.allocated, out.allocation = allocate_record(
|
out.shares, out.allocated, out.allocation = allocate_record(
|
||||||
out.target_w, bounds, weights=weights, live=live,
|
out.target_w, bounds, weights=weights, live=live,
|
||||||
scope_errors=out.scope_errors, site_priority=out.winner_priority,
|
scope_errors=out.scope_errors, site_priority=out.winner_priority,
|
||||||
tick=tick)
|
tick=tick, deciding_priority=gate)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _limits_above(site_claims: list, prio: int) -> tuple:
|
||||||
|
"""OPT-11: `(lo, lo_src, hi, hi_src)` over the site LIMITs above `prio`.
|
||||||
|
|
||||||
|
These are what a mode may never take the site target outside of: the peak
|
||||||
|
guard (P_PEAK), maintenance shaping (P_MAINT_SHAPE) and the hard limits.
|
||||||
|
"""
|
||||||
|
lo, hi = -inf, inf
|
||||||
|
lo_src = hi_src = None
|
||||||
|
for c in site_claims:
|
||||||
|
if c.kind != LIMIT or c.priority <= prio:
|
||||||
|
continue
|
||||||
|
if c.lo > lo:
|
||||||
|
lo, lo_src = c.lo, c
|
||||||
|
if c.hi < hi:
|
||||||
|
hi, hi_src = c.hi, c
|
||||||
|
return lo, lo_src, hi, hi_src
|
||||||
|
|
||||||
|
|
||||||
|
def _yield_to(out: Resolution, t1: float, above: tuple):
|
||||||
|
"""OPT-11: the target `t1` the modes allow, moved only as far as the limits
|
||||||
|
above the mode need (`_limits_above()`). Returns the claim that moved it,
|
||||||
|
or None when `t1` already satisfies them, and sets `out.target_w`."""
|
||||||
|
lo, lo_src, hi, hi_src = above
|
||||||
|
t2 = max(lo, min(hi, t1))
|
||||||
|
out.target_w = float(t2)
|
||||||
|
if t2 == t1:
|
||||||
|
return None
|
||||||
|
binder = lo_src if t2 == lo else hi_src
|
||||||
|
out.bound_by = (f"{binder.source}({binder.reason})" if binder.reason
|
||||||
|
else binder.source)
|
||||||
|
out.deciding_priority = max(out.deciding_priority or binder.priority,
|
||||||
|
binder.priority)
|
||||||
|
return binder
|
||||||
|
|
||||||
|
|
||||||
|
def _site_mode(out: Resolution, modes: list, site_claims: list) -> None:
|
||||||
|
"""OPT-11 at N=1: the one battery's mode bounds the SITE target, after stage 2.
|
||||||
|
|
||||||
|
A SET above the mode (a safety stop) decides alone. Otherwise the target is
|
||||||
|
clamped into the mode range and then back into the site limits above the
|
||||||
|
mode, so the mode gives way only as far as those limits need: a peak floor
|
||||||
|
of 2000 W over an Off battery asks it for 2000 W, whether a trade or the
|
||||||
|
loop won stage 2, and a charge the peak guard merely caps stays at 0 W. A
|
||||||
|
range without 0 W is a software fault that drops the mode
|
||||||
|
(`out.mode_fault`). Clamping one number cannot contradict anything.
|
||||||
|
"""
|
||||||
|
out.considered = out.considered + [f"{c.source}:{c.kind}" for c in modes]
|
||||||
|
lims = [c for c in modes if c.kind == LIMIT]
|
||||||
|
if not lims or out.contradiction:
|
||||||
|
return
|
||||||
|
lo = max(c.lo for c in lims)
|
||||||
|
hi = min(c.hi for c in lims)
|
||||||
|
prio = min(c.priority for c in lims)
|
||||||
|
if not (lo <= 0.0 <= hi):
|
||||||
|
out.mode_fault = f"mode range [{lo:.0f}, {hi:.0f}] leaves out 0 W"
|
||||||
|
return
|
||||||
|
if out.winner_priority is not None and out.winner_priority > prio:
|
||||||
|
return
|
||||||
|
before = out.target_w
|
||||||
|
t1 = max(lo, min(hi, before))
|
||||||
|
if _yield_to(out, t1, _limits_above(site_claims, prio)) is None \
|
||||||
|
and t1 != before:
|
||||||
|
c = lims[0]
|
||||||
|
out.bound_by = f"{c.source}({c.reason})" if c.reason else c.source
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_modes(out: Resolution, site_claims: list, bounds: dict, live):
|
||||||
|
"""OPT-11 at N>1: `(gate, held)` for stage 3.
|
||||||
|
|
||||||
|
`gate` is the `deciding_priority` handed to the allocator: None applies
|
||||||
|
every unit's mode, a number above P_MODE releases them. `held` is the set
|
||||||
|
of units whose mode holds them back in the target's direction; while the
|
||||||
|
modes are released they weigh 0, so they carry only what the other units
|
||||||
|
cannot (the allocator's last-resort pass).
|
||||||
|
|
||||||
|
The rule matches N=1: a SET above the mode decides alone. Otherwise the
|
||||||
|
target is clamped into what the units can place with their modes
|
||||||
|
(`allocator.loop_bound()`) and back into the site limits above the mode.
|
||||||
|
If that point is still placeable with the modes, they hold and the target
|
||||||
|
is unchanged (what they cannot place is reported unmet, as before). If a
|
||||||
|
limit above the mode needs more, the target moves only that far, and the
|
||||||
|
modes are released for it.
|
||||||
|
"""
|
||||||
|
mode_units = [u for u, b in bounds.items()
|
||||||
|
if getattr(b, "mode_priority", None) is not None]
|
||||||
|
if not mode_units or out.contradiction:
|
||||||
|
return out.deciding_priority, set()
|
||||||
|
prio = min(bounds[u].mode_priority for u in mode_units)
|
||||||
|
if out.winner_priority is not None and out.winner_priority > prio:
|
||||||
|
return out.deciding_priority, set()
|
||||||
|
clo, chi = allocator.loop_bound(bounds, live)
|
||||||
|
t1 = max(clo, min(chi, out.target_w))
|
||||||
|
before = out.target_w
|
||||||
|
binder = _yield_to(out, t1, _limits_above(site_claims, prio))
|
||||||
|
if binder is None:
|
||||||
|
out.target_w = float(before)
|
||||||
|
return None, set()
|
||||||
|
held = set()
|
||||||
|
for u in mode_units:
|
||||||
|
b = bounds[u]
|
||||||
|
nlo, nhi, note = allocator.unit_interval(b)
|
||||||
|
if note is not None:
|
||||||
|
continue
|
||||||
|
if (nhi < b.hi) if out.target_w > 0 else (nlo > b.lo):
|
||||||
|
held.add(u)
|
||||||
|
return binder.priority, held
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ exists because its absence produced a specific, observed failure.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from math import inf
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -293,6 +294,10 @@ REFUSED_BELOW_W = PROBE_ANSWER_W
|
|||||||
PROBE_EVERY_S = 900.0
|
PROBE_EVERY_S = 900.0
|
||||||
PROBE_S = 34.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)
|
@dataclass(frozen=True)
|
||||||
class Tuning:
|
class Tuning:
|
||||||
@@ -475,6 +480,7 @@ def compute(
|
|||||||
i_w: float | None = None,
|
i_w: float | None = None,
|
||||||
refused: int | None = None,
|
refused: int | None = None,
|
||||||
probe: bool = False,
|
probe: bool = False,
|
||||||
|
bound: tuple | None = None,
|
||||||
) -> Decision:
|
) -> Decision:
|
||||||
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
||||||
|
|
||||||
@@ -485,8 +491,24 @@ def compute(
|
|||||||
`refused` SAFETY-52's carried refused direction, or None to leave it out
|
`refused` SAFETY-52's carried refused direction, or None to leave it out
|
||||||
(the `i_w=None` equivalence path does)
|
(the `i_w=None` equivalence path does)
|
||||||
`probe` the caller's clock says a refused direction may try PROBE_W now
|
`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"
|
reason = "tracking"
|
||||||
|
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
|
||||||
|
# mode may do. It is dropped rather than obeyed (a software fault, and
|
||||||
|
# main.py never builds one: every term of `loop_bound()` holds 0).
|
||||||
|
if bound is not None and not (bound[0] <= 0.0 <= bound[1]):
|
||||||
|
bound = None
|
||||||
|
# ...and a side of it that reaches the rail is left to the rail, so that
|
||||||
|
# SAFETY-03's `i_clamped` still fires when the loop winds into it. With
|
||||||
|
# every battery on, the bound IS the rail, and the law is exactly as it was.
|
||||||
|
if bound is not None:
|
||||||
|
_rail = min(tuning.max_w, tuning.max_w if tuning.integrator_max_w is None
|
||||||
|
else tuning.integrator_max_w)
|
||||||
|
bound = (float(bound[0]) if bound[0] > -_rail else -inf,
|
||||||
|
float(bound[1]) if bound[1] < _rail else inf)
|
||||||
|
|
||||||
# --- saturation, WITH the duration term -------------------------------
|
# --- saturation, WITH the duration term -------------------------------
|
||||||
# Command and actual diverging means the inverter cannot follow - it is at
|
# Command and actual diverging means the inverter cannot follow - it is at
|
||||||
@@ -589,6 +611,15 @@ def compute(
|
|||||||
else:
|
else:
|
||||||
i_w = max(moved, i_w)
|
i_w = max(moved, i_w)
|
||||||
|
|
||||||
|
# OPT-11: the integrator does not wind past what the modes let the
|
||||||
|
# batteries place. Only a side tighter than the rail is left in `bound`
|
||||||
|
# (above), so this never takes SAFETY-03's `i_clamped` away from the rail.
|
||||||
|
if bound is not None:
|
||||||
|
held = max(float(bound[0]), min(float(bound[1]), i_w))
|
||||||
|
if held != i_w:
|
||||||
|
i_w = held
|
||||||
|
reason = MODE_BOUND
|
||||||
|
|
||||||
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
|
# ⚠️ 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
|
# 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.
|
# knowable instead of a function of how long the error happened to stand.
|
||||||
@@ -796,6 +827,22 @@ def compute(
|
|||||||
target = refused * PROBE_W if probe and wants else 0.0
|
target = refused * PROBE_W if probe and wants else 0.0
|
||||||
reason = PROBING if probe and wants else REFUSED
|
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 ----------------------------------------------------------
|
# --- quantise ----------------------------------------------------------
|
||||||
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
|
# 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
|
# response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent
|
||||||
@@ -803,6 +850,13 @@ def compute(
|
|||||||
# 10 W simply avoids a visible staircase on dashboards.
|
# 10 W simply avoids a visible staircase on dashboards.
|
||||||
step = max(1, int(tuning.step_w))
|
step = max(1, int(tuning.step_w))
|
||||||
target = round(target / step) * step
|
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),
|
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||||
i_clamped, clamped, float(i_bound_w), float(want),
|
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
|
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
|
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
|
cycle, the peak limit and "follows the house". OPT-11 P1 adds each battery's
|
||||||
built, so nothing here invents a mode plan. Every sentence is in the words
|
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.
|
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,
|
running=running, held=held,
|
||||||
would_w=shares.get(inv.name),
|
would_w=shares.get(inv.name),
|
||||||
settled=at_zero(inv)),
|
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,
|
"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
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@@ -723,6 +732,11 @@ def mode_house() -> str:
|
|||||||
return _t("mode_house")
|
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:
|
def attn_shadow() -> str:
|
||||||
return _t("attn_shadow")
|
return _t("attn_shadow")
|
||||||
|
|
||||||
@@ -964,6 +978,7 @@ def _samples() -> list:
|
|||||||
out = [("title", t) for t in words["verdict"].values()]
|
out = [("title", t) for t in words["verdict"].values()]
|
||||||
out += [("title", _t("zero_going")), ("title", _t("zero_held"))]
|
out += [("title", _t("zero_going")), ("title", _t("zero_held"))]
|
||||||
out += [("title", t) for t in words["attn"].values()]
|
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()),
|
out += [("title", mode_house()), ("title", attn_shadow()),
|
||||||
("line", attn_shadow_body())]
|
("line", attn_shadow_body())]
|
||||||
for k in ("running", "shadow", "stopped", "alarm", "warning"):
|
for k in ("running", "shadow", "stopped", "alarm", "warning"):
|
||||||
|
|||||||
+176
-26
@@ -37,9 +37,11 @@ import aiohttp
|
|||||||
|
|
||||||
from . import arbitrage
|
from . import arbitrage
|
||||||
from .arbiter import (
|
from .arbiter import (
|
||||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY,
|
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_MODE, P_PEAK, P_SAFETY,
|
||||||
Claim, Resolution, actuator_for, resolve,
|
Claim, Resolution, actuator_for, pre_resolve, resolve,
|
||||||
)
|
)
|
||||||
|
from . import allocator
|
||||||
|
from . import packmode
|
||||||
from . import control as _ctl
|
from . import control as _ctl
|
||||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||||
from .delivery import (
|
from .delivery import (
|
||||||
@@ -67,7 +69,8 @@ from .hass import HomeAssistant
|
|||||||
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
|
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
|
||||||
roster_entries, roster_names)
|
roster_entries, roster_names)
|
||||||
from .maintenance import (
|
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,
|
from .mqtt import (MqttPublisher, announced_units, optional_keys, unit_slugs,
|
||||||
unit_state_key)
|
unit_state_key)
|
||||||
@@ -894,6 +897,13 @@ class Controller:
|
|||||||
self._peak_saturated = False
|
self._peak_saturated = False
|
||||||
self._peak_floor_limited = False
|
self._peak_floor_limited = False
|
||||||
self._maint_shape_suspended = 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.
|
# [(pack name, why)] for packs that are due and waiting their turn.
|
||||||
# Empty at a roster of one, where nothing can defer anything.
|
# Empty at a roster of one, where nothing can defer anything.
|
||||||
self._maint_deferred = []
|
self._maint_deferred = []
|
||||||
@@ -1953,7 +1963,103 @@ class Controller:
|
|||||||
that have nothing to do with the allocator, and a weighting held from
|
that have nothing to do with the allocator, and a weighting held from
|
||||||
construction prefers a pack that has since emptied.
|
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
|
@property
|
||||||
def why_words(self) -> str:
|
def why_words(self) -> str:
|
||||||
@@ -2696,9 +2802,27 @@ class Controller:
|
|||||||
# Calling `tick()` without the answer this produced is the
|
# Calling `tick()` without the answer this produced is the
|
||||||
# two-packs-draining-at-once defect with the interlock present but not
|
# 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.
|
# 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 = []
|
maint_results = []
|
||||||
for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan):
|
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)
|
res = machine.tick(now, inv.soc, blocked_by=blocked)
|
||||||
for msg in res.events:
|
for msg in res.events:
|
||||||
self.log_event(msg, hint=messages.hint("maintenance",
|
self.log_event(msg, hint=messages.hint("maintenance",
|
||||||
@@ -3564,6 +3688,23 @@ class Controller:
|
|||||||
actuator=self.actuator_of("maintenance",
|
actuator=self.actuator_of("maintenance",
|
||||||
inv)))
|
inv)))
|
||||||
|
|
||||||
|
# --- OPT-11: each battery's mode, a soft unit limit at P_MODE ---------
|
||||||
|
# Filed only with battery modes on, and never on a battery in any
|
||||||
|
# maintenance phase: maintenance at 60/40 outranks the mode. The range
|
||||||
|
# always holds 0 W (packmode.mode_range()). pre_resolve() keeps it
|
||||||
|
# apart from the hard limits, and it gives way to the peak guard's
|
||||||
|
# site floor (P_PEAK) only as far as the floor needs, so the two never
|
||||||
|
# meet in a `lo > hi` (arbiter._unit_modes()).
|
||||||
|
if self.modes_on:
|
||||||
|
for inv, result in zip(self.inverters, maint_results):
|
||||||
|
if result.phase != IDLE:
|
||||||
|
continue
|
||||||
|
pm = self.pack_mode(inv)
|
||||||
|
lo, hi = packmode.mode_range(pm, inv.max_w)
|
||||||
|
claims.append(Claim.limit("mode", P_MODE, lo, hi, pm.key,
|
||||||
|
actuator=self.actuator_of("mode",
|
||||||
|
inv)))
|
||||||
|
|
||||||
# --- the grid-following loop -----------------------------------------
|
# --- the grid-following loop -----------------------------------------
|
||||||
# ⚠️ SAFETY-10: `or self.shadow`. The loop is the leg a shadow run most
|
# ⚠️ 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
|
# needs and the one that is switched off by `auto_start: false`, so
|
||||||
@@ -3588,6 +3729,28 @@ class Controller:
|
|||||||
self._unit_refusals()
|
self._unit_refusals()
|
||||||
_probing = {inv.name: self._unit_probe(inv)
|
_probing = {inv.name: self._unit_probe(inv)
|
||||||
for inv in self.inverters if inv.refused}
|
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:
|
if (self.auto or self.shadow) and self.inputs_ok:
|
||||||
# ⚠️ prev is the ARBITER's last output, not the loop's own last
|
# ⚠️ prev is the ARBITER's last output, not the loop's own last
|
||||||
# wish. If something outranked the loop, that is what the hardware
|
# 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
|
refused=self.batt_refused if len(self.inverters) == 1
|
||||||
else self._site_refused(),
|
else self._site_refused(),
|
||||||
probe=probe,
|
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:
|
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
|
||||||
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
|
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
|
# 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
|
# unit being commanded and cannot move a failing unit's load to its
|
||||||
# sibling. The `pack` claim filed above is the half that constrains.
|
# 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()
|
_live = self.live_units()
|
||||||
while True:
|
while True:
|
||||||
resolution = resolve(claims, units=self.units, live=_live,
|
resolution = resolve(claims, units=self.units, live=_live,
|
||||||
@@ -3796,6 +3942,8 @@ class Controller:
|
|||||||
*_refused_interval(1 if claims[k].value > 0 else -1),
|
*_refused_interval(1 if claims[k].value > 0 else -1),
|
||||||
_ctl.REFUSED, actuator=claims[k].actuator)
|
_ctl.REFUSED, actuator=claims[k].actuator)
|
||||||
self.allocation = resolution.allocation
|
self.allocation = resolution.allocation
|
||||||
|
if self.modes_on:
|
||||||
|
self._mode_fault_log(resolution)
|
||||||
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
|
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
|
||||||
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
|
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
|
||||||
# which is then written like any other cycle at
|
# which is then written like any other cycle at
|
||||||
@@ -5743,8 +5891,10 @@ class Controller:
|
|||||||
out["home_error"] = True
|
out["home_error"] = True
|
||||||
return out
|
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)
|
now = datetime.now(timezone.utc)
|
||||||
|
if what in ("pack_use", "pack_mode"):
|
||||||
|
return self._pack_action(what, unit, value, now)
|
||||||
if what == "auto_toggle":
|
if what == "auto_toggle":
|
||||||
self.auto = not self.auto
|
self.auto = not self.auto
|
||||||
self.store.set("auto", self.auto)
|
self.store.set("auto", self.auto)
|
||||||
|
|||||||
@@ -919,9 +919,26 @@ HINTS = {
|
|||||||
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
|
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
|
||||||
"maintenance": "Battery maintenance: {phase}",
|
"maintenance": "Battery maintenance: {phase}",
|
||||||
"maint_paused": "Maintenance charging paused to stay under the peak limit",
|
"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:
|
def hint(key: str, **kw) -> str:
|
||||||
"""The plain words for one event. `w` is formatted as watts, `sw` as
|
"""The plain words for one event. `w` is formatted as watts, `sw` as
|
||||||
watts with their sign (positive gives power, negative charges)."""
|
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;
|
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() {
|
function renderBatteries() {
|
||||||
if (!S.batteries) { $("#bcards").replaceChildren(); return; }
|
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" },
|
$("#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" : "")),
|
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),
|
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) + " %"),
|
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,
|
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.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 ---------------- */
|
/* ---------------- control buttons ---------------- */
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
<section class="screen" id="s-batt" role="tabpanel" aria-labelledby="t-batt" hidden>
|
<section class="screen" id="s-batt" role="tabpanel" aria-labelledby="t-batt" hidden>
|
||||||
<div id="bcards"></div>
|
<div id="bcards"></div>
|
||||||
<div class="card">
|
<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>
|
<a class="btn" href="#settings" data-t="batteries.settings"></a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"parts": {
|
"parts": {
|
||||||
"meter.timing": {"name": "Timing"},
|
"meter.timing": {"name": "Timing"},
|
||||||
"inverters.solar": {"name": "Solar"},
|
"inverters.solar": {"name": "Solar"},
|
||||||
|
"inverters.modes": {"name": "Battery modes"},
|
||||||
"tariff.selling": {"name": "Charging and selling on price"},
|
"tariff.selling": {"name": "Charging and selling on price"},
|
||||||
"tariff.feed": {"name": "Price feed details"},
|
"tariff.feed": {"name": "Price feed details"},
|
||||||
"safety.peak": {"name": "Peak limit"},
|
"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."},
|
"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."},
|
"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"},
|
"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"}
|
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}
|
||||||
},
|
},
|
||||||
"choices_later": {
|
"choices_later": {
|
||||||
@@ -228,6 +236,7 @@
|
|||||||
},
|
},
|
||||||
"home_words": {
|
"home_words": {
|
||||||
"mode_house": "Follows the house",
|
"mode_house": "Follows the house",
|
||||||
|
"modes": {"solar_first": "Solar first", "leftover_solar": "Leftover solar", "off": "Off"},
|
||||||
"attn_shadow": "Shadow mode is on",
|
"attn_shadow": "Shadow mode is on",
|
||||||
"attn_shadow_body": "The add-on decides everything, but every inverter stays at 0 W.",
|
"attn_shadow_body": "The add-on decides everything, but every inverter stays at 0 W.",
|
||||||
"zero_held": "Held at 0 W",
|
"zero_held": "Held at 0 W",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"parts": {
|
"parts": {
|
||||||
"meter.timing": {"name": "Timing"},
|
"meter.timing": {"name": "Timing"},
|
||||||
"inverters.solar": {"name": "Zonnepanelen"},
|
"inverters.solar": {"name": "Zonnepanelen"},
|
||||||
|
"inverters.modes": {"name": "Batterijmodi"},
|
||||||
"tariff.selling": {"name": "Laden en verkopen op prijs"},
|
"tariff.selling": {"name": "Laden en verkopen op prijs"},
|
||||||
"tariff.feed": {"name": "Details van de prijsfeed"},
|
"tariff.feed": {"name": "Details van de prijsfeed"},
|
||||||
"safety.peak": {"name": "Piekgrens"},
|
"safety.peak": {"name": "Piekgrens"},
|
||||||
@@ -79,7 +80,14 @@
|
|||||||
"toast": {"started": "Regeling gestart.", "stopped": "Regeling gestopt.", "done": "Klaar.", "failed": "Dat lukte niet. Probeer opnieuw."},
|
"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."},
|
"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"},
|
"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"}
|
"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"}},
|
"choices_later": {"region": {"wallonia": "Wallonië, nog geen standaardwaarde", "brussels": "Brussel, nog geen standaardwaarde"}},
|
||||||
@@ -202,6 +210,7 @@
|
|||||||
},
|
},
|
||||||
"home_words": {
|
"home_words": {
|
||||||
"mode_house": "Volgt het huis",
|
"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": "Schaduwmodus staat aan",
|
||||||
"attn_shadow_body": "De add-on beslist alles, maar elke omvormer blijft op 0 W.",
|
"attn_shadow_body": "De add-on beslist alles, maar elke omvormer blijft op 0 W.",
|
||||||
"zero_held": "Op 0 W gehouden",
|
"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.
|
# pv.py reads each entity's own unit, so a kW sensor is fine here.
|
||||||
Part("solar", (Field("ui.solar", SOLAR_LIST, kind="power",
|
Part("solar", (Field("ui.solar", SOLAR_LIST, kind="power",
|
||||||
wizard=3),)),
|
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", (
|
Group("tariff", (
|
||||||
Part("", (
|
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).
|
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
|
UX-15's setup-only mode serves these routes from `setuponly.SetupOnly`, which
|
||||||
commands nothing; there `/action` answers 409.
|
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
|
import io
|
||||||
@@ -53,6 +57,9 @@ from .uimodel import DeriveError, options_to_ui
|
|||||||
|
|
||||||
_LOG = logging.getLogger("goodwe.web")
|
_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,
|
# 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
|
# 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
|
# 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.
|
# UX-15: nothing to start or stop until setup is saved.
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"ok": False, "error": messages.row_setup_only()}, status=409)
|
{"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:
|
if result is None:
|
||||||
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
|
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
|
||||||
return web.json_response({"ok": True, "result": result})
|
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.
|
# options blob, and the upgrade leaves an add-on that cannot start.
|
||||||
inverters: []
|
inverters: []
|
||||||
estop_fitted: false
|
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
|
# 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
|
# DECIDES ANYTHING. Optional, empty on every install that does not set it, and
|
||||||
@@ -943,6 +948,7 @@ schema:
|
|||||||
capacity_kwh: float(0.1,200)?
|
capacity_kwh: float(0.1,200)?
|
||||||
|
|
||||||
estop_fitted: bool
|
estop_fitted: bool
|
||||||
|
battery_modes_enabled: bool
|
||||||
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
|
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
|
||||||
# `price_now_entity`/`price_avg_entity`, which are legal only while
|
# `price_now_entity`/`price_avg_entity`, which are legal only while
|
||||||
# `price_provider` is `off` and make the add-on REFUSE TO START otherwise.
|
# `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:]))
|
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()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} FAILED: {', '.join(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")
|
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
|
||||||
|
|
||||||
print()
|
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
|
# 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
|
# 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
|
# 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 "
|
"refused": "THIS battery's own reading gave nothing against THIS unit's "
|
||||||
"own standing command, so that direction is closed on THIS "
|
"own standing command, so that direction is closed on THIS "
|
||||||
"unit and its share goes to the others. Filed at N>1 only",
|
"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()):
|
for _src, _why in sorted(SITE_SOURCES.items()):
|
||||||
print(f" site {_src:<11} {_why}")
|
print(f" site {_src:<11} {_why}")
|
||||||
for _src, _why in sorted(UNIT_SOURCES.items()):
|
for _src, _why in sorted(UNIT_SOURCES.items()):
|
||||||
print(f" unit {_src:<11} {_why}")
|
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))
|
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
|
||||||
check("the six that are about the GRID CONNECTION are site claims",
|
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))
|
{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))
|
{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() "
|
check("a source nobody classified cannot be routed at all - actuator_for() "
|
||||||
"raises rather than defaulting to the site, because defaulting to the "
|
"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))
|
all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE))
|
||||||
check("...and with none configured at all, likewise",
|
check("...and with none configured at all, likewise",
|
||||||
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
|
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 "
|
"per-inverter sources route to the unit this controller drives, the six "
|
||||||
"site sources stay on the grid connection",
|
"site sources stay on the grid connection",
|
||||||
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
|
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
|
||||||
@@ -540,6 +544,252 @@ check("...and a correctly routed cycle is unaffected, which is what says this "
|
|||||||
resolve(SITE_SIDE, units=TWO).allocated is True)
|
resolve(SITE_SIDE, units=TWO).allocated is True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("OPT-11 9. the mode layer: a unit `hi` never meets the site `lo`")
|
||||||
|
# A battery's mode is a SOFT unit limit at P_MODE (35). Stage 1 keeps it apart
|
||||||
|
# from the hard limits; stage 3 applies it only while the site target was
|
||||||
|
# decided at or below P_MODE. So the peak guard's site floor (P_PEAK) releases
|
||||||
|
# every mode and SAFETY-25 section 0a's cross-inverter contradiction - 0 W in a
|
||||||
|
# billed period - still cannot be written down.
|
||||||
|
from app.arbiter import MODE_SOURCE, P_MODE # noqa: E402
|
||||||
|
from app.allocator import MODE_FAULT, MODE_RELEASED # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _two(loop_w, *, peak_lo=None, modes=None, rating=2000.0, arb=None):
|
||||||
|
"""A two-inverter cycle's claims: ratings, each unit's mode, the loop,
|
||||||
|
optionally the peak floor and an arbitrage set."""
|
||||||
|
cl = []
|
||||||
|
for u in TWO:
|
||||||
|
cl.append(Claim.limit("supervised", P_SAFETY, -rating, rating,
|
||||||
|
"max_w", actuator=u))
|
||||||
|
for u, (lo, hi) in (modes or {}).items():
|
||||||
|
cl.append(Claim.limit(MODE_SOURCE, P_MODE, lo, hi, "mode", actuator=u))
|
||||||
|
cl.append(Claim.set("loop", P_LOOP, loop_w))
|
||||||
|
if arb is not None:
|
||||||
|
cl.append(Claim.set("arbitrage", P_ARBITRAGE, arb))
|
||||||
|
if peak_lo is not None:
|
||||||
|
cl.append(Claim.limit("peak", P_PEAK, lo=peak_lo))
|
||||||
|
return cl
|
||||||
|
|
||||||
|
|
||||||
|
check("P_MODE sits between arbitrage and the peak guard, so a trade stays "
|
||||||
|
"inside each mode and the peak floor releases them all",
|
||||||
|
P_ARBITRAGE < P_MODE < P_PEAK and P_MODE < P_MAINT_SHAPE)
|
||||||
|
_off_b = {"inverter-b": (0.0, 0.0)}
|
||||||
|
r = resolve(_two(-500.0, peak_lo=3000.0, modes=_off_b), units=TWO)
|
||||||
|
print(f" peak floor 3000 W, loop asks -500 W, B off: {r.explain()}, "
|
||||||
|
f"deciding {r.deciding_priority}, shares {r.shares}")
|
||||||
|
check("⭐ at N=2 a mode `hi` of 0 W on B and the peak guard's site `lo` of "
|
||||||
|
"3000 W do NOT contradict: no 0 W stop, the floor decides S (priority "
|
||||||
|
"50), the mode is released, and the shares still sum to S",
|
||||||
|
not r.contradiction and r.allocated and r.target_w == 3000.0
|
||||||
|
and r.deciding_priority == P_PEAK
|
||||||
|
and sum(r.shares.values()) == 3000 and r.allocation.unmet_w == 0)
|
||||||
|
_forbidden = [c if c.source != MODE_SOURCE
|
||||||
|
else Claim.limit(MODE_SOURCE, P_MODE, c.lo, c.hi,
|
||||||
|
actuator=ACTUATOR_SITE)
|
||||||
|
for c in _two(-500.0, peak_lo=3000.0, modes=_off_b)]
|
||||||
|
_rf = site_resolve([c for c in _forbidden if c.actuator == ACTUATOR_SITE])
|
||||||
|
check("...and the SAME mode filed as a SITE limit is exactly the fault the "
|
||||||
|
"layer exists to keep out: stage 2 then reads `lo` 3000 > `hi` 0 and "
|
||||||
|
"stops the site at 0 W (so the check above sees the difference)",
|
||||||
|
_rf.contradiction and _rf.target_w == 0.0)
|
||||||
|
check("...and resolve() refuses that mis-filed mode at N=2 (a UNIT claim on "
|
||||||
|
"the site), rather than dividing a number that may be wrong",
|
||||||
|
resolve(_forbidden, units=TWO).allocated is False)
|
||||||
|
|
||||||
|
# The sweep: every mode range that holds 0 W, every loop ask, every floor.
|
||||||
|
_bad = []
|
||||||
|
_n = 0
|
||||||
|
_spans = [(0.0, 0.0), (-2000.0, 0.0), (0.0, 2000.0), (-2000.0, 2000.0),
|
||||||
|
(-700.0, 300.0)]
|
||||||
|
for _ma in _spans:
|
||||||
|
for _mb in _spans:
|
||||||
|
for _loop in (-5000.0, -1200.0, 0.0, 800.0, 3900.0):
|
||||||
|
for _peak in (None, 0.0, 1500.0, 3000.0, 4000.0):
|
||||||
|
_n += 1
|
||||||
|
_r = resolve(_two(_loop, peak_lo=_peak,
|
||||||
|
modes={"inverter-a": _ma,
|
||||||
|
"inverter-b": _mb}), units=TWO)
|
||||||
|
_al = _r.allocation
|
||||||
|
_sh = list(_r.shares.values())
|
||||||
|
if (_r.contradiction or not _r.allocated
|
||||||
|
or sum(_sh) + _al.unmet_w != _al.sum_w
|
||||||
|
or (any(x > 0 for x in _sh) and any(x < 0 for x in _sh))
|
||||||
|
or (_peak is not None and _r.target_w < _peak)):
|
||||||
|
_bad.append((_ma, _mb, _loop, _peak, _r.explain(),
|
||||||
|
_al.explain()))
|
||||||
|
for x in _bad[:5]:
|
||||||
|
print(f" BAD {x}")
|
||||||
|
check(f"over {_n} two-inverter cycles (5x5 mode ranges holding 0 W, 5 loop "
|
||||||
|
f"asks, 5 peak floors) no cycle contradicts, refuses, loses a watt of "
|
||||||
|
f"the sum, splits the sign, or goes under the peak floor",
|
||||||
|
not _bad and _n == 625)
|
||||||
|
|
||||||
|
check("deciding_priority: the loop alone decides at 10",
|
||||||
|
resolve(_two(800.0), units=TWO).deciding_priority == P_LOOP)
|
||||||
|
check("...a trade decides at 30, so the modes still apply to it",
|
||||||
|
resolve(_two(800.0, arb=-1500.0), units=TWO).deciding_priority
|
||||||
|
== P_ARBITRAGE)
|
||||||
|
check("...a peak floor that does not bind leaves the loop deciding at 10",
|
||||||
|
resolve(_two(3500.0, peak_lo=1000.0), units=TWO).deciding_priority
|
||||||
|
== P_LOOP)
|
||||||
|
check("...and a peak floor that binds decides at 50",
|
||||||
|
resolve(_two(500.0, peak_lo=1000.0), units=TWO).deciding_priority
|
||||||
|
== P_PEAK)
|
||||||
|
_r35 = resolve(_two(3000.0, modes=_off_b), units=TWO)
|
||||||
|
check("with the loop deciding, B's Off mode holds: B gets 0 W and A carries "
|
||||||
|
"what it can, the rest reported as unmet",
|
||||||
|
_r35.shares == {"inverter-a": 2000, "inverter-b": 0}
|
||||||
|
and _r35.allocation.unmet_w == 1000)
|
||||||
|
|
||||||
|
_ub = pre_resolve([Claim.limit("supervised", P_SAFETY, -2000, 2000),
|
||||||
|
Claim.limit(MODE_SOURCE, P_MODE, 0.0, 0.0)])
|
||||||
|
check("stage 1 keeps the mode APART from the hard limits: the unit's own "
|
||||||
|
"interval is still its rating and the mode travels beside it",
|
||||||
|
(_ub.lo, _ub.hi, _ub.mode_lo, _ub.mode_hi, _ub.mode_priority)
|
||||||
|
== (-2000, 2000, 0.0, 0.0, P_MODE) and not _ub.contradiction)
|
||||||
|
_ub2 = pre_resolve([Claim.limit("supervised", P_SAFETY, -2000, 2000),
|
||||||
|
Claim.limit(MODE_SOURCE, P_MODE, 500.0, 2000.0)])
|
||||||
|
check("...so a mode that does not hold 0 W cannot make the unit contradict "
|
||||||
|
"itself at stage 1; stage 3 drops it as a software fault instead",
|
||||||
|
not _ub2.contradiction
|
||||||
|
and allocate(1000.0, {"inverter-a": _ub2, "inverter-b": UnitBounds()})[1]
|
||||||
|
and resolve(_two(1000.0, modes={"inverter-a": (500.0, 2000.0)}),
|
||||||
|
units=TWO).allocation.mode_faults
|
||||||
|
== (("inverter-a", MODE_FAULT),))
|
||||||
|
_ubm = pre_resolve([Claim.limit("supervised", P_SAFETY, -2000, 2000),
|
||||||
|
Claim.set("maintenance", P_MAINTENANCE, 2500.0, "drain"),
|
||||||
|
Claim.limit(MODE_SOURCE, P_MODE, 0.0, 0.0)])
|
||||||
|
check("...and a unit's own maintenance SET (60) gates the mode out, as it "
|
||||||
|
"gates every lower limit: maintenance outranks the mode",
|
||||||
|
_ubm.mode_priority is None and _ubm.pinned_by == "maintenance")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("OPT-11 10. at N=1 the mode bounds the site target, by the same rule")
|
||||||
|
_one = [Claim.limit("supervised", P_SAFETY, -5000, 5000, "max_w"),
|
||||||
|
Claim.limit(MODE_SOURCE, P_MODE, 0.0, 0.0, "off")]
|
||||||
|
_r1 = resolve(_one + [Claim.set("loop", P_LOOP, 1200.0)], units=ONE)
|
||||||
|
check("the loop deciding: an Off battery's site target is 0 W, named as the "
|
||||||
|
"mode's doing, with no contradiction",
|
||||||
|
_r1.target_w == 0.0 and not _r1.contradiction
|
||||||
|
and _r1.bound_by == "mode(off)" and _r1.allocated)
|
||||||
|
_r1p = resolve(_one + [Claim.set("loop", P_LOOP, 1200.0),
|
||||||
|
Claim.limit("peak", P_PEAK, lo=2000.0)], units=ONE)
|
||||||
|
check("...a binding peak floor releases it (2000 W, no contradiction), "
|
||||||
|
"which at N=1 would otherwise be `lo` 2000 > `hi` 0 and a 0 W stop",
|
||||||
|
_r1p.target_w == 2000.0 and not _r1p.contradiction
|
||||||
|
and _r1p.deciding_priority == P_PEAK)
|
||||||
|
_r1a = resolve(_one + [Claim.set("arbitrage", P_ARBITRAGE, -3000.0)],
|
||||||
|
units=ONE)
|
||||||
|
check("...a trade (30) stays inside it", _r1a.target_w == 0.0)
|
||||||
|
_r1f = resolve([Claim.limit(MODE_SOURCE, P_MODE, 100.0, 900.0),
|
||||||
|
Claim.set("loop", P_LOOP, -1200.0)], units=ONE)
|
||||||
|
check("...and a mode range without 0 W is dropped and reported, never "
|
||||||
|
"obeyed: the target stays the loop's -1200 W",
|
||||||
|
_r1f.target_w == -1200.0 and _r1f.mode_fault is not None)
|
||||||
|
check("...and with no mode claim at all, stage 2's answer is untouched "
|
||||||
|
"(the N=1 path with battery modes off)",
|
||||||
|
resolve([Claim.set("loop", P_LOOP, 1200.0)], units=ONE).target_w
|
||||||
|
== 1200.0)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("OPT-11 11. a mode gives way only as far as the peak limit needs")
|
||||||
|
# Review of PR #149. The first cut released the modes only when a limit above
|
||||||
|
# P_MODE bound S. A trade (30) that won above the peak floor then kept the
|
||||||
|
# modes, and the units placed less than the floor: the mode beat the peak
|
||||||
|
# guard (a billed peak). And a peak limit that merely capped a charge released
|
||||||
|
# the modes, so an Off battery was charged from the grid. The rule now: clamp
|
||||||
|
# into what the modes can place, then back into the limits above the mode.
|
||||||
|
_offb = {"inverter-b": (0.0, 0.0)}
|
||||||
|
|
||||||
|
|
||||||
|
def _placed(r):
|
||||||
|
return sum(r.shares.values())
|
||||||
|
|
||||||
|
|
||||||
|
_ra = resolve(_two(0.0, arb=3000.0, peak_lo=2000.0, rating=1500.0,
|
||||||
|
modes=_offb)[:-1] + [
|
||||||
|
Claim.set("arbitrage", P_ARBITRAGE, 3000.0),
|
||||||
|
Claim.limit("peak", P_PEAK, lo=2000.0)], units=TWO)
|
||||||
|
print(f" trade 3000 W over a 2000 W floor, A 1500 W, B off: {_ra.shares}")
|
||||||
|
check("⭐ a trade above the peak floor with B Off: the units still place the "
|
||||||
|
"floor (2000 W), A to its rating and B only the 500 W A cannot give",
|
||||||
|
_placed(_ra) == 2000 and _ra.shares == {"inverter-a": 1500,
|
||||||
|
"inverter-b": 500})
|
||||||
|
_r1t = resolve(_one + [Claim.set("arbitrage", P_ARBITRAGE, 3000.0),
|
||||||
|
Claim.limit("peak", P_PEAK, lo=2000.0)], units=ONE)
|
||||||
|
check("⭐ ...and at N=1 an Off battery under a trade gives the peak floor "
|
||||||
|
"(2000 W), not 0 W", _r1t.target_w == 2000.0
|
||||||
|
and _r1t.bound_by == "peak")
|
||||||
|
_rc = resolve(_two(0.0, arb=-3000.0, peak_lo=-1000.0, modes=_offb),
|
||||||
|
units=TWO)
|
||||||
|
check("⭐ a grid charge the peak limit caps at 1000 W does not release B's "
|
||||||
|
"Off: A takes it all and B stays at 0 W",
|
||||||
|
_rc.shares == {"inverter-a": -1000, "inverter-b": 0})
|
||||||
|
_r1c = resolve(_one + [Claim.set("arbitrage", P_ARBITRAGE, -3000.0),
|
||||||
|
Claim.limit("peak", P_PEAK, lo=-1000.0)], units=ONE)
|
||||||
|
check("...and at N=1 an Off battery is not charged from the grid under a "
|
||||||
|
"capped trade", _r1c.target_w == 0.0)
|
||||||
|
_rp = resolve(_two(500.0, peak_lo=1500.0, modes=_offb), units=TWO)
|
||||||
|
check("a peak floor A can carry alone leaves B Off at 0 W",
|
||||||
|
_rp.shares == {"inverter-a": 1500, "inverter-b": 0}
|
||||||
|
and _rp.target_w == 1500.0)
|
||||||
|
_rall = resolve(_two(0.0, peak_lo=3000.0,
|
||||||
|
modes={"inverter-a": (0.0, 0.0),
|
||||||
|
"inverter-b": (0.0, 0.0)}), units=TWO)
|
||||||
|
check("every battery Off and a 3000 W peak floor: the floor is placed, "
|
||||||
|
"split between them, and nothing contradicts",
|
||||||
|
_placed(_rall) == 3000 and not _rall.contradiction
|
||||||
|
and _rall.allocation.refused is None)
|
||||||
|
|
||||||
|
import random as _rnd # noqa: E402
|
||||||
|
_g = _rnd.Random(149)
|
||||||
|
_worse, _needless, _sign, _nn = [], [], [], 0
|
||||||
|
for _ in range(20000):
|
||||||
|
_units = ("inverter-a", "inverter-b", "inverter-c")[:_g.choice((2, 3))]
|
||||||
|
_cl = []
|
||||||
|
for _u in _units:
|
||||||
|
_rt = _g.choice((1000.0, 2500.0))
|
||||||
|
_cl.append(Claim.limit("supervised", P_SAFETY, -_rt, _rt,
|
||||||
|
actuator=_u))
|
||||||
|
if _g.random() < 0.2:
|
||||||
|
_cl.append(Claim.limit("pack", P_SAFETY,
|
||||||
|
*_g.choice(((0.0, inf), (-inf, 0.0))),
|
||||||
|
actuator=_u))
|
||||||
|
_cl.append(Claim.limit(MODE_SOURCE, P_MODE,
|
||||||
|
_g.choice((0.0, -_rt)), _g.choice((0.0, _rt)),
|
||||||
|
actuator=_u))
|
||||||
|
_src = _g.choice(("loop", "arbitrage"))
|
||||||
|
_cl.append(Claim.set(_src, P_LOOP if _src == "loop" else P_ARBITRAGE,
|
||||||
|
_g.uniform(-7000.0, 7000.0)))
|
||||||
|
_pk = _g.uniform(-4000.0, 6000.0)
|
||||||
|
_cl.append(Claim.limit("peak", P_PEAK, lo=_pk))
|
||||||
|
_r = resolve(_cl, units=_units)
|
||||||
|
_r0 = resolve([c for c in _cl if c.source != MODE_SOURCE], units=_units)
|
||||||
|
_nn += 1
|
||||||
|
if _r.allocation.refused is not None:
|
||||||
|
_sign.append(_cl)
|
||||||
|
continue
|
||||||
|
if _placed(_r) < _pk - 1 and _placed(_r0) >= _pk - 1:
|
||||||
|
_worse.append(_cl)
|
||||||
|
# An Off unit gives power only when the peak floor is short without it.
|
||||||
|
_off = [c.actuator for c in _cl if c.source == MODE_SOURCE
|
||||||
|
and c.lo == 0.0 and c.hi == 0.0]
|
||||||
|
# What the units whose mode lets them give power can give, within it.
|
||||||
|
_on_cap = sum(max(0.0, min(_r.unit_bounds[u].hi, _r.unit_bounds[u].mode_hi))
|
||||||
|
for u in _units if _r.unit_bounds[u].mode_hi > 0.0
|
||||||
|
and _r.unit_bounds[u].lo <= 0.0)
|
||||||
|
if any(_r.shares.get(u, 0) > 0 for u in _off) and _pk <= _on_cap - 1:
|
||||||
|
_needless.append(_cl)
|
||||||
|
check(f"over {_nn} random N=2/3 cycles (modes, packs, a trade or the loop, a "
|
||||||
|
f"peak floor): no split is refused, the placed power never falls under "
|
||||||
|
f"the floor where the same cycle without modes met it, and an Off "
|
||||||
|
f"battery gives power only when the others cannot carry the floor "
|
||||||
|
f"({len(_sign)}, {len(_worse)}, {len(_needless)})",
|
||||||
|
not _sign and not _worse and not _needless)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||||
|
|||||||
@@ -1156,6 +1156,142 @@ check("...while the GAIN does own it: 0.6 -> 0.3 -> 0.2 falls monotonically, at
|
|||||||
_R["gain03"]["over"] < _R["rail"]["over"]
|
_R["gain03"]["over"] < _R["rail"]["over"]
|
||||||
and _R["gain02"]["over"] < _R["gain03"]["over"])
|
and _R["gain02"]["over"] < _R["gain03"]["over"])
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("OPT-11: the loop is told what the battery modes allow")
|
||||||
|
# compute(bound=(lo, hi)) is the site output bound: the sum over live units of
|
||||||
|
# (hard limits with the mode applied), allocator.loop_bound(). Without it the
|
||||||
|
# loop asks for watts no battery may give, the allocator reports them unmet,
|
||||||
|
# and the saturation freeze parks the target on them (SAFETY-73 section 1).
|
||||||
|
import random as _random # noqa: E402
|
||||||
|
from app.control import MODE_BOUND # noqa: E402
|
||||||
|
from named_absence import present # noqa: E402
|
||||||
|
|
||||||
|
TB = Tuning(gain=0.6, max_w=5000.0, slew_w=400.0, deadband_w=15.0,
|
||||||
|
step_w=10, target_grid_w=-10.0)
|
||||||
|
|
||||||
|
|
||||||
|
def closed_loop(cycles, load, *, bound=None, deliver=None, change=None):
|
||||||
|
"""A plant that follows 94 % of the command each cycle, capped at
|
||||||
|
`deliver` (what the allocator can place). `change(i)` may return a new
|
||||||
|
bound. Rows: (command, actual, sat_count, frozen, reason, i_w)."""
|
||||||
|
prev, actual, sat, i_w, rows = 0.0, 0.0, 0, None, []
|
||||||
|
for i in range(cycles):
|
||||||
|
if change is not None:
|
||||||
|
bound = change(i, bound)
|
||||||
|
d = compute(prev, load - actual, actual, TB, sat, i_w, bound=bound)
|
||||||
|
prev, sat, i_w = d.target_w, d.sat_count, d.i_w
|
||||||
|
cmd = prev if deliver is None else max(deliver[0],
|
||||||
|
min(deliver[1], prev))
|
||||||
|
actual = actual + 0.94 * (cmd - actual)
|
||||||
|
rows.append((prev, actual, sat, d.frozen, d.reason, d.i_w))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# (a) One battery may give 800 W (the other is Off), the house takes 3000 W.
|
||||||
|
_with = closed_loop(40, 3000.0, bound=(-800.0, 800.0))
|
||||||
|
_without = closed_loop(40, 3000.0, deliver=(-800.0, 800.0))
|
||||||
|
print(f" house 3000 W, modes allow 800 W: with the bound the command ends "
|
||||||
|
f"at {nth(_with, -1)[0]:.0f} W (sat {max(r[2] for r in _with)}, reason "
|
||||||
|
f"{nth(_with, -1)[4]}); without it {nth(_without, -1)[0]:.0f} W with "
|
||||||
|
f"{sum(r[3] for r in _without)} frozen cycles")
|
||||||
|
check("⭐ THE LOOP BOUND HOLDS: every command and every integrator value "
|
||||||
|
"stays inside [-800, 800], and the command settles at the bound",
|
||||||
|
all(-800.0 <= r[0] <= 800.0 and -800.0 <= r[5] <= 800.0 for r in _with)
|
||||||
|
and present(nth(_with, -1)) and nth(_with, -1)[0] == 800.0
|
||||||
|
and nth(_with, -1)[4] == MODE_BOUND)
|
||||||
|
_big = []
|
||||||
|
_pv, _iw, _sat = 0.0, None, 0
|
||||||
|
for _i in range(30):
|
||||||
|
_d = compute(_pv, 12000.0, _pv, TB, _sat, _iw, bound=(-800.0, 800.0))
|
||||||
|
_pv, _iw, _sat = _d.target_w, _d.i_w, _d.sat_count
|
||||||
|
_big.append(_d)
|
||||||
|
check("...and a house far beyond what the modes allow (12 kW against 800 W) "
|
||||||
|
"does not read as the loop winding into its rail: SAFETY-03's flags "
|
||||||
|
"stay false on every cycle",
|
||||||
|
_big and not any(d.i_clamped or d.clamped for d in _big)
|
||||||
|
and all(d.target_w <= 800.0 for d in _big))
|
||||||
|
check("⭐ NO SATURATION FREEZE from unmet watts: sat_count never reaches the "
|
||||||
|
"freeze and no cycle is frozen",
|
||||||
|
not any(r[3] for r in _with)
|
||||||
|
and max(r[2] for r in _with) < TB.saturation_cycles)
|
||||||
|
check("...and the same site WITHOUT the bound is the failure it removes: the "
|
||||||
|
"loop asks past 800 W, the plant cannot follow and the freeze engages",
|
||||||
|
any(r[3] for r in _without) and max(r[0] for r in _without) > 800.0)
|
||||||
|
|
||||||
|
# (b) Every battery switched off: the bound is [0, 0].
|
||||||
|
_off = closed_loop(20, 2500.0, bound=(0.0, 0.0))
|
||||||
|
check("all batteries off: the loop asks 0 W on every cycle, with the "
|
||||||
|
"integrator at 0, and never freezes",
|
||||||
|
all(r[0] == 0.0 and r[5] == 0.0 and not r[3] for r in _off))
|
||||||
|
|
||||||
|
# (c) A mode closes while the battery gives 2000 W: the ask drops at once.
|
||||||
|
_close = closed_loop(30, 2000.0, bound=(-5000.0, 5000.0),
|
||||||
|
change=lambda i, b: (0.0, 0.0) if i >= 20 else b)
|
||||||
|
check("a mode that closes mid-run drops the ask to 0 W on the same cycle "
|
||||||
|
"(no slew steps of unmet watts) and carries no integrator past it",
|
||||||
|
present(nth(_close, 20)) and nth(_close, 19)[0] > 1500.0
|
||||||
|
and nth(_close, 20)[0] == 0.0
|
||||||
|
and nth(_close, 20)[5] == 0.0 and not any(r[3] for r in _close[20:]))
|
||||||
|
|
||||||
|
# (d) Rounding never steps outside the bound.
|
||||||
|
_q = compute(1000.0, 3000.0, 1000.0, TB, 0, 1236.0, bound=(-5000.0, 1236.0))
|
||||||
|
check("quantising to 10 W does not step outside the bound: 1236 W becomes "
|
||||||
|
"1230 W, not 1240 W", _q.target_w == 1230.0)
|
||||||
|
_qn = compute(-1000.0, -3000.0, -1000.0, TB, 0, -1236.0,
|
||||||
|
bound=(-1236.0, 5000.0))
|
||||||
|
check("...on the charge side too: -1236 W becomes -1230 W",
|
||||||
|
_qn.target_w == -1230.0)
|
||||||
|
|
||||||
|
# (e) A bound that leaves out 0 W is a fault and is not obeyed.
|
||||||
|
_rngc = _random.Random(1131)
|
||||||
|
_same, _nb = True, 0
|
||||||
|
for _ in range(4000):
|
||||||
|
args = (_rngc.uniform(-5000, 5000), _rngc.uniform(-4000, 4000),
|
||||||
|
_rngc.uniform(-5000, 5000), TB, _rngc.randint(0, 5),
|
||||||
|
_rngc.choice((None, _rngc.uniform(-6000, 6000))))
|
||||||
|
_nb += 1
|
||||||
|
if (compute(*args, bound=(200.0, 900.0)) != compute(*args)
|
||||||
|
or compute(*args, bound=None) != compute(*args)):
|
||||||
|
_same = False
|
||||||
|
check(f"a bound that leaves out 0 W is ignored, and bound=None is the law as "
|
||||||
|
f"it was: {_nb} random cycles give the identical Decision",
|
||||||
|
_same and _nb == 4000)
|
||||||
|
|
||||||
|
# (f) Inside the bound nothing changes: a bound wider than every command.
|
||||||
|
_wide = True
|
||||||
|
for _ in range(4000):
|
||||||
|
args = (_rngc.uniform(-4000, 4000), _rngc.uniform(-3000, 3000),
|
||||||
|
_rngc.uniform(-4000, 4000), TB, _rngc.randint(0, 5),
|
||||||
|
_rngc.choice((None, _rngc.uniform(-4000, 4000))))
|
||||||
|
if compute(*args, bound=(-9000.0, 9000.0)) != compute(*args):
|
||||||
|
_wide = False
|
||||||
|
check("a bound wider than the rail changes no Decision (4000 random cycles)",
|
||||||
|
_wide)
|
||||||
|
|
||||||
|
# (g) Review of PR #149: a bound EQUAL to the rail - every battery on, the
|
||||||
|
# default once modes are on - must leave SAFETY-03 exactly as it was. Before
|
||||||
|
# the fix the mode clamp ran first and took `i_clamped` away from the rail.
|
||||||
|
_rail_same, _rail_hits = True, 0
|
||||||
|
for _tb in (TB, Tuning(gain=0.6, max_w=5000.0, slew_w=400.0, deadband_w=15.0,
|
||||||
|
step_w=10, target_grid_w=-10.0,
|
||||||
|
integrator_max_w=7000.0)):
|
||||||
|
_lim = _tb.max_w
|
||||||
|
for _ in range(4000):
|
||||||
|
args = (_rngc.uniform(-5000, 5000), _rngc.uniform(-12000, 12000),
|
||||||
|
_rngc.uniform(-5000, 5000), _tb, _rngc.randint(0, 5),
|
||||||
|
_rngc.choice((None, _rngc.uniform(-8000, 8000))))
|
||||||
|
_d0 = compute(*args)
|
||||||
|
_rail_hits += _d0.i_clamped
|
||||||
|
if compute(*args, bound=(-_lim, _lim)) != _d0:
|
||||||
|
_rail_same = False
|
||||||
|
check(f"a bound equal to the rail changes no Decision, SAFETY-03's i_clamped "
|
||||||
|
f"included ({_rail_hits} of 8000 random cycles wind into the rail), and "
|
||||||
|
f"with integrator_max_w above max_w too", _rail_same and _rail_hits > 100)
|
||||||
|
_one_side = compute(4000.0, 12000.0, 4000.0, TB, 0, 4990.0,
|
||||||
|
bound=(0.0, 5000.0))
|
||||||
|
check("...and a bound that closes only the other side still lets the rail "
|
||||||
|
"flag a loop winding into it", _one_side.i_clamped)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||||
|
|||||||
@@ -0,0 +1,488 @@
|
|||||||
|
"""Runnable check for OPT-11 P1, battery modes. `python3 test_modes.py`
|
||||||
|
|
||||||
|
Each battery gets an on/off switch and a mode (Solar first, Leftover solar),
|
||||||
|
kept in the store and filed as a SOFT unit limit at P_MODE (35). Drives the
|
||||||
|
REAL `Controller` (read_inputs + cycle) through tools/opt11_identity.py's
|
||||||
|
harness, in simulated time. DECISIONS/OPT-11-battery-modes.md has the record.
|
||||||
|
|
||||||
|
1. With battery modes off, every command is byte-identical to release/1.0
|
||||||
|
a5dddad, at one inverter and at two (digests measured on that tree).
|
||||||
|
2. Solar order: surplus solar fills the Solar first battery, then spills to the
|
||||||
|
Leftover solar one; a full Solar first battery hands all of it on.
|
||||||
|
3. Off: the battery is asked for 0 W, the others carry the site, the loop is
|
||||||
|
bounded and never freezes on watts no battery may give; all off at N=1 and
|
||||||
|
N=2 asks 0 W.
|
||||||
|
4. Off stops and skips maintenance, with a log line; a battery in maintenance
|
||||||
|
gets no mode claim.
|
||||||
|
5. The actions: `pack_use` and `pack_mode`, what they store, log and refuse.
|
||||||
|
6. A mode range without 0 W is dropped as a software fault, logged once.
|
||||||
|
7. The Batteries screen's data.
|
||||||
|
|
||||||
|
Mutations (MUTATION.md, purged copies, graded over this file and the named
|
||||||
|
tests) are listed in the pull request and DECISIONS/OPT-11-battery-modes.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||||
|
|
||||||
|
import opt11_identity as rig # noqa: E402
|
||||||
|
from named_absence import nth, present # noqa: E402
|
||||||
|
import app.main as app_main # noqa: E402
|
||||||
|
from app import homeview, messages, packmode # noqa: E402
|
||||||
|
from app.control import MODE_BOUND # noqa: E402
|
||||||
|
from app.maintenance import DRAIN, IDLE # noqa: E402
|
||||||
|
from app.store import unit_key # noqa: E402
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
ON = {"battery_modes_enabled": True}
|
||||||
|
A, B = "inverter-a", "inverter-b"
|
||||||
|
|
||||||
|
|
||||||
|
def store_with(**modes):
|
||||||
|
"""A store where each named battery has (use, mode)."""
|
||||||
|
s = rig._Store()
|
||||||
|
for name, (use, mode) in modes.items():
|
||||||
|
s.data[unit_key(name, packmode.USE_KEY)] = use
|
||||||
|
s.data[unit_key(name, packmode.MODE_KEY)] = mode
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def spy_claims():
|
||||||
|
"""Record every claim list main.py's own `resolve` is handed."""
|
||||||
|
seen, real = [], app_main.resolve
|
||||||
|
|
||||||
|
def _spy(claims, **kw):
|
||||||
|
seen.append(list(claims))
|
||||||
|
return real(claims, **kw)
|
||||||
|
app_main.resolve = _spy
|
||||||
|
return seen, lambda: setattr(app_main, "resolve", real)
|
||||||
|
|
||||||
|
|
||||||
|
def spy_bounds():
|
||||||
|
"""Record the `bound` main.py hands `compute()` on every cycle."""
|
||||||
|
seen, real = [], app_main.compute
|
||||||
|
|
||||||
|
def _spy(*a, **kw):
|
||||||
|
seen.append(kw.get("bound", "absent"))
|
||||||
|
return real(*a, **kw)
|
||||||
|
app_main.compute = _spy
|
||||||
|
return seen, lambda: setattr(app_main, "compute", real)
|
||||||
|
|
||||||
|
|
||||||
|
def flat(t):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================ #
|
||||||
|
print("1. battery modes off: byte-identical to release/1.0 a5dddad")
|
||||||
|
# The digests below were printed by `python3 tools/opt11_identity.py` run on
|
||||||
|
# the a5dddad tree (the same file copied in, nothing else changed), in the CI
|
||||||
|
# image, on 2026-09-28. Each hashes 540 cycles: site target, every inverter's
|
||||||
|
# written value, the loop's reason, integrator and saturation count, the
|
||||||
|
# arbiter's sentence and the allocation record.
|
||||||
|
A5DDDAD = {
|
||||||
|
"n1": "fc3bca170c46920f08d86baee62ea883954a89ea6222d6bbf7d6652877226262",
|
||||||
|
"n2": "a60003b1930fa3cba21f3220670d9172b9ff438347d77ba9bc3383fca1ceb15a",
|
||||||
|
"n1_off": "fc3bca170c46920f08d86baee62ea883954a89ea6222d6bbf7d6652877226262",
|
||||||
|
"n2_off": "a60003b1930fa3cba21f3220670d9172b9ff438347d77ba9bc3383fca1ceb15a",
|
||||||
|
# Added in review (PR #149), printed on a5dddad the same way: both
|
||||||
|
# batteries due for maintenance, and the arbiter alone on 6000 seeded
|
||||||
|
# claim lists with the peak floor, trades, maintenance and refusals.
|
||||||
|
"n1_maint": "5a21d43ec7cbf95a224bbb9bca13a8e62a00161101b586ae9fa68ab565992977",
|
||||||
|
"n2_maint": "48a1ad96121592e207a4664fb2da9f5208a49b88e5802a358873a5d30cb8c18e",
|
||||||
|
"resolve": "43a874216711936e14b9185bcc8d26c58fe4f3cfc3c1c8eaf89bffd95d6e81ef",
|
||||||
|
}
|
||||||
|
_claims, _undo_c = spy_claims()
|
||||||
|
_bounds, _undo_b = spy_bounds()
|
||||||
|
try:
|
||||||
|
_now = rig.digests()
|
||||||
|
finally:
|
||||||
|
_undo_c()
|
||||||
|
_undo_b()
|
||||||
|
for k in A5DDDAD:
|
||||||
|
print(f" {k:7} {'same' if _now[k] == A5DDDAD[k] else 'DIFFERS'} "
|
||||||
|
f"{_now[k][:16]}")
|
||||||
|
check("⭐ at N=1 and N=2, with the option absent and with it false, every "
|
||||||
|
"command, reason and allocation record over 540 cycles each is "
|
||||||
|
"byte-identical to a5dddad (house steps, a solar surplus, a battery "
|
||||||
|
"at its cut-out level, at N=2 a refusal with probes, both batteries "
|
||||||
|
"due for maintenance), and so is the arbiter on 6000 claim lists",
|
||||||
|
_now == A5DDDAD)
|
||||||
|
check("...and no cycle in those runs filed a `mode` claim or handed "
|
||||||
|
"`compute()` a bound",
|
||||||
|
_claims and not any(c.source == "mode" for cl in _claims for c in cl)
|
||||||
|
and _bounds and set(_bounds) == {None})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================ #
|
||||||
|
print("\n2. the solar order: Solar first fills, the rest spills to Leftover")
|
||||||
|
_surplus = (lambda t: -3200.0) # 3200 W more solar than the house uses
|
||||||
|
_full_a = (lambda u, t: 100.0 if (u == "a" and t >= 300) else 50.0)
|
||||||
|
_rows, _c = rig.run(2, extra=ON, dur=600.0, load=_surplus, soc=_full_a,
|
||||||
|
cut=lambda u, t: False,
|
||||||
|
store=store_with(**{A: (True, packmode.SOLAR_FIRST),
|
||||||
|
B: (True, packmode.LEFTOVER_SOLAR)}))
|
||||||
|
_room = [r for r in _rows if 200 <= r[0] < 300]
|
||||||
|
_full = [r for r in _rows if r[0] >= 500]
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd(r):
|
||||||
|
"""One row's written values, by battery; ABSENTs if the row is missing."""
|
||||||
|
return (dict(r[2]) if present(r)
|
||||||
|
else {"a": nth((), 0, "a"), "b": nth((), 0, "b")})
|
||||||
|
|
||||||
|
|
||||||
|
print(f" A has room: A {_cmd(nth(_room, -1))['a']:+.0f} W, B "
|
||||||
|
f"{_cmd(nth(_room, -1))['b']:+.0f} W | A full: A {_cmd(nth(_full, -1))['a']:+.0f} "
|
||||||
|
f"W, B {_cmd(nth(_full, -1))['b']:+.0f} W")
|
||||||
|
check("⭐ while A (Solar first) has room it takes all it can, 2500 W, and B "
|
||||||
|
"(Leftover solar) gets only the spill, about 700 W",
|
||||||
|
present(nth(_room, -1))
|
||||||
|
and all(_cmd(r)["a"] == -2500.0 and -800.0 <= _cmd(r)["b"] <= -600.0
|
||||||
|
for r in _room))
|
||||||
|
check("⭐ once A is full, the surplus spills to B, up to B's rating",
|
||||||
|
present(nth(_full, -1))
|
||||||
|
and all(_cmd(r)["a"] == 0.0 and _cmd(r)["b"] == -2500.0 for r in _full))
|
||||||
|
_rows_off, _ = rig.run(2, dur=300.0, load=_surplus,
|
||||||
|
soc=lambda u, t: 50.0, cut=lambda u, t: False)
|
||||||
|
check("...and the same site with battery modes off splits 50/50, which is "
|
||||||
|
"what says the order above comes from the modes",
|
||||||
|
_cmd(_rows_off[-1])["a"] == _cmd(_rows_off[-1])["b"] < -1500.0)
|
||||||
|
_give = rig.run(2, extra=ON, dur=300.0, load=lambda t: 3000.0,
|
||||||
|
soc=lambda u, t: 50.0, cut=lambda u, t: False,
|
||||||
|
store=store_with(**{A: (True, packmode.SOLAR_FIRST),
|
||||||
|
B: (True, packmode.LEFTOVER_SOLAR)}))[0]
|
||||||
|
check("while giving power the modes keep OPT-09's weights: A Solar first and "
|
||||||
|
"B Leftover solar, both half full, share a 3000 W house equally",
|
||||||
|
_cmd(_give[-1])["a"] == _cmd(_give[-1])["b"] > 1400.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================ #
|
||||||
|
print("\n3. Off: 0 W on that battery, the loop bounded, no freeze")
|
||||||
|
_rows, _c = rig.run(2, extra=ON, dur=600.0, load=lambda t: 4000.0,
|
||||||
|
soc=lambda u, t: 50.0, cut=lambda u, t: False,
|
||||||
|
store=store_with(**{B: (False, packmode.SOLAR_FIRST)}))
|
||||||
|
_tail = _rows[40:]
|
||||||
|
print(f" house 4000 W, B off: A {_cmd(_tail[-1])['a']:+.0f} W, B "
|
||||||
|
f"{_cmd(_tail[-1])['b']:+.0f} W, site {_tail[-1][1]:+.0f} W, reason "
|
||||||
|
f"{_tail[-1][3]}, {_tail[-1][7]}")
|
||||||
|
check("⭐ B (Off) is asked for 0 W on every cycle and A carries the house up "
|
||||||
|
"to its rating",
|
||||||
|
all(_cmd(r)["b"] == 0.0 for r in _rows)
|
||||||
|
and all(_cmd(r)["a"] == 2500.0 for r in _tail))
|
||||||
|
check("⭐ the loop stays inside what the modes allow (2500 W) and never "
|
||||||
|
"freezes: no saturated-freeze reason, and the saturation count stays "
|
||||||
|
"below the freeze",
|
||||||
|
all(r[1] <= 2500.0 for r in _rows)
|
||||||
|
and not any(r[3] == "saturated-freeze" for r in _rows)
|
||||||
|
and max(r[5] for r in _rows) < _c.tuning.saturation_cycles
|
||||||
|
and _tail[-1][3] == MODE_BOUND)
|
||||||
|
check("...and nothing is left unmet: the allocation places the whole target",
|
||||||
|
all("UNMET" not in (r[7] or "") for r in _tail))
|
||||||
|
_bounds, _undo_b = spy_bounds()
|
||||||
|
try:
|
||||||
|
rig.run(2, extra=ON, dur=100.0, load=lambda t: 4000.0,
|
||||||
|
soc=lambda u, t: 50.0, cut=lambda u, t: False,
|
||||||
|
store=store_with(**{B: (False, packmode.SOLAR_FIRST)}))
|
||||||
|
finally:
|
||||||
|
_undo_b()
|
||||||
|
check("...because main.py hands compute() the loop bound, B's 0 W included: "
|
||||||
|
"[-2500, 2500]", _bounds and set(_bounds) == {(-2500.0, 2500.0)})
|
||||||
|
# Review (PR #149): a unit the allocator drops (stale link) must not count in
|
||||||
|
# the loop bound either, or the loop asks for watts nobody live can place.
|
||||||
|
_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat, soc=lambda u, t: 50.0,
|
||||||
|
cut=lambda u, t: False)
|
||||||
|
_both = _c.loop_bound([app_main.Claim.limit(
|
||||||
|
"supervised", app_main.P_SAFETY, -2500.0, 2500.0,
|
||||||
|
actuator=_c.actuator_of("supervised", i)) for i in _c.inverters])
|
||||||
|
_c.live_units = lambda: (A,)
|
||||||
|
_one_live = _c.loop_bound([app_main.Claim.limit(
|
||||||
|
"supervised", app_main.P_SAFETY, -2500.0, 2500.0,
|
||||||
|
actuator=_c.actuator_of("supervised", i)) for i in _c.inverters])
|
||||||
|
check("the loop bound counts only live units: both live [-5000, 5000], B's "
|
||||||
|
"link stale [-2500, 2500]",
|
||||||
|
_both == (-5000.0, 5000.0) and _one_live == (-2500.0, 2500.0))
|
||||||
|
|
||||||
|
for _n in (1, 2):
|
||||||
|
_st = store_with(**{A: (False, packmode.SOLAR_FIRST),
|
||||||
|
B: (False, packmode.LEFTOVER_SOLAR)})
|
||||||
|
if _n == 1:
|
||||||
|
_st = rig._Store()
|
||||||
|
_st.data[unit_key(app_main.UNIT_A, packmode.USE_KEY)] = False
|
||||||
|
_rows, _c = rig.run(_n, extra=ON, dur=300.0, load=lambda t: 3000.0,
|
||||||
|
soc=lambda u, t: 50.0, cut=lambda u, t: False,
|
||||||
|
store=_st)
|
||||||
|
check(f"every battery off at N={_n}: every command is 0 W, the site "
|
||||||
|
f"target is 0 W, the integrator stays at 0 and nothing freezes",
|
||||||
|
all(all(v == 0.0 for _u, v in r[2]) and r[1] == 0.0 and r[4] == 0.0
|
||||||
|
and r[3] != "saturated-freeze" for r in _rows))
|
||||||
|
_rows1, _ = rig.run(1, extra=ON, dur=300.0, load=lambda t: 1800.0,
|
||||||
|
soc=lambda u, t: 50.0)
|
||||||
|
_rows1o, _ = rig.run(1, dur=300.0, load=lambda t: 1800.0,
|
||||||
|
soc=lambda u, t: 50.0)
|
||||||
|
check("at N=1 a battery that is on (Solar first by default) follows the "
|
||||||
|
"house exactly as with battery modes off",
|
||||||
|
[r[:6] for r in _rows1] == [r[:6] for r in _rows1o]
|
||||||
|
and _cmd(_rows1[-1])["a"] > 1700.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================ #
|
||||||
|
print("\n4. Off stops and skips maintenance; maintenance outranks the mode")
|
||||||
|
MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11}
|
||||||
|
_st = store_with(**{A: (False, packmode.SOLAR_FIRST)})
|
||||||
|
_rows, _c = rig.run(2, extra={**ON, **MAINT}, dur=60.0,
|
||||||
|
load=lambda t: 500.0, soc=lambda u, t: 60.0,
|
||||||
|
cut=lambda u, t: False, store=_st)
|
||||||
|
print(f" both due at 11:00, A off: A {_c.maints[0].phase}, "
|
||||||
|
f"B {_c.maints[1].phase}")
|
||||||
|
check("⭐ a battery that is off starts no cycle, and does not hold the slot: "
|
||||||
|
"both are due, A (first in the roster) is off, so B starts",
|
||||||
|
_c.maints[0].phase == IDLE and _c.maints[1].phase == DRAIN)
|
||||||
|
_st2 = store_with()
|
||||||
|
_seen, _undo_c = spy_claims()
|
||||||
|
_msgs = []
|
||||||
|
|
||||||
|
|
||||||
|
async def _switch_b_off(c, t):
|
||||||
|
if t == 30.0:
|
||||||
|
_msgs.append(await c.handle_action("pack_use", unit=B, value=False))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
_rows, _c = rig.run(2, extra={**ON, **MAINT}, dur=60.0,
|
||||||
|
load=lambda t: 500.0, soc=lambda u, t: 60.0,
|
||||||
|
cut=lambda u, t: False, store=_st2,
|
||||||
|
before=_switch_b_off)
|
||||||
|
finally:
|
||||||
|
_undo_c()
|
||||||
|
_during = [cl for cl in _seen[:6]]
|
||||||
|
print(f" B off at 30 s while A drains: A {_c.maints[0].phase}, "
|
||||||
|
f"B {_c.maints[1].phase}; events: "
|
||||||
|
f"{[e for e in _c.events if 'switched' in str(e) or 'ABORT' in str(e)]}")
|
||||||
|
check("while A runs its cycle it gets no mode claim (maintenance at 60/40 "
|
||||||
|
"outranks the mode) and B, idle, gets one",
|
||||||
|
all({c.actuator for c in cl if c.source == "mode"} == {B}
|
||||||
|
for cl in _during))
|
||||||
|
_st3 = store_with()
|
||||||
|
_st3.data[unit_key(B, "phase")] = DRAIN
|
||||||
|
_st3.data["maint_schema_version"] = 1
|
||||||
|
|
||||||
|
|
||||||
|
async def _b_off_now(c, t):
|
||||||
|
if t == 10.0:
|
||||||
|
_msgs.append(await c.handle_action("pack_use", unit=B, value=False))
|
||||||
|
|
||||||
|
|
||||||
|
_rows, _c = rig.run(2, extra=ON, dur=30.0, load=lambda t: 500.0,
|
||||||
|
soc=lambda u, t: 60.0, cut=lambda u, t: False,
|
||||||
|
store=_st3, before=_b_off_now)
|
||||||
|
_ev = [str(e) for e in _c.events]
|
||||||
|
print(f" B mid-drain, switched off at 10 s: B {_c.maints[1].phase}; "
|
||||||
|
f"{[e for e in _ev if 'switched off' in e or 'ABORT' in e]}")
|
||||||
|
check("⭐ switching off a battery that is mid-cycle stops its cycle at once, "
|
||||||
|
"with a log line saying why",
|
||||||
|
_c.maints[1].phase == IDLE
|
||||||
|
and any("battery switched off" in e for e in _ev)
|
||||||
|
and _cmd(_rows[-1])["b"] == 0.0)
|
||||||
|
_st4 = store_with(**{B: (False, packmode.SOLAR_FIRST)})
|
||||||
|
_st4.data[unit_key(B, "phase")] = DRAIN
|
||||||
|
_st4.data["maint_schema_version"] = 1
|
||||||
|
_rows, _c = rig.run(2, extra=ON, dur=20.0, load=lambda t: 500.0,
|
||||||
|
soc=lambda u, t: 60.0, cut=lambda u, t: False, store=_st4)
|
||||||
|
check("...and a store that says Off for a battery mid-cycle (a restart) stops "
|
||||||
|
"it on the first cycle, and it is never asked to drain",
|
||||||
|
_c.maints[1].phase == IDLE
|
||||||
|
and all(_cmd(r)["b"] == 0.0 for r in _rows))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================ #
|
||||||
|
print("\n5. the actions: pack_use and pack_mode")
|
||||||
|
_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat,
|
||||||
|
soc=lambda u, t: 50.0, cut=lambda u, t: False)
|
||||||
|
_r1 = asyncio.run(_c.handle_action("pack_mode", unit=B,
|
||||||
|
value=packmode.LEFTOVER_SOLAR))
|
||||||
|
_r2 = asyncio.run(_c.handle_action("pack_use", unit=A, value=False))
|
||||||
|
print(f" {_r1} {_r2}")
|
||||||
|
check("pack_mode stores the mode under the battery's own key and says what "
|
||||||
|
"it now is",
|
||||||
|
_r1 == {"unit": B, "use": True, "mode": packmode.LEFTOVER_SOLAR}
|
||||||
|
and _c.store.data[unit_key(B, packmode.MODE_KEY)]
|
||||||
|
== packmode.LEFTOVER_SOLAR)
|
||||||
|
check("pack_use stores the switch, and both are written to the action log",
|
||||||
|
_r2 == {"unit": A, "use": False, "mode": packmode.SOLAR_FIRST}
|
||||||
|
and _c.store.data[unit_key(A, packmode.USE_KEY)] is False
|
||||||
|
and any("inverter-a switched off by operator" in str(e)
|
||||||
|
for e in _c.events)
|
||||||
|
and any("inverter-b mode set to leftover solar" in str(e)
|
||||||
|
for e in _c.events))
|
||||||
|
_bad = [asyncio.run(_c.handle_action("pack_use", unit="inverter-z",
|
||||||
|
value=True)),
|
||||||
|
asyncio.run(_c.handle_action("pack_use", unit=A, value="yes")),
|
||||||
|
# 0 == False in Python: a stored 0 would read back as ON.
|
||||||
|
asyncio.run(_c.handle_action("pack_use", unit=A, value=0)),
|
||||||
|
asyncio.run(_c.handle_action("pack_use", unit=A, value=1)),
|
||||||
|
asyncio.run(_c.handle_action("pack_mode", unit=A,
|
||||||
|
value=packmode.CHEAP_HOURS)),
|
||||||
|
asyncio.run(_c.handle_action("pack_mode", unit=A, value=None))]
|
||||||
|
check("an unknown battery, a switch that is not true or false (0 and 1 "
|
||||||
|
"included), and a mode "
|
||||||
|
"this release does not offer (Cheap hours arrives in P3) are refused, "
|
||||||
|
"with a plain reason, and store nothing",
|
||||||
|
all(isinstance(x, dict) and set(x) == {"error"} for x in _bad)
|
||||||
|
and unit_key(A, packmode.MODE_KEY) not in _c.store.data
|
||||||
|
and _c.store.data[unit_key(A, packmode.USE_KEY)] is False)
|
||||||
|
_rows, _c0 = rig.run(2, dur=10.0, load=flat, soc=lambda u, t: 50.0,
|
||||||
|
cut=lambda u, t: False)
|
||||||
|
_r0 = asyncio.run(_c0.handle_action("pack_use", unit=A, value=False))
|
||||||
|
check("with battery modes off the actions are refused and store nothing",
|
||||||
|
set(_r0) == {"error"}
|
||||||
|
and unit_key(A, packmode.USE_KEY) not in _c0.store.data)
|
||||||
|
check("the old actions still take no battery and answer as before",
|
||||||
|
asyncio.run(_c0.handle_action("nonsense")) is None)
|
||||||
|
check("a store value from a later version (an unknown mode) reads as the "
|
||||||
|
"default, Solar first and on, never as Off",
|
||||||
|
packmode.read({unit_key(A, packmode.MODE_KEY): "grid_only",
|
||||||
|
unit_key(A, packmode.USE_KEY): "maybe"}, A)
|
||||||
|
== packmode.PackMode(True, packmode.SOLAR_FIRST))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================ #
|
||||||
|
print("\n6. a mode range without 0 W is a software fault, never obeyed")
|
||||||
|
_real_range = packmode.mode_range
|
||||||
|
packmode.mode_range = lambda pm, r: (100.0, 900.0)
|
||||||
|
_log = []
|
||||||
|
|
||||||
|
|
||||||
|
class _H(logging.Handler):
|
||||||
|
def emit(self, rec):
|
||||||
|
_log.append((rec.levelno, rec.getMessage()))
|
||||||
|
|
||||||
|
|
||||||
|
_h = _H()
|
||||||
|
logging.getLogger("goodwe").addHandler(_h)
|
||||||
|
try:
|
||||||
|
_rows, _c = rig.run(2, extra=ON, dur=60.0, load=lambda t: -2000.0,
|
||||||
|
soc=lambda u, t: 50.0, cut=lambda u, t: False)
|
||||||
|
finally:
|
||||||
|
packmode.mode_range = _real_range
|
||||||
|
logging.getLogger("goodwe").removeHandler(_h)
|
||||||
|
_errs = [msg for lv, msg in _log if lv >= logging.ERROR and "MODE_RANGE" in msg]
|
||||||
|
print(f" {_errs}")
|
||||||
|
check("⭐ both batteries still charge from the surplus (the forced range is "
|
||||||
|
"dropped, so no battery is made to give power) and no split is refused",
|
||||||
|
all(v <= 0.0 for _u, v in _rows[-1][2])
|
||||||
|
and _cmd(_rows[-1])["a"] < 0.0
|
||||||
|
and not any("REFUSED" in (r[7] or "") for r in _rows))
|
||||||
|
check("...and it is logged once per battery, as an ERROR naming the fault "
|
||||||
|
"code, not once per cycle",
|
||||||
|
len(_errs) == 2 and any(A in e for e in _errs) and any(B in e for e in _errs))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================ #
|
||||||
|
print("\n7. the Batteries screen's data")
|
||||||
|
_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat, soc=lambda u, t: 50.0,
|
||||||
|
cut=lambda u, t: False,
|
||||||
|
store=store_with(**{B: (False, packmode.LEFTOVER_SOLAR)}))
|
||||||
|
_bat = homeview.battery_rows(_c, False, True)
|
||||||
|
_bat_nl = homeview.build(_c, level="ok", checks=[], lang="nl")["batteries"]
|
||||||
|
print(f" {[(b['name'], b['use'], b['mode_key'], b['mode']) for b in _bat]}")
|
||||||
|
check("with battery modes on each row carries its switch, its state and its "
|
||||||
|
"stored mode, in the page's words",
|
||||||
|
[(b["use"], b["mode_key"], b["mode_choice"], b["mode"]) for b in _bat]
|
||||||
|
== [(True, "solar_first", "solar_first", "Solar first"),
|
||||||
|
(False, "off", "leftover_solar", "Off")]
|
||||||
|
and _bat_nl[1]["mode"] == "Uit")
|
||||||
|
_rows, _c0 = rig.run(2, dur=10.0, load=flat, soc=lambda u, t: 50.0,
|
||||||
|
cut=lambda u, t: False)
|
||||||
|
check("...and with them off the rows are what they were: no new keys, the "
|
||||||
|
"mode reads as before",
|
||||||
|
all("mode_key" not in b and b["mode"] == homeview.mode_house()
|
||||||
|
for b in homeview.battery_rows(_c0, False, True)))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================ #
|
||||||
|
print("\n8. the page's route: /action and /status over the real web app")
|
||||||
|
import socket # noqa: E402
|
||||||
|
|
||||||
|
import aiohttp # noqa: E402
|
||||||
|
from aiohttp import web as aioweb # noqa: E402
|
||||||
|
|
||||||
|
from app import web as app_web # noqa: E402
|
||||||
|
|
||||||
|
_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat, soc=lambda u, t: 50.0,
|
||||||
|
cut=lambda u, t: False)
|
||||||
|
|
||||||
|
|
||||||
|
async def _page():
|
||||||
|
# The configuration routes are not used here; a stand-in keeps the app
|
||||||
|
# from building a Supervisor client.
|
||||||
|
app = app_web.build_app(_c, selfcfg=object())
|
||||||
|
runner = aioweb.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
await aioweb.TCPSite(runner, "127.0.0.1", port).start()
|
||||||
|
out = {}
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as s:
|
||||||
|
root = f"http://127.0.0.1:{port}/"
|
||||||
|
for key, body in (
|
||||||
|
("mode", {"action": "pack_mode", "unit": B,
|
||||||
|
"value": packmode.LEFTOVER_SOLAR}),
|
||||||
|
("off", {"action": "pack_use", "unit": A,
|
||||||
|
"value": False}),
|
||||||
|
("bad", {"action": "pack_mode", "unit": A,
|
||||||
|
"value": "grid_only"})):
|
||||||
|
async with s.post(root + "action", json=body) as r:
|
||||||
|
out[key] = (r.status, await r.json(content_type=None))
|
||||||
|
async with s.get(root + "status") as r:
|
||||||
|
out["status"] = (r.status, await r.json(content_type=None))
|
||||||
|
async with s.get(root) as r:
|
||||||
|
out["page"] = (r.status, await r.text())
|
||||||
|
finally:
|
||||||
|
await runner.cleanup()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_web = asyncio.run(_page())
|
||||||
|
print(f" {_web['mode']} {_web['off']} {_web['bad']}")
|
||||||
|
check("POST /action with pack_mode and pack_use names a battery and a value, "
|
||||||
|
"and answers what it stored",
|
||||||
|
_web["mode"] == (200, {"ok": True, "result": {
|
||||||
|
"unit": B, "use": True, "mode": packmode.LEFTOVER_SOLAR}})
|
||||||
|
and _web["off"][0] == 200 and _web["off"][1]["result"]["use"] is False)
|
||||||
|
check("...a value this release does not offer is a 400 with the plain reason "
|
||||||
|
"the page shows",
|
||||||
|
_web["bad"][0] == 400 and _web["bad"][1]["ok"] is False
|
||||||
|
and _web["bad"][1]["error"] == messages.pack_refused("value"))
|
||||||
|
_sb = {b["name"]: b for b in _web["status"][1].get("batteries", [])}
|
||||||
|
check("...and /status then shows both batteries' switch and mode for the "
|
||||||
|
"Batteries screen",
|
||||||
|
_web["status"][0] == 200
|
||||||
|
and (_sb.get(A, {}).get("mode_key"), _sb.get(B, {}).get("mode_key"))
|
||||||
|
== ("off", packmode.LEFTOVER_SOLAR))
|
||||||
|
check("the page carries the mode names from app/ui_text for its cards",
|
||||||
|
_web["page"][0] == 200 and "Leftover solar" in _web["page"][1]
|
||||||
|
and "Overschot zonne-energie" in _web["page"][1])
|
||||||
|
|
||||||
|
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||||
|
sys.exit(1)
|
||||||
|
print("all checks passed")
|
||||||
@@ -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
|
# If you are here because you added a TWELFTH, that argument is what this line
|
||||||
# wants from you, not an edit.
|
# 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",
|
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
|
||||||
"arbitrage", "maintenance", "loop", "circulation",
|
"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
|
# 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
|
# that a THIRD such indirection reads as an unrecognised append rather than as a
|
||||||
# silent gap in the scan.
|
# 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 "
|
"- 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 "
|
"than be counted as nothing, which is how a scan like this goes quietly "
|
||||||
"vacuous", len(_appends) >= 10 and None not in _filed)
|
"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 "
|
"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 "
|
"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 "
|
"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"}
|
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"}
|
||||||
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
|
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
|
||||||
== {"device", "supervised", "failsafe", "maintenance", "pack",
|
== {"device", "supervised", "failsafe", "maintenance", "pack",
|
||||||
"refused"})
|
"refused", "mode"})
|
||||||
|
|
||||||
# The shipped roster is ONE inverter, so §6.2's identity holds and every claim a
|
# 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
|
# 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",
|
_routed == {"device": "inverter-a", "supervised": "inverter-a",
|
||||||
"failsafe": "inverter-a", "maintenance": "inverter-a",
|
"failsafe": "inverter-a", "maintenance": "inverter-a",
|
||||||
"pack": "inverter-a", "refused": "inverter-a",
|
"pack": "inverter-a", "refused": "inverter-a",
|
||||||
|
"mode": "inverter-a",
|
||||||
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
|
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
|
||||||
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
|
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
|
||||||
"circulation": ACTUATOR_SITE, "sign_site": 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]]
|
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: "
|
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))
|
+ ", ".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 "
|
"`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 "
|
"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 "
|
"the argument, or hand-wrote a string, fails HERE rather than being "
|
||||||
"silently skipped - which is how a scan like this goes quietly vacuous, "
|
"silently skipped - which is how a scan like this goes quietly vacuous, "
|
||||||
"and 10f is in this file because that already happened once",
|
"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('")
|
and all(a is not None and a.startswith("self.actuator_of('")
|
||||||
for _s, a, _ln in _routing))
|
for _s, a, _ln in _routing))
|
||||||
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
|
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 "
|
"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` "
|
"`device` limit built inside the roster loop and routed without `inv` "
|
||||||
"files inverter B's rating on inverter A, where it binds the wrong "
|
"files inverter B's rating on inverter A, where it binds the wrong "
|
||||||
"share. Eight of the thirteen appends are SCOPE_UNIT - `device`, "
|
"share. Nine of the fourteen appends are SCOPE_UNIT - `device`, "
|
||||||
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack` and "
|
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack`, "
|
||||||
"SAFETY-73's two `refused` - and all eight pass one. "
|
"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 "
|
"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, beside `circulation`, because the number that tripped it is "
|
||||||
"the site target",
|
"the site target",
|
||||||
_unit_sources_unrouted == []
|
_unit_sources_unrouted == []
|
||||||
and len([r for r in _routing
|
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 "
|
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 "
|
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
|
||||||
"`actuator=` at the call site to mis-type. Their routing is pinned where "
|
"`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",
|
"arbitrage.claim() by test_arbitrage.py",
|
||||||
sorted(_indirect) == sorted(CLAIM_BUILDERS)
|
sorted(_indirect) == sorted(CLAIM_BUILDERS)
|
||||||
and len(_direct) + len(_indirect) == len(_appends))
|
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 "
|
"sources 10f pins, so the routing pin and the source set are one scan "
|
||||||
"and neither can drift from the other",
|
"and neither can drift from the other",
|
||||||
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
|
{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}
|
{c.source for c in _cl2}
|
||||||
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
|
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
|
||||||
"arbitrage", "circulation", "sign_site",
|
"arbitrage", "circulation", "sign_site",
|
||||||
"refused"})
|
"refused", "mode"})
|
||||||
# ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three
|
# ⚠️ 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
|
# 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
|
# 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 --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 --sweep # AC 3: gain to 1.0 at every split
|
||||||
python3 tools/alloc_sim.py --all
|
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.
|
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
|
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():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
ap.add_argument("--table", action="store_true")
|
ap.add_argument("--table", action="store_true")
|
||||||
ap.add_argument("--splits", action="store_true")
|
ap.add_argument("--splits", action="store_true")
|
||||||
ap.add_argument("--sweep", action="store_true")
|
ap.add_argument("--sweep", action="store_true")
|
||||||
ap.add_argument("--all", 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("--cycles", type=int, default=40)
|
||||||
ap.add_argument("--load", type=float, default=2000.0)
|
ap.add_argument("--load", type=float, default=2000.0)
|
||||||
ap.add_argument("--gain", type=float, default=1.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, "
|
"or one inverter's power (the PRE-DEV-02 sensing, "
|
||||||
"kept because the comparison is the evidence)")
|
"kept because the comparison is the evidence)")
|
||||||
a = ap.parse_args()
|
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
|
a.all = True
|
||||||
|
|
||||||
sim = find_sim()
|
sim = find_sim()
|
||||||
@@ -288,6 +375,11 @@ def main():
|
|||||||
print()
|
print()
|
||||||
if a.sweep or a.all:
|
if a.sweep or a.all:
|
||||||
sweep(sitesim, control, watchdog, a.cycles, a.load)
|
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
|
return 0 if held else 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""OPT-11: with battery modes off, is every command byte-identical to before?
|
||||||
|
|
||||||
|
python3 tools/opt11_identity.py # prints one digest per scenario
|
||||||
|
|
||||||
|
Drives the REAL `Controller` (read_inputs + cycle: the freshness gate, the
|
||||||
|
arbiter, the allocator, OPT-09's pack claim, SAFETY-73's refusal claims) in
|
||||||
|
simulated time, and hashes, per cycle, everything a command depends on: the
|
||||||
|
site target, each inverter's written value, the loop's reason, integrator and
|
||||||
|
saturation count, and the allocation record. It uses only the Controller API
|
||||||
|
that release/1.0 a5dddad already had, so the SAME file runs on the old tree and
|
||||||
|
the new one. test_modes.py part 1 holds the new tree to the digests this file
|
||||||
|
printed on a5dddad (they are written there, with that provenance).
|
||||||
|
|
||||||
|
Scenarios: N=1 and N=2, a house that steps, a surplus of solar, a battery
|
||||||
|
reaching its cut-out level and, at N=2, a battery that refuses (SAFETY-73) and
|
||||||
|
is probed; the same with both batteries due for maintenance; and the arbiter
|
||||||
|
alone on 6000 seeded claim lists (peak floor, trades, maintenance, refusals).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import app.main as m # noqa: E402
|
||||||
|
from app.main import Controller # noqa: E402
|
||||||
|
|
||||||
|
TICK = 5.0
|
||||||
|
BASE = datetime(2026, 9, 20, 11, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
def __init__(self):
|
||||||
|
from app.store import DEFAULTS
|
||||||
|
self.data = dict(DEFAULTS)
|
||||||
|
|
||||||
|
def set(self, k, v):
|
||||||
|
self.data[k] = v
|
||||||
|
|
||||||
|
def get_time(self, k):
|
||||||
|
v = self.data.get(k)
|
||||||
|
return datetime.fromisoformat(v) if v else None
|
||||||
|
|
||||||
|
def set_time(self, k, when):
|
||||||
|
self.data[k] = when.isoformat() if when else None
|
||||||
|
|
||||||
|
|
||||||
|
class _Pub:
|
||||||
|
enabled = False
|
||||||
|
|
||||||
|
def publish(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def house(t):
|
||||||
|
"""A house that steps, then a solar surplus, then an evening load."""
|
||||||
|
if t < 300:
|
||||||
|
return 800.0
|
||||||
|
if t < 900:
|
||||||
|
return 3200.0
|
||||||
|
if t < 1500:
|
||||||
|
return -2600.0 # more solar than the house uses
|
||||||
|
if t < 2100:
|
||||||
|
return 1400.0
|
||||||
|
return 4200.0
|
||||||
|
|
||||||
|
|
||||||
|
class _Hass:
|
||||||
|
"""One meter, `units` batteries that follow their command unless `cut`."""
|
||||||
|
|
||||||
|
def __init__(self, units, soc, cut, load=house):
|
||||||
|
self.units, self.soc, self.cut, self.load = units, soc, cut, load
|
||||||
|
self.cmd = {u: 0.0 for u in units}
|
||||||
|
self.t = 0.0
|
||||||
|
|
||||||
|
def power(self, u):
|
||||||
|
c = self.cmd[u]
|
||||||
|
if self.cut(u, self.t) and c >= -4.0:
|
||||||
|
return -4.0
|
||||||
|
return float(round(c))
|
||||||
|
|
||||||
|
def grid(self):
|
||||||
|
return self.load(self.t) - sum(self.power(u) for u in self.units)
|
||||||
|
|
||||||
|
async def limits(self, e):
|
||||||
|
return (-5000.0, 5000.0, 1.0)
|
||||||
|
|
||||||
|
async def call(self, *a):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def number(self, e, invert=False):
|
||||||
|
if e == "sensor.grid":
|
||||||
|
return self.grid()
|
||||||
|
u = e[-1]
|
||||||
|
if e.startswith("sensor.soc"):
|
||||||
|
return self.soc(u, self.t)
|
||||||
|
if e.startswith("sensor.batt"):
|
||||||
|
return self.power(u)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set_number(self, e, v):
|
||||||
|
self.cmd[e[-1]] = float(v)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _options(n, extra):
|
||||||
|
common = {"meter_entity": "sensor.grid", "meter_source": "off",
|
||||||
|
"gain": 0.6, "slew_w": 400, "target_grid_w": -10,
|
||||||
|
"deadband_w": 15, "step_w": 10, "heartbeat_s": 10,
|
||||||
|
"stale_input_s": 15, "auto_start": True}
|
||||||
|
if n == 1:
|
||||||
|
common.update({"setpoint_entity": "number.sp_a",
|
||||||
|
"soc_entity": "sensor.soc_a",
|
||||||
|
"batt_entity": "sensor.batt_a", "max_w": 5000,
|
||||||
|
"bms_soc_floor": 10})
|
||||||
|
else:
|
||||||
|
common["inverters"] = [
|
||||||
|
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
|
||||||
|
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
|
||||||
|
"max_w": 2500, "bms_soc_floor": 10} for u in "ab"[:n]]
|
||||||
|
common.update(extra or {})
|
||||||
|
return common
|
||||||
|
|
||||||
|
|
||||||
|
def _soc(u, t):
|
||||||
|
# A empties toward its cut-out level over the run; B stays half full.
|
||||||
|
return max(8.0, 60.0 - t / 40.0) if u == "a" else 50.0
|
||||||
|
|
||||||
|
|
||||||
|
def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
|
||||||
|
soc=_soc, cut=None, before=None):
|
||||||
|
"""Cycle a real Controller; returns (rows, controller). `before(c, t)`
|
||||||
|
runs before each cycle, `on_cycle(c, t)` after it."""
|
||||||
|
units = tuple("ab"[:n])
|
||||||
|
if cut is None:
|
||||||
|
def cut(u, t):
|
||||||
|
return n > 1 and u == "a" and 1600.0 <= t < 2300.0
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
hass = _Hass(units, soc, cut, load)
|
||||||
|
c = Controller(_options(n, extra), hass, store or _Store(), _Pub(),
|
||||||
|
stop=asyncio.Event())
|
||||||
|
for i in c.inverters:
|
||||||
|
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||||
|
c.auto = True
|
||||||
|
real_dt = m.datetime
|
||||||
|
clock = [0.0]
|
||||||
|
|
||||||
|
class _DT(real_dt):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return BASE + timedelta(seconds=clock[0])
|
||||||
|
|
||||||
|
m.datetime = _DT
|
||||||
|
rows = []
|
||||||
|
try:
|
||||||
|
t = 0.0
|
||||||
|
while t < dur:
|
||||||
|
hass.t = clock[0] = t
|
||||||
|
if before is not None:
|
||||||
|
r = before(c, t)
|
||||||
|
if asyncio.iscoroutine(r):
|
||||||
|
await r
|
||||||
|
await c.read_inputs(now_mono=t)
|
||||||
|
await c.cycle()
|
||||||
|
alloc = getattr(c, "allocation", None)
|
||||||
|
rows.append((t, c.target, tuple(sorted(hass.cmd.items())),
|
||||||
|
c.loop_reason, round(float(c.i_w), 6),
|
||||||
|
c.sat_count, c.reason,
|
||||||
|
alloc.explain() if alloc is not None else None))
|
||||||
|
if on_cycle is not None:
|
||||||
|
on_cycle(c, t)
|
||||||
|
t += TICK
|
||||||
|
finally:
|
||||||
|
m.datetime = real_dt
|
||||||
|
return rows, c
|
||||||
|
return asyncio.run(go())
|
||||||
|
|
||||||
|
|
||||||
|
def digest(rows) -> str:
|
||||||
|
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# Both batteries due for their monthly cycle at the run's first cycle, so the
|
||||||
|
# maintenance slot, its pins and its shaping are in every row (PR #149 review:
|
||||||
|
# the scenarios above never reach maintenance).
|
||||||
|
_MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11}
|
||||||
|
|
||||||
|
SCENARIOS = {
|
||||||
|
"n1": dict(n=1),
|
||||||
|
"n2": dict(n=2),
|
||||||
|
# The option present and false: what an install that saved the new
|
||||||
|
# option without turning it on runs. The old tree ignores the key.
|
||||||
|
"n1_off": dict(n=1, extra={"battery_modes_enabled": False}),
|
||||||
|
"n2_off": dict(n=2, extra={"battery_modes_enabled": False}),
|
||||||
|
"n1_maint": dict(n=1, extra=_MAINT),
|
||||||
|
"n2_maint": dict(n=2, extra=_MAINT),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_rows(cases=6000, seed=1149):
|
||||||
|
"""The arbiter alone, on seeded random claim lists with no mode claim:
|
||||||
|
the peak floor, a trade, the loop, maintenance pins and shaping, SAFETY-73
|
||||||
|
refusal limits and probe pins, packs, ratings and safety stops, at one to
|
||||||
|
three inverters. The peak guard needs a P1 meter the cycle runs above do
|
||||||
|
not have, so its claim is graded here. Uses only a5dddad's API."""
|
||||||
|
import random
|
||||||
|
from math import inf
|
||||||
|
from app import arbiter as a
|
||||||
|
rnd = random.Random(seed)
|
||||||
|
rows = []
|
||||||
|
for _ in range(cases):
|
||||||
|
n = rnd.choice((1, 2, 3))
|
||||||
|
units = ("inverter-a", "inverter-b", "inverter-c")[:n]
|
||||||
|
|
||||||
|
def act(src, u):
|
||||||
|
return a.actuator_for(src, u, units)
|
||||||
|
cl = []
|
||||||
|
for u in units:
|
||||||
|
r = rnd.choice((1000.0, 2500.0, 5000.0))
|
||||||
|
cl.append(a.Claim.limit("supervised", a.P_SAFETY, -r, r, "max_w",
|
||||||
|
actuator=act("supervised", u)))
|
||||||
|
x = rnd.random()
|
||||||
|
if x < 0.15:
|
||||||
|
cl.append(a.Claim.limit("pack", a.P_SAFETY, *rnd.choice(
|
||||||
|
((0.0, inf), (-inf, 0.0))), actuator=act("pack", u)))
|
||||||
|
elif x < 0.25:
|
||||||
|
cl.append(a.Claim.limit("refused", a.P_SAFETY, *rnd.choice(
|
||||||
|
((-inf, 0.0), (0.0, inf))), "refused",
|
||||||
|
actuator=act("refused", u)))
|
||||||
|
elif x < 0.32:
|
||||||
|
cl.append(a.Claim.set("refused", a.P_SAFETY,
|
||||||
|
rnd.choice((200.0, -200.0)), "probing",
|
||||||
|
actuator=act("refused", u)))
|
||||||
|
elif x < 0.42:
|
||||||
|
cl.append(a.Claim.set("maintenance", a.P_MAINTENANCE,
|
||||||
|
rnd.choice((2000.0, -2000.0)), "drain",
|
||||||
|
actuator=act("maintenance", u)))
|
||||||
|
elif x < 0.5:
|
||||||
|
cl.append(a.Claim.limit("maintenance", a.P_MAINT_SHAPE,
|
||||||
|
hi=-rnd.choice((500.0, 2000.0)),
|
||||||
|
actuator=act("maintenance", u)))
|
||||||
|
y = rnd.random()
|
||||||
|
if y < 0.8:
|
||||||
|
cl.append(a.Claim.set("loop", a.P_LOOP, rnd.uniform(-8000, 8000)))
|
||||||
|
if 0.5 < y < 0.9:
|
||||||
|
cl.append(a.Claim.set("arbitrage", a.P_ARBITRAGE,
|
||||||
|
rnd.uniform(-6000, 6000)))
|
||||||
|
if rnd.random() < 0.6:
|
||||||
|
cl.append(a.Claim.limit("peak", a.P_PEAK,
|
||||||
|
lo=rnd.uniform(-4000, 6000)))
|
||||||
|
if rnd.random() < 0.05:
|
||||||
|
cl.append(a.Claim.set("safety", a.P_SAFETY, 0.0, "stopped"))
|
||||||
|
rnd.shuffle(cl)
|
||||||
|
r = a.resolve(cl, units=units if n > 1 else None,
|
||||||
|
weights=(None if rnd.random() < 0.5 else
|
||||||
|
{u: rnd.choice((0.0, 0.5, 1.0)) for u in units}))
|
||||||
|
rows.append((r.target_w, r.winner, r.bound_by, r.contradiction,
|
||||||
|
tuple(sorted((r.shares or {}).items())), r.allocated,
|
||||||
|
r.allocation.explain() if r.allocation else None))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def digests() -> dict:
|
||||||
|
out = {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
|
||||||
|
out["resolve"] = digest(resolve_rows())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--summary" in sys.argv:
|
||||||
|
for k, v in SCENARIOS.items():
|
||||||
|
rows = run(**v)[0]
|
||||||
|
print(f"{k}: {len(rows)} cycles, loop reasons "
|
||||||
|
f"{sorted({r[3] for r in rows})}, arbiter "
|
||||||
|
f"{sorted({r[6].split(' ->')[0] for r in rows})}")
|
||||||
|
rr = resolve_rows()
|
||||||
|
print(f"resolve: {len(rr)} claim lists, winners "
|
||||||
|
f"{sorted({r[1] for r in rr})}, bound by "
|
||||||
|
f"{sorted({str(r[2]).split('(')[0] for r in rr})}")
|
||||||
|
for k, v in digests().items():
|
||||||
|
print(f"{k} {v}")
|
||||||
@@ -82,6 +82,9 @@ configuration:
|
|||||||
estop_fitted:
|
estop_fitted:
|
||||||
name: "Hardware · RS485 emergency stop is 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."
|
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:
|
pv_entity:
|
||||||
name: "Hardware · Solar production entity (W)"
|
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."
|
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:
|
estop_fitted:
|
||||||
name: "Hardware · RS485-noodstop is geplaatst"
|
name: "Hardware · RS485-noodstop is geplaatst"
|
||||||
description: "Zet dit aan als de noodstop geïnstalleerd is. Het verandert alleen een waarschuwing op de statuspagina."
|
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:
|
pv_entity:
|
||||||
name: "Hardware · Entiteit zonneproductie (W)"
|
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."
|
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