MAINT-21: app/ctl_tasks.py - Controller methods that run as tasks
Moves the methods named in the card and the constants only they read out of app/main.py unchanged. TasksMixin: run_control, run_tariff_refresh, fresh_tariff_plan and the store-migration grade. tools/maint21_same_ast.py: OK. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
66952beb53
commit
27a867f34b
@@ -0,0 +1,242 @@
|
|||||||
|
"""Controller methods that run as background tasks.
|
||||||
|
|
||||||
|
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from . import messages
|
||||||
|
from .inverter import UNIT_A
|
||||||
|
from .price import brussels, price_option
|
||||||
|
from .static_tariff import resolve_tariff
|
||||||
|
from .validate import refuse, validate_startup_config, validate_unit_ranges
|
||||||
|
from .ctl_inputs import TARIFF_MAX_AGE_S, TARIFF_REFRESH_S
|
||||||
|
|
||||||
|
_LOG = logging.getLogger("goodwe")
|
||||||
|
|
||||||
|
|
||||||
|
class TasksMixin:
|
||||||
|
def _grade_store_migration(self, store) -> None:
|
||||||
|
"""DEV-02. Did SAFETY-02's latch actually survive the key rename?
|
||||||
|
Graded, not performed: a latch that silently fails to move is a
|
||||||
|
DISARMED SAFETY GUARD on an install that believes it is protected. The
|
||||||
|
pre-DEV-02 flat value is compared with what the roster came up with; a
|
||||||
|
disagreement re-arms the latch by hand (the fail-safe direction is ON).
|
||||||
|
It runs on EVERY start, not only the migrating one: gating on `rep.ran`
|
||||||
|
swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip
|
||||||
|
in the window, upgrade; performed on this site 2026-09-02).
|
||||||
|
DECISIONS/MAIN-orchestration.md."""
|
||||||
|
rep = self.store_migration
|
||||||
|
if rep.ran and not rep.ok:
|
||||||
|
_LOG.error("The saved safety stops did not copy over cleanly, so "
|
||||||
|
"they are read again from the older saved state: %s.",
|
||||||
|
rep.explain())
|
||||||
|
data = getattr(store, "data", {})
|
||||||
|
legacy_tripped = bool(data.get("sign_tripped", False))
|
||||||
|
if not legacy_tripped or not self.inverters:
|
||||||
|
return
|
||||||
|
# The question is "is any namespaced latch set THAT A MACHINE READS"
|
||||||
|
# (SAFETY-44), asked of the STORE's keys, not the roster's objects:
|
||||||
|
# re-arming A off a flat key that B's trip mirrored would latch a
|
||||||
|
# healthy inverter on every restart. Three cases: on the roster, stop;
|
||||||
|
# off-roster named `UNIT_A` (the broken migration's fingerprint),
|
||||||
|
# repair onto `inverters[0]`; off-roster, other name: stop unless a
|
||||||
|
# stranded `UNIT_A` latch is present. This guard is the ONLY repair for
|
||||||
|
# sites upgraded before the fix. Residual: a first unit really named
|
||||||
|
# `inverter-a`, latched and removed, is re-armed on `inverters[0]`
|
||||||
|
# (conservative). DECISIONS/MAIN-orchestration.md.
|
||||||
|
on_roster = {inv.name for inv in self.inverters}
|
||||||
|
stranded, on_roster_latched, other_off_roster = [], False, False
|
||||||
|
for k, v in data.items():
|
||||||
|
if not (k.startswith("unit/") and k.endswith("/sign_tripped")):
|
||||||
|
continue
|
||||||
|
if not bool(v):
|
||||||
|
continue
|
||||||
|
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1:
|
||||||
|
# split through the writer's own spelling.
|
||||||
|
unit = k.split("/")[1]
|
||||||
|
if unit in on_roster:
|
||||||
|
on_roster_latched = True
|
||||||
|
elif unit == UNIT_A:
|
||||||
|
stranded.append(k)
|
||||||
|
else:
|
||||||
|
other_off_roster = True
|
||||||
|
if on_roster_latched or any(inv.sign_wd.tripped
|
||||||
|
for inv in self.inverters):
|
||||||
|
return
|
||||||
|
if other_off_roster and not stranded:
|
||||||
|
# A removed unit's own latch and no misdirected one: the flat key
|
||||||
|
# is that unit's mirror, so re-arming `inverters[0]` from it would
|
||||||
|
# latch a healthy pack.
|
||||||
|
return
|
||||||
|
if stranded:
|
||||||
|
_LOG.error(
|
||||||
|
"A saved safety stop belongs to %s, which is not in the "
|
||||||
|
"inverter list %s, so it is set again on the first inverter.",
|
||||||
|
", ".join(sorted(stranded)), sorted(on_roster))
|
||||||
|
first = self.inverters[0]
|
||||||
|
# The one state this method exists for.
|
||||||
|
reason = str(data.get("sign_reason", "") or "")
|
||||||
|
first.sign_wd.tripped = True
|
||||||
|
first.sign_wd.reason = reason or (
|
||||||
|
"SIGN_DISAGREEMENT: latched before this version; the reason was "
|
||||||
|
"not carried across the per-inverter store migration")
|
||||||
|
first.store_set("sign_reason", first.sign_wd.reason)
|
||||||
|
first.store_set("sign_tripped", True)
|
||||||
|
_LOG.error(
|
||||||
|
"%s is set to 0 W again because an older saved safety stop was "
|
||||||
|
"found, so find out why before you start control: %s.",
|
||||||
|
first.name, messages.reason_code(first.sign_wd.reason))
|
||||||
|
_LOG.debug("Evidence for %s: %s.", first.name, first.sign_wd.reason)
|
||||||
|
_LOG.debug(
|
||||||
|
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
|
||||||
|
"sign-disagreement failsafe is latched and no inverter came up "
|
||||||
|
"latched. Either the per-inverter migration lost it, or an older "
|
||||||
|
"add-on wrote it during a rollback. Both would be a disarmed "
|
||||||
|
"guard, so the latch is re-armed on %s.", first.name)
|
||||||
|
self.log_event("FAILSAFE re-armed from the pre-DEV-02 store keys - "
|
||||||
|
+ first.sign_wd.reason,
|
||||||
|
hint=messages.hint("stop_restored", unit=first.name))
|
||||||
|
|
||||||
|
# -- tasks ---------------------------------------------------------------
|
||||||
|
# -- OPT-04: the background tariff refresh -------------------------------
|
||||||
|
def fresh_tariff_plan(self, now):
|
||||||
|
"""The stored plan if it is still current, else None. NEVER a stale
|
||||||
|
one. THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for
|
||||||
|
`loop_is_following()`'s reason: a check has to be able to assert on the
|
||||||
|
shipped expression rather than on a transcription that drifts.
|
||||||
|
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||||
|
if self.tariff_plan is None or self.tariff_at is None:
|
||||||
|
return None
|
||||||
|
if (now - self.tariff_at).total_seconds() > TARIFF_MAX_AGE_S:
|
||||||
|
return None
|
||||||
|
return self.tariff_plan
|
||||||
|
|
||||||
|
async def run_tariff_refresh(self) -> None:
|
||||||
|
"""Resolve the tariff off the control path, forever. NEVER on the tick.
|
||||||
|
⚠️ IT CATCHES EVERYTHING AND KEEPS GOING: raising out of here would
|
||||||
|
freeze the plan, looking current for ever (`fresh_tariff_plan()`'s age
|
||||||
|
bound is the backstop). ⚠️ A FAILURE DOES NOT CLEAR THE PLAN
|
||||||
|
(TARIFF-12): a degraded plan replaces a standing observation only once
|
||||||
|
it has aged out; a §11.4 fallback never replaces a fresh provider plan.
|
||||||
|
⚠️ `tariff_at` DOES NOT MOVE ON A KEPT PLAN.
|
||||||
|
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||||
|
horizon = float(price_option(self.o, "price_horizon_h"))
|
||||||
|
while not self.stopping:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
plan = await resolve_tariff(
|
||||||
|
self.o, now, now + timedelta(hours=horizon), now=now,
|
||||||
|
history=self.price_history, watch=self.price_watch)
|
||||||
|
standing = self.fresh_tariff_plan(now)
|
||||||
|
# Three terms, and each of them is the only false one in some
|
||||||
|
# check: the feed answered (replace), nothing current stands
|
||||||
|
# (take the degradation - this is what makes the age bound
|
||||||
|
# govern), or what stands is itself a degradation (replace).
|
||||||
|
keep = (not plan.from_feed
|
||||||
|
and standing is not None and standing.from_feed)
|
||||||
|
if keep:
|
||||||
|
_LOG.warning(
|
||||||
|
"The price update was incomplete, so the plan from %s "
|
||||||
|
"is kept until it is %s old. The update held %s.",
|
||||||
|
messages.when(brussels(self.tariff_at)),
|
||||||
|
messages.duration(TARIFF_MAX_AGE_S), plan.words)
|
||||||
|
else:
|
||||||
|
self.tariff_plan, self.tariff_at = plan, now
|
||||||
|
_LOG.info("tariff refreshed: %s", plan.words)
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
# ⚠️ NOT THE OUTAGE PATH - see the docstring: `load_prices()`
|
||||||
|
# returns on every failure a site can have, so what reaches
|
||||||
|
# here is unexpected, and the plan is kept on the same terms.
|
||||||
|
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||||
|
_LOG.warning(
|
||||||
|
"The price update failed (%s), so the plan from %s is kept "
|
||||||
|
"until it is %s old, and trading stops then.",
|
||||||
|
err, messages.when(brussels(self.tariff_at)),
|
||||||
|
messages.duration(TARIFF_MAX_AGE_S))
|
||||||
|
with contextlib.suppress(asyncio.TimeoutError):
|
||||||
|
await asyncio.wait_for(self.stop_event.wait(),
|
||||||
|
timeout=TARIFF_REFRESH_S)
|
||||||
|
|
||||||
|
async def run_control(self) -> None:
|
||||||
|
# ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4 (one
|
||||||
|
# read of one `setpoint_entity` left a second inverter's clamp, freeze
|
||||||
|
# rail and `achievable_w` on the first one's numbers). Concurrently: a
|
||||||
|
# slow ESPHome on one unit must not delay the start on the others.
|
||||||
|
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||||
|
async def _limits(inv):
|
||||||
|
if inv.driver.configured():
|
||||||
|
inv.dev_min, inv.dev_max = await self._driver(inv).limits()
|
||||||
|
await asyncio.gather(*(_limits(inv) for inv in self.inverters))
|
||||||
|
entity = self.o.get("setpoint_entity", "")
|
||||||
|
# ⚠️ THIS USED TO WARN AND CARRY ON, the bug CFG-01 exists to remove: a
|
||||||
|
# max_w above the device's rating was silently clamped. amain() ran
|
||||||
|
# this gate already; it runs AGAIN because ESPHome is often not up at
|
||||||
|
# boot (limits None) and this is the first moment the real rating is
|
||||||
|
# known; a conflict refuses through the normal shutdown (0 W). ⚠️ THE
|
||||||
|
# `if entity:` THAT WRAPPED THIS BLOCK FAILED PERMISSIVE (CFG-04's
|
||||||
|
# hazard): a per-inverter config leaves the flat `setpoint_entity`
|
||||||
|
# blank, so the re-check was skipped; the per-unit rule now runs
|
||||||
|
# unconditionally. (DECISIONS/MAIN-orchestration-archive.md)
|
||||||
|
problems = []
|
||||||
|
if entity:
|
||||||
|
_first = self.inverters[0]
|
||||||
|
problems += validate_startup_config(self.o, _first.dev_min,
|
||||||
|
_first.dev_max)
|
||||||
|
problems += validate_unit_ranges(self.inverters, skip_entity=entity)
|
||||||
|
if problems:
|
||||||
|
refuse(problems)
|
||||||
|
self.refused = problems
|
||||||
|
self.log_event("REFUSED: " + problems[0],
|
||||||
|
hint=messages.hint("refused"))
|
||||||
|
# Signal and return WITHOUT entering the loop: amain() runs the
|
||||||
|
# wind-down (0 W, or a hand-back: SMA-10) and exits with code 78.
|
||||||
|
self.stop_event.set()
|
||||||
|
return
|
||||||
|
|
||||||
|
last_grid = object()
|
||||||
|
heartbeat = float(self.o.get("heartbeat_s", 10))
|
||||||
|
# ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not
|
||||||
|
# a second scheduler: every cycle resets it, so on a healthy meter it
|
||||||
|
# never elapses and the per-cycle gain is unchanged. It exists because
|
||||||
|
# a meter that repeats a value would run NO cycles, and the `inputs
|
||||||
|
# missing -> 0 W` claim lives inside cycle(). NOT a freeze detector. ⚠️
|
||||||
|
# THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN: it sits under
|
||||||
|
# validate.FIRMWARE_INGEST_VETO_S (30 s) and `meter_max_age_s` and
|
||||||
|
# moves with the firmware's veto; the schema minimum of 5 s is the
|
||||||
|
# reference meter's interval and deserves a second look.
|
||||||
|
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||||
|
floor = float(self.o.get("cycle_floor_s", 15))
|
||||||
|
last_cycle = None
|
||||||
|
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 -
|
||||||
|
# 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:
|
||||||
|
last_grid = self.grid
|
||||||
|
last_cycle = now_mono
|
||||||
|
# ⚠️ The FULL cycle, not a degraded one: in P1 mode past
|
||||||
|
# `meter_max_age_s` (30 s) p1.net_w is None ("inputs missing").
|
||||||
|
# ⚠️ TRANSPORT-CONDITIONAL: with `meter_source: off`,
|
||||||
|
# read_inputs() has NO age gate, so a quiet entity reads as
|
||||||
|
# current; the floor does not make it fresh. The integrator
|
||||||
|
# advances per CYCLE, so bounding the gap bounds the
|
||||||
|
# accumulation. (DECISIONS/MAIN-orchestration-archive.md)
|
||||||
|
await self.cycle()
|
||||||
|
# ⚠️ The heartbeat is not an optimisation: the ESP32 zeroes the
|
||||||
|
# inverter after ~30 s of silence, and refreshing the SAME value
|
||||||
|
# proves we are alive. ⚠️ ONE DEADLINE PER INVERTER, refreshed
|
||||||
|
# concurrently, each bounded by WRITE_BUDGET_S (budget plus the
|
||||||
|
# largest `heartbeat_s` stays under layer 1's veto: graded); each
|
||||||
|
# unit refreshes its own `inv.target`, never the site target.
|
||||||
|
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||||
|
await self.send_heartbeats(asyncio.get_running_loop().time(),
|
||||||
|
heartbeat)
|
||||||
|
self.publish()
|
||||||
|
self.loop_pass_mono = time.monotonic()
|
||||||
|
await asyncio.sleep(1)
|
||||||
@@ -103,6 +103,7 @@ from .watchdog import (
|
|||||||
)
|
)
|
||||||
from . import setuponly
|
from . import setuponly
|
||||||
from . import web
|
from . import web
|
||||||
|
from .ctl_tasks import TasksMixin
|
||||||
from .ctl_status import StatusMixin
|
from .ctl_status import StatusMixin
|
||||||
from .ctl_alarms import (
|
from .ctl_alarms import (
|
||||||
AlarmsMixin, HANDBACK_CYCLES, REANNOUNCE_S, ROLLING_BOUND_CYCLES,
|
AlarmsMixin, HANDBACK_CYCLES, REANNOUNCE_S, ROLLING_BOUND_CYCLES,
|
||||||
@@ -174,7 +175,7 @@ def _load_latch(data, now=None):
|
|||||||
|
|
||||||
|
|
||||||
class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||||
StatusMixin):
|
StatusMixin, TasksMixin):
|
||||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
||||||
peak_db_path: str = "/data/tariff.db",
|
peak_db_path: str = "/data/tariff.db",
|
||||||
price_history_path: str = "/data/price_history.json",
|
price_history_path: str = "/data/price_history.json",
|
||||||
@@ -793,88 +794,6 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
|||||||
if hint is not None:
|
if hint is not None:
|
||||||
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
|
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
|
||||||
|
|
||||||
def _grade_store_migration(self, store) -> None:
|
|
||||||
"""DEV-02. Did SAFETY-02's latch actually survive the key rename?
|
|
||||||
Graded, not performed: a latch that silently fails to move is a
|
|
||||||
DISARMED SAFETY GUARD on an install that believes it is protected. The
|
|
||||||
pre-DEV-02 flat value is compared with what the roster came up with; a
|
|
||||||
disagreement re-arms the latch by hand (the fail-safe direction is ON).
|
|
||||||
It runs on EVERY start, not only the migrating one: gating on `rep.ran`
|
|
||||||
swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip
|
|
||||||
in the window, upgrade; performed on this site 2026-09-02).
|
|
||||||
DECISIONS/MAIN-orchestration.md."""
|
|
||||||
rep = self.store_migration
|
|
||||||
if rep.ran and not rep.ok:
|
|
||||||
_LOG.error("The saved safety stops did not copy over cleanly, so "
|
|
||||||
"they are read again from the older saved state: %s.",
|
|
||||||
rep.explain())
|
|
||||||
data = getattr(store, "data", {})
|
|
||||||
legacy_tripped = bool(data.get("sign_tripped", False))
|
|
||||||
if not legacy_tripped or not self.inverters:
|
|
||||||
return
|
|
||||||
# The question is "is any namespaced latch set THAT A MACHINE READS"
|
|
||||||
# (SAFETY-44), asked of the STORE's keys, not the roster's objects:
|
|
||||||
# re-arming A off a flat key that B's trip mirrored would latch a
|
|
||||||
# healthy inverter on every restart. Three cases: on the roster, stop;
|
|
||||||
# off-roster named `UNIT_A` (the broken migration's fingerprint),
|
|
||||||
# repair onto `inverters[0]`; off-roster, other name: stop unless a
|
|
||||||
# stranded `UNIT_A` latch is present. This guard is the ONLY repair for
|
|
||||||
# sites upgraded before the fix. Residual: a first unit really named
|
|
||||||
# `inverter-a`, latched and removed, is re-armed on `inverters[0]`
|
|
||||||
# (conservative). DECISIONS/MAIN-orchestration.md.
|
|
||||||
on_roster = {inv.name for inv in self.inverters}
|
|
||||||
stranded, on_roster_latched, other_off_roster = [], False, False
|
|
||||||
for k, v in data.items():
|
|
||||||
if not (k.startswith("unit/") and k.endswith("/sign_tripped")):
|
|
||||||
continue
|
|
||||||
if not bool(v):
|
|
||||||
continue
|
|
||||||
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1:
|
|
||||||
# split through the writer's own spelling.
|
|
||||||
unit = k.split("/")[1]
|
|
||||||
if unit in on_roster:
|
|
||||||
on_roster_latched = True
|
|
||||||
elif unit == UNIT_A:
|
|
||||||
stranded.append(k)
|
|
||||||
else:
|
|
||||||
other_off_roster = True
|
|
||||||
if on_roster_latched or any(inv.sign_wd.tripped
|
|
||||||
for inv in self.inverters):
|
|
||||||
return
|
|
||||||
if other_off_roster and not stranded:
|
|
||||||
# A removed unit's own latch and no misdirected one: the flat key
|
|
||||||
# is that unit's mirror, so re-arming `inverters[0]` from it would
|
|
||||||
# latch a healthy pack.
|
|
||||||
return
|
|
||||||
if stranded:
|
|
||||||
_LOG.error(
|
|
||||||
"A saved safety stop belongs to %s, which is not in the "
|
|
||||||
"inverter list %s, so it is set again on the first inverter.",
|
|
||||||
", ".join(sorted(stranded)), sorted(on_roster))
|
|
||||||
first = self.inverters[0]
|
|
||||||
# The one state this method exists for.
|
|
||||||
reason = str(data.get("sign_reason", "") or "")
|
|
||||||
first.sign_wd.tripped = True
|
|
||||||
first.sign_wd.reason = reason or (
|
|
||||||
"SIGN_DISAGREEMENT: latched before this version; the reason was "
|
|
||||||
"not carried across the per-inverter store migration")
|
|
||||||
first.store_set("sign_reason", first.sign_wd.reason)
|
|
||||||
first.store_set("sign_tripped", True)
|
|
||||||
_LOG.error(
|
|
||||||
"%s is set to 0 W again because an older saved safety stop was "
|
|
||||||
"found, so find out why before you start control: %s.",
|
|
||||||
first.name, messages.reason_code(first.sign_wd.reason))
|
|
||||||
_LOG.debug("Evidence for %s: %s.", first.name, first.sign_wd.reason)
|
|
||||||
_LOG.debug(
|
|
||||||
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
|
|
||||||
"sign-disagreement failsafe is latched and no inverter came up "
|
|
||||||
"latched. Either the per-inverter migration lost it, or an older "
|
|
||||||
"add-on wrote it during a rollback. Both would be a disarmed "
|
|
||||||
"guard, so the latch is re-armed on %s.", first.name)
|
|
||||||
self.log_event("FAILSAFE re-armed from the pre-DEV-02 store keys - "
|
|
||||||
+ first.sign_wd.reason,
|
|
||||||
hint=messages.hint("stop_restored", unit=first.name))
|
|
||||||
|
|
||||||
# the roster, and the N=1 views of it
|
# the roster, and the N=1 views of it
|
||||||
@property
|
@property
|
||||||
def units(self) -> tuple:
|
def units(self) -> tuple:
|
||||||
@@ -1108,146 +1027,6 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
|||||||
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
|
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
|
||||||
for inv in self.inverters]
|
for inv in self.inverters]
|
||||||
|
|
||||||
# -- tasks ---------------------------------------------------------------
|
|
||||||
# -- OPT-04: the background tariff refresh -------------------------------
|
|
||||||
def fresh_tariff_plan(self, now):
|
|
||||||
"""The stored plan if it is still current, else None. NEVER a stale
|
|
||||||
one. THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for
|
|
||||||
`loop_is_following()`'s reason: a check has to be able to assert on the
|
|
||||||
shipped expression rather than on a transcription that drifts.
|
|
||||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
|
||||||
if self.tariff_plan is None or self.tariff_at is None:
|
|
||||||
return None
|
|
||||||
if (now - self.tariff_at).total_seconds() > TARIFF_MAX_AGE_S:
|
|
||||||
return None
|
|
||||||
return self.tariff_plan
|
|
||||||
|
|
||||||
async def run_tariff_refresh(self) -> None:
|
|
||||||
"""Resolve the tariff off the control path, forever. NEVER on the tick.
|
|
||||||
⚠️ IT CATCHES EVERYTHING AND KEEPS GOING: raising out of here would
|
|
||||||
freeze the plan, looking current for ever (`fresh_tariff_plan()`'s age
|
|
||||||
bound is the backstop). ⚠️ A FAILURE DOES NOT CLEAR THE PLAN
|
|
||||||
(TARIFF-12): a degraded plan replaces a standing observation only once
|
|
||||||
it has aged out; a §11.4 fallback never replaces a fresh provider plan.
|
|
||||||
⚠️ `tariff_at` DOES NOT MOVE ON A KEPT PLAN.
|
|
||||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
|
||||||
horizon = float(price_option(self.o, "price_horizon_h"))
|
|
||||||
while not self.stopping:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
try:
|
|
||||||
plan = await resolve_tariff(
|
|
||||||
self.o, now, now + timedelta(hours=horizon), now=now,
|
|
||||||
history=self.price_history, watch=self.price_watch)
|
|
||||||
standing = self.fresh_tariff_plan(now)
|
|
||||||
# Three terms, and each of them is the only false one in some
|
|
||||||
# check: the feed answered (replace), nothing current stands
|
|
||||||
# (take the degradation - this is what makes the age bound
|
|
||||||
# govern), or what stands is itself a degradation (replace).
|
|
||||||
keep = (not plan.from_feed
|
|
||||||
and standing is not None and standing.from_feed)
|
|
||||||
if keep:
|
|
||||||
_LOG.warning(
|
|
||||||
"The price update was incomplete, so the plan from %s "
|
|
||||||
"is kept until it is %s old. The update held %s.",
|
|
||||||
messages.when(brussels(self.tariff_at)),
|
|
||||||
messages.duration(TARIFF_MAX_AGE_S), plan.words)
|
|
||||||
else:
|
|
||||||
self.tariff_plan, self.tariff_at = plan, now
|
|
||||||
_LOG.info("tariff refreshed: %s", plan.words)
|
|
||||||
except Exception as err: # noqa: BLE001
|
|
||||||
# ⚠️ NOT THE OUTAGE PATH - see the docstring: `load_prices()`
|
|
||||||
# returns on every failure a site can have, so what reaches
|
|
||||||
# here is unexpected, and the plan is kept on the same terms.
|
|
||||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
|
||||||
_LOG.warning(
|
|
||||||
"The price update failed (%s), so the plan from %s is kept "
|
|
||||||
"until it is %s old, and trading stops then.",
|
|
||||||
err, messages.when(brussels(self.tariff_at)),
|
|
||||||
messages.duration(TARIFF_MAX_AGE_S))
|
|
||||||
with contextlib.suppress(asyncio.TimeoutError):
|
|
||||||
await asyncio.wait_for(self.stop_event.wait(),
|
|
||||||
timeout=TARIFF_REFRESH_S)
|
|
||||||
|
|
||||||
async def run_control(self) -> None:
|
|
||||||
# ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4 (one
|
|
||||||
# read of one `setpoint_entity` left a second inverter's clamp, freeze
|
|
||||||
# rail and `achievable_w` on the first one's numbers). Concurrently: a
|
|
||||||
# slow ESPHome on one unit must not delay the start on the others.
|
|
||||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
|
||||||
async def _limits(inv):
|
|
||||||
if inv.driver.configured():
|
|
||||||
inv.dev_min, inv.dev_max = await self._driver(inv).limits()
|
|
||||||
await asyncio.gather(*(_limits(inv) for inv in self.inverters))
|
|
||||||
entity = self.o.get("setpoint_entity", "")
|
|
||||||
# ⚠️ THIS USED TO WARN AND CARRY ON, the bug CFG-01 exists to remove: a
|
|
||||||
# max_w above the device's rating was silently clamped. amain() ran
|
|
||||||
# this gate already; it runs AGAIN because ESPHome is often not up at
|
|
||||||
# boot (limits None) and this is the first moment the real rating is
|
|
||||||
# known; a conflict refuses through the normal shutdown (0 W). ⚠️ THE
|
|
||||||
# `if entity:` THAT WRAPPED THIS BLOCK FAILED PERMISSIVE (CFG-04's
|
|
||||||
# hazard): a per-inverter config leaves the flat `setpoint_entity`
|
|
||||||
# blank, so the re-check was skipped; the per-unit rule now runs
|
|
||||||
# unconditionally. (DECISIONS/MAIN-orchestration-archive.md)
|
|
||||||
problems = []
|
|
||||||
if entity:
|
|
||||||
_first = self.inverters[0]
|
|
||||||
problems += validate_startup_config(self.o, _first.dev_min,
|
|
||||||
_first.dev_max)
|
|
||||||
problems += validate_unit_ranges(self.inverters, skip_entity=entity)
|
|
||||||
if problems:
|
|
||||||
refuse(problems)
|
|
||||||
self.refused = problems
|
|
||||||
self.log_event("REFUSED: " + problems[0],
|
|
||||||
hint=messages.hint("refused"))
|
|
||||||
# Signal and return WITHOUT entering the loop: amain() runs the
|
|
||||||
# wind-down (0 W, or a hand-back: SMA-10) and exits with code 78.
|
|
||||||
self.stop_event.set()
|
|
||||||
return
|
|
||||||
|
|
||||||
last_grid = object()
|
|
||||||
heartbeat = float(self.o.get("heartbeat_s", 10))
|
|
||||||
# ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not
|
|
||||||
# a second scheduler: every cycle resets it, so on a healthy meter it
|
|
||||||
# never elapses and the per-cycle gain is unchanged. It exists because
|
|
||||||
# a meter that repeats a value would run NO cycles, and the `inputs
|
|
||||||
# missing -> 0 W` claim lives inside cycle(). NOT a freeze detector. ⚠️
|
|
||||||
# THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN: it sits under
|
|
||||||
# validate.FIRMWARE_INGEST_VETO_S (30 s) and `meter_max_age_s` and
|
|
||||||
# moves with the firmware's veto; the schema minimum of 5 s is the
|
|
||||||
# reference meter's interval and deserves a second look.
|
|
||||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
|
||||||
floor = float(self.o.get("cycle_floor_s", 15))
|
|
||||||
last_cycle = None
|
|
||||||
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 -
|
|
||||||
# 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:
|
|
||||||
last_grid = self.grid
|
|
||||||
last_cycle = now_mono
|
|
||||||
# ⚠️ The FULL cycle, not a degraded one: in P1 mode past
|
|
||||||
# `meter_max_age_s` (30 s) p1.net_w is None ("inputs missing").
|
|
||||||
# ⚠️ TRANSPORT-CONDITIONAL: with `meter_source: off`,
|
|
||||||
# read_inputs() has NO age gate, so a quiet entity reads as
|
|
||||||
# current; the floor does not make it fresh. The integrator
|
|
||||||
# advances per CYCLE, so bounding the gap bounds the
|
|
||||||
# accumulation. (DECISIONS/MAIN-orchestration-archive.md)
|
|
||||||
await self.cycle()
|
|
||||||
# ⚠️ The heartbeat is not an optimisation: the ESP32 zeroes the
|
|
||||||
# inverter after ~30 s of silence, and refreshing the SAME value
|
|
||||||
# proves we are alive. ⚠️ ONE DEADLINE PER INVERTER, refreshed
|
|
||||||
# concurrently, each bounded by WRITE_BUDGET_S (budget plus the
|
|
||||||
# largest `heartbeat_s` stays under layer 1's veto: graded); each
|
|
||||||
# unit refreshes its own `inv.target`, never the site target.
|
|
||||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
|
||||||
await self.send_heartbeats(asyncio.get_running_loop().time(),
|
|
||||||
heartbeat)
|
|
||||||
self.publish()
|
|
||||||
self.loop_pass_mono = time.monotonic()
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
"""Deterministic wind-down. Do not skip this."""
|
"""Deterministic wind-down. Do not skip this."""
|
||||||
self.stopping = True
|
self.stopping = True
|
||||||
|
|||||||
Reference in New Issue
Block a user