MAINT-05: one recalibration per pack, and only one pack moves power at a time
checks / unit (pull_request) Successful in 4m48s
checks / unit (pull_request) Successful in 4m48s
§12's monthly cycle drains a pack to empty and refills it to true its coulomb
counter. There was exactly one `Maintenance` and one un-namespaced history, so
at two packs only the first was ever recalibrated - and nothing prevented the
cycle draining pack A while the loop charged pack B, which is two power-moving
phases on one grid connection pulling against each other.
* ONE `Maintenance` PER ROSTER ENTRY, each with its own schedule, phase and
history under `unit/<name>/...`. `store.migrate_maint_keys()` moves an
install's flat history into the first pack's namespace under
`migrate_unit_keys()`'s three rules - shared in code via `_move_flat_keys()`
and `_verify_moves()`, not restated - on its own marker, because unlike
DEV-02's it runs only where the roster has more than one pack. The flat keys
are kept LIVE as the rollback view.
* ONE MAINTENANCE SLOT PER SITE (`maintenance.arbitrate()`). At most one pack
in a power-moving phase - drain, charge AND hold. The holder is DERIVED from
the packs' own stored phases, never persisted, so a restart mid-drain
re-derives it. Several packs due in the same hour: the slot goes to the one
that has gone longest without a completed cycle; roster order breaks a
numeric tie and nothing else. Nothing preempts a cycle in flight.
* THE DEFERRED PACK IS NOT STARVED AND IS NOT A FAULT. A deferral does not
stamp `last_start_attempt` and does not move the phase, so neither the 12 h
drain nor the 36 h charge timeout is reachable by waiting.
* The claim is filed on the pack whose state machine produced it, and stays
SCOPE_UNIT - ARCH-01's rule.
* `validate.py` rule 6 grades `maintenance_charge_w` against the WEAKEST cap on
the roster now, as its own comment said it must once `maint_unit` stopped
being `inverters[0]`.
* At a roster of one nothing changes, and it is asserted: no new store key, the
migration does not run, the four cycle events are the shipped strings, the
banner is the bare phase.
* RECORDED NOT FIXED: a drain pins its pack while the loop's target is a site
claim, so below the pin the allocator refuses the split and the site parks at
0 W. Pre-existing at 2d025ee. Pinned as a running check.
Verified: 19/19 suites green in the CI image; 17 mutations run through
tools/mutation_run.sh (bytecode purged), every one observed red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
+304
-107
@@ -54,14 +54,16 @@ from .hass import HomeAssistant
|
||||
# window an operator would look up here is the one the roster actually uses.
|
||||
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
|
||||
roster_entries)
|
||||
from .maintenance import IDLE, MaintConfig, Maintenance
|
||||
from .maintenance import (
|
||||
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
|
||||
)
|
||||
from .mqtt import MqttPublisher
|
||||
from . import peakguard
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
from .tariff import PeakStore, PeakTracker
|
||||
from .store import migrate_unit_keys
|
||||
from .store import migrate_maint_keys, migrate_unit_keys
|
||||
from .validate import (ConfigRefused, refuse, validate_startup_config,
|
||||
validate_unit_ranges)
|
||||
from .watchdog import (
|
||||
@@ -265,7 +267,53 @@ class Controller:
|
||||
# maintenance.MAINT_OPTION_DEFAULTS before adding a ninth key: it goes
|
||||
# in config.yaml's `options:` and `schema:` blocks and in that dict, and
|
||||
# nowhere else.
|
||||
self.maint = Maintenance(MaintConfig.from_options(opts), store)
|
||||
# --- MAINT-05: ONE `Maintenance` PER ROSTER ENTRY --------------------
|
||||
# ⚠️ AND THE MIGRATION RUNS FIRST, FOR THE REASON THE SAFETY-02 ONE
|
||||
# DOES. These objects READ their history in their constructors' wake -
|
||||
# `phase`, `last_completed` - so a history still sitting under the flat
|
||||
# spelling when they are built is a pack that comes up believing it has
|
||||
# never been recalibrated, on a site whose state file says otherwise.
|
||||
# It targets `self.inverters[0].name` and NOT `UNIT_A`: the flat keys
|
||||
# belonged to whichever pack the one FSM was driving, which is the first
|
||||
# roster entry, and a declared `inverters:` roster names its own entries
|
||||
# (`inverter.build_roster()` says so). Writing `UNIT_A` here would
|
||||
# deposit an install's whole maintenance history under a name that is
|
||||
# not on the roster at all.
|
||||
self.maint_migration = migrate_maint_keys(
|
||||
store, self.inverters[0].name, len(self.inverters))
|
||||
_LOG.info("maintenance history: %s", self.maint_migration.explain())
|
||||
if not self.maint_migration.ok:
|
||||
# Not a disarmed guard - history, not a latch - so it does not
|
||||
# re-arm anything. It is still the difference between a pack that is
|
||||
# recalibrated on schedule and one that silently is not, which is
|
||||
# §12's opening sentence, so it is said where an operator looks.
|
||||
self.log_event("maintenance history did NOT migrate cleanly: "
|
||||
+ self.maint_migration.explain())
|
||||
# ⚠️ ONE CONFIG, N MACHINES. The `maintenance_*` options describe the
|
||||
# PROCEDURE - how hard to drain, how full to charge, how long to hold -
|
||||
# and the procedure is the same on every pack in the roster; what is per
|
||||
# pack is the SCHEDULE and the HISTORY, which live in the store. A
|
||||
# per-entry config block would be a second shape for CFG-04's roster to
|
||||
# carry and a fifth place for a `maintenance_charge_w` to disagree with
|
||||
# config.yaml. If a site ever needs two different procedures, that is a
|
||||
# roster-schema card and it starts here.
|
||||
_maint_cfg = MaintConfig.from_options(opts)
|
||||
_one_pack = len(self.inverters) <= 1
|
||||
self.maints = [
|
||||
Maintenance(
|
||||
_maint_cfg, store,
|
||||
# ⚠️ `None` AT A ROSTER OF ONE IS THE FLAT LAYOUT, NOT A MISSING
|
||||
# NAME. §6.2's identity: one pack, one history, and the keys it
|
||||
# has been written under since the add-on shipped. See
|
||||
# `maintenance.UnitHistory` and `store.migrate_maint_keys()` -
|
||||
# this is the line that keeps a one-inverter state file
|
||||
# byte-identical.
|
||||
unit=None if _one_pack else inv.name,
|
||||
name=inv.name,
|
||||
# The rollback view, and only ever the first pack's: the flat
|
||||
# keys mean `inverters[0]` on every install that has them.
|
||||
mirror_flat=(not _one_pack and i == 0))
|
||||
for i, inv in enumerate(self.inverters)]
|
||||
|
||||
# SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own
|
||||
# docstring has always said and what this Controller did not do. Each
|
||||
@@ -453,6 +501,9 @@ class Controller:
|
||||
self._peak_saturated = False
|
||||
self._peak_floor_limited = False
|
||||
self._maint_shape_suspended = False
|
||||
# [(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 = []
|
||||
# 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.
|
||||
@@ -667,18 +718,79 @@ class Controller:
|
||||
return tuple(inv.name for inv in self.inverters)
|
||||
|
||||
@property
|
||||
def maint_unit(self):
|
||||
"""The pack the ONE maintenance state machine is recalibrating.
|
||||
def maint_index(self) -> int:
|
||||
"""Which roster slot holds the site's ONE maintenance slot right now.
|
||||
|
||||
⚠️ NAMED RATHER THAN ASSUMED. This add-on holds exactly one
|
||||
`Maintenance`, whose history lives under the store's un-namespaced
|
||||
`phase` / `last_completed` keys, so the pack it drives is the first in
|
||||
the roster. Two packs recalibrating on two schedules needs a second FSM
|
||||
and a namespaced history of its own; what THIS card fixes is only that
|
||||
the claim is filed on ONE actuator instead of on whichever one happened
|
||||
to be asked. Read store.LEGACY_UNIT_KEYS on why those keys did not move.
|
||||
The pack that is mid-cycle if there is one, otherwise the first - which
|
||||
at a roster of one is always 0, always was, and is the whole of this
|
||||
property's N=1 behaviour.
|
||||
"""
|
||||
return self.inverters[0]
|
||||
for i, m in enumerate(self.maints):
|
||||
if m.phase in POWER_MOVING:
|
||||
return i
|
||||
return 0
|
||||
|
||||
@property
|
||||
def maint(self):
|
||||
"""The `Maintenance` the site's one maintenance slot belongs to.
|
||||
|
||||
⚠️ MAINT-05 TURNED THIS FROM AN ATTRIBUTE INTO A VIEW, AND THE THING IT
|
||||
IS A VIEW OF CHANGED WITH IT. There are now `self.maints` - one FSM per
|
||||
roster entry, each with its own schedule, phase and history - and every
|
||||
SITE-level surface that used to read `self.maint` (the `phase` MQTT
|
||||
value, the status banner, the operator's start/abort buttons) wants THE
|
||||
ONE THAT IS RECALIBRATING, not "the first one". `maintenance.arbitrate()`
|
||||
guarantees at most one pack is ever out of IDLE, so "the one that is
|
||||
recalibrating" is well defined; when none is, this is `maints[0]` and
|
||||
the surfaces read exactly what they read before.
|
||||
|
||||
⚠️ AT A ROSTER OF ONE IT IS `maints[0]` ON EVERY TICK, in both branches,
|
||||
which is the object that used to be assigned here. Nothing downstream
|
||||
can tell the difference - that is AC 6, and test_maintenance.py part 9
|
||||
asserts it rather than trusting this paragraph.
|
||||
"""
|
||||
return self.maints[self.maint_index]
|
||||
|
||||
@property
|
||||
def maint_unit(self):
|
||||
"""The pack the site's one maintenance slot is currently on.
|
||||
|
||||
⚠️ THIS USED TO BE `self.inverters[0]`, FLATLY, AND THE COMMENT THAT
|
||||
STOOD HERE EXPLAINED WHY: there was exactly one `Maintenance`, its
|
||||
history lived under the store's un-namespaced `phase` / `last_completed`
|
||||
keys, so the pack it drove could only be the first in the roster. That
|
||||
sentence is what MAINT-05 removed. There is now one FSM and one
|
||||
namespaced history PER ROSTER ENTRY, so the pack being recalibrated is
|
||||
whichever one holds the site's maintenance slot, and `maint_index` -
|
||||
not the roster's order - is what answers it.
|
||||
|
||||
⚠️ AND `validate.py` RULE 6 MOVED WITH IT, exactly as its own comment
|
||||
said it must ("when `maint_unit` stops being `inverters[0]`, this line
|
||||
moves with it"). It graded `maintenance_charge_w` against the FIRST
|
||||
entry's cap on the argument that the cycle never touches the others;
|
||||
that argument is now false, so it grades against the weakest cap on the
|
||||
roster, like rule 5. See the ⚠️ block there.
|
||||
"""
|
||||
return self.inverters[self.maint_index]
|
||||
|
||||
def _maint_button(self, now):
|
||||
"""The pack an operator's one maintenance button acts on.
|
||||
|
||||
The pack holding the site's maintenance slot when one holds it, so
|
||||
"abort" stops the cycle that is running and "start" restarts it -
|
||||
exactly what both buttons meant while there was one pack, and the reason
|
||||
this is not simply "the longest-waiting one". When every pack is idle it
|
||||
IS the longest-waiting one, which is `arbitrate()`'s own clause 2, so a
|
||||
forced start and a scheduled start choose the same pack rather than two
|
||||
different ones.
|
||||
|
||||
⚠️ AT A ROSTER OF ONE BOTH BRANCHES ARE `maints[0]`. There is no third
|
||||
case: `max()` over a one-element list is that element.
|
||||
"""
|
||||
if self.maints[self.maint_index].phase in POWER_MOVING:
|
||||
return self.maints[self.maint_index]
|
||||
return max(enumerate(self.maints),
|
||||
key=lambda p: (p[1].waited_days(now), -p[0]))[1]
|
||||
|
||||
def inverter(self, name: str):
|
||||
"""The roster entry called `name`, or None."""
|
||||
@@ -1329,14 +1441,36 @@ class Controller:
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# ⚠️ THE PACK IT IS MAINTAINING, NOT "the SoC". §12's recalibration
|
||||
# drains and refills ONE pack and grades ONE coulomb counter, so it is
|
||||
# graded on that pack's own reading - see `maint_unit`. A minimum across
|
||||
# packs would abort the cycle on a sibling's state, and an average would
|
||||
# declare a pack full that is not.
|
||||
result = self.maint.tick(now, self.maint_unit.soc)
|
||||
for msg in result.events:
|
||||
self.log_event(msg)
|
||||
# ⚠️ EACH PACK ON ITS OWN READING, NOT "the SoC". §12's recalibration
|
||||
# drains and refills ONE pack and grades ONE coulomb counter, so each
|
||||
# state machine is ticked with the SoC of the inverter it belongs to. A
|
||||
# minimum across packs would abort a cycle on a sibling's state, and an
|
||||
# average would declare a pack full that is not. The pairing is
|
||||
# positional and both lists are built from the roster in `__init__`, so
|
||||
# a pack cannot be graded on its neighbour's meter.
|
||||
#
|
||||
# ⚠️ AND `arbitrate()` RUNS FIRST, ONCE, OVER ALL OF THEM. It is the
|
||||
# site's one maintenance slot - see maintenance.py - and a pack that
|
||||
# does not hold it is told so by name rather than left to discover it.
|
||||
# Calling `tick()` without the answer this produced is the
|
||||
# two-packs-draining-at-once defect with the interlock present but not
|
||||
# wired, which is why the plan is built here and not inside the loop.
|
||||
maint_plan = maint_arbitrate(self.maints, now)
|
||||
maint_results = []
|
||||
for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan):
|
||||
res = machine.tick(now, inv.soc, blocked_by=blocked)
|
||||
for msg in res.events:
|
||||
self.log_event(msg)
|
||||
maint_results.append(res)
|
||||
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
|
||||
# deferral is edge-logged (it lasts as long as the other pack's cycle),
|
||||
# so by the time an operator asks "why has pack B not recalibrated" the
|
||||
# line that said so has scrolled off. §12's whole failure mode is a
|
||||
# schedule that silently stops firing; a pack that is due, idle and
|
||||
# WAITING has to be able to say so at the moment it is looked at.
|
||||
self._maint_deferred = [(m.name, r.deferred)
|
||||
for m, r in zip(self.maints, maint_results)
|
||||
if r.deferred]
|
||||
|
||||
# --- SAFETY-02, and it runs BEFORE the early return below -------------
|
||||
# ⚠️ ORDER MATTERS HERE. If this were evaluated after the "inputs missing
|
||||
@@ -1838,88 +1972,114 @@ class Controller:
|
||||
self.log_event(self.arb.explain())
|
||||
|
||||
# --- maintenance -----------------------------------------------------
|
||||
if result.owns_setpoint:
|
||||
# PER-INVERTER. §12's monthly recalibration is a PACK procedure -
|
||||
# drain THIS pack to its floor, charge THIS pack full, recalibrate
|
||||
# THIS pack's coulomb counter. Two packs recalibrate on two
|
||||
# schedules. See arbiter.CLAIM_SCOPE.
|
||||
# ⚠️ FILED ON THE UNIT BEING MAINTAINED, WHICH IS THE FIRST ONE.
|
||||
# §12's monthly recalibration is a PACK procedure and this add-on
|
||||
# holds exactly ONE `Maintenance` state machine, so the pack it is
|
||||
# maintaining is `self.maint_unit`. Two packs recalibrating on two
|
||||
# schedules is a second FSM with its own namespaced history, which
|
||||
# is a card of its own - what this card must not do is file one
|
||||
# pack's procedure on every actuator.
|
||||
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
||||
float(result.setpoint_w or 0.0), result.phase,
|
||||
actuator=self.actuator_of("maintenance",
|
||||
self.maint_unit)))
|
||||
self.sat_count = 0
|
||||
elif result.charge_only:
|
||||
# Money outranks the maintenance schedule: while the quarter-hour
|
||||
# projection is over the cap, the charge-only shaping is simply not
|
||||
# claimed, so the loop can discharge and shave the peak. When a
|
||||
# dedicated peak-shaving strategy arrives it will claim SET at
|
||||
# P_PEAK and outrank this limit without any code here changing.
|
||||
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
|
||||
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's claim
|
||||
# is `lo >= 0`. Both are limits, arbiter.py applies EVERY limit at or
|
||||
# above the winner's priority, and most restrictive wins - so a peak
|
||||
# event during a maintenance charge window files `lo > hi`, which is
|
||||
# a CONTRADICTION and resolves to 0 W. The battery would park in the
|
||||
# exact quarter it should be shaving. Priority does not save it:
|
||||
# clause 2 has no notion of a higher-priority limit overriding a
|
||||
# lower one, and it should not - "most restrictive" is what makes the
|
||||
# safety bounds unconditional.
|
||||
#
|
||||
# So the higher-priority claimant SUPPRESSES the lower one rather
|
||||
# than out-voting it, which is what this branch already did for
|
||||
# `peak_forecast_entity`. That entity is optional and empty on a
|
||||
# default install, so until now the suspension could not fire on the
|
||||
# site that needed it. `self.peak_guard` is measured from the meter
|
||||
# and is always there.
|
||||
#
|
||||
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is not
|
||||
# tidiness. It was inline, and a reviewer mutated it back to its
|
||||
# historical broken form - re-gated on `peak_forecast_entity` alone -
|
||||
# and every suite in this repository stayed green while a default
|
||||
# install parked at 0 W in the quarter that sets the month's bill.
|
||||
# Read shaping_suspended() before touching this: both terms are
|
||||
# there, and why neither is sufficient alone.
|
||||
if peakguard.shaping_suspended(
|
||||
self.peak_guard, self.peak_fc,
|
||||
float(self.o.get("peak_cap_w", 3500))):
|
||||
# Edge-logged: level-logging here would write one line per cycle
|
||||
# for as long as the event lasts and flush the 40-line action log
|
||||
# of the very context that explains it.
|
||||
if not self._maint_shape_suspended:
|
||||
self._maint_shape_suspended = True
|
||||
self.log_event("peak at risk - maintenance charge shaping "
|
||||
"suspended so the peak claim is not "
|
||||
"contradicted")
|
||||
else:
|
||||
self._maint_shape_suspended = False
|
||||
hi = 0.0
|
||||
reason = "charge-only"
|
||||
if self.cheap:
|
||||
# ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ
|
||||
# OF `self.o`. This line used to re-open the options dict
|
||||
# with its own literal fallback, so the rate the shaping
|
||||
# asked for and the rate the state machine drained at could
|
||||
# be different numbers from the same install - and, with the
|
||||
# key absent, they were. The Controller already parsed this
|
||||
# once in __init__; asking the object that owns it cannot
|
||||
# drift from it.
|
||||
floor = maintenance_charge_floor(
|
||||
self.maint.cfg.charge_w,
|
||||
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
|
||||
if floor > 0:
|
||||
hi, reason = -floor, "cheap-window charge"
|
||||
# PER-INVERTER, like the SET above and for the same reason -
|
||||
# the shaping is about the pack being charged.
|
||||
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason,
|
||||
actuator=self.actuator_of("maintenance",
|
||||
self.maint_unit)))
|
||||
# ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST STAYS SHORT.
|
||||
# At most one machine is out of IDLE on any tick, so at most one of
|
||||
# these branches fires and the claim list is the shape it has always
|
||||
# been. The loop is not a widening of what maintenance may ask for; it
|
||||
# is what makes the ONE claim it does file land on the pack that is
|
||||
# actually being recalibrated, whichever roster slot that is.
|
||||
for inv, result in zip(self.inverters, maint_results):
|
||||
if result.owns_setpoint:
|
||||
# PER-INVERTER. §12's monthly recalibration is a PACK procedure -
|
||||
# drain THIS pack to its floor, charge THIS pack full,
|
||||
# recalibrate THIS pack's coulomb counter. Two packs recalibrate
|
||||
# on two schedules. See arbiter.CLAIM_SCOPE.
|
||||
#
|
||||
# ⚠️ FILED ON THE PACK WHOSE STATE MACHINE PRODUCED IT - `inv`,
|
||||
# the roster entry this `result` was ticked from - and that is
|
||||
# the whole of MAINT-05 at this line. It used to be
|
||||
# `self.maint_unit`, which was `inverters[0]` flatly, because
|
||||
# there was one FSM and its history had nowhere else to live.
|
||||
# Pack B's drain filed on pack A is one pack being commanded for
|
||||
# another's procedure while the pack that is actually draining
|
||||
# stays under the loop: the site would move the right total and
|
||||
# recalibrate nothing.
|
||||
#
|
||||
# ⚠️ AND IT STAYS `SCOPE_UNIT`. Two FSMs mean two claims on two
|
||||
# actuators, which the allocator consumes as per-unit intervals;
|
||||
# ARCH-01 is explicit that this must never become a site claim,
|
||||
# because per-inverter constraints filed site-wide are the
|
||||
# cross-inverter `lo > hi` contradiction at arbiter.py:114.
|
||||
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
||||
float(result.setpoint_w or 0.0), result.phase,
|
||||
actuator=self.actuator_of("maintenance", inv)))
|
||||
self.sat_count = 0
|
||||
elif result.charge_only:
|
||||
# Money outranks the maintenance schedule: while the quarter-hour
|
||||
# projection is over the cap, the charge-only shaping is simply not
|
||||
# claimed, so the loop can discharge and shave the peak. When a
|
||||
# dedicated peak-shaving strategy arrives it will claim SET at
|
||||
# P_PEAK and outrank this limit without any code here changing.
|
||||
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
|
||||
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's
|
||||
# claim is `lo >= 0`. Both are limits, arbiter.py applies EVERY
|
||||
# limit at or above the winner's priority, and most restrictive
|
||||
# wins - so a peak event during a maintenance charge window files
|
||||
# `lo > hi`, which is a CONTRADICTION and resolves to 0 W. The
|
||||
# battery would park in the exact quarter it should be shaving.
|
||||
# Priority does not save it: clause 2 has no notion of a
|
||||
# higher-priority limit overriding a lower one, and it should not -
|
||||
# "most restrictive" is what makes the safety bounds unconditional.
|
||||
#
|
||||
# So the higher-priority claimant SUPPRESSES the lower one rather
|
||||
# than out-voting it, which is what this branch already did for
|
||||
# `peak_forecast_entity`. That entity is optional and empty on a
|
||||
# default install, so until now the suspension could not fire on the
|
||||
# site that needed it. `self.peak_guard` is measured from the meter
|
||||
# and is always there.
|
||||
#
|
||||
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is
|
||||
# not tidiness. It was inline, and a reviewer mutated it back to its
|
||||
# historical broken form - re-gated on `peak_forecast_entity` alone
|
||||
# - and every suite in this repository stayed green while a default
|
||||
# install parked at 0 W in the quarter that sets the month's bill.
|
||||
# Read shaping_suspended() before touching this: both terms are
|
||||
# there, and why neither is sufficient alone.
|
||||
#
|
||||
# ⚠️ ONE SUSPENSION FLAG FOR THE SITE, NOT ONE PER PACK, AND THE
|
||||
# INTERLOCK IS WHAT MAKES THAT TRUE. The quantity it edge-logs is
|
||||
# a peak event at the grid connection, and `arbitrate()` allows
|
||||
# at most one pack to be in CHARGE at a time, so this branch can
|
||||
# run at most once per tick. Were two packs ever allowed to
|
||||
# charge together, this flag would have to become per-pack - and
|
||||
# so would the reasoning above it.
|
||||
if peakguard.shaping_suspended(
|
||||
self.peak_guard, self.peak_fc,
|
||||
float(self.o.get("peak_cap_w", 3500))):
|
||||
# Edge-logged: level-logging here would write one line per
|
||||
# cycle for as long as the event lasts and flush the 40-line
|
||||
# action log of the very context that explains it.
|
||||
if not self._maint_shape_suspended:
|
||||
self._maint_shape_suspended = True
|
||||
self.log_event("peak at risk - maintenance charge shaping "
|
||||
"suspended so the peak claim is not "
|
||||
"contradicted")
|
||||
else:
|
||||
self._maint_shape_suspended = False
|
||||
hi = 0.0
|
||||
reason = "charge-only"
|
||||
if self.cheap:
|
||||
# ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ
|
||||
# OF `self.o`. This line used to re-open the options dict
|
||||
# with its own literal fallback, so the rate the shaping
|
||||
# asked for and the rate the state machine drained at
|
||||
# could be different numbers from the same install - and,
|
||||
# with the key absent, they were. The Controller already
|
||||
# parsed this once in __init__; asking the object that
|
||||
# owns it cannot drift from it. Every pack's machine
|
||||
# holds the same `MaintConfig` instance, so this is the
|
||||
# rate the pack in front of us is charging at.
|
||||
floor = maintenance_charge_floor(
|
||||
self.maint.cfg.charge_w,
|
||||
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
|
||||
if floor > 0:
|
||||
hi, reason = -floor, "cheap-window charge"
|
||||
# PER-INVERTER, like the SET above and for the same reason -
|
||||
# the shaping is about the pack being charged, which is `inv`.
|
||||
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi,
|
||||
reason=reason,
|
||||
actuator=self.actuator_of("maintenance",
|
||||
inv)))
|
||||
|
||||
# --- the grid-following loop -----------------------------------------
|
||||
if self.auto and self.inputs_ok:
|
||||
@@ -3310,14 +3470,29 @@ class Controller:
|
||||
elif not self.auto:
|
||||
level, banner = "warn", "Stopped — inverter commanded to 0 W"
|
||||
elif self.maint.phase != IDLE:
|
||||
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
|
||||
# of one this is the string it has always been, character for
|
||||
# character; at two, "Maintenance: drain" without a name is a
|
||||
# banner an operator cannot act on, because the remedy is at one
|
||||
# device and the site has two.
|
||||
level, banner = "warn", f"Maintenance: {self.maint.phase}"
|
||||
if len(self.inverters) > 1:
|
||||
banner += f" ({self.maint_unit.name})"
|
||||
else:
|
||||
level, banner = "ok", "Running — holding grid at zero"
|
||||
|
||||
def w(v):
|
||||
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
|
||||
|
||||
next_due = self.maint.next_due(datetime.now(timezone.utc))
|
||||
# ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. One date on a
|
||||
# site with two schedules has to be the one that comes first, or the row
|
||||
# says "2026-10-01" while a pack recalibrates tomorrow. `None` means
|
||||
# "never run, due now" and has to win over any date, so it is tested for
|
||||
# rather than sorted with. At a roster of one this is that pack's own
|
||||
# date, unchanged.
|
||||
_now = datetime.now(timezone.utc)
|
||||
_dues = [m.next_due(_now) for m in self.maints]
|
||||
next_due = None if any(d is None for d in _dues) else min(_dues)
|
||||
rows = [
|
||||
("Grid", w(self.grid)),
|
||||
("Battery", w(self.site_batt_w)),
|
||||
@@ -3333,7 +3508,14 @@ class Controller:
|
||||
else f"{self.pack_floor_soc:g} %"),
|
||||
("Commanded", w(self.target)),
|
||||
("Why", self.reason),
|
||||
("Maintenance phase", self.maint.phase),
|
||||
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
|
||||
else f"{self.maint.phase} ({self.maint_unit.name})"),
|
||||
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
|
||||
# page - where `_maint_deferred` is empty on every tick - carries
|
||||
# exactly the rows it carried before.
|
||||
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
|
||||
for n, why in self._maint_deferred)),)
|
||||
if self._maint_deferred else ()),
|
||||
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
||||
("Cheap window", "yes" if self.cheap else "no"),
|
||||
("Tariff", self.tariff_words),
|
||||
@@ -3445,11 +3627,26 @@ class Controller:
|
||||
await self.command_site_zero()
|
||||
return {"auto": self.auto}
|
||||
if what == "maint_start":
|
||||
for msg in self.maint.force_start(now):
|
||||
# ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
|
||||
# ONE LABEL. `_maint_button()` answers it: the pack that holds the
|
||||
# site's maintenance slot if one does - which is what "start"
|
||||
# already meant at a roster of one, where pressing it mid-drain
|
||||
# re-entered DRAIN - and otherwise the pack that has gone longest
|
||||
# without a cycle, which is the same pack `arbitrate()` would have
|
||||
# picked on its own. At N=1 both branches are `maints[0]` and this
|
||||
# is the shipped call, byte for byte.
|
||||
#
|
||||
# A PER-PACK BUTTON IS A UI CARD, NOT THIS ONE. `web.py` posts a
|
||||
# bare `maint_start` with no unit, and inventing a unit parameter
|
||||
# here would ship half a surface: the page would still have one
|
||||
# button and the API would have two meanings.
|
||||
for msg in self._maint_button(now).force_start(now):
|
||||
self.log_event(msg)
|
||||
return {"phase": self.maint.phase}
|
||||
if what == "maint_abort":
|
||||
for msg in self.maint.abort(now, "operator"):
|
||||
# Aborts the pack that is actually cycling - `_maint_button()` again
|
||||
# - and then zeroes the SITE, which is what it always did.
|
||||
for msg in self._maint_button(now).abort(now, "operator"):
|
||||
self.log_event(msg)
|
||||
await self.command_site_zero()
|
||||
return {"phase": self.maint.phase}
|
||||
|
||||
Reference in New Issue
Block a user