From 1b6bb05fb43a6f1b1d3d1b6c14d8ccba9ea8f74b Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 9 Nov 2026 00:29:37 +0100 Subject: [PATCH] MAINT-21: app/rows.py - the status-row and Why-line module functions Moves why_raised, why_key, tariff_plan_row and arbitrage_row out of app/main.py unchanged. main.py imports them, so app.main still exports them. tools/maint21_same_ast.py: OK. Co-Authored-By: Claude --- goodwe_controller/app/main.py | 77 +------------------------------- goodwe_controller/app/rows.py | 83 +++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 76 deletions(-) create mode 100644 goodwe_controller/app/rows.py diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index b5b7460..4db46d1 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -103,6 +103,7 @@ from .watchdog import ( ) from . import setuponly from . import web +from .rows import arbitrage_row, tariff_plan_row, why_key, why_raised OPTIONS_PATH = "/data/options.json" INGRESS_PORT = 8099 @@ -227,19 +228,6 @@ def _night_limit(born): return bell if bell > born else solarfc._bell_start(d1) -def why_raised(resolution, claims) -> bool: - """F2 (OPT-11 P1 test): did the limit that bound the site target push it - ABOVE what the winner asked (the peak floor asking for more power)? Then - the Why line must not say "held back". False when nothing bound it.""" - if not resolution.bound_by or resolution.contradiction: - return False - asked = next((c.value for c in sorted( - (c for c in claims if c.kind == SET - and c.actuator == ACTUATOR_SITE and c.source == resolution.winner), - key=lambda c: c.priority, reverse=True)), None) - return asked is not None and resolution.target_w > float(asked) - - def pack_energy_of(inv): """OPT-09. ONE roster entry as the view `packshare` consumes. One builder for two callers (`pack_energy` needs the roster, `cycle()` one @@ -278,69 +266,6 @@ def _refused_interval(sign: int) -> tuple: return (-math.inf, 0.0) if sign > 0 else (0.0, math.inf) -def why_key(winner: str, claims) -> str: - """The key of `messages.WHY_WINNER` for a resolution's winner. "safety" and - "failsafe" stand for several causes, so their own claim reason is the - key ("stopped", "inputs missing", "sign disagreement", or "unsplit" when - this module refuses to split the power).""" - if winner == "loop": - # SAFETY-52: a loop holding a refused direction says so. - return next((c.reason for c in claims if c.source == "loop" - and c.reason in (_ctl.REFUSED, _ctl.PROBING)), winner) - if winner in ("safety", "failsafe"): - return next((c.reason for c in claims - if c.source == winner and c.kind == "set"), - "no claim was made" if not any( - c.kind == "set" for c in claims) else "unsplit") - return winner - - -def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float, - provider: bool, max_age_s: float) -> str: - """The *Tariff* status row: the 30 s tick's sentence AND the provider plan - (TARIFF-11): two sources, two clocks, and the row says so. The plan is - stamped with its own age from `tariff_at` (not moved on a kept plan, - TARIFF-12), up to TARIFF_MAX_AGE_S (5400 s); `age_s` is the caller's - measurement. "No plan" is said aloud on a site that configured a provider. - DECISIONS/MAIN-orchestration.md.""" - # UX-08: plain words, every figure kept. The tick's sentence is "now"; the - # plan carries its own age. - if plan is None: - if not provider: - return tick_words - return (f"{tick_words}. No price plan: none yet, or the last one is " - f"over {max_age_s:.0f} s old") - return (f"{tick_words}. Price plan from {age_s:.0f} s ago, for " - f"{horizon_h:.0f} hours ahead: {plan.words}") - - -def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now, - why: str) -> str: - """The *Arbitrage* status row: three states (TARIFF-11): off (not armed), - armed with no decision this cycle (`why` is the loop's reason), or a - decision with its age. - `on` is read from the options, never inferred from `decision is None`: - `cycle()` returns at the input hold before `self.arb = None`, so the old - row named an option that was ON. The age is always printed, even 0. - DECISIONS/MAIN-orchestration.md.""" - # UX-08: plain words; the setting is named by its label. - if not on: - return (f"Off, “{messages.LABELS['arbitrage_enabled']}” is not " - f"switched on") - if decided_at is None: - return (f"On, but no decision yet, because control has not got that " - f"far. Control says: {why or 'no reason recorded'}") - age = (now - decided_at).total_seconds() - stale = cycle_at is not None and decided_at < cycle_at - head = (f"On, no decision this cycle ({why or 'no reason recorded'}), " - f"the last one {age:.0f} s ago: " if stale else - f"On, decided {age:.0f} s ago: ") - if decision is None: - return (f"On, no decision this cycle ({why or 'no reason recorded'}) " - f"and none before") - return head + decision.explain() - - class Controller: def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop, peak_db_path: str = "/data/tariff.db", diff --git a/goodwe_controller/app/rows.py b/goodwe_controller/app/rows.py new file mode 100644 index 0000000..a44c305 --- /dev/null +++ b/goodwe_controller/app/rows.py @@ -0,0 +1,83 @@ +"""Module functions for the status rows and the Why line. + +Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md.""" + +from .arbiter import ACTUATOR_SITE, SET +from . import control as _ctl +from . import messages + + +def why_raised(resolution, claims) -> bool: + """F2 (OPT-11 P1 test): did the limit that bound the site target push it + ABOVE what the winner asked (the peak floor asking for more power)? Then + the Why line must not say "held back". False when nothing bound it.""" + if not resolution.bound_by or resolution.contradiction: + return False + asked = next((c.value for c in sorted( + (c for c in claims if c.kind == SET + and c.actuator == ACTUATOR_SITE and c.source == resolution.winner), + key=lambda c: c.priority, reverse=True)), None) + return asked is not None and resolution.target_w > float(asked) + + +def why_key(winner: str, claims) -> str: + """The key of `messages.WHY_WINNER` for a resolution's winner. "safety" and + "failsafe" stand for several causes, so their own claim reason is the + key ("stopped", "inputs missing", "sign disagreement", or "unsplit" when + this module refuses to split the power).""" + if winner == "loop": + # SAFETY-52: a loop holding a refused direction says so. + return next((c.reason for c in claims if c.source == "loop" + and c.reason in (_ctl.REFUSED, _ctl.PROBING)), winner) + if winner in ("safety", "failsafe"): + return next((c.reason for c in claims + if c.source == winner and c.kind == "set"), + "no claim was made" if not any( + c.kind == "set" for c in claims) else "unsplit") + return winner + + +def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float, + provider: bool, max_age_s: float) -> str: + """The *Tariff* status row: the 30 s tick's sentence AND the provider plan + (TARIFF-11): two sources, two clocks, and the row says so. The plan is + stamped with its own age from `tariff_at` (not moved on a kept plan, + TARIFF-12), up to TARIFF_MAX_AGE_S (5400 s); `age_s` is the caller's + measurement. "No plan" is said aloud on a site that configured a provider. + DECISIONS/MAIN-orchestration.md.""" + # UX-08: plain words, every figure kept. The tick's sentence is "now"; the + # plan carries its own age. + if plan is None: + if not provider: + return tick_words + return (f"{tick_words}. No price plan: none yet, or the last one is " + f"over {max_age_s:.0f} s old") + return (f"{tick_words}. Price plan from {age_s:.0f} s ago, for " + f"{horizon_h:.0f} hours ahead: {plan.words}") + + +def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now, + why: str) -> str: + """The *Arbitrage* status row: three states (TARIFF-11): off (not armed), + armed with no decision this cycle (`why` is the loop's reason), or a + decision with its age. + `on` is read from the options, never inferred from `decision is None`: + `cycle()` returns at the input hold before `self.arb = None`, so the old + row named an option that was ON. The age is always printed, even 0. + DECISIONS/MAIN-orchestration.md.""" + # UX-08: plain words; the setting is named by its label. + if not on: + return (f"Off, “{messages.LABELS['arbitrage_enabled']}” is not " + f"switched on") + if decided_at is None: + return (f"On, but no decision yet, because control has not got that " + f"far. Control says: {why or 'no reason recorded'}") + age = (now - decided_at).total_seconds() + stale = cycle_at is not None and decided_at < cycle_at + head = (f"On, no decision this cycle ({why or 'no reason recorded'}), " + f"the last one {age:.0f} s ago: " if stale else + f"On, decided {age:.0f} s ago: ") + if decision is None: + return (f"On, no decision this cycle ({why or 'no reason recorded'}) " + f"and none before") + return head + decision.explain()