OPT-11 P1: per-battery mode limits, on/off, solar order
A battery's mode is a soft unit limit (source `mode`, P_MODE 35) kept
apart from the hard limits in pre_resolve(). The allocator applies it
only while the site target is decided at or below 35 (new
Resolution.deciding_priority), so the peak floor releases it and a mode
`hi` never meets the site `lo`. At N=1 resolve() applies the same rule
to the site target after stage 2. A mode range without 0 W is dropped
as a software fault, so REFUSE_SIGN stays unreachable from a mode.
compute() gets the loop bound (sum over live units of hard limits with
modes applied), so the loop never asks for watts no battery may give
and the saturation freeze never parks on them. While charging, Leftover
solar weighs 0 so Solar first fills first. Store keys pack_use and
pack_mode per battery, set by two new /action values; Off stops and
skips maintenance with a log line. Minimal Batteries cards (EN/NL).
Off by default (battery_modes_enabled); off, commands are byte-identical
to a5dddad at N=1 and N=2 (test_modes.py part 1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -293,6 +293,10 @@ REFUSED_BELOW_W = PROBE_ANSWER_W
|
||||
PROBE_EVERY_S = 900.0
|
||||
PROBE_S = 34.0
|
||||
|
||||
# OPT-11. The loop's reason when the battery modes, not the meter, set the
|
||||
# command: every battery that may move this way is at its mode's limit.
|
||||
MODE_BOUND = "mode-bound"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
@@ -475,6 +479,7 @@ def compute(
|
||||
i_w: float | None = None,
|
||||
refused: int | None = None,
|
||||
probe: bool = False,
|
||||
bound: tuple | None = None,
|
||||
) -> Decision:
|
||||
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
||||
|
||||
@@ -485,8 +490,16 @@ def compute(
|
||||
`refused` SAFETY-52's carried refused direction, or None to leave it out
|
||||
(the `i_w=None` equivalence path does)
|
||||
`probe` the caller's clock says a refused direction may try PROBE_W now
|
||||
`bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
|
||||
their modes applied (`allocator.loop_bound()`), or None. See
|
||||
MODE_BOUND below. None is the law as it was.
|
||||
"""
|
||||
reason = "tracking"
|
||||
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
|
||||
# mode may do. It is dropped rather than obeyed (a software fault, and
|
||||
# main.py never builds one: every term of `loop_bound()` holds 0).
|
||||
if bound is not None and not (bound[0] <= 0.0 <= bound[1]):
|
||||
bound = None
|
||||
|
||||
# --- saturation, WITH the duration term -------------------------------
|
||||
# Command and actual diverging means the inverter cannot follow - it is at
|
||||
@@ -589,6 +602,16 @@ def compute(
|
||||
else:
|
||||
i_w = max(moved, i_w)
|
||||
|
||||
# OPT-11: the integrator does not wind past what the modes let the
|
||||
# batteries place. Applied BEFORE the integrator bound below, so a loop
|
||||
# held by a mode reads as `mode-bound` and not as SAFETY-03's winding into
|
||||
# the rail (`i_clamped`). That bound still bites where it is the tighter.
|
||||
if bound is not None:
|
||||
held = max(float(bound[0]), min(float(bound[1]), i_w))
|
||||
if held != i_w:
|
||||
i_w = held
|
||||
reason = MODE_BOUND
|
||||
|
||||
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
|
||||
# bound is not. It is what makes the worst-case unwind time finite and
|
||||
# knowable instead of a function of how long the error happened to stand.
|
||||
@@ -796,6 +819,22 @@ def compute(
|
||||
target = refused * PROBE_W if probe and wants else 0.0
|
||||
reason = PROBING if probe and wants else REFUSED
|
||||
|
||||
# --- OPT-11: the command stays inside what the modes allow -------------
|
||||
# Applied after the slew limit and the freeze, so a mode that closes drops
|
||||
# the ask at once rather than one slew step per cycle. That moves no
|
||||
# battery further than the allocator would: it places at most this bound
|
||||
# when the loop decides. What it removes is the unmet part, which would
|
||||
# otherwise hold `prev_w` away from `actual_w` and trip the saturation
|
||||
# freeze on watts no battery was allowed to give.
|
||||
if bound is not None:
|
||||
inside = max(float(bound[0]), min(float(bound[1]), target))
|
||||
if inside != target:
|
||||
target = inside
|
||||
reason = MODE_BOUND
|
||||
# SAFETY-48's lead cap above may pull the integrator back toward the
|
||||
# slewed command; what is CARRIED stays inside the bound too.
|
||||
i_w = max(float(bound[0]), min(float(bound[1]), i_w))
|
||||
|
||||
# --- quantise ----------------------------------------------------------
|
||||
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
|
||||
# response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent
|
||||
@@ -803,6 +842,13 @@ def compute(
|
||||
# 10 W simply avoids a visible staircase on dashboards.
|
||||
step = max(1, int(tuning.step_w))
|
||||
target = round(target / step) * step
|
||||
if bound is not None:
|
||||
# OPT-11: rounding must not step outside the bound. The bound holds
|
||||
# 0 W, so one step toward 0 is always inside it.
|
||||
if target > bound[1]:
|
||||
target -= step
|
||||
elif target < bound[0]:
|
||||
target += step
|
||||
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||
i_clamped, clamped, float(i_bound_w), float(want),
|
||||
|
||||
Reference in New Issue
Block a user