Merge release/1.0 (SAFETY-78, #154) into opt11-p4-solar-forecast
Conflicts: modetrade.decide() takes both the day plan (targets) and SAFETY-78's draining flag; the Why line keeps SAFETY-78's other_cycle ahead of the plan's word (test added). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -344,6 +344,16 @@ class _Narrowed:
|
||||
self.lo, self.hi = lo, hi
|
||||
|
||||
|
||||
def pin_w(b):
|
||||
"""The watts a unit's own SET places once held inside its hard limits,
|
||||
as `plan()` places a standing pin, or None when the unit has no SET."""
|
||||
pv = getattr(b, "pinned_w", None)
|
||||
if getattr(b, "gate", None) is None or pv is None:
|
||||
return None
|
||||
lo, hi = _int_envelope(b)
|
||||
return float(pv) if lo > hi else float(_clamp_int(round(float(pv)), lo, hi))
|
||||
|
||||
|
||||
def loop_bound(bounds, live=None):
|
||||
"""OPT-11. `(lo, hi)`: what the units can place when the LOOP decides.
|
||||
|
||||
@@ -364,11 +374,8 @@ def loop_bound(bounds, live=None):
|
||||
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))
|
||||
p = pin_w(b)
|
||||
if p is not None:
|
||||
lo_sum += min(0.0, p)
|
||||
hi_sum += max(0.0, p)
|
||||
continue
|
||||
|
||||
@@ -184,6 +184,12 @@ CLAIM_SCOPE = {
|
||||
# the banner and the claim an install has had since SAFETY-02 shipped.
|
||||
# `Controller.cycle()` does not even arm the site detector there.
|
||||
"sign_site": SCOPE_SITE,
|
||||
# main.py: `Claim.limit("drain", P_MAINTENANCE, lo=...)`, SAFETY-78. At
|
||||
# N>=2, while a pack's maintenance drain is pinned, the site target may
|
||||
# not go below what that pin places: below it a sibling would have to
|
||||
# charge against the drain (circulation, refused, 0 W). A site `lo`,
|
||||
# never filed at N=1. DECISIONS/SAFETY-78-drain-at-two.md.
|
||||
"drain": SCOPE_SITE,
|
||||
|
||||
# --- UNIT: about one pack, one inverter, one device -------------------
|
||||
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
|
||||
|
||||
@@ -695,6 +695,14 @@ def battery_rows(c, shadow, running, held=False):
|
||||
# carries its share (SAFETY-73)?
|
||||
other = next((o.name for o in c.inverters if o is not inv
|
||||
and refusing(c, o) > 0 and not _off(c, o)), None)
|
||||
# SAFETY-78: another battery drains for its monthly cycle, so this
|
||||
# one never charges now. Not while that drain waits: it files no
|
||||
# floor then, so this battery may charge from solar.
|
||||
waits = getattr(c, "_drain_waits", ())
|
||||
cycling = next((o.name for j, o in enumerate(c.inverters)
|
||||
if o is not inv and j < len(c.maints)
|
||||
and c.maints[j].phase == M_DRAIN
|
||||
and o.name not in waits), None)
|
||||
role = None if tick is None else tick.roles.get(inv.name)
|
||||
# Only once a cycle has run with modes on (`tick`): before that
|
||||
# nothing is known, so nothing is claimed about the prices.
|
||||
@@ -711,6 +719,7 @@ def battery_rows(c, shadow, running, held=False):
|
||||
refused=refusing(c, inv), peak=peak,
|
||||
giving=flow(inv.batt) == "giving", other_refuses=other,
|
||||
sf_ahead=sf_ahead, role=role, plan=plan, has_at=bool(at),
|
||||
other_cycle=cycling,
|
||||
day=None if pm is None or pm.mode not in PRICE_MODES
|
||||
else day_key(c, inv.name))
|
||||
rows.append({
|
||||
@@ -730,7 +739,8 @@ def battery_rows(c, shadow, running, held=False):
|
||||
# UX-13 P3: why it does what "now" says, from what the add-on
|
||||
# knows this cycle. Price and forecast reasons are OPT-11 P3/P4.
|
||||
"why_key": why,
|
||||
"why": why_words(why, name=other, at=at),
|
||||
"why": why_words(why, name=cycling if why == "other_cycle"
|
||||
else other, at=at),
|
||||
})
|
||||
if modes_on:
|
||||
# OPT-11 P1: the battery's switch and mode, for the Batteries
|
||||
@@ -764,7 +774,7 @@ def day_key(c, name):
|
||||
def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
|
||||
held, refused, peak, sf_ahead, giving=False,
|
||||
other_refuses=None, role=None, plan=None,
|
||||
has_at=False, day=None) -> str:
|
||||
has_at=False, other_cycle=None, day=None) -> str:
|
||||
"""The key of one battery's "why" line (`home_words.why`), most urgent
|
||||
first. Pure, so every branch is tested without a controller."""
|
||||
if not reading:
|
||||
@@ -784,6 +794,8 @@ def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
|
||||
return "refuses_give" if refused > 0 else "refuses_charge"
|
||||
if peak:
|
||||
return "peak"
|
||||
if other_cycle:
|
||||
return "other_cycle"
|
||||
if giving and other_refuses:
|
||||
return "other_refuses"
|
||||
if not modes_on:
|
||||
|
||||
@@ -73,7 +73,7 @@ from .hass import HomeAssistant
|
||||
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
|
||||
roster_entries, roster_names)
|
||||
from .maintenance import (
|
||||
IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
|
||||
DRAIN, IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
|
||||
arbitrate as maint_arbitrate,
|
||||
)
|
||||
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
|
||||
@@ -979,6 +979,9 @@ class Controller:
|
||||
# [(pack name, why)] for packs that are due and waiting their turn.
|
||||
# Empty at a roster of one, where nothing can defer anything.
|
||||
self._maint_deferred = []
|
||||
# SAFETY-78: names of draining packs that cannot give power now, so
|
||||
# the "waits" line is logged once, not every cycle.
|
||||
self._drain_waits = set()
|
||||
# Set by amain() once the transport is built, so the status page can show
|
||||
# what only the transport knows (homewizard_local's unchanged_s). Stays
|
||||
# None when P1 is off, or under a transport that has no such counter.
|
||||
@@ -2248,7 +2251,8 @@ class Controller:
|
||||
headroom_w=self.arb_headroom_w, floor_pct=floor_pct,
|
||||
ceiling_pct=ceiling_pct, house_w=house, charging_now=was,
|
||||
blocked=self._charge_blocked_q == quarter,
|
||||
done_charge=done_c, done_sell=done_s, targets=targets)
|
||||
done_charge=done_c, done_sell=done_s, targets=targets,
|
||||
draining=any(r.phase == DRAIN for r in maint_results or ()))
|
||||
# A charge the guard or the headroom stopped stays off this period.
|
||||
if was and self.mode_tick.action != modetrade.CHARGE and \
|
||||
self.mode_tick.why in ("peak", "headroom"):
|
||||
@@ -2473,6 +2477,42 @@ class Controller:
|
||||
except Exception: # noqa: BLE001
|
||||
_LOG.exception("Home Assistant command could not be handled")
|
||||
|
||||
def _drain_floor(self, claims, maint_results):
|
||||
"""SAFETY-78: the site `lo` for a pinned maintenance drain, or None.
|
||||
|
||||
At N>=2 the drain pins its pack and the site target is split around
|
||||
the pin. Below the pin a sibling would have to charge (opposite
|
||||
directions, refused, 0 W, and the drain never moves). So the site
|
||||
target may not go below what the pin places: the drained pack covers
|
||||
the house up to its drain rate and exports the rest, as at N=1, and
|
||||
a sibling only ever adds power in the same direction. At N=1 the
|
||||
drain is the site SET itself, so nothing is filed.
|
||||
A draining pack whose own limits leave it nothing to give (a refusal,
|
||||
a stale link) adds no floor, and that is logged once.
|
||||
"""
|
||||
if len(self.inverters) < 2:
|
||||
return None
|
||||
live = set(self.live_units())
|
||||
floor, waits = 0.0, set()
|
||||
for inv, res in zip(self.inverters, maint_results):
|
||||
if res.phase != DRAIN or not res.owns_setpoint \
|
||||
or not (res.setpoint_w or 0.0) > 0.0:
|
||||
continue
|
||||
u = self.actuator_of("maintenance", inv)
|
||||
b = pre_resolve([c for c in claims if c.actuator == u])
|
||||
p = (None if u not in live or b.contradiction
|
||||
or b.pinned_by != "maintenance" else allocator.pin_w(b))
|
||||
if p is not None and p > 0.0:
|
||||
floor += p
|
||||
else:
|
||||
waits.add(inv.name)
|
||||
for name in sorted(waits - self._drain_waits):
|
||||
self.log_event(f"maintenance: {name} cannot give power now, so "
|
||||
f"its monthly cycle waits",
|
||||
hint=messages.hint("maint_waits", unit=name))
|
||||
self._drain_waits = waits
|
||||
return floor if floor > 0.0 else None
|
||||
|
||||
def _maint_stop_off(self, machine, now) -> None:
|
||||
"""OPT-11: stop this battery's maintenance cycle because it is off."""
|
||||
if machine.phase == IDLE:
|
||||
@@ -4207,6 +4247,13 @@ class Controller:
|
||||
_ctl.REFUSED,
|
||||
actuator=self.actuator_of("refused",
|
||||
inv)))
|
||||
# --- SAFETY-78: a pinned drain is a floor under the site target -----
|
||||
# After every unit claim, so the floor is what the pin really places.
|
||||
drain = self._drain_floor(claims, maint_results)
|
||||
if drain is not None:
|
||||
claims.append(Claim.limit("drain", P_MAINTENANCE, lo=drain,
|
||||
reason="maintenance drain",
|
||||
actuator=self.actuator_of("drain")))
|
||||
# --- OPT-11 P3: the site trade, after every unit claim --------------
|
||||
# Sized from what the batteries may place with their modes and hard
|
||||
# limits (the loop bound), so none is asked for watts it may not give.
|
||||
|
||||
@@ -931,6 +931,7 @@ HINTS = {
|
||||
"pack_mode": "{unit}: battery mode changed",
|
||||
"pack_sell": "{unit}: selling switched",
|
||||
"maint_off": "{unit}: maintenance stopped, the battery is switched off",
|
||||
"maint_waits": "{unit} cannot give power now, so its monthly cycle waits",
|
||||
"mode_fault": "{unit}: battery mode ignored because of a software fault",
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ Rules that keep this safe (tests: test_modes_p3.py):
|
||||
* a battery out of a run (full, or at the floor) stays out until the run
|
||||
ends, so the ceiling and the floor do not re-arm within one run;
|
||||
* a battery that refuses a direction (SAFETY-52/73) takes no part in a
|
||||
run in that direction: the refusal wins over the run.
|
||||
run in that direction: the refusal wins over the run;
|
||||
* no grid charge while another battery drains for its monthly cycle
|
||||
(`draining`, SAFETY-78): it would charge against the drain.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -149,7 +151,7 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(),
|
||||
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
|
||||
blocked=False, done_charge=(), done_sell=(),
|
||||
targets=None) -> Tick:
|
||||
targets=None, draining=False) -> Tick:
|
||||
"""This tick's ranges, roles and site ask.
|
||||
|
||||
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
|
||||
@@ -161,6 +163,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
`charging_now` says last tick charged (then any headroom above 0 keeps
|
||||
it going); `blocked` that the guard or the headroom stopped a charge in
|
||||
this 15-minute period. `targets` is the day plan (`charge_to()`).
|
||||
`draining` says a battery drains for its monthly cycle, so none may
|
||||
charge from the grid (why "cycle").
|
||||
"""
|
||||
plan_on = runs is not None and runs.on
|
||||
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
|
||||
@@ -170,6 +174,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
why = None
|
||||
ch = chargers(packs, ceiling_pct, done_charge, targets) if in_cheap \
|
||||
else []
|
||||
if ch and draining:
|
||||
ch, why = [], "cycle"
|
||||
charging = False
|
||||
if ch:
|
||||
need = 0.0 if charging_now else START_W
|
||||
|
||||
@@ -327,6 +327,7 @@
|
||||
"leftover_alone": "No Solar first battery is on, so it gets solar first.",
|
||||
"covers_house": "It gives power to cover the house.",
|
||||
"other_refuses": "{name} refuses to give power, so this battery takes over.",
|
||||
"other_cycle": "{name} is emptying for its monthly cycle. This one does not charge.",
|
||||
"grid_charge": "Prices are low, so it charges from the grid.",
|
||||
"rests_charge": "Another battery charges from the grid, so it rests.",
|
||||
"selling": "Prices are high, so it sells to the grid.",
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"leftover_alone": "Geen batterij met Eerst zonne-energie staat aan, dus zij krijgt eerst zonne-energie.",
|
||||
"covers_house": "Ze levert stroom om het huis te dekken.",
|
||||
"other_refuses": "{name} weigert te leveren, dus deze batterij neemt het over.",
|
||||
"other_cycle": "{name} loopt leeg voor haar maandelijkse cyclus. Deze laadt niet.",
|
||||
"grid_charge": "De prijs is laag, dus ze laadt van het net.",
|
||||
"rests_charge": "Een andere batterij laadt van het net, dus deze rust.",
|
||||
"selling": "De prijs is hoog, dus ze verkoopt aan het net.",
|
||||
|
||||
Reference in New Issue
Block a user