MAINT-20 part 2: main.py: keep whole sentences, no truncated warnings; refresh archive
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
6889667fcd
commit
3d31b679eb
@@ -327,20 +327,6 @@ money-saving discharge only, and grading a pack against it
|
||||
explains a wedged unit away in a band an ordinary install
|
||||
has ten points of.
|
||||
|
||||
### L4619 REASON FIRST, FLAG SECOND, and each `set` writes the file -
|
||||
|
||||
REASON FIRST, FLAG SECOND, and each `set` writes the file -
|
||||
the same atomicity ordering the three trips above use: a crash
|
||||
between the two must not persist a latch with no explanation
|
||||
for the operator who has to decide about clearing it.
|
||||
|
||||
⚠️ AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02
|
||||
HAS ONE. store.py's rule 2 keeps the flat keys "so a rollback
|
||||
still finds its latch", and the version being rolled back TO
|
||||
drives exactly ONE actuator - a roster at which this check is
|
||||
inert by construction. A flat mirror would hand a downgraded
|
||||
add-on a latch taken on evidence about a roster it cannot have.
|
||||
|
||||
### L4650 ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
|
||||
|
||||
⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
|
||||
|
||||
@@ -946,8 +946,7 @@ class Controller:
|
||||
self._handback = None
|
||||
self._hidden = ()
|
||||
self.stopping = False
|
||||
# SAFETY-61. time.monotonic() at the end of the last run_control()
|
||||
# pass.
|
||||
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
|
||||
self.loop_pass_mono = None
|
||||
|
||||
# helpers; SAFETY-52: a battery that refuses is asked for 0 W
|
||||
@@ -3374,15 +3373,16 @@ class Controller:
|
||||
if inv.delivery_wd.evaluate(_delinquent.get(inv.name), now) \
|
||||
and not was_short:
|
||||
# REASON FIRST, FLAG SECOND, and each `set` writes the file -
|
||||
# the same atomicity ordering the three trips above use: a
|
||||
# crash between the two must not persist a latch with no
|
||||
# explanation for the operator who has to decide about clearing
|
||||
# it.
|
||||
# AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02 HAS
|
||||
# ONE. store.py's rule 2 keeps the flat keys "so a rollback
|
||||
# the same atomicity ordering the three trips above use: a crash
|
||||
# between the two must not persist a latch with no explanation
|
||||
# for the operator who has to decide about clearing it.
|
||||
#
|
||||
# ⚠️ AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02
|
||||
# HAS ONE. store.py's rule 2 keeps the flat keys "so a rollback
|
||||
# still finds its latch", and the version being rolled back TO
|
||||
# drives exactly ONE...
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
# drives exactly ONE actuator - a roster at which this check is
|
||||
# inert by construction. A flat mirror would hand a downgraded
|
||||
# add-on a latch taken on evidence about a roster it cannot have.
|
||||
inv.store_set("delivery_reason", inv.delivery_wd.reason)
|
||||
inv.store_set("delivery_tripped", True)
|
||||
self._delivery_announced.discard(inv.name)
|
||||
@@ -3609,8 +3609,7 @@ class Controller:
|
||||
charge_w=charge_w, discharge_w=discharge_w,
|
||||
soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct)
|
||||
# SITE, and arbitrage.claim() says so on the object it builds. It
|
||||
# buys and sells at the grid against a day-ahead price: the price
|
||||
# is
|
||||
# buys and sells at the grid against a day-ahead price: the price is
|
||||
# a property of the connection and the energy crosses the meter.
|
||||
arb_claim = arbitrage.claim(self.arb)
|
||||
if arb_claim is not None:
|
||||
@@ -3654,8 +3653,7 @@ class Controller:
|
||||
if peakguard.shaping_suspended(
|
||||
self.peak_guard, self.peak_fc, self.peak_aim_w()):
|
||||
# Edge-logged: level-logging here would write one line per
|
||||
# cycle for as long as the event lasts and flush the
|
||||
# 40-line
|
||||
# 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
|
||||
@@ -3677,10 +3675,8 @@ class Controller:
|
||||
floor = self._charge_headroom(floor, claims)
|
||||
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`.
|
||||
# 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",
|
||||
@@ -3747,8 +3743,7 @@ class Controller:
|
||||
reason="maintenance drain",
|
||||
actuator=self.actuator_of("drain")))
|
||||
self._peak_over_drain(claims, maint_results)
|
||||
# --- SAFETY-86: while a HOLD gives, the site stays at the peak floor
|
||||
# --
|
||||
# --- SAFETY-86: while a HOLD gives, the site stays at the peak floor --
|
||||
# So the loop never asks for watts the hold keeps back.
|
||||
hold_hi = self._peak_over_hold(claims, maint_results)
|
||||
if hold_hi is not None:
|
||||
@@ -3781,7 +3776,7 @@ class Controller:
|
||||
# The integrator has to track the same reality, and it is no longer
|
||||
# prev_w, so it needs saying out loud: if the arbiter did not give
|
||||
# the loop what it asked for last cycle, the loop's accumulated
|
||||
# error...
|
||||
# error belongs to a command that never happened.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
moved = self.loop_w is None or self.target != self.loop_w
|
||||
if moved:
|
||||
@@ -3806,8 +3801,7 @@ class Controller:
|
||||
# SAFETY-95: then a drain that ended, last (it reads the start
|
||||
# the others left).
|
||||
self._reseed_drain_end(maint_results, _live96)
|
||||
# No probe while it is judged afresh (the loop does not hold the
|
||||
# wire).
|
||||
# No probe while it is judged afresh (the loop does not hold the wire).
|
||||
probe = False if rejudged else self._refusal_probe()
|
||||
if len(self.inverters) > 1:
|
||||
probe = any(_probing.values())
|
||||
@@ -3914,8 +3908,7 @@ class Controller:
|
||||
self._loop_decision = None
|
||||
|
||||
# SAFETY-28. A new resolution is a new tick, and the previous record is
|
||||
# stale from this line onwards. Incremented BEFORE resolve() so that
|
||||
# the
|
||||
# stale from this line onwards. Incremented BEFORE resolve() so that the
|
||||
# record resolve() builds is stamped with the tick it is for.
|
||||
self.alloc_tick += 1
|
||||
# ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD.
|
||||
@@ -4059,8 +4052,7 @@ class Controller:
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await self.check_meter_frozen()
|
||||
|
||||
# --- PRICE-01: no price for now, said once and taken down once
|
||||
# --------
|
||||
# --- PRICE-01: no price for now, said once and taken down once --------
|
||||
# Not a safety check, so it is one of the cheap ones: an attribute test
|
||||
# and, on a change only, one Home Assistant call.
|
||||
await self.check_price_gap()
|
||||
@@ -4079,10 +4071,8 @@ class Controller:
|
||||
# has touched. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await self.check_not_delivering()
|
||||
|
||||
# --- SAFETY-07's alarm, and it is NOT the engagement
|
||||
# ------------------
|
||||
# Its boundary is unconstrained: it grades `self.peak_guard`, which
|
||||
# this
|
||||
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------
|
||||
# Its boundary is unconstrained: it grades `self.peak_guard`, which this
|
||||
# method assigned above and nothing since has touched.
|
||||
await self.check_peak_saturated()
|
||||
|
||||
@@ -4312,9 +4302,6 @@ class Controller:
|
||||
|
||||
async def check_peak_saturated(self) -> None:
|
||||
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter.
|
||||
AN ENGAGEMENT IS NOT AN ALARM, AND THE DIFFERENCE IS §10.3's DOCTRINE
|
||||
READ CAREFULLY. "Treat an out-of-range computed setpoint as a bug
|
||||
signal - clamp it, then alarm" is SAFETY-03's rule and it is...
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
g = self.peak_guard
|
||||
batt, prev = self.site_batt_w, self._peak_batt_prev
|
||||
@@ -4360,8 +4347,7 @@ class Controller:
|
||||
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
|
||||
hint=messages.hint("peak_saturated"))
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. A saturated peak
|
||||
# guard
|
||||
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
||||
# is not a frozen meter and is not an out-of-range setpoint.
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.peak_saturated(
|
||||
@@ -4518,8 +4504,7 @@ class Controller:
|
||||
unchanged = getattr(self.p1_source, "unchanged_s", None)
|
||||
# SITE, all three inputs: one meter, the SITE target the arbiter
|
||||
# resolved, and the rail the SITE could stand at - which is the summed
|
||||
# per-unit conjunction, so a small inverter B lowers the site rail
|
||||
# rather
|
||||
# per-unit conjunction, so a small inverter B lowers the site rail rather
|
||||
# than being graded against inverter A's.
|
||||
fired = self.freeze_wd.evaluate(unchanged, self.target,
|
||||
self.site_max_w,
|
||||
@@ -4646,8 +4631,7 @@ class Controller:
|
||||
while not self.stopping:
|
||||
await self.read_inputs()
|
||||
now_mono = asyncio.get_running_loop().time()
|
||||
# A cycle is one meter update, exactly as on the reference install
|
||||
# -
|
||||
# A cycle is one meter update, exactly as on the reference install -
|
||||
# or the floor elapsing, whichever comes first.
|
||||
overdue = last_cycle is None or (now_mono - last_cycle) >= floor
|
||||
if self.grid != last_grid or overdue:
|
||||
@@ -4657,7 +4641,9 @@ class Controller:
|
||||
# THAT GUARANTEE IS TRANSPORT-CONDITIONAL, and saying so
|
||||
# matters more than the guarantee: with `meter_source: off`,
|
||||
# read_inputs() takes self.grid straight from hass.number()
|
||||
# with NO age gate at all, so...
|
||||
# with NO age gate at all, so a Home Assistant entity that has
|
||||
# gone quiet reads as a current value on that path with or
|
||||
# without this change.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await self.cycle()
|
||||
# ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence
|
||||
@@ -4675,7 +4661,9 @@ class Controller:
|
||||
"""Refresh every inverter whose own deadline has come. AC 6 lives here.
|
||||
IT IS A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` FOR ONE
|
||||
REASON: the acceptance criterion is "a hung write to A cannot consume
|
||||
B's layer-1 budget - verified by injecting the hang", and a...
|
||||
B's layer-1 budget - verified by injecting the hang", and a check
|
||||
cannot inject a hang into the middle of a `while not self.stopping`
|
||||
loop that also sleeps, reads inputs and cycles.
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
due = [inv for inv in self.inverters
|
||||
if loop_now - inv.last_beat >= heartbeat_s]
|
||||
@@ -4743,8 +4731,7 @@ class Controller:
|
||||
values[unit_state_key(slug, "soc")] = inv.soc
|
||||
values[unit_state_key(slug, "sign_latch")] = (
|
||||
"tripped" if inv.sign_wd.tripped else "clear")
|
||||
# OPT-11 P2: each battery's mode and switch; nothing while modes are
|
||||
# off.
|
||||
# OPT-11 P2: each battery's mode and switch; nothing while modes are off.
|
||||
values.update(self.mode_values())
|
||||
self.mqtt.publish(values)
|
||||
|
||||
@@ -4830,8 +4817,7 @@ class Controller:
|
||||
out.append({"ok": False, "warn": False, "id": "meter_frozen",
|
||||
"text": messages.row_meter_frozen()})
|
||||
elif getattr(self.p1_source, "unchanged_s", None) is None:
|
||||
# Not "armed". Every transport but homewizard_local is
|
||||
# invisible
|
||||
# Not "armed". Every transport but homewizard_local is invisible
|
||||
# to this check, and saying "armed" there would be a lie the
|
||||
# status page tells on behalf of a guard that cannot run.
|
||||
out.append({"ok": False, "warn": True, "id": "freeze_check",
|
||||
@@ -5110,8 +5096,7 @@ class Controller:
|
||||
"text": messages.row_estop_fitted()})
|
||||
|
||||
if not o.get("peak_forecast_entity"):
|
||||
# ok=False + warn=True renders as a caution, not a pass. A check
|
||||
# that
|
||||
# ok=False + warn=True renders as a caution, not a pass. A check that
|
||||
# is both is a check nobody reads.
|
||||
out.append({"ok": False, "warn": True, "id": "peak_forecast",
|
||||
"text": messages.row_no_peak_forecast()})
|
||||
@@ -5458,8 +5443,7 @@ class Controller:
|
||||
phase=_m.phase))
|
||||
return {"phase": self.maint.phase}
|
||||
if what == "maint_abort":
|
||||
# Aborts the pack that is actually cycling - `_maint_button()`
|
||||
# again
|
||||
# Aborts the pack that is actually cycling - `_maint_button()` again
|
||||
# - and then zeroes the SITE, which is what it always did.
|
||||
_m = self._maint_button(now)
|
||||
for msg in _m.abort(now, "operator"):
|
||||
@@ -5710,8 +5694,7 @@ async def amain() -> None:
|
||||
if tariff_refresh_wanted(opts):
|
||||
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
||||
|
||||
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be
|
||||
# driven
|
||||
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
|
||||
# off the control loop: telegrams arrive every ~5 s and the loop would
|
||||
# decimate them, so the 15-minute average - the capacity-tariff billing
|
||||
# unit - would be computed from a fraction of the data.
|
||||
|
||||
Reference in New Issue
Block a user