Files
goodwe-addon/goodwe_controller/app/rows.py
T
glenn schrooyenandClaude 1b6bb05fb4 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 <noreply@anthropic.com>
2026-11-09 00:29:37 +01:00

84 lines
4.0 KiB
Python

"""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()