UX-08 2d: plain log lines, the rest of the status page, and DOCS
Every WARNING and ERROR line is one plain line of at most 25 words, with no card ids, capitals or semicolons; the reasoning of the ten longest is on a DEBUG line. The ux08 allowlist is empty and its cap is 0 (43 -> 0). The status page: the month-peak and peak-guard rows, the stale-reading row, the top table (Why, Tariff, Arbitrage, Shadow mode, labels), the Stopped banner and the line under the buttons, which now shows the plain hint of each event instead of the raw action log. The Supervisor question at start logs one line with a real reason. Refusals are counted as shown (labels expanded, long cases driven), seven were shortened, the card and parse errors lost their internals, a blank value reads "empty", and the panel no longer files inverters.max_w under the flat max_w. Option names lose "(JSON)", the NL texts are checked too. DOCS gets a glossary (control board, isolator, Developer Tools) and card ids move from headings to a "For installers" line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
@@ -413,10 +413,9 @@ function showProblems(payload){
|
||||
save.disabled = total > 0;
|
||||
if(total){
|
||||
note(banner, 'bad', 'This configuration would not start.',
|
||||
'The add-on refuses to run on it and would stop with exit 78. '
|
||||
+ 'Saving is blocked until these are fixed; nothing has been written. '
|
||||
+ 'Each message below is the add-on\\u2019s own words, from the same '
|
||||
+ 'check that runs at startup.');
|
||||
'The add-on would refuse to start on it. Saving is blocked until these '
|
||||
+ 'are fixed, and nothing has been written. Each message below comes '
|
||||
+ 'from the same check that runs at start.');
|
||||
banner.insertBefore(banner.lastChild, banner.firstChild);
|
||||
msg(total + (total === 1 ? ' problem' : ' problems')
|
||||
+ ' - saving is blocked', 'bad');
|
||||
|
||||
@@ -289,7 +289,10 @@ def problems_by_field(problems, keys):
|
||||
cross-field rule is about all of its fields, and hiding it from one of them
|
||||
would be this card's own defect in miniature.
|
||||
"""
|
||||
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_])" + re.escape(key)
|
||||
# ⚠️ `.` BEFORE THE KEY DOES NOT COUNT (UX-08 2d): `inverters.max_w`
|
||||
# names a setting of one inverter in the list, which is changed under
|
||||
# `inverters`, and it must not also land beside the flat `max_w`.
|
||||
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_.])" + re.escape(key)
|
||||
+ r"(?![A-Za-z0-9_])")) for key in keys]
|
||||
by_key, unanchored = {}, []
|
||||
for problem in problems:
|
||||
|
||||
@@ -175,6 +175,8 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from . import messages
|
||||
|
||||
|
||||
class FormulaInvalid(Exception):
|
||||
"""`price_formula` cannot be used. Raised by the parser, refused by
|
||||
@@ -285,30 +287,28 @@ _LEGS = {
|
||||
'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
|
||||
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
|
||||
'"expect_incl_vat":15.55}}'),
|
||||
when=("Leave it empty unless “Dynamic price source” (price_provider) "
|
||||
"is 'custom_formula'."),
|
||||
when="Leave it empty unless “Dynamic price source” is 'custom_formula'.",
|
||||
zero_factor=" and trading never finds a price difference",
|
||||
flat="use “Fixed or day/night tariff” (static_profile) instead",
|
||||
neg_factor="Put the minus sign on the offset, not on the factor",
|
||||
card_required=True,
|
||||
card_note="The add-on needs it to check your two numbers",
|
||||
card_causes=("that network charges are in “Distribution, levies and VAT” "
|
||||
"(price_taxes_eur_kwh), not in the offset"),
|
||||
card_causes=("the units, the VAT, and that network charges are not "
|
||||
"in the offset"),
|
||||
),
|
||||
EXPORT_FORMULA_OPTION: _Leg(
|
||||
option=EXPORT_FORMULA_OPTION,
|
||||
what="injection",
|
||||
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
|
||||
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
|
||||
when=("Fill it in only if your injection price follows the market. It "
|
||||
"is used whatever the price source is."),
|
||||
when="Fill it in only if your injection price follows the market.",
|
||||
zero_factor="",
|
||||
flat="use “Injection price” (export_price_eur_kwh) instead",
|
||||
neg_factor="Put the minus sign on the offset, not on the factor",
|
||||
card_required=False,
|
||||
card_note=("It is optional here. Without it the add-on only checks "
|
||||
"the size of your two numbers"),
|
||||
card_causes="that vat_pct is 0 for injection",
|
||||
card_causes="the units, and that the VAT is 0 for injection",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -527,10 +527,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
||||
value = json.loads(text)
|
||||
except ValueError as e:
|
||||
raise FormulaInvalid(
|
||||
f"{option} is not valid JSON ({e}). Write it on one line, for "
|
||||
f"example\n"
|
||||
f" {leg.example}\n"
|
||||
f"{leg.when}")
|
||||
f"{messages.not_valid(option, e)}. Copy the example from the "
|
||||
f"documentation. {leg.when}")
|
||||
if not isinstance(value, dict):
|
||||
raise FormulaInvalid(
|
||||
f"{option} is {type(value).__name__}, not a JSON object with "
|
||||
@@ -685,9 +683,9 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
|
||||
got = in_card_units(spec, spec.card_index_eur_per_mwh)
|
||||
leg = _LEGS[spec.option]
|
||||
return (
|
||||
f"{spec.option} does not match its own card: it gives {got:.4f} "
|
||||
f"{spec.units} and the card says {spec.card_expect_incl_vat:g}. Check "
|
||||
f"the units, the VAT and {leg.card_causes}.")
|
||||
f"{spec.option} gives {got:.4f} "
|
||||
f"{messages.UNIT_WORDS.get(spec.units, spec.units)}, the card says "
|
||||
f"{spec.card_expect_incl_vat:g}. Check {leg.card_causes}.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -408,7 +408,8 @@ class Inverter:
|
||||
stop the site (`inputs_ok`). Folding one into the other is how a
|
||||
measurement fault ends up handing a sibling extra power.
|
||||
"""
|
||||
return self.batt_fresh.note(self.name)
|
||||
return (messages.row_battery_stuck(self.name) if self.batt_fresh.stale
|
||||
else "")
|
||||
|
||||
def link_note(self, now: datetime) -> str:
|
||||
"""One line for the status page. Empty when there is nothing to say.
|
||||
|
||||
+300
-214
@@ -73,7 +73,7 @@ from . import packshare
|
||||
from . import pv as pv_read
|
||||
from . import shadow
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option, provider_configured
|
||||
from .price import Uncertainty, brussels, price_option, provider_configured
|
||||
from .price_history import PriceHistory
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
from .tariff import PeakStore, PeakTracker
|
||||
@@ -267,14 +267,15 @@ def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
|
||||
past `max_age_s`, is the exact state the soak measured; rendering it as
|
||||
the bare static sentence is how it stayed invisible for 802 samples.
|
||||
"""
|
||||
# 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} (recomputed this tick); NO provider plan - "
|
||||
f"none has been resolved yet, or the last one is older than "
|
||||
f"{max_age_s:.0f} s")
|
||||
return (f"{tick_words} (recomputed this tick); provider plan fetched "
|
||||
f"{age_s:.0f} s ago for a {horizon_h:.0f} h horizon: {plan.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,
|
||||
@@ -312,21 +313,21 @@ def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
|
||||
render, from one that never reports it - and the bound TARIFF-10's
|
||||
comment claimed did not exist is ~15 s of it.
|
||||
"""
|
||||
# UX-08: plain words; the setting is named by its label.
|
||||
if not on:
|
||||
return "off - `arbitrage_enabled` is false, so nothing trades"
|
||||
return (f"Off, “{messages.LABELS['arbitrage_enabled']}” is not "
|
||||
f"switched on")
|
||||
if decided_at is None:
|
||||
return (f"ON (`arbitrage_enabled`), but NO DECISION has been reached "
|
||||
f"yet - no cycle has run as far as the arbitrage step. The "
|
||||
f"loop's own reason: {why or 'none recorded'}")
|
||||
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"no decision this cycle ({why or 'no reason recorded'}); the "
|
||||
f"last one was taken {age:.0f} s ago: " if stale else
|
||||
f"decided {age:.0f} s ago: ")
|
||||
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 (`arbitrage_enabled`), no decision this cycle "
|
||||
f"({why or 'no reason recorded'}), and none stands from an "
|
||||
f"earlier one")
|
||||
return (f"On, no decision this cycle ({why or 'no reason recorded'}) "
|
||||
f"and none before")
|
||||
return head + decision.explain()
|
||||
|
||||
|
||||
@@ -354,6 +355,8 @@ class Controller:
|
||||
# their behalf, and a guard that raises AttributeError on the way to
|
||||
# saying so is a guard that stops the add-on booting.
|
||||
self.events: list[str] = []
|
||||
# UX-08: the last event in plain words, for the page. See log_event().
|
||||
self.hint = ""
|
||||
|
||||
# --- DEV-02: THE ROSTER, BUILT BEFORE ANYTHING THAT READS A RATING ---
|
||||
# ⚠️ EVERY PER-DEVICE VALUE IN THIS CONTROLLER LIVES ON ONE OF THESE
|
||||
@@ -467,7 +470,8 @@ class Controller:
|
||||
"soc_entity / batt_entity / batt_invert / max_w keys are NOT "
|
||||
"read in this shape; each entry carries its own."
|
||||
% (len(self.inverters),
|
||||
", ".join(f"{i.name} {i.max_w:.0f} W" for i in self.inverters)))
|
||||
", ".join(f"{i.name} {i.max_w:.0f} W" for i in self.inverters)),
|
||||
hint=messages.hint("roster", n=len(self.inverters)))
|
||||
|
||||
self.tuning = Tuning(
|
||||
gain=float(opts.get("gain", 0.6)),
|
||||
@@ -541,7 +545,8 @@ class Controller:
|
||||
# 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())
|
||||
+ self.maint_migration.explain(),
|
||||
hint=messages.hint("maint_history"))
|
||||
# ⚠️ 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
|
||||
@@ -590,11 +595,9 @@ class Controller:
|
||||
self._grade_store_migration(store)
|
||||
for inv in self.inverters:
|
||||
if inv.sign_wd.tripped:
|
||||
_LOG.error("SAFETY-02 was TRIPPED on %s before this restart and "
|
||||
"stays tripped: %s. That inverter is held at 0 W. "
|
||||
"Restarting the add-on does not clear this - start "
|
||||
"control from the UI once you know why the command "
|
||||
"opposed the meter.", inv.name,
|
||||
_LOG.error("%s stays at 0 W after the restart because its "
|
||||
"power went the wrong way: %s. Start control once "
|
||||
"you know why.", inv.name,
|
||||
inv.sign_wd.reason or "(reason not recorded)")
|
||||
|
||||
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
|
||||
@@ -616,11 +619,9 @@ class Controller:
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
_LOG.error(
|
||||
"SAFETY-42 was TRIPPED on %s before this restart and stays "
|
||||
"tripped: %s. NOTHING IS BEING COMMANDED DIFFERENTLY - this "
|
||||
"guard reports and does not act - so if that inverter is "
|
||||
"still not delivering, the site is still short by its "
|
||||
"share. Restarting does not clear this.",
|
||||
"%s is still marked as not delivering after the restart: "
|
||||
"%s. Control keeps running, and only stopping and starting "
|
||||
"control clears this.",
|
||||
inv.name, inv.delivery_wd.reason or "(reason not recorded)")
|
||||
|
||||
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
|
||||
@@ -650,10 +651,9 @@ class Controller:
|
||||
reason=str(store.data.get("site_sign_reason", "") or ""))
|
||||
if self.site_sign_wd.tripped:
|
||||
_LOG.error(
|
||||
"SAFETY-30 (site) was TRIPPED before this restart and stays "
|
||||
"tripped: %s. Every inverter is held at 0 W. Restarting does "
|
||||
"not clear this - start control from the UI once you know why "
|
||||
"the site's total command opposed the meter.",
|
||||
"Every inverter stays at 0 W after the restart because the "
|
||||
"total power went the wrong way: %s. Start control once you "
|
||||
"know why.",
|
||||
self.site_sign_wd.reason or "(reason not recorded)")
|
||||
# And the per-unit detectors are told which question they are asking,
|
||||
# here as well as in `cycle()`. `checks()` and the status page read
|
||||
@@ -704,10 +704,9 @@ class Controller:
|
||||
self._announced_mono = {}
|
||||
if self.circ_wd.tripped:
|
||||
_LOG.error(
|
||||
"SAFETY-31 was TRIPPED before this restart and stays tripped: "
|
||||
"%s. Every inverter is held at 0 W. Restarting does not clear "
|
||||
"this - start control from the UI once you know why two "
|
||||
"inverters were pushing against each other.",
|
||||
"Every inverter stays at 0 W after the restart because the "
|
||||
"inverters worked against each other: %s. Start control once "
|
||||
"you know why.",
|
||||
self.circ_wd.reason or "(reason not recorded)")
|
||||
|
||||
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
|
||||
@@ -913,6 +912,7 @@ class Controller:
|
||||
# than re-derived a cycle later. Starts False: nothing is on the wire.
|
||||
self.following = False
|
||||
self.reason = "starting"
|
||||
self.why_words = "Starting"
|
||||
# ALLOC-01. The allocation record from the last resolution, or None
|
||||
# before the first cycle. ⚠️ IT IS KEPT SO THAT SOMETHING CAN GATE ON
|
||||
# IT: SAFETY-28 puts the write path behind this record, and DEV-02 reads
|
||||
@@ -959,7 +959,7 @@ class Controller:
|
||||
# TARIFF-03. One line of prose for the status page saying WHICH tariff
|
||||
# window we are in and why it is or is not cheap - so "Cheap window:
|
||||
# no" is an evidenced answer rather than a bare no.
|
||||
self.tariff_words = "no static profile"
|
||||
self.tariff_words = "No fixed tariff set"
|
||||
|
||||
# OPT-04. The tariff plan the BACKGROUND task last resolved, and when.
|
||||
# cycle() reads these two attributes and never awaits a fetch - see
|
||||
@@ -1005,10 +1005,15 @@ class Controller:
|
||||
self.loop_pass_mono = None
|
||||
|
||||
# -- helpers -------------------------------------------------------------
|
||||
def log_event(self, msg: str) -> None:
|
||||
def log_event(self, msg: str, hint: str | None = None) -> None:
|
||||
"""`msg` goes on the action log, the add-on's own technical record.
|
||||
`hint` is `messages.hint()`'s plain words for the same event, and is
|
||||
what the page shows under the buttons (UX-08)."""
|
||||
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||
self.events.insert(0, f"{stamp} {msg}")
|
||||
del self.events[40:]
|
||||
if hint is not None:
|
||||
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?
|
||||
@@ -1066,9 +1071,9 @@ class Controller:
|
||||
"""
|
||||
rep = self.store_migration
|
||||
if rep.ran and not rep.ok:
|
||||
_LOG.error("SAFETY-02: the per-inverter store migration did not "
|
||||
"verify (%s). The latch is being re-derived from the "
|
||||
"legacy keys.", rep.explain())
|
||||
_LOG.error("The saved safety stops did not copy over cleanly "
|
||||
"(%s), so they are read again from the older saved "
|
||||
"state.", rep.explain())
|
||||
data = getattr(store, "data", {})
|
||||
legacy_tripped = bool(data.get("sign_tripped", False))
|
||||
if not legacy_tripped or not self.inverters:
|
||||
@@ -1194,10 +1199,8 @@ class Controller:
|
||||
return
|
||||
if stranded:
|
||||
_LOG.error(
|
||||
"SAFETY-44: this store carries a LATCHED sign-disagreement "
|
||||
"failsafe under %s, and that unit is NOT on today's roster "
|
||||
"%s - so no machine reads it and the site came up DRIVING on "
|
||||
"a state file that says it is latched. Re-arming below.",
|
||||
"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.
|
||||
@@ -1209,17 +1212,18 @@ class Controller:
|
||||
first.store_set("sign_reason", first.sign_wd.reason)
|
||||
first.store_set("sign_tripped", True)
|
||||
_LOG.error(
|
||||
"SAFETY-02 LATCH GUARD: this store carries a LATCHED "
|
||||
"sign-disagreement failsafe under the pre-DEV-02 flat key and NO "
|
||||
"inverter came up latched. Either the per-inverter migration lost "
|
||||
"it, or it was written by an older add-on while this install was "
|
||||
"rolled back. Both are a disarmed safety guard on an install whose "
|
||||
"state file says it is protected, so the latch has been re-armed "
|
||||
"on %s from the legacy value: %s. %s is held at 0 W until control "
|
||||
"is started from the UI.",
|
||||
first.name, first.sign_wd.reason, first.name)
|
||||
"%s is set to 0 W again, because an older saved safety stop was "
|
||||
"found: %s. Start control once you know why.",
|
||||
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)
|
||||
+ first.sign_wd.reason,
|
||||
hint=messages.hint("stop_restored", unit=first.name))
|
||||
|
||||
# -- the roster, and the N=1 views of it ---------------------------------
|
||||
@property
|
||||
@@ -1805,7 +1809,7 @@ class Controller:
|
||||
now_p = await self.hass.number(now_e)
|
||||
avg_p = await self.hass.number(avg_e)
|
||||
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
|
||||
self.tariff_words = "Home Assistant price entities"
|
||||
self.tariff_words = "Prices from Home Assistant entities"
|
||||
else:
|
||||
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
|
||||
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
|
||||
@@ -1851,7 +1855,8 @@ class Controller:
|
||||
|
||||
if self.inputs_ok:
|
||||
if self.inputs_bad_since is not None:
|
||||
self.log_event("inputs recovered")
|
||||
self.log_event("inputs recovered",
|
||||
hint=messages.hint("inputs_back"))
|
||||
self.inputs_bad_since = None
|
||||
elif self.inputs_bad_since is None:
|
||||
self.inputs_bad_since = datetime.now(timezone.utc)
|
||||
@@ -1886,7 +1891,8 @@ class Controller:
|
||||
# §11.1 rule 1 and is already what a missing reading does here.
|
||||
inv.batt = inv.batt_fresh.reading()
|
||||
if inv.batt_fresh.stale and not was_stale:
|
||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason)
|
||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason,
|
||||
hint=messages.hint("battery_stuck", unit=inv.name))
|
||||
# ⚠️ SAFETY-57 REWROTE THE LAST SENTENCE OF THIS LINE AND NOTHING
|
||||
# ELSE IN IT. The escape clause used to read "It clears itself the
|
||||
# moment the register reports a different value", full stop - and
|
||||
@@ -1913,26 +1919,31 @@ class Controller:
|
||||
# this line already interpolates, is where the pair is spelled out.
|
||||
# Saying it in both halves of one sentence is how an operator
|
||||
# learns to skim it.
|
||||
_LOG.error("SAFETY-37: %s battery-power reading did not move under "
|
||||
"a command that did, WHICH IS NOT BY ITSELF A VERDICT "
|
||||
"ON THE REGISTER - %s. Treating it as MISSING, which "
|
||||
"commands 0 W. It clears itself the moment the register "
|
||||
"reports a different value, and failing that %.0f s "
|
||||
"after the standing command falls below %.0f W "
|
||||
"(SAFETY-57: a verdict whose own consequence is a 0 W "
|
||||
"command cannot be tested again from inside it).",
|
||||
inv.name, inv.batt_fresh.reason,
|
||||
inv.batt_fresh.bound.release_s,
|
||||
inv.batt_fresh.bound.move_w)
|
||||
# UX-08 2d: plain, with SAFETY-57's escape clause kept as an
|
||||
# ordered pair (time, then power), and the reason still in `%s`.
|
||||
_LOG.error("%s: battery power reading stopped changing. Inverters "
|
||||
"go to 0 W until it changes, or %.0f s after the "
|
||||
"command drops below %.0f W: %s",
|
||||
inv.name, inv.batt_fresh.bound.release_s,
|
||||
inv.batt_fresh.bound.move_w, inv.batt_fresh.reason)
|
||||
elif inv.batt_fresh.releases > was_releases:
|
||||
# ⚠️ SAFETY-57. REPORTED, NEVER SILENT. A latch that lets go on a
|
||||
# timer and says nothing is a latch no operator can audit - and this
|
||||
# one is withdrawing a SITE STOP. `release_note()` is written to say
|
||||
# that nothing has been learned about the register, because nothing
|
||||
# has.
|
||||
self.log_event(f"STALE WITHDRAWN {inv.name}")
|
||||
_LOG.warning("SAFETY-57: %s",
|
||||
inv.batt_fresh.release_note(inv.name))
|
||||
self.log_event(f"STALE WITHDRAWN {inv.name}",
|
||||
hint=messages.hint("battery_back", unit=inv.name))
|
||||
# Not "recovered": nothing is known about the reading yet, so the
|
||||
# line gives the value it still holds and the window it is
|
||||
# rechecked over (SAFETY-57).
|
||||
_LOG.warning("%s: the command stayed below %.0f W for %.0f s, so "
|
||||
"battery power reading %+.0f W is used again and "
|
||||
"rechecked over %.0f s.", inv.name,
|
||||
inv.batt_fresh.bound.move_w,
|
||||
inv.batt_fresh.bound.release_s, inv.batt_fresh.held,
|
||||
inv.batt_fresh.bound.unchanged_s)
|
||||
_LOG.debug("SAFETY-57: %s", inv.batt_fresh.release_note(inv.name))
|
||||
|
||||
@property
|
||||
def shadow(self) -> bool:
|
||||
@@ -2066,14 +2077,15 @@ class Controller:
|
||||
self.log_event(
|
||||
f"refusing to broadcast {float(value):+.0f} W to "
|
||||
f"{len(targets)} inverters - that is the ARCH-01 fault; "
|
||||
f"commanding 0 W")
|
||||
_LOG.error("write_setpoint() was asked to put %+.0f W on every "
|
||||
"one of %d inverters. A site target handed to each "
|
||||
"unit is supplied by EACH of them: the gain at the "
|
||||
"meter becomes N times the per-unit gain. Only 0 W "
|
||||
"may be written site-wide; shares come from the "
|
||||
"allocator. Commanding 0 W.", float(value),
|
||||
len(targets))
|
||||
f"commanding 0 W", hint=messages.hint("all_blocked"))
|
||||
_LOG.error("Refused to send %+.0f W to all %d inverters at "
|
||||
"once, so every inverter is set to 0 W. This is a "
|
||||
"software fault.", float(value), len(targets))
|
||||
_LOG.debug("ARCH-01: a site target handed to each unit is "
|
||||
"supplied by each of them, so the gain at the meter "
|
||||
"becomes N times the per-unit gain. Only 0 W may be "
|
||||
"written site-wide, and shares come from the "
|
||||
"allocator.")
|
||||
value = 0.0
|
||||
else:
|
||||
targets = [unit]
|
||||
@@ -2101,7 +2113,8 @@ class Controller:
|
||||
# must not be counted as a clamp engagement, or a transport bug lands in
|
||||
# the winding-loop counter.
|
||||
if not is_commandable(value):
|
||||
self.log_event(f"non-finite setpoint {value!r} - commanding 0 W")
|
||||
self.log_event(f"non-finite setpoint {value!r} - commanding 0 W",
|
||||
hint=messages.hint("bad_value"))
|
||||
_LOG.error("refusing to write a non-finite setpoint %r - commanding "
|
||||
"0 W and continuing to command. This is a bug upstream, "
|
||||
"not a limit.", value)
|
||||
@@ -2139,11 +2152,11 @@ class Controller:
|
||||
refusal = self.write_refusal(value, inv)
|
||||
if refusal is not None:
|
||||
self.log_event(
|
||||
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W")
|
||||
_LOG.error("SAFETY-28 REFUSED a write of %+.0f W to %s: %s. "
|
||||
"Commanding 0 W instead. Every setpoint must be one the "
|
||||
"allocator placed on THIS tick, for THIS unit - see "
|
||||
"Controller.write_refusal().", value, inv.name, refusal)
|
||||
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W",
|
||||
hint=messages.hint("write_blocked", unit=inv.name))
|
||||
_LOG.error("Refused to send %+.0f W to %s (%s), so it is set to "
|
||||
"0 W instead. This is a software fault.",
|
||||
value, inv.name, refusal)
|
||||
value = 0.0
|
||||
|
||||
# ⚠️ THIS UNIT'S OWN THREE NUMBERS. `inv.max_w` is the supervised cap on
|
||||
@@ -2158,7 +2171,8 @@ class Controller:
|
||||
# (§15), and it is a different surface from the notification, which
|
||||
# IS deduplicated. The count is what says how hard the loop pushed;
|
||||
# the notification only says that it did.
|
||||
self.log_event(f"{inv.name}: {result.describe()}")
|
||||
self.log_event(f"{inv.name}: {result.describe()}",
|
||||
hint=messages.hint("write_cut", unit=inv.name))
|
||||
value = result.applied_w
|
||||
|
||||
# --- SAFETY-10: AND THIS IS THE LAST THING BEFORE THE WIRE -----------
|
||||
@@ -2209,12 +2223,12 @@ class Controller:
|
||||
timeout=WRITE_BUDGET_S)
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
ok = False
|
||||
_LOG.error("the write of %+.0f W to %s (%s) did not complete in "
|
||||
"%.0f s and was abandoned. It will be re-issued on the "
|
||||
"next tick; no other inverter waited for it.",
|
||||
_LOG.error("Sending %+.0f W to %s (%s) took over %.0f s and was "
|
||||
"dropped, so it is sent again next cycle.",
|
||||
value, inv.name, entity, WRITE_BUDGET_S)
|
||||
self.log_event(f"{inv.name}: write timed out after "
|
||||
f"{WRITE_BUDGET_S:.0f} s - re-issuing next tick")
|
||||
f"{WRITE_BUDGET_S:.0f} s - re-issuing next tick",
|
||||
hint=messages.hint("write_slow", unit=inv.name))
|
||||
now = datetime.now(timezone.utc)
|
||||
inv.note_write(bool(ok), now)
|
||||
if ok:
|
||||
@@ -2339,7 +2353,8 @@ class Controller:
|
||||
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)
|
||||
self.log_event(msg, hint=messages.hint("maintenance",
|
||||
phase=machine.phase))
|
||||
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),
|
||||
@@ -2432,11 +2447,10 @@ class Controller:
|
||||
# operator press that clears the namespaced ones.
|
||||
self.store.set("sign_reason", inv.sign_wd.reason)
|
||||
self.store.set("sign_tripped", True)
|
||||
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason)
|
||||
_LOG.error("SAFETY-02 TRIPPED on %s - %s. Commanding 0 W on "
|
||||
"that inverter and holding it until control is "
|
||||
"started again. This survives an add-on restart; "
|
||||
"restarting will not clear it.",
|
||||
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason,
|
||||
hint=messages.hint("direction", unit=inv.name))
|
||||
_LOG.error("%s is set to 0 W because its power went the wrong "
|
||||
"way: %s. Only starting control clears this.",
|
||||
inv.name, inv.sign_wd.reason)
|
||||
# ⚠️ THE SITE'S OWN STANDING COMMAND, WHICH IS THE SUM OF THE UNITS'.
|
||||
# SAFETY-07 and SAFETY-18 both grade a SITE quantity - the quarter-hour
|
||||
@@ -2481,14 +2495,11 @@ class Controller:
|
||||
# the same reason `circ_*` is.
|
||||
self.store.set("site_sign_reason", self.site_sign_wd.reason)
|
||||
self.store.set("site_sign_tripped", True)
|
||||
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason)
|
||||
_LOG.error("SAFETY-30 TRIPPED at the SITE - %s. That is the "
|
||||
"SUM of every standing command opposing the meter, "
|
||||
"which no single inverter is answerable for. "
|
||||
"Commanding 0 W on every inverter and holding it "
|
||||
"until control is started again. This survives an "
|
||||
"add-on restart; restarting will not clear it.",
|
||||
self.site_sign_wd.reason)
|
||||
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason,
|
||||
hint=messages.hint("site_direction"))
|
||||
_LOG.error("Every inverter is set to 0 W because the total "
|
||||
"power went the wrong way: %s. Only starting "
|
||||
"control clears this.", self.site_sign_wd.reason)
|
||||
|
||||
# --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON -----
|
||||
# Evaluated EVERY cycle, unconditionally, above the "inputs missing but
|
||||
@@ -2516,12 +2527,12 @@ class Controller:
|
||||
self.store.set("circ_reason", self.circ_wd.reason)
|
||||
self.store.set("circ_tripped", True)
|
||||
self._circ_announced = False
|
||||
self.log_event("CIRCULATING POWER - " + self.circ_wd.reason)
|
||||
self.log_event("CIRCULATING POWER - " + self.circ_wd.reason,
|
||||
hint=messages.hint("circulating"))
|
||||
_LOG.error(
|
||||
"SAFETY-31 TRIPPED - %s. Commanding 0 W on every inverter and "
|
||||
"holding it until control is started again. This survives an "
|
||||
"add-on restart AND a Home Assistant restart; restarting will "
|
||||
"not clear it.", self.circ_wd.reason)
|
||||
"Every inverter is set to 0 W because the inverters worked "
|
||||
"against each other: %s. Only starting control clears this.",
|
||||
self.circ_wd.reason)
|
||||
|
||||
# --- SAFETY-42, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's REASON
|
||||
# Evaluated EVERY cycle, unconditionally, ABOVE the "inputs missing but
|
||||
@@ -2623,16 +2634,18 @@ class Controller:
|
||||
inv.store_set("delivery_tripped", True)
|
||||
self._delivery_announced.discard(inv.name)
|
||||
self.log_event(f"NOT DELIVERING {inv.name}: "
|
||||
+ inv.delivery_wd.reason)
|
||||
+ inv.delivery_wd.reason,
|
||||
hint=messages.hint("not_delivering",
|
||||
unit=inv.name))
|
||||
_LOG.error(
|
||||
"SAFETY-42 TRIPPED on %s - %s. NOTHING IS COMMANDED "
|
||||
"DIFFERENTLY: this guard reports and does not act, because "
|
||||
"commanding a unit that is not listening achieves nothing "
|
||||
"(HW-01) and the only re-allocation this add-on can express "
|
||||
"would throw away a partially-delivering unit's remaining "
|
||||
"output. The site is short by that unit's share until "
|
||||
"somebody looks at it. This survives an add-on AND a Home "
|
||||
"Assistant restart.", inv.name, inv.delivery_wd.reason)
|
||||
"%s is not delivering: %s. Nothing was changed and control "
|
||||
"keeps running, and only stopping and starting control "
|
||||
"clears this.", inv.name, inv.delivery_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-42 reports and does not act: commanding a unit "
|
||||
"that is not listening achieves nothing (HW-01), and the "
|
||||
"only re-allocation the add-on can express would throw "
|
||||
"away a partly delivering unit's output.")
|
||||
|
||||
stale_after = float(self.o.get("stale_input_s", 15))
|
||||
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
|
||||
@@ -2653,6 +2666,7 @@ class Controller:
|
||||
# A single missed poll is not a fault. Hold, keep the heartbeat
|
||||
# going, and give it a few seconds to come back.
|
||||
self.reason = f"inputs missing {bad_for:.0f}s"
|
||||
self.why_words = messages.why_waiting(bad_for)
|
||||
return
|
||||
|
||||
claims = []
|
||||
@@ -2828,7 +2842,8 @@ class Controller:
|
||||
actuator=self.actuator_of("safety")))
|
||||
elif not self.inputs_ok:
|
||||
if self.target != 0.0:
|
||||
self.log_event("inputs missing - commanding 0 W")
|
||||
self.log_event("inputs missing - commanding 0 W",
|
||||
hint=messages.hint("inputs_missing"))
|
||||
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing",
|
||||
actuator=self.actuator_of("safety")))
|
||||
|
||||
@@ -2949,7 +2964,12 @@ class Controller:
|
||||
if engaged_now != self._peak_engaged:
|
||||
self._peak_engaged = engaged_now
|
||||
if self.peak_guard is not None:
|
||||
self.log_event(self.peak_guard.explain())
|
||||
_pg = self.peak_guard
|
||||
self.log_event(_pg.explain(), hint=messages.hint(
|
||||
"peak_off" if not _pg.engaged else
|
||||
"peak_blind" if _pg.blind else
|
||||
"peak_reserve" if _pg.floor_limited else
|
||||
"peak_saturated" if _pg.saturated else "peak_on"))
|
||||
|
||||
# --- OPT-04: price arbitrage (§10.1 priority 3) ----------------------
|
||||
# ONE `set` claim at P_ARBITRAGE, and app/arbitrage.py carries the whole
|
||||
@@ -3061,7 +3081,10 @@ class Controller:
|
||||
# operator needs - "it started buying at 03:00", not 240 copies.
|
||||
if self.arb.action != self._arb_action:
|
||||
self._arb_action = self.arb.action
|
||||
self.log_event(self.arb.explain())
|
||||
self.log_event(self.arb.explain(), hint=messages.hint(
|
||||
"trade_charge" if self.arb.action == arbitrage.CHARGE else
|
||||
"trade_give" if self.arb.action == arbitrage.DISCHARGE
|
||||
else "trade_off"))
|
||||
|
||||
# --- maintenance -----------------------------------------------------
|
||||
# ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST STAYS SHORT.
|
||||
@@ -3145,7 +3168,8 @@ class Controller:
|
||||
self._maint_shape_suspended = True
|
||||
self.log_event("peak at risk - maintenance charge shaping "
|
||||
"suspended so the peak claim is not "
|
||||
"contradicted")
|
||||
"contradicted",
|
||||
hint=messages.hint("maint_paused"))
|
||||
else:
|
||||
self._maint_shape_suspended = False
|
||||
hi = 0.0
|
||||
@@ -3229,7 +3253,8 @@ class Controller:
|
||||
)
|
||||
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
|
||||
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
|
||||
f"{site_batt:.0f} W)")
|
||||
f"{site_batt:.0f} W)",
|
||||
hint=messages.hint("not_keeping_up"))
|
||||
self.sat_count = decision.sat_count
|
||||
self.i_w = decision.i_w
|
||||
self.loop_w = decision.target_w
|
||||
@@ -3358,7 +3383,8 @@ class Controller:
|
||||
# made by commanding one pack to charge while another discharges.
|
||||
# `allocator.plan()` carries the argument for each.
|
||||
if not resolution.allocated:
|
||||
self.log_event("ARBITER: " + resolution.allocation.explain())
|
||||
self.log_event("ARBITER: " + resolution.allocation.explain(),
|
||||
hint=messages.hint("split"))
|
||||
resolution = Resolution(0.0, "failsafe", None, False,
|
||||
resolution.considered,
|
||||
unit_bounds=resolution.unit_bounds,
|
||||
@@ -3394,7 +3420,7 @@ class Controller:
|
||||
f"ARBITER: the site target was allocated across "
|
||||
f"{len(resolution.shares)} inverters and this controller has no "
|
||||
f"setpoint entity for {undeliverable} - refusing to command a "
|
||||
f"partial site")
|
||||
f"partial site", hint=messages.hint("no_setpoint"))
|
||||
# ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE
|
||||
# DEV-02 THAT IS LOAD-BEARING RATHER THAN TIDY. The write loop below
|
||||
# commands each inverter `shares.get(name, 0.0)`, so carrying the
|
||||
@@ -3422,9 +3448,11 @@ class Controller:
|
||||
for _u, _why in (self.allocation.dropped if self.allocation else ()):
|
||||
self.log_event(f"ALLOCATOR: {_u} dropped from the split "
|
||||
f"({_why}) - the remaining inverter(s) now carry "
|
||||
f"its share of the site target")
|
||||
f"its share of the site target",
|
||||
hint=messages.hint("unit_dropped", unit=_u))
|
||||
if self._dropped_last and not _dropped:
|
||||
self.log_event("ALLOCATOR: every inverter is back in the split")
|
||||
self.log_event("ALLOCATOR: every inverter is back in the split",
|
||||
hint=messages.hint("units_back"))
|
||||
self._dropped_last = _dropped
|
||||
# 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER.
|
||||
# `unmet_w` is the site target the envelopes could not absorb. It is
|
||||
@@ -3441,9 +3469,11 @@ class Controller:
|
||||
if _unmet:
|
||||
self.log_event(
|
||||
f"ALLOCATOR: {_unmet:+d} W of the site target could not be "
|
||||
f"placed on any inverter - {self.allocation.explain()}")
|
||||
f"placed on any inverter - {self.allocation.explain()}",
|
||||
hint=messages.hint("unmet", w=_unmet))
|
||||
else:
|
||||
self.log_event("ALLOCATOR: the whole site target fits again")
|
||||
self.log_event("ALLOCATOR: the whole site target fits again",
|
||||
hint=messages.hint("unmet_gone"))
|
||||
self._unmet_last = _unmet
|
||||
if resolution.scope_errors:
|
||||
# A claim reached the arbiter on an actuator its source's scope does
|
||||
@@ -3452,9 +3482,11 @@ class Controller:
|
||||
# card removes, and a site constraint on one unit is a bound the
|
||||
# other unit never gets. Loud, every cycle, and it does not change
|
||||
# the resolution - the arbiter already routed by `actuator`.
|
||||
_LOG.error("ARBITER SCOPE: %s", "; ".join(resolution.scope_errors))
|
||||
_LOG.error("A limit was filed on the wrong inverter, which is a "
|
||||
"software fault: %s", " / ".join(resolution.scope_errors))
|
||||
if resolution.contradiction:
|
||||
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log")
|
||||
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log",
|
||||
hint=messages.hint("clash"))
|
||||
# --- SAFETY-02's arming gate ------------------------------------------
|
||||
# Was the number about to go on the wire chosen by the grid-following
|
||||
# loop, or by a strategy that commands against the meter on purpose?
|
||||
@@ -3471,6 +3503,17 @@ class Controller:
|
||||
self.following = loop_is_following(resolution, self.loop_w)
|
||||
self.target = resolution.target_w
|
||||
self.reason = resolution.explain()
|
||||
# UX-08: the same answer in plain words, for the page's "Why" line.
|
||||
# `reason` stays the technical record the log and the checks read.
|
||||
_why = resolution.winner
|
||||
if _why in ("safety", "failsafe"):
|
||||
_why = next((c.reason for c in claims
|
||||
if c.source == _why and c.kind == "set"),
|
||||
"no claim was made" if not any(
|
||||
c.kind == "set" for c in claims) else "unsplit")
|
||||
self.why_words = messages.why(_why, resolution.target_w,
|
||||
resolution.bound_by,
|
||||
contradiction=resolution.contradiction)
|
||||
# --- DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE ---------------------
|
||||
# ⚠️ ITS OWN SHARE, NEVER THE SITE TARGET. `resolution.shares` is the
|
||||
# allocator's answer and `sum(shares) + unmet_w == round(target_w)` is
|
||||
@@ -3647,20 +3690,20 @@ class Controller:
|
||||
self.log_event(
|
||||
"SHADOW MODE ON - " + shadow.why(
|
||||
self.o, control_started=self.auto)
|
||||
+ f"; inverter held at {shadow.WIRE_W:.0f} W")
|
||||
+ f"; inverter held at {shadow.WIRE_W:.0f} W",
|
||||
hint=messages.hint("shadow_on"))
|
||||
_LOG.warning(
|
||||
"SAFETY-10 SHADOW MODE IS ON. The full decision path runs "
|
||||
"and NOTHING is commanded: every inverter is held at "
|
||||
"%+.0f W on the ordinary heartbeat. %s", shadow.WIRE_W,
|
||||
"Shadow mode is on, so every inverter stays at %.0f W and "
|
||||
"nothing the add-on decides is sent. %s.", shadow.WIRE_W,
|
||||
shadow.why(self.o, control_started=self.auto))
|
||||
elif not first:
|
||||
self.log_event(
|
||||
"SHADOW MODE OFF - the battery is being controlled again, "
|
||||
f"resuming from {shadow.WIRE_W:.0f} W")
|
||||
f"resuming from {shadow.WIRE_W:.0f} W",
|
||||
hint=messages.hint("shadow_off"))
|
||||
_LOG.warning(
|
||||
"SAFETY-10 shadow mode is OFF. This controller is "
|
||||
"commanding the battery again, starting from the "
|
||||
"%+.0f W that was standing on the wire.", shadow.WIRE_W)
|
||||
"Shadow mode is off, so the add-on controls the battery "
|
||||
"again, starting from %.0f W.", shadow.WIRE_W)
|
||||
if not engaged:
|
||||
self.shadow_would_w = None
|
||||
self.shadow_sized_from_meter = False
|
||||
@@ -3693,7 +3736,9 @@ class Controller:
|
||||
self._shadow_winner = edge
|
||||
self.log_event(shadow.log_line(
|
||||
would_w=resolution.target_w, reason=resolution.explain(),
|
||||
sized_from_meter=self.shadow_sized_from_meter))
|
||||
sized_from_meter=self.shadow_sized_from_meter),
|
||||
hint=messages.hint("shadow_decision",
|
||||
sw=resolution.target_w))
|
||||
self.target = shadow.WIRE_W
|
||||
for inv in self.inverters:
|
||||
inv.target = shadow.WIRE_W
|
||||
@@ -3771,25 +3816,29 @@ class Controller:
|
||||
stamp = self.events[0].split(" ", 1)[0]
|
||||
self.events[0] = f"{stamp} {row}"
|
||||
else:
|
||||
self.log_event(row)
|
||||
self.log_event(row, hint=messages.hint(
|
||||
"power_too_high" if bug else "not_keeping_up"))
|
||||
if not fired:
|
||||
return
|
||||
if not self.clamp_wd.bug:
|
||||
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
|
||||
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
|
||||
# it can never replace a BUG SIGNAL notice still on screen.
|
||||
_LOG.warning("SAFETY-03 - %s Engagements so far: %d.",
|
||||
self.clamp_wd.reason, self.clamp_wd.engagements)
|
||||
_LOG.warning("The battery gives less than asked: %s Held back %d "
|
||||
"times so far.", self.clamp_wd.reason,
|
||||
self.clamp_wd.engagements)
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.loop_bound().data())
|
||||
return
|
||||
_LOG.error(
|
||||
"SAFETY-03 - %s Silently clipping this is how a winding integrator "
|
||||
"reached 14 768 W on this project before: the clamp makes every "
|
||||
"dashboard read a legal number while the thing producing it goes on "
|
||||
"climbing. The command IS clamped and the loop is STILL RUNNING - "
|
||||
"this is an alarm, not a trip. Engagements so far: %d.",
|
||||
"Power request too high, which is a software fault: %s Control is "
|
||||
"still running. Cut back %d times so far.",
|
||||
self.clamp_wd.reason, self.clamp_wd.engagements)
|
||||
_LOG.debug(
|
||||
"SAFETY-03: silently clipping this is how a winding integrator "
|
||||
"reached 14 768 W on this project before. The clamp makes every "
|
||||
"dashboard read a legal number while the thing producing it goes "
|
||||
"on climbing. This is an alarm, not a trip.")
|
||||
# A fixed notification_id, for the reason check_meter_frozen() gives -
|
||||
# and a DIFFERENT one from every other alarm this add-on raises, because
|
||||
# a clamp is not a frozen meter and neither is a saturation freeze.
|
||||
@@ -3829,13 +3878,12 @@ class Controller:
|
||||
was, self._peak_saturated = self._peak_saturated, saturated
|
||||
if not saturated or was:
|
||||
return
|
||||
self.log_event("PEAK GUARD SATURATED - " + g.reason)
|
||||
self.log_event("PEAK GUARD SATURATED - " + g.reason,
|
||||
hint=messages.hint("peak_saturated"))
|
||||
_LOG.error(
|
||||
"SAFETY-07 - %s. The guard is asking for everything the inverter "
|
||||
"has and the quarter is still projected over the ceiling: this "
|
||||
"quarter will set or raise the month's billed capacity peak. The "
|
||||
"controller is STILL RUNNING and still shaving; there is nothing "
|
||||
"further it can do. Shed load.", g.reason)
|
||||
"The battery is asked for all it can give, but this 15-minute "
|
||||
"period still goes over the peak limit: %s. Switch off large "
|
||||
"loads.", g.reason)
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
||||
# is not a frozen meter and is not an out-of-range setpoint.
|
||||
@@ -3889,12 +3937,10 @@ class Controller:
|
||||
# a second copy is the kind of stutter that makes a 40-line action log
|
||||
# read like boilerplate. The saturated path needs its prefix because its
|
||||
# reason carries the word in the middle of a sentence, not at the front.
|
||||
self.log_event(g.reason)
|
||||
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
|
||||
_LOG.error(
|
||||
"SAFETY-24 - %s. The controller is STILL RUNNING and charging is "
|
||||
"still blocked, but it is not discharging to hold this quarter, so "
|
||||
"the quarter will set or raise the month's billed capacity peak. "
|
||||
"Shed load, or lower peak_soc_floor if this pack can afford it.",
|
||||
"The peak guard may not draw the battery lower, so this 15-minute "
|
||||
"period will go over the peak limit: %s. Switch off large loads.",
|
||||
g.reason)
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. A battery at its
|
||||
@@ -4051,15 +4097,17 @@ class Controller:
|
||||
dev_max=self.site_dev_bound("dev_max"))
|
||||
if not fired:
|
||||
return
|
||||
self.log_event("METER FROZEN? " + self.freeze_wd.reason)
|
||||
self.log_event("METER FROZEN? " + self.freeze_wd.reason,
|
||||
hint=messages.hint("meter_frozen"))
|
||||
_LOG.error(
|
||||
"SAFETY-18 - %s. The grid-following loop is regulating against a "
|
||||
"number that has stopped moving, and nothing in this stack stops "
|
||||
"it: the meter is still answering, so the age is fresh, the "
|
||||
"firmware watchdog is fed and the arbiter only bounds the "
|
||||
"magnitude. CHECK THE METER. Sustained for a quarter of an hour "
|
||||
"this sets the month's billed capacity-tariff peak. This is an "
|
||||
"ALARM ONLY - the loop is still running.", self.freeze_wd.reason)
|
||||
"The meter shows the same value while the battery is asked for "
|
||||
"full power: %s. Control still follows it, so check the meter.",
|
||||
self.freeze_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-18: nothing else stops this. The meter still answers, so "
|
||||
"its age is fresh, the firmware watchdog is fed and the arbiter "
|
||||
"only bounds the magnitude. Kept up for a quarter of an hour, it "
|
||||
"sets the month's billed capacity peak.")
|
||||
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
|
||||
# rather than stacking. An operator who has to dismiss eleven identical
|
||||
# notifications learns to dismiss the twelfth without reading it.
|
||||
@@ -4168,12 +4216,9 @@ class Controller:
|
||||
and standing is not None and standing.from_feed)
|
||||
if keep:
|
||||
_LOG.warning(
|
||||
"tariff refresh degraded (%s); KEEPING the plan "
|
||||
"resolved at %s, %.0f s ago. tariff_at is NOT moved, "
|
||||
"so it retires %.0f s after that fetch; arbitrage, "
|
||||
"where it is armed, stops with it. A kept plan is "
|
||||
"never presented as a current one.", plan.words,
|
||||
self.tariff_at,
|
||||
"The price update was incomplete (%s), so the plan "
|
||||
"from %s, %.0f s ago, is kept until it is %.0f s old.",
|
||||
plan.words, self.tariff_at,
|
||||
(now - self.tariff_at).total_seconds(),
|
||||
TARIFF_MAX_AGE_S)
|
||||
else:
|
||||
@@ -4185,9 +4230,9 @@ class Controller:
|
||||
# here is something nobody expected, and the plan is kept for
|
||||
# the same reason and on the same terms.
|
||||
_LOG.warning(
|
||||
"tariff refresh raised (%s); keeping the plan resolved at "
|
||||
"%s. It retires on its own after %.0f s and arbitrage "
|
||||
"stops with it.", err, self.tariff_at, TARIFF_MAX_AGE_S)
|
||||
"The price update failed (%s), so the plan from %s is kept "
|
||||
"until it is %.0f s old, and trading stops then.",
|
||||
err, self.tariff_at, TARIFF_MAX_AGE_S)
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(self.stop_event.wait(),
|
||||
timeout=TARIFF_REFRESH_S)
|
||||
@@ -4239,7 +4284,8 @@ class Controller:
|
||||
if problems:
|
||||
refuse(problems)
|
||||
self.refused = problems
|
||||
self.log_event("REFUSED: " + problems[0])
|
||||
self.log_event("REFUSED: " + problems[0],
|
||||
hint=messages.hint("refused"))
|
||||
# Signal, then return WITHOUT entering the loop. amain() wakes
|
||||
# on this, runs the normal wind-down (which commands 0 W) and
|
||||
# re-raises ConfigRefused afterwards, so the exit code is 78 and
|
||||
@@ -4646,21 +4692,28 @@ class Controller:
|
||||
# a normal state on the 1st; "unreadable" is a broken instrument, and
|
||||
# a status page that spelled them the same way would let a 0 W read
|
||||
# look like a quiet month right up until SAFETY-07 shaved nothing.
|
||||
# UX-08: in plain words. The store's state at start is on the INFO
|
||||
# line `capacity-tariff peak store: ...` and `_pk.explain()` on the
|
||||
# log; the coverage figure (TARIFF-13) is a row of its own.
|
||||
_pk = self.peak.peak(when)
|
||||
if _pk.is_unreadable:
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": "capacity-tariff peak: " + _pk.explain()})
|
||||
out.append({"ok": False, "warn": True, "id": "month_peak",
|
||||
"text": messages.row_month_peak_unreadable()})
|
||||
else:
|
||||
# §9.1's "warning entity at 80 %", as a status row rather than
|
||||
# a second entity - see the constructor on not doubling up.
|
||||
_frac = self.peak.warning_frac(when) or 0.0
|
||||
_ok = _frac < 0.8
|
||||
out.append({"ok": _ok, "warn": not _ok,
|
||||
"text": _pk.explain()
|
||||
+ f" - {_frac * 100:.0f} % of the "
|
||||
f"{self.peak.cap_w:.0f} W soft limit"
|
||||
+ f", {self.peak.recorded} quarter(s) banked"
|
||||
+ f", on start: {self.peak.restore_state}"})
|
||||
out.append({"ok": _ok, "warn": not _ok, "id": "month_peak",
|
||||
"text": (messages.row_month_peak_none()
|
||||
if not _pk.is_observed else
|
||||
messages.row_month_peak(
|
||||
_pk.peak_w,
|
||||
brussels(_pk.quarter_start),
|
||||
_frac))})
|
||||
out.append({"ok": True, "warn": False, "id": "periods_measured",
|
||||
"text": messages.row_periods_measured(
|
||||
self.peak.recorded)})
|
||||
|
||||
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
|
||||
# are opposite states and a page that spelled them the same way
|
||||
@@ -4715,10 +4768,16 @@ class Controller:
|
||||
else ()))})
|
||||
elif _pg.blind:
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": _pg.explain()})
|
||||
"text": messages.row_peak_guard_blind(
|
||||
peakguard.blind_kind(_pg.blind))})
|
||||
elif _pg.engaged:
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_on(
|
||||
_pg.floor_w or 0.0, _pg.ceiling_w,
|
||||
shadow=self.shadow)})
|
||||
else:
|
||||
out.append({"ok": not _pg.engaged, "warn": _pg.engaged,
|
||||
"id": "peak_guard", "text": _pg.explain()})
|
||||
out.append({"ok": True, "warn": False, "id": "peak_guard",
|
||||
"text": _pg.explain()})
|
||||
else:
|
||||
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point.
|
||||
# SAFETY-07 is NOT COVERED on the legacy single-entity path - the
|
||||
@@ -4913,7 +4972,7 @@ class Controller:
|
||||
# watching something that cannot happen. Say what is true instead.
|
||||
out.append({"ok": True, "warn": False, "id": "circulating",
|
||||
"text": messages.row_one_inverter(
|
||||
"Circulating power check")})
|
||||
messages.CHECK_CIRCULATING)})
|
||||
else:
|
||||
held = self.circ_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False, "id": "circulating",
|
||||
@@ -5079,7 +5138,7 @@ class Controller:
|
||||
# the row's first sentence, so it stays a headline.
|
||||
level, banner = "bad", messages.banner_not_ready(bad[0])
|
||||
elif not self.auto:
|
||||
level, banner = "warn", "Stopped — inverter commanded to 0 W"
|
||||
level, banner = "warn", messages.banner_stopped()
|
||||
elif any(i.delivery_wd.tripped for i in self.inverters):
|
||||
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
|
||||
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
|
||||
@@ -5137,10 +5196,11 @@ class Controller:
|
||||
# question it answers is the one those two cannot: at "Grid 0 W,
|
||||
# Battery 0 W" the site is either becalmed or throwing away every
|
||||
# watt it makes, and only this row separates them.
|
||||
*(() if not self.pv_roster else ((("PV", w(self.pv_w)),))),
|
||||
("State of charge", "—" if self.pack_floor_soc is None
|
||||
*(() if not self.pv_roster else ((("Solar production",
|
||||
w(self.pv_w)),))),
|
||||
("Battery level", "—" if self.pack_floor_soc is None
|
||||
else f"{self.pack_floor_soc:g} %"),
|
||||
("Commanded", w(self.target)),
|
||||
("Power request", w(self.target)),
|
||||
# SAFETY-10, and it sits BETWEEN the two rows it reconciles.
|
||||
# "Commanded 0 W" above and a "Why" below that explains a trade is
|
||||
# the exact pair an operator would otherwise have to guess at; this
|
||||
@@ -5155,8 +5215,8 @@ class Controller:
|
||||
# - the page runs on its own task and `self.peak_guard` has
|
||||
# moved on. See `Controller.__init__`.
|
||||
sized_from_meter=self.shadow_sized_from_meter,
|
||||
reason=self.reason)),
|
||||
("Why", self.reason),
|
||||
reason=self.why_words)),
|
||||
("Why", self.why_words),
|
||||
("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
|
||||
@@ -5195,11 +5255,11 @@ class Controller:
|
||||
("Arbitrage", arbitrage_row(
|
||||
on=arbitrage.enabled(self.o), decision=self.arb,
|
||||
decided_at=self.arb_at, cycle_at=self.cycle_at,
|
||||
now=datetime.now(timezone.utc), why=self.reason)),
|
||||
now=datetime.now(timezone.utc), why=self.why_words)),
|
||||
]
|
||||
return {
|
||||
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
||||
"hint": self.events[0] if self.events else "",
|
||||
"hint": self.hint,
|
||||
}
|
||||
|
||||
async def handle_action(self, what: str):
|
||||
@@ -5207,7 +5267,9 @@ class Controller:
|
||||
if what == "auto_toggle":
|
||||
self.auto = not self.auto
|
||||
self.store.set("auto", self.auto)
|
||||
self.log_event("control started" if self.auto else "control stopped")
|
||||
self.log_event("control started" if self.auto else "control stopped",
|
||||
hint=messages.hint("control_started" if self.auto
|
||||
else "control_stopped"))
|
||||
if self.auto:
|
||||
# ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose.
|
||||
# It clears on a deliberate human start, not on the condition
|
||||
@@ -5230,7 +5292,9 @@ class Controller:
|
||||
inv.store_set("sign_tripped", False)
|
||||
inv.store_set("sign_reason", "")
|
||||
self.log_event(f"sign-disagreement failsafe cleared by "
|
||||
f"operator on {inv.name}")
|
||||
f"operator on {inv.name}",
|
||||
hint=messages.hint("stop_cleared",
|
||||
unit=inv.name))
|
||||
# ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, UNCONDITIONALLY.
|
||||
# This is what makes `flat True + namespaced False` MEAN
|
||||
# something, and it is the reason `_grade_store_migration()` can
|
||||
@@ -5271,7 +5335,8 @@ class Controller:
|
||||
# moment a second inverter was configured again.
|
||||
if self.site_sign_wd.tripped:
|
||||
self.log_event("site sign-disagreement failsafe cleared "
|
||||
"by operator")
|
||||
"by operator",
|
||||
hint=messages.hint("site_stop_cleared"))
|
||||
self.site_sign_wd.reset()
|
||||
self.store.set("site_sign_tripped", False)
|
||||
self.store.set("site_sign_reason", "")
|
||||
@@ -5289,7 +5354,8 @@ class Controller:
|
||||
self.circ_wd.reset()
|
||||
self.store.set("circ_tripped", False)
|
||||
self.store.set("circ_reason", "")
|
||||
self.log_event("circulating-power trip cleared by operator")
|
||||
self.log_event("circulating-power trip cleared by operator",
|
||||
hint=messages.hint("site_stop_cleared"))
|
||||
self._circ_announced = False
|
||||
# SAFETY-42, cleared by the SAME press, and for a reason that is
|
||||
# NOT the one above. The other three latches are cleared here
|
||||
@@ -5307,7 +5373,10 @@ class Controller:
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
self.log_event(f"not-delivering report on {inv.name} "
|
||||
f"cleared by operator")
|
||||
f"cleared by operator",
|
||||
hint=messages.hint(
|
||||
"not_delivering_cleared",
|
||||
unit=inv.name))
|
||||
inv.delivery_wd.reset()
|
||||
inv.store_set("delivery_tripped", False)
|
||||
inv.store_set("delivery_reason", "")
|
||||
@@ -5333,14 +5402,18 @@ class Controller:
|
||||
# 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)
|
||||
_m = self._maint_button(now)
|
||||
for msg in _m.force_start(now):
|
||||
self.log_event(msg, hint=messages.hint("maintenance",
|
||||
phase=_m.phase))
|
||||
return {"phase": self.maint.phase}
|
||||
if what == "maint_abort":
|
||||
# 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)
|
||||
_m = self._maint_button(now)
|
||||
for msg in _m.abort(now, "operator"):
|
||||
self.log_event(msg, hint=messages.hint("maintenance",
|
||||
phase=_m.phase))
|
||||
await self.command_site_zero()
|
||||
return {"phase": self.maint.phase}
|
||||
return None
|
||||
@@ -5362,8 +5435,9 @@ def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
|
||||
if stop.is_set() or controller.stopping:
|
||||
return # the normal wind-down, or run_control()'s refusal path
|
||||
exc = None if task.cancelled() else task.exception()
|
||||
_LOG.critical("CONTROL LOOP DIED (%s) - commanding 0 W and exiting with "
|
||||
"status %d", "cancelled" if task.cancelled() else
|
||||
_LOG.critical("Control stopped unexpectedly (%s), so every inverter is "
|
||||
"set to 0 W and the add-on exits with code %d.",
|
||||
"cancelled" if task.cancelled() else
|
||||
(repr(exc) if exc else "returned"), EXIT_CONTROL_DIED,
|
||||
exc_info=exc)
|
||||
died.append(exc)
|
||||
@@ -5381,21 +5455,33 @@ async def docs_link_facts(session, token: str) -> dict:
|
||||
Asked together with a 3 s budget: a link may never hold up control start.
|
||||
"""
|
||||
async def ask(url, field):
|
||||
"""`(value, why)`: the field, or None and a short reason (UX-09)."""
|
||||
try:
|
||||
async with session.get(url, timeout=DOCS_LINK_TIMEOUT_S, headers={
|
||||
"Authorization": f"Bearer {token}"}) as r:
|
||||
body = await r.json(content_type=None)
|
||||
data = body.get("data", body) if isinstance(body, dict) else {}
|
||||
return data.get(field) if isinstance(data, dict) else None
|
||||
value = data.get(field) if isinstance(data, dict) else None
|
||||
return value, (None if value else f"no {field} in its answer")
|
||||
except Exception as err: # noqa: BLE001 - a link, never a stop
|
||||
_LOG.info("docs link: %s unreadable (%s)", url, err)
|
||||
return None
|
||||
# A timeout's own text is empty, so the type is always named.
|
||||
text = str(err).strip()
|
||||
return None, (f"{type(err).__name__}: {text}" if text
|
||||
else type(err).__name__)
|
||||
|
||||
slug, ha = await asyncio.gather(
|
||||
(slug, why_slug), (ha, why_ha) = await asyncio.gather(
|
||||
ask("http://supervisor/addons/self/info", "slug"),
|
||||
ask("http://supervisor/info", "homeassistant"))
|
||||
out = {"slug": slug, "ha_version": ha}
|
||||
_LOG.info("docs link: slug %s, Home Assistant %s", slug, ha)
|
||||
# ONE line, whatever happened: the link is a convenience, not a fault.
|
||||
if slug and ha:
|
||||
_LOG.info("Documentation link: add-on %s, Home Assistant %s", slug, ha)
|
||||
else:
|
||||
missed = [f"{what} ({why})" for what, why in
|
||||
(("add-on name", why_slug), ("Home Assistant version", why_ha))
|
||||
if why]
|
||||
_LOG.info("Documentation link: the Supervisor gave no %s, so the "
|
||||
"link is guessed or left out.", " and no ".join(missed))
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ per title; the body ends with a link to it.
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
# Severity (STYLE.md section 3). An ALARM may use up to three sentences so
|
||||
# that no action a person must take is lost; everything else gets one.
|
||||
@@ -335,13 +336,13 @@ def banner_circulating() -> str:
|
||||
|
||||
|
||||
def banner_site_direction() -> str:
|
||||
return "Total command and meter point opposite ways. All inverters at 0 W."
|
||||
return "Total power went the wrong way. All inverters at 0 W."
|
||||
|
||||
|
||||
def banner_direction(several: bool) -> str:
|
||||
if several:
|
||||
return "An inverter's own power opposes its command. It is at 0 W."
|
||||
return "Command and meter point opposite ways. Inverter set to 0 W."
|
||||
return "An inverter's own power went the wrong way. Set to 0 W."
|
||||
return "The meter saw power go the wrong way. Inverter at 0 W."
|
||||
|
||||
|
||||
def banner_not_delivering(units) -> str:
|
||||
@@ -350,6 +351,10 @@ def banner_not_delivering(units) -> str:
|
||||
return f"{names(units)} {verb} not delivering. Control keeps running."
|
||||
|
||||
|
||||
def banner_stopped() -> str:
|
||||
return "Stopped — the inverters are set to 0 W"
|
||||
|
||||
|
||||
def banner_shadow(latched: bool) -> str:
|
||||
"""SAFETY-10: first in the cascade, never displaced by another banner.
|
||||
|
||||
@@ -502,9 +507,15 @@ def row_commands_refused(name, held_s, limit_s) -> str:
|
||||
|
||||
|
||||
def row_control(lo_w, hi_w, unit=None) -> str:
|
||||
def w(v):
|
||||
return "?" if v is None else signed_watts(v)
|
||||
return f"Inverter control{for_unit(unit)}: ready, from {w(lo_w)} to {w(hi_w)}"
|
||||
"""The inverter's own range: charging below 0 W, giving power above it."""
|
||||
head = f"Inverter control{for_unit(unit)}: ready"
|
||||
if lo_w is None or hi_w is None or lo_w > 0 or hi_w < 0:
|
||||
def w(v):
|
||||
return "?" if v is None else signed_watts(v)
|
||||
return f"{head}, from {w(lo_w)} to {w(hi_w)}"
|
||||
if abs(float(lo_w)) == float(hi_w):
|
||||
return f"{head}, up to {watts(hi_w)} each way"
|
||||
return f"{head}, {watts(lo_w)} charging, {watts(hi_w)} giving"
|
||||
|
||||
|
||||
# Rows: the two loop alarms. The names are the notification titles, so the
|
||||
@@ -541,9 +552,9 @@ def row_write_refused(unit=None) -> str:
|
||||
# Rows: the direction checks (SAFETY-02/30).
|
||||
def row_direction_trip(unit=None) -> str:
|
||||
"""Per inverter. `unit` only at several inverters, graded on its own power."""
|
||||
what = (f"{unit}: command and its own power point opposite ways, so it is"
|
||||
if unit else "Command and meter point opposite ways, so the "
|
||||
"inverter is")
|
||||
what = (f"{unit}'s own power went against what the add-on asked, so it is"
|
||||
if unit else
|
||||
"Grid power went against what the add-on asked, so the inverter is")
|
||||
return (f"{what} set to 0 W. Find out why, then start control. A restart "
|
||||
f"does not clear this.")
|
||||
|
||||
@@ -557,9 +568,9 @@ def row_direction_check(held_s, hold_s, unit=None) -> str:
|
||||
|
||||
|
||||
def row_site_direction_trip() -> str:
|
||||
return ("Total command and meter point opposite ways, so every inverter is "
|
||||
"set to 0 W. The cause is the total, not one inverter. Only starting "
|
||||
"control clears this.")
|
||||
return ("Grid power went against the add-on's total request, so every "
|
||||
"inverter is set to 0 W. The cause is the total, not one inverter. "
|
||||
"Only starting control clears this.")
|
||||
|
||||
|
||||
def row_site_direction_check(held_s, hold_s) -> str:
|
||||
@@ -575,10 +586,15 @@ def row_circulating_trip() -> str:
|
||||
"0 W. The meter cannot see this. Only starting control clears this.")
|
||||
|
||||
|
||||
CHECK_CIRCULATING = "Check for inverters working against each other"
|
||||
|
||||
|
||||
def row_circulating_check(now_w, held_s, hold_s) -> str:
|
||||
return ("Circulating power check: on"
|
||||
+ (f", now {watts(now_w)}" if now_w else "")
|
||||
+ (f", for {held_s:.0f} of {seconds(hold_s)}" if held_s else ""))
|
||||
if held_s:
|
||||
return (f"{CHECK_CIRCULATING}: {watts(now_w or 0)} for {held_s:.0f} of "
|
||||
f"{seconds(hold_s)}")
|
||||
return (f"{CHECK_CIRCULATING}: on"
|
||||
+ (f", now {watts(now_w)}" if now_w else ""))
|
||||
|
||||
|
||||
def row_one_inverter(check: str) -> str:
|
||||
@@ -676,6 +692,184 @@ def row_pv_stray(n) -> str:
|
||||
f"entity in use")
|
||||
|
||||
|
||||
# Rows: this month's peak and the peak guard (UX-08 phase 2d). The detail
|
||||
# (the projection's reason, the store's state at start) is in the add-on log.
|
||||
def row_month_peak(peak_w, at, frac) -> str:
|
||||
"""`at` is the local start of the 15-minute period that set the peak."""
|
||||
return (f"Month peak: {watts(peak_w)} on {at.day} {at:%b} at {at:%H:%M}, "
|
||||
f"{frac * 100:.0f} % of the peak limit")
|
||||
|
||||
|
||||
def row_month_peak_none() -> str:
|
||||
return "Month peak: none recorded yet this month"
|
||||
|
||||
|
||||
def row_month_peak_unreadable() -> str:
|
||||
"""The store did not answer. This is not a zero peak (TARIFF-01)."""
|
||||
return "Month peak: cannot be read, so only the peak limit is used"
|
||||
|
||||
|
||||
def row_periods_measured(n) -> str:
|
||||
"""TARIFF-13's coverage figure. `n` is an int, or a text when unreadable."""
|
||||
shown = n if isinstance(n, int) else "cannot be read"
|
||||
return f"15-minute periods measured this month: {shown}"
|
||||
|
||||
|
||||
def row_peak_guard_idle(projected_w, limit_w, month_peak: bool) -> str:
|
||||
"""The projection first and the limit second (SAFETY-38's ordered pair)."""
|
||||
limit = (f"this month's {watts(limit_w)} peak" if month_peak
|
||||
else f"the {watts(limit_w)} peak limit")
|
||||
return f"Peak guard: idle, heading for {watts(projected_w)}, under {limit}"
|
||||
|
||||
|
||||
def row_peak_guard_quiet() -> str:
|
||||
return "Peak guard: idle"
|
||||
|
||||
|
||||
BLIND_WORDS = {
|
||||
"no_block": "waiting for meter readings",
|
||||
"gap": "meter readings have a gap",
|
||||
"frozen": "meter reading is stuck",
|
||||
}
|
||||
|
||||
|
||||
def row_peak_guard_blind(why: str) -> str:
|
||||
"""`why` is a key of BLIND_WORDS. The battery may give power, not charge."""
|
||||
return (f"Peak guard: {BLIND_WORDS[why]}, so the battery does not charge "
|
||||
f"meanwhile")
|
||||
|
||||
|
||||
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
|
||||
if shadow:
|
||||
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
|
||||
f"{watts(limit_w)}")
|
||||
return (f"Peak guard: asks the battery for {watts(floor_w)} to stay under "
|
||||
f"{watts(limit_w)}")
|
||||
|
||||
|
||||
def row_battery_stuck(unit) -> str:
|
||||
"""SAFETY-37/56. A fact, not a verdict: the reading may be stuck, or the
|
||||
battery may be refusing, and the add-on cannot tell which."""
|
||||
return (f"{unit}: battery power reading stopped changing, so it is not used "
|
||||
f"for now")
|
||||
|
||||
|
||||
# The top of the page: why the battery does what it does (the "Why" line).
|
||||
WHY_WINNER = {
|
||||
"loop": "Keeping the grid near zero",
|
||||
"peak": "Keeping under the peak limit",
|
||||
"arbitrage": "Trading on the price",
|
||||
"maintenance": "Battery maintenance",
|
||||
"failsafe": "Safety stop, power went the wrong way",
|
||||
"sign_site": "Safety stop, total power went the wrong way",
|
||||
"circulation": "Safety stop, inverters worked against each other",
|
||||
"stopped": "Control is stopped",
|
||||
"inputs missing": "A reading is missing",
|
||||
"no claim was made": "Nothing asked for power",
|
||||
}
|
||||
WHY_LIMIT = {
|
||||
"peak(quarter peak)": "the peak limit",
|
||||
"peak(blind)": "no charging while the meter is unclear",
|
||||
"peak(SoC floor)": "the peak reserve",
|
||||
"supervised": RATING,
|
||||
"device": "the inverter's own limit",
|
||||
"pack": "the battery level",
|
||||
"reserve": "the battery reserve",
|
||||
"maintenance": "battery maintenance",
|
||||
}
|
||||
|
||||
|
||||
def why(winner, target_w, bound_by=None, *, contradiction=False) -> str:
|
||||
"""`winner` is the arbiter's winning source, or its reason for the two
|
||||
that share a source ("stopped", "inputs missing", "no claim was made").
|
||||
`bound_by` is `Resolution.bound_by`, e.g. "peak(quarter peak)"."""
|
||||
if contradiction:
|
||||
return "Two limits clash, so every inverter is set to 0 W"
|
||||
out = f"{WHY_WINNER.get(winner, 'Other')}: {signed_watts(target_w)}"
|
||||
if bound_by:
|
||||
limit = WHY_LIMIT.get(bound_by) or WHY_LIMIT.get(
|
||||
str(bound_by).split("(")[0], "another limit")
|
||||
out += f", held back by {limit}"
|
||||
return out
|
||||
|
||||
|
||||
def why_waiting(missing_s) -> str:
|
||||
"""A reading went missing a moment ago: control holds and waits."""
|
||||
return f"A reading is missing for {seconds(missing_s)}, control waits"
|
||||
|
||||
|
||||
# The last thing that happened, shown under the buttons. The add-on keeps its
|
||||
# own technical record of each event (`Controller.events`); the page shows
|
||||
# these words instead. Every `log_event()` call names one (test_messages.py).
|
||||
HINTS = {
|
||||
"control_started": "Control started",
|
||||
"control_stopped": "Control stopped",
|
||||
"settings_saved": "Settings saved, they apply after a restart",
|
||||
"restart_requested": "Restart requested from the settings page",
|
||||
"roster": "Inverter list read: {n} inverters",
|
||||
"maint_history": "Maintenance history did not move over cleanly",
|
||||
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
|
||||
"battery_stuck": "{unit}: battery power reading stopped changing",
|
||||
"battery_back": "{unit}: battery power reading is used again",
|
||||
"bad_value": "The add-on made an invalid power value, inverters set to 0 W",
|
||||
"write_blocked": "{unit}: a power request was blocked, it is set to 0 W",
|
||||
"all_blocked": "A power request was blocked, inverters set to 0 W",
|
||||
"write_cut": "{unit}: " + T_OUTPUT_CLAMP.lower(),
|
||||
"write_slow": "{unit}: the inverter answered too slowly, trying again",
|
||||
"direction": "{unit}: power went the wrong way, it is set to 0 W",
|
||||
"site_direction": "Total power went the wrong way, inverters set to 0 W",
|
||||
"circulating": T_CIRCULATING + ", all set to 0 W",
|
||||
"not_delivering": T_NOT_DELIVERING,
|
||||
"peak_on": "Peak guard started",
|
||||
"peak_off": "Peak guard idle",
|
||||
"peak_blind": "Peak guard cannot read the meter well",
|
||||
"peak_saturated": T_PEAK_SATURATED,
|
||||
"peak_reserve": T_PEAK_RESERVE,
|
||||
"meter_frozen": T_METER_FROZEN,
|
||||
"power_too_high": T_OUTPUT_CLAMP,
|
||||
"not_keeping_up": T_LOOP_BOUND,
|
||||
"trade_charge": "Trading: charging while the price is low",
|
||||
"trade_give": "Trading: giving power while the price is high",
|
||||
"trade_off": "Trading: waiting for a better price",
|
||||
"split": "The power split between inverters changed",
|
||||
"clash": "Two limits clash, inverters set to 0 W",
|
||||
"no_setpoint": "An inverter has no “Setpoint entity”, inverters stay at 0 W",
|
||||
"unit_dropped": "{unit} left out of control, the others take its share",
|
||||
"units_back": "Every inverter is back in control",
|
||||
"unmet": "The inverters cannot give {w} of the power asked",
|
||||
"unmet_gone": "The inverters can give all the power asked again",
|
||||
"shadow_on": "Shadow mode on, inverters stay at 0 W",
|
||||
"shadow_off": "Shadow mode off, the add-on controls the battery",
|
||||
"shadow_decision": "Shadow mode: would ask the battery for {sw}",
|
||||
"refused": "Settings refused, the add-on log shows why",
|
||||
"inputs_missing": "A reading is missing, inverters set to 0 W",
|
||||
"inputs_back": "All readings are back",
|
||||
"stop_cleared": "{unit}: safety stop cleared by starting control",
|
||||
"site_stop_cleared": "Safety stop cleared by starting control",
|
||||
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
|
||||
"maintenance": "Battery maintenance: {phase}",
|
||||
"maint_paused": "Maintenance charging paused to stay under the peak limit",
|
||||
}
|
||||
|
||||
|
||||
def hint(key: str, **kw) -> str:
|
||||
"""The plain words for one event. `w` is formatted as watts, `sw` as
|
||||
watts with their sign (positive gives power, negative charges)."""
|
||||
if "w" in kw:
|
||||
kw["w"] = watts(kw["w"])
|
||||
if "sw" in kw:
|
||||
kw["sw"] = signed_watts(kw["sw"])
|
||||
return HINTS[key].format(**kw)
|
||||
|
||||
|
||||
def hint_samples() -> list:
|
||||
"""`(key, text)` for every hint, rendered, for the test."""
|
||||
kw = {"n": 2, "unit": "inverter-b", "w": 1200, "sw": -1200,
|
||||
"phase": "drain"}
|
||||
return [(k, hint(k, **{f: kw[f] for f in kw if "{" + f + "}" in v}))
|
||||
for k, v in HINTS.items()]
|
||||
|
||||
|
||||
RED, AMBER, GREEN = "bad", "warn", "ok"
|
||||
|
||||
|
||||
@@ -769,6 +963,24 @@ def status_samples() -> list:
|
||||
("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)),
|
||||
("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
|
||||
("row_pv_stray", AMBER, row_pv_stray(2)),
|
||||
("banner_stopped", None, banner_stopped()),
|
||||
("row_control", GREEN, row_control(-4000, 5000, "inverter-b")),
|
||||
("row_month_peak", GREEN, row_month_peak(
|
||||
4998, datetime(2026, 8, 12, 19, 15), 0.83)),
|
||||
("row_month_peak_none", GREEN, row_month_peak_none()),
|
||||
("row_month_peak_unreadable", AMBER, row_month_peak_unreadable()),
|
||||
("row_periods_measured", GREEN, row_periods_measured(120)),
|
||||
("row_periods_measured", GREEN, row_periods_measured("unreadable")),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
|
||||
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
|
||||
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
|
||||
for k in BLIND_WORDS),
|
||||
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
|
||||
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500,
|
||||
shadow=True)),
|
||||
("row_battery_stuck", AMBER, row_battery_stuck("inverter-b")),
|
||||
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
|
||||
]
|
||||
|
||||
|
||||
@@ -843,6 +1055,27 @@ LABELS = {
|
||||
}
|
||||
|
||||
|
||||
def shown(value) -> str:
|
||||
"""A setting's value inside a refusal: `'abc'`, `5`, or `empty` for a value
|
||||
that is missing or blank, never Python's `None` (UX-08 2d)."""
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
return "empty"
|
||||
return repr(value)
|
||||
|
||||
|
||||
def not_valid(option: str, err) -> str:
|
||||
"""A setting whose text does not parse, without the parser's own words:
|
||||
`static_profile is not valid near character 6` (UX-08 2d)."""
|
||||
pos = getattr(err, "pos", None)
|
||||
if isinstance(pos, int):
|
||||
return f"{option} is not valid near character {pos + 1}"
|
||||
return f"{option} is not valid"
|
||||
|
||||
|
||||
# The units a formula's "units" field may name, as a reader writes them.
|
||||
UNIT_WORDS = {"c_eur_per_kwh": "c/kWh", "eur_per_kwh": "EUR/kWh"}
|
||||
|
||||
|
||||
def setting(key: str, unit: str = "") -> str:
|
||||
"""`max_w` -> `“Inverter rating” (max_w)`. For one inverter in a list it is
|
||||
`“Inverter rating” for inverter-b (inverters.max_w)`, so the config panel
|
||||
|
||||
@@ -472,7 +472,7 @@ class P1Ingest:
|
||||
"""
|
||||
self.parse_errors += 1
|
||||
self.last_error = str(err)
|
||||
_LOG.warning("P1 telegram rejected: %s", err)
|
||||
_LOG.warning("Meter reading refused: %s", err)
|
||||
|
||||
# -- what consumers read -------------------------------------------------
|
||||
def age_s(self) -> float | None:
|
||||
@@ -1167,7 +1167,7 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
|
||||
problems.append(
|
||||
f"{_S('meter_source')} is {SOURCE_HA!r}, so it needs "
|
||||
f"{_S('p1_import_entity')} and {_S('p1_export_entity')}. They "
|
||||
f"are {imp or '<blank>'!r} and {exp or '<blank>'!r}.")
|
||||
f"are {messages.shown(imp)} and {messages.shown(exp)}.")
|
||||
elif source == SOURCE_HA_SIGNED:
|
||||
net = str(opts.get("p1_net_entity", "") or "").strip()
|
||||
phase_net = [str(e).strip() for e in
|
||||
@@ -1194,7 +1194,7 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
|
||||
try:
|
||||
int(port_s)
|
||||
except ValueError:
|
||||
problems.append(f"{_S('p1_host')} is {opts.get('p1_host')!r}, and "
|
||||
problems.append(f"{_S('p1_host')} is {messages.shown(opts.get('p1_host'))}, and "
|
||||
"its port is not a number.")
|
||||
try:
|
||||
# ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a
|
||||
@@ -1204,7 +1204,7 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
|
||||
poll_s = float(5 if raw is None or raw == "" else raw)
|
||||
except (TypeError, ValueError):
|
||||
problems.append(f"{_S('meter_poll_s')} is "
|
||||
f"{opts.get('meter_poll_s')!r}, which is not a "
|
||||
f"{messages.shown(opts.get('meter_poll_s'))}, which is not a "
|
||||
"number.")
|
||||
else:
|
||||
if poll_s <= 0:
|
||||
@@ -1269,9 +1269,8 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
|
||||
# 0 W cycle, which is fail-safe. The refusal above is for cadences that
|
||||
# can NEVER be fresh.
|
||||
if poll_s > ingest.max_age_s / 2:
|
||||
_LOG.warning("meter_poll_s %.1f leaves no room under meter_max_age_s "
|
||||
"%.1f: one missed poll makes the reading stale. The meter "
|
||||
"updates every ~5 s; 5 s against 30 s is the tested pair.",
|
||||
_LOG.warning("“Time between meter reads” (%.1f s) is too close to "
|
||||
"“Reading too old after” (%.1f s). Use 5 s and 30 s.",
|
||||
poll_s, ingest.max_age_s)
|
||||
return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s)
|
||||
if source == SOURCE_MQTT:
|
||||
|
||||
@@ -395,6 +395,7 @@ not need re-testing. Nothing here indexes a phase.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import messages
|
||||
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
|
||||
from .control import peak_at_risk
|
||||
from .tariff import QUARTER_S
|
||||
@@ -485,17 +486,29 @@ class PeakDecision:
|
||||
reason: str = ""
|
||||
|
||||
def explain(self) -> str:
|
||||
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
|
||||
technical record with every figure, for the log and the action log."""
|
||||
if self.floor_limited:
|
||||
return self.reason
|
||||
if not self.engaged:
|
||||
if self.projected_w is None:
|
||||
return f"peak guard idle - {self.reason}"
|
||||
return (f"peak guard idle - quarter projects "
|
||||
f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W "
|
||||
f"ceiling ({self.ceiling_from})")
|
||||
return messages.row_peak_guard_quiet()
|
||||
return messages.row_peak_guard_idle(
|
||||
self.projected_w, self.ceiling_w,
|
||||
month_peak=self.ceiling_from == CEILING_MONTH_PEAK)
|
||||
return self.reason
|
||||
|
||||
|
||||
def blind_kind(blind: str) -> str:
|
||||
"""Which of the three kinds of blindness, as a key of
|
||||
`messages.BLIND_WORDS`, for the status row."""
|
||||
if blind == BLIND_FROZEN:
|
||||
return "frozen"
|
||||
if blind == BLIND_NO_BLOCK:
|
||||
return "no_block"
|
||||
return "gap"
|
||||
|
||||
|
||||
def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
|
||||
"""The average this quarter may not exceed, and where the number came from.
|
||||
|
||||
|
||||
@@ -1314,8 +1314,8 @@ def code_leaks(cls) -> list:
|
||||
src = dedent(inspect.getsource(cls))
|
||||
tree = ast.parse(src)
|
||||
except (OSError, TypeError, IndentationError, SyntaxError) as e:
|
||||
_LOG.warning("could not read %s's source for the framework-key scan "
|
||||
"(%s); the double-counting guard is NOT enforced for it",
|
||||
_LOG.warning("Could not read the code of price source %s (%s), so its "
|
||||
"double-counting check is skipped.",
|
||||
getattr(cls, "id", cls), e)
|
||||
return []
|
||||
for node in ast.walk(tree):
|
||||
@@ -1718,9 +1718,8 @@ def _points_from_columnar_json(body, expect_unit, scale,
|
||||
# Not a refusal: today's prices are still prices. But §11.4 makes a
|
||||
# dead feed look exactly like a quiet one, so the warning that the
|
||||
# source is going away has to be emitted while it still works.
|
||||
_LOG.warning("the price feed reports deprecated=%r; it still answers, "
|
||||
"but this endpoint is scheduled to stop",
|
||||
doc.get("deprecated"))
|
||||
_LOG.warning("The price feed says it will stop (deprecated=%r), "
|
||||
"though it still answers for now.", doc.get("deprecated"))
|
||||
out = []
|
||||
dropped = []
|
||||
for i, (t, v) in enumerate(zip(times, prices)):
|
||||
@@ -1739,9 +1738,8 @@ def _points_from_columnar_json(body, expect_unit, scale,
|
||||
out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale,
|
||||
provisional=bool(provisional_default)))
|
||||
if dropped:
|
||||
_LOG.warning("the feed published no price for %d of %d instants "
|
||||
"(index %s); those instants are left OUT of the series, "
|
||||
"never zero-filled - a missing hour is not a free hour",
|
||||
_LOG.warning("The price feed gave no price for %d of %d times (index "
|
||||
"%s). Those times are left out, not counted as free.",
|
||||
len(dropped), len(times),
|
||||
", ".join(str(i) for i in dropped[:5])
|
||||
+ (", ..." if len(dropped) > 5 else ""))
|
||||
@@ -1790,16 +1788,16 @@ def dayahead_feed_url(plugin_id, opts, since, until, base, bzn) -> str:
|
||||
override = str(opts.get("price_feed_url") or "").strip()
|
||||
if override:
|
||||
_LOG.warning(
|
||||
"price_feed_url is set, so %s is requesting %s exactly as "
|
||||
"given and is adding NO date range to it. The public feed "
|
||||
"answers a single Brussels day to a request that names no "
|
||||
"range, and every hour past that day is then carried forward "
|
||||
"from yesterday's profile and flagged provisional - §8.3 "
|
||||
"drops provisional hours from the trade set, so arbitrage "
|
||||
"would never see tomorrow as firm. Clear price_feed_url to "
|
||||
"let this plugin ask for the %s..%s it was given.",
|
||||
"price_feed_url is set, so %s asks for %s as given, and "
|
||||
"tomorrow's prices may stay provisional. Clear it to ask for %s "
|
||||
"to %s.",
|
||||
plugin_id, override, *(d.isoformat()
|
||||
for d in brussels_date_range(since, until)))
|
||||
_LOG.debug(
|
||||
"The public feed answers one Brussels day to a request that names "
|
||||
"no range. Every later hour is then copied from yesterday and "
|
||||
"marked provisional, and §8.3 leaves provisional hours out of "
|
||||
"trading.")
|
||||
return override
|
||||
first, last = brussels_date_range(since, until)
|
||||
return dated_feed_url(base, bzn=bzn, start=first.isoformat(),
|
||||
@@ -2083,8 +2081,8 @@ def _emit_startup_preview(plugin, pid: str, points) -> None:
|
||||
try:
|
||||
lines = list(plugin.startup_preview(points))
|
||||
except Exception as e: # noqa: BLE001
|
||||
_LOG.warning("%s's startup preview raised (%s: %s); prices are "
|
||||
"unaffected - this is a log line, not a decision",
|
||||
_LOG.warning("%s could not show its start-up preview (%s: %s). Prices "
|
||||
"are not affected.",
|
||||
pid, type(e).__name__, e)
|
||||
return
|
||||
for line in lines:
|
||||
@@ -2131,12 +2129,12 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
# Not silent, and not a traceback into the 30 s tick. validate.py
|
||||
# should have refused this at startup; if the add-on is running with it
|
||||
# anyway, the operator is told which key and what is wrong with it.
|
||||
_LOG.error("price_taxes_profile cannot be used (%s: %s), so this fetch "
|
||||
"has NO tax component to add - it is not falling back to "
|
||||
"price_taxes_eur_kwh, because a silently different tax "
|
||||
"basis is the defect this option exists to remove. Fix the "
|
||||
"table or clear it; validate.py refuses it at startup",
|
||||
_LOG.error("price_taxes_profile cannot be used (%s: %s), so no taxes "
|
||||
"are added to this price update. Fix the table or clear it.",
|
||||
type(e).__name__, e)
|
||||
_LOG.debug("price_taxes_eur_kwh is not used instead, because a "
|
||||
"silently different tax basis is the fault this setting "
|
||||
"exists to remove.")
|
||||
return PriceRead.no_data(
|
||||
f"price_taxes_profile cannot be used: {e}")
|
||||
if table is not None:
|
||||
|
||||
@@ -345,8 +345,8 @@ def _row_of(day: str, points: int, hours: int) -> str:
|
||||
whole day to carry, and that is the HOUR figure; the point count says how
|
||||
finely it is priced. Neither answers for the other.
|
||||
"""
|
||||
return (f"{day} ({points} point{'' if points == 1 else 's'} "
|
||||
f"over {hours} h)")
|
||||
return (f"{day} ({points} price{'' if points == 1 else 's'} "
|
||||
f"over {hours} hour{'' if hours == 1 else 's'})")
|
||||
|
||||
|
||||
def _bad_rows(rec) -> int:
|
||||
@@ -437,9 +437,9 @@ class PriceHistory:
|
||||
# exactly what this handler says, and a third `ValueError` subclass
|
||||
# nobody has thought of is how this defect arrived the first time.
|
||||
self.last_error = f"unreadable ({err})"
|
||||
_LOG.warning("price history at %s is %s - starting from empty. "
|
||||
"§8.3 carry-forward has nothing to carry until the "
|
||||
"next refresh has written a full day",
|
||||
_LOG.warning("Price history at %s is %s, so it starts empty. "
|
||||
"Missing hours cannot be filled in until a full day "
|
||||
"is saved.",
|
||||
self.path, self.last_error)
|
||||
return
|
||||
found = blob.get("version") if isinstance(blob, dict) else None
|
||||
@@ -775,8 +775,8 @@ class PriceHistory:
|
||||
f"which is not a PriceProduct. The optimiser switches on that "
|
||||
f"enum, so a record that cannot name one cannot be a series")
|
||||
if bad:
|
||||
_LOG.warning("price history: %s in the %s record were dropped; "
|
||||
"%d point(s) carried forward", bad, want, len(points))
|
||||
_LOG.warning("Price history: %s in the %s record were dropped, "
|
||||
"and %d points were kept.", bad, want, len(points))
|
||||
return PriceRead.series(points, product,
|
||||
str(rec.get("provider") or "price_history"),
|
||||
stamp)
|
||||
|
||||
@@ -381,8 +381,8 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
"""
|
||||
problems = await problems_for(proposed, hass)
|
||||
if problems:
|
||||
_LOG.warning("panel REFUSED to save: %d configuration problem(s); "
|
||||
"nothing written. First: %s", len(problems), problems[0])
|
||||
_LOG.warning("The settings page did not save, because of %d "
|
||||
"problems. The first: %s", len(problems), problems[0])
|
||||
raise SaveRefused("invalid", problems=problems)
|
||||
|
||||
info = await selfcfg.info()
|
||||
@@ -390,8 +390,8 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
actual = fingerprint(stored)
|
||||
if actual != expect:
|
||||
changed = changed_keys(stored, proposed)
|
||||
_LOG.warning("panel REFUSED to save: the stored options changed "
|
||||
"underneath this page (keys: %s); nothing written",
|
||||
_LOG.warning("The settings page did not save, because the settings "
|
||||
"changed while it was open (keys: %s).",
|
||||
", ".join(changed) or "none this page also edited")
|
||||
raise SaveRefused("stale", changed=changed, stored=stored)
|
||||
|
||||
|
||||
@@ -318,11 +318,8 @@ WIRE_W = 0.0
|
||||
#: a cap that does not bind - and a number invented for the row would be a
|
||||
#: second helping of the defect this constant exists to disclose.
|
||||
SIZED_FROM_METER = (
|
||||
"⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE "
|
||||
"HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same "
|
||||
"trade against a meter the battery is moving, so this is an UPPER BOUND "
|
||||
"and not a prediction; how far under it a live run lands is a closed-loop "
|
||||
"question and a shadow run cannot answer it")
|
||||
"This is an upper limit, not a forecast, because the meter did not see "
|
||||
"the battery move.")
|
||||
|
||||
|
||||
def mode(opts: dict) -> str:
|
||||
@@ -373,18 +370,18 @@ def why(opts: dict, *, control_started: bool) -> str:
|
||||
# discount. What is true in every live state is that what this add-on
|
||||
# decides reaches the inverter; whether control is RUNNING is the
|
||||
# `Stopped` banner's question and it is answered there.
|
||||
# UX-08: the same four rungs, in plain words. The setting is named by its
|
||||
# value, "automatic" for `auto`, so each rung still reads differently.
|
||||
m = mode(opts)
|
||||
live = "what the add-on decides reaches the inverters"
|
||||
inert = "the add-on decides but sends nothing"
|
||||
if m == "off":
|
||||
return ("off (shadow_mode: off) - NOT in shadow mode: what this "
|
||||
"add-on decides DOES reach the inverter")
|
||||
return f"Off, {live}"
|
||||
if m == "on":
|
||||
return ("on (shadow_mode: on) - decisions are recorded and nothing "
|
||||
"is commanded")
|
||||
return f"On, {inert}"
|
||||
if control_started:
|
||||
return ("off (shadow_mode: auto, control is started) - NOT in shadow "
|
||||
"mode: what this add-on decides DOES reach the inverter")
|
||||
return ("on (shadow_mode: auto, control is stopped) - decisions are "
|
||||
"recorded and nothing is commanded")
|
||||
return f"Off (automatic, control is started), {live}"
|
||||
return f"On (automatic, control is stopped), {inert}"
|
||||
|
||||
|
||||
def words(opts: dict, *, control_started: bool, would_w: float | None,
|
||||
@@ -411,10 +408,10 @@ def words(opts: dict, *, control_started: bool, would_w: float | None,
|
||||
that decided it did not apply, which is the shape of defect this card is
|
||||
about. TEST-14's rule with a new name on it.
|
||||
|
||||
⚠️ AND THE PHRASE IS "ON THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
|
||||
⚠️ AND THE PHRASE IS "THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
|
||||
CAVEAT FIRES. Every number a shadow run records was reached from a plant
|
||||
that did not respond; "WOULD HAVE COMMANDED -2 000 W" reads as a forecast
|
||||
of a live run and no number here is one. The ⚠️ below is the STRONGER,
|
||||
that did not respond; "would ask for -2 000 W" alone reads as a forecast
|
||||
of a live run and no number here is one. UX-08 2d put it in plain words. The ⚠️ below is the STRONGER,
|
||||
conditional statement about the cycles where the answer can be multiples
|
||||
out - see this module's header for the measured table.
|
||||
|
||||
@@ -431,19 +428,19 @@ def words(opts: dict, *, control_started: bool, would_w: float | None,
|
||||
if not engaged(opts, control_started=control_started):
|
||||
return head
|
||||
if would_w is None:
|
||||
return (f"{head}; no decision has been reached yet - the inverter is "
|
||||
f"held at {WIRE_W:.0f} W")
|
||||
return (f"{head}. No decision yet, the inverters stay at "
|
||||
f"{WIRE_W:.0f} W.")
|
||||
# ⚠️ THE THOUSANDS SEPARATOR IS SWAPPED ON THE NUMBER ALONE. Applied to
|
||||
# the whole sentence it ate the comma out of `head` - and `head` is the
|
||||
# half that says WHICH RUNG decided, which is the claim MUTATION.md's
|
||||
# masking rule says must survive.
|
||||
num = f"{float(would_w):+,.0f}".replace(",", " ")
|
||||
out = (f"{head}; ON THIS CYCLE WOULD HAVE COMMANDED {num} W, inverter "
|
||||
f"held at {WIRE_W:.0f} W")
|
||||
out = (f"{head}. This cycle it would ask for {num} W, the inverters stay "
|
||||
f"at {WIRE_W:.0f} W.")
|
||||
if sized_from_meter:
|
||||
out += f" {SIZED_FROM_METER}"
|
||||
if reason:
|
||||
out += f" - {reason}"
|
||||
out += f" Why: {reason}."
|
||||
return out
|
||||
|
||||
|
||||
@@ -504,6 +501,6 @@ def problems(opts: dict) -> list:
|
||||
text = str("" if raw is None else raw).strip().lower()
|
||||
if text in MODES:
|
||||
return []
|
||||
return [f"{messages.setting('shadow_mode')} is {raw!r}. Choose "
|
||||
return [f"{messages.setting('shadow_mode')} is {messages.shown(raw)}. Choose "
|
||||
+ ", ".join(f"'{m}'" for m in MODES[:-1])
|
||||
+ f" or '{MODES[-1]}'."]
|
||||
|
||||
@@ -204,6 +204,7 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import messages
|
||||
from .price import (
|
||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
||||
@@ -597,11 +598,9 @@ def parse_static_profile(value, *, option: str = "static_profile",
|
||||
value = json.loads(text)
|
||||
except ValueError as e:
|
||||
raise ProfileInvalid(
|
||||
f"{option} is not valid JSON ({e}). Write it on one line with "
|
||||
f"a \"periods\" list, for example\n"
|
||||
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
|
||||
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
|
||||
f"Leave it empty if you have no fixed tariff.")
|
||||
f"{messages.not_valid(option, e)}. Copy the example from the "
|
||||
f"documentation, or leave it empty if you have no fixed "
|
||||
f"tariff.")
|
||||
|
||||
if isinstance(value, dict):
|
||||
tz = str(value.get("timezone", "") or "").strip().lower()
|
||||
@@ -1120,18 +1119,24 @@ class TariffPlan:
|
||||
for 76 % of a 40-hour soak and mixed 900/3600 s for 70 %, and no HTTP
|
||||
surface said either.
|
||||
"""
|
||||
# UX-08: plain words, every figure in its own slot as before.
|
||||
if not self.has_prices:
|
||||
return f"no tariff - {self.read.reason}"
|
||||
tag = {"provider": self.read.provider, "static": "static_profile",
|
||||
"fallback": "static_profile (feed down)"}.get(self.origin,
|
||||
self.origin)
|
||||
return f"no prices, {self.read.reason}"
|
||||
tag = {"provider": "the price feed", "static": "your fixed tariff",
|
||||
"fallback": "your fixed tariff, because the feed is down"}.get(
|
||||
self.origin, self.origin)
|
||||
what = {PriceProduct.DAY_AHEAD: "day-ahead prices",
|
||||
PriceProduct.REALTIME: "real-time prices",
|
||||
PriceProduct.STATIC: "day/night prices",
|
||||
PriceProduct.FIXED: "a fixed price"}.get(self.product,
|
||||
str(self.product))
|
||||
gaps = self.spacings_s
|
||||
return (f"{self.product.value} from {tag}, "
|
||||
f"{self.n_points} points over {self.hours} h, "
|
||||
f"{self.n_firm} firm / {self.n_carried} carried"
|
||||
+ (f", {'+'.join(str(g) for g in gaps)} s spacing"
|
||||
return (f"{what} from {tag}, "
|
||||
f"{self.n_points} prices over {self.hours} hours, "
|
||||
f"{self.n_firm} published and {self.n_carried} estimated"
|
||||
+ (f", {'+'.join(str(g) for g in gaps)} s apart"
|
||||
if gaps else "")
|
||||
+ (", provisional" if self.provisional else ""))
|
||||
+ (", not final yet" if self.provisional else ""))
|
||||
|
||||
|
||||
def _whole_hours(points) -> int:
|
||||
@@ -1225,21 +1230,22 @@ def static_status(opts: dict, now: datetime):
|
||||
plan = static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
|
||||
now=now)
|
||||
except ProfileInvalid as e:
|
||||
_LOG.warning("static_profile is unusable (%s); no cheap window. This "
|
||||
"should have been refused at startup - see validate.py", e)
|
||||
return False, f"static_profile unusable: {e}"
|
||||
_LOG.warning("static_profile cannot be used (%s), so there is no "
|
||||
"cheap window. The start-up check should have refused "
|
||||
"it.", e)
|
||||
return False, f"Fixed tariff cannot be used: {e}"
|
||||
if not plan.has_prices:
|
||||
return False, "no static profile"
|
||||
return False, "No fixed tariff set"
|
||||
cheap = plan.cheap_now(now)
|
||||
window = plan.window_at(now)
|
||||
price = plan.price_at(now)
|
||||
when = plan.profile.next_change(now)
|
||||
until = "" if when is None else f" until {brussels(when):%H:%M}"
|
||||
kind = ("fixed" if plan.product is PriceProduct.FIXED else "day/night")
|
||||
return cheap, (f"{kind}: {window}{until} at {price:.4f} EUR/kWh, "
|
||||
f"mean {plan.mean_eur_kwh():.4f} over "
|
||||
f"{MEAN_WINDOW_DAYS} d - "
|
||||
f"{'CHEAP window' if cheap else 'not a cheap window'}")
|
||||
kind = ("Fixed" if plan.product is PriceProduct.FIXED else "Day/night")
|
||||
return cheap, (f"{kind} tariff, {window}{until}: {price:.4f} EUR/kWh, "
|
||||
f"{'cheap' if cheap else 'not cheap'} against the "
|
||||
f"{MEAN_WINDOW_DAYS}-day average of "
|
||||
f"{plan.mean_eur_kwh():.4f}")
|
||||
|
||||
|
||||
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
@@ -1336,13 +1342,12 @@ def _degrade(opts, read, since, until, stamp) -> TariffPlan:
|
||||
"(%s), so there is no tariff at all", e)
|
||||
profile = None
|
||||
if profile is None:
|
||||
_LOG.warning("§11.4: %s, and no static_profile is configured to fall "
|
||||
"back to. The plan is provisional and arbitrage is off; "
|
||||
"self-consumption, peak shaving and reserve management "
|
||||
"continue (§6, §10.1).", read.reason)
|
||||
_LOG.warning("%s, and no static_profile is set to fall back to, so "
|
||||
"prices are provisional and trading is off. Peak "
|
||||
"limiting and home use go on.", read.reason)
|
||||
return TariffPlan(read, None, "none")
|
||||
_LOG.warning("§11.4: %s - falling back to the static profile, plan marked "
|
||||
"PROVISIONAL, arbitrage off.", read.reason)
|
||||
_LOG.warning("%s, so the static_profile tariff is used instead, marked "
|
||||
"provisional, and trading is off.", read.reason)
|
||||
points = profile.series(since, until, provisional=True)
|
||||
return TariffPlan(
|
||||
PriceRead.series(points, profile.product, "static_profile", stamp),
|
||||
|
||||
@@ -328,8 +328,8 @@ def migrate_unit_keys(store, unit: str, roster=None) -> MigrationReport:
|
||||
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
|
||||
# next start rather than be recorded as finished - the one thing worse
|
||||
# than a failed migration is a failed migration a later version skips.
|
||||
_LOG.error("per-inverter store migration LOST STATE and has not been "
|
||||
"marked done: %s", rep.explain())
|
||||
_LOG.error("Moving the saved state to one part per inverter lost "
|
||||
"data, so it is not marked done: %s", rep.explain())
|
||||
return rep
|
||||
|
||||
|
||||
|
||||
@@ -647,7 +647,7 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
where = f"inverters[{i}]"
|
||||
if not isinstance(entry, dict):
|
||||
problems.append(
|
||||
f"{where} in the “Inverter list” is {entry!r}. Each entry needs "
|
||||
f"{where} in the “Inverter list” is {messages.shown(entry)}. Each entry needs "
|
||||
"a name, a setpoint entity and an inverter rating.")
|
||||
continue
|
||||
name = str(entry.get("name") or "").strip()
|
||||
@@ -670,7 +670,7 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0:
|
||||
problems.append(
|
||||
f"{who} has no usable {S('max_w')}: it is "
|
||||
f"{entry.get('max_w')!r}. Set this inverter's own rating in "
|
||||
f"{messages.shown(entry.get('max_w'))}. Set this inverter's own rating in "
|
||||
"watts.")
|
||||
|
||||
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
|
||||
@@ -690,7 +690,7 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
val = None
|
||||
if val is None or not (val > 0.0):
|
||||
problems.append(
|
||||
f"{who} has {S('capacity_kwh')} {raw!r}, which is not a usable "
|
||||
f"{who} has {S('capacity_kwh')} {messages.shown(raw)}, which is not a usable "
|
||||
"number. Set it in kWh, or leave it empty on every inverter.")
|
||||
else:
|
||||
stated.append(who)
|
||||
@@ -831,7 +831,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# has answered; it is not called from here because at startup no device has.
|
||||
max_w = _number(opts, "max_w", 2000.0)
|
||||
if max_w is None:
|
||||
problems.append(f"{S('max_w')} is {opts.get('max_w')!r}, which is not "
|
||||
problems.append(f"{S('max_w')} is {messages.shown(opts.get('max_w'))}, which is not "
|
||||
"a number. Set it in watts.")
|
||||
# ⚠️ config.yaml's int(100,5000) already blocks this on a real install, so
|
||||
# the check is not for the Supervisor path - it is because
|
||||
@@ -866,8 +866,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
if phases is None or max_age_s is None:
|
||||
problems.append(f"{S('meter_phases')} and {S('meter_max_age_s')} must "
|
||||
f"both be numbers. They are "
|
||||
f"{opts.get('meter_phases')!r} and "
|
||||
f"{opts.get('meter_max_age_s')!r}.")
|
||||
f"{messages.shown(opts.get('meter_phases'))} and "
|
||||
f"{messages.shown(opts.get('meter_max_age_s'))}.")
|
||||
else:
|
||||
problems.extend(validate_meter_options(opts, int(phases), max_age_s))
|
||||
|
||||
@@ -918,14 +918,14 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
weakest = min(caps) if caps else None
|
||||
if sign_w is None:
|
||||
problems.append(f"{S('sign_disagree_w')} is "
|
||||
f"{opts.get('sign_disagree_w')!r}, which is not a "
|
||||
f"{messages.shown(opts.get('sign_disagree_w'))}, which is not a "
|
||||
"number. Set it back to 300 W.")
|
||||
elif weakest is not None and weakest > 0 and sign_w >= weakest:
|
||||
where = _unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
||||
problems.append(
|
||||
f"{S('sign_disagree_w')} is {sign_w:.0f} W, but the "
|
||||
f"{S('max_w', where)} is {weakest:.0f} W, so this safety stop can "
|
||||
f"never trip. Lower it below {weakest:.0f} W, or raise the rating.")
|
||||
f"{S('sign_disagree_w')} is {sign_w:.0f} W, not below "
|
||||
f"{S('max_w', where)} at {weakest:.0f} W, so this safety stop can "
|
||||
f"never trip. Lower it or raise the rating.")
|
||||
|
||||
# ⚠️ AND THE SAME RULE FOR SAFETY-30's PER-UNIT THRESHOLD, WHICH IS A
|
||||
# SEPARATE COMPARISON AND THEREFORE A SEPARATE WAY TO DISARM. It grades a
|
||||
@@ -972,15 +972,14 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
if caps_ix else "")
|
||||
if unit_sign_w is None:
|
||||
problems.append(f"{S('unit_sign_disagree_w')} is "
|
||||
f"{opts.get('unit_sign_disagree_w')!r}, which is not a "
|
||||
f"{messages.shown(opts.get('unit_sign_disagree_w'))}, which is not a "
|
||||
"number. Set it back to "
|
||||
f"{SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W.")
|
||||
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
|
||||
problems.append(
|
||||
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, but the "
|
||||
f"{S('max_w', unit_where)} is {weakest:.0f} W, so this safety stop "
|
||||
f"can never trip. Lower it below {weakest:.0f} W, or raise the "
|
||||
"rating.")
|
||||
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, not below "
|
||||
f"{S('max_w', unit_where)} at {weakest:.0f} W, so this safety stop "
|
||||
f"can never trip. Lower it or raise the rating.")
|
||||
|
||||
# --- 6. a maintenance charge the ceiling can actually accommodate --------
|
||||
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
|
||||
@@ -1054,7 +1053,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
if charge_w is None:
|
||||
problems.append(
|
||||
f"{S('maintenance_charge_w')} is "
|
||||
f"{opts.get('maintenance_charge_w')!r}, which is not a number. Set "
|
||||
f"{messages.shown(opts.get('maintenance_charge_w'))}, which is not a number. Set "
|
||||
"it in watts.")
|
||||
elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap:
|
||||
problems.append(
|
||||
@@ -1089,7 +1088,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
slew_w = _number(opts, "slew_w", 0.0)
|
||||
rail = site_rail_w(opts)
|
||||
if slew_w is None:
|
||||
problems.append(f"{S('slew_w')} is {opts.get('slew_w')!r}, which is "
|
||||
problems.append(f"{S('slew_w')} is {messages.shown(opts.get('slew_w'))}, which is "
|
||||
"not a number. Set it to 0 to follow the inverter "
|
||||
"rating.")
|
||||
elif slew_w < 0:
|
||||
@@ -1133,8 +1132,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
if soc_floor is None or bms_floor is None:
|
||||
problems.append(
|
||||
f"{S('peak_soc_floor')} and {S('bms_soc_floor', where)} must "
|
||||
f"both be numbers. They are {opts.get('peak_soc_floor')!r} and "
|
||||
f"{_raw!r}.")
|
||||
f"both be numbers. They are {messages.shown(opts.get('peak_soc_floor'))} and "
|
||||
f"{messages.shown(_raw)}.")
|
||||
elif soc_floor <= bms_floor:
|
||||
problems.append(
|
||||
f"{S('peak_soc_floor')} is {soc_floor:.0f} %, not above the "
|
||||
@@ -1147,10 +1146,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
|
||||
if integ_w is not None and max_w is not None and integ_w > max_w:
|
||||
_LOG.warning(
|
||||
"integrator_max_w %.0f W is above max_w %.0f W. Not refused - the "
|
||||
"arbiter still bounds the wire to +-max_w - but the only thing the "
|
||||
"extra headroom buys is more wrong-direction power to unwind after "
|
||||
"saturation. 0 (follow max_w) is the recommended value.",
|
||||
"integrator_max_w %.0f W is above max_w %.0f W. This is allowed, "
|
||||
"but it only adds overshoot, so 0 is recommended.",
|
||||
integ_w, max_w)
|
||||
|
||||
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
|
||||
@@ -1173,15 +1170,13 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
if (disch_w is not None and maint_cap is not None and maint_cap > 0
|
||||
and disch_w > maint_cap):
|
||||
_LOG.warning(
|
||||
"maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not "
|
||||
"refused - the drain phase SETS the setpoint and the arbiter clamps "
|
||||
"it to %.0f W and names the clamp ('limited by supervised(max_w)'), "
|
||||
"so nothing is silent and the cycle still completes. But it drains "
|
||||
"at %.0f W, not the %.0f W you configured, and the drain phase has "
|
||||
"a 720 min timeout. Lower it to %.0f W or raise max_w to remove the "
|
||||
"surprise.",
|
||||
disch_w, maint_where, maint_cap, maint_cap, maint_cap, disch_w,
|
||||
maint_cap)
|
||||
"maintenance_discharge_w %.0f W is above max_w%s %.0f W, so "
|
||||
"draining runs at %.0f W. Lower it to %.0f W or raise max_w.",
|
||||
disch_w, maint_where, maint_cap, maint_cap, maint_cap)
|
||||
_LOG.debug(
|
||||
"Not refused: the drain phase sets the setpoint, the arbiter "
|
||||
"clamps it to %.0f W and names the clamp, and the drain phase has "
|
||||
"a 720 min timeout.", maint_cap)
|
||||
|
||||
return problems
|
||||
|
||||
@@ -1289,10 +1284,9 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
if str(opts.get(k, "") or "").strip()]
|
||||
if pid != "off" and ha_price:
|
||||
problems.append(
|
||||
f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} "
|
||||
f"{'is' if len(ha_price) == 1 else 'are'} also set and would win. "
|
||||
f"Clear {'it' if len(ha_price) == 1 else 'them'}, or set the price "
|
||||
"source to 'off'.")
|
||||
f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} would "
|
||||
f"win over it. Clear {'it' if len(ha_price) == 1 else 'them'}, or "
|
||||
"set the source to 'off'.")
|
||||
|
||||
# ⚠️ THE SAME RULE FOR THE SOURCE TARIFF-03 ADDED, AND THIS ONE IS A LIVE
|
||||
# CONFLICT RATHER THAN A LATENT ONE. main.py's read_inputs() takes `cheap`
|
||||
@@ -1302,7 +1296,8 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
if ha_price and str(opts.get("static_profile", "") or "").strip():
|
||||
problems.append(
|
||||
f"{S('static_profile')} is set, but {_labels(ha_price)} would win "
|
||||
"over it. Clear the one you do not use.")
|
||||
f"over it. Clear the fixed tariff or "
|
||||
f"{'the entity' if len(ha_price) == 1 else 'the two entities'}.")
|
||||
|
||||
# ⚠️ THE PROFILE ITSELF, PARSED AT STARTUP RATHER THAN AT 02:00. A profile
|
||||
# that does not tile the day has hours with no price, and the only ways to
|
||||
@@ -1374,9 +1369,8 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
and not any(abs(scalar - u) <= 1e-12 for u in untouched):
|
||||
problems.append(
|
||||
f"{S(TAXES_PROFILE_OPTION)} is set, so "
|
||||
f"{S('price_taxes_eur_kwh')} is not used, but it reads {scalar} "
|
||||
f"instead of {shipped}. Set it back to {shipped}, or clear the "
|
||||
"table.")
|
||||
f"{S('price_taxes_eur_kwh')} is not used. Set it back to "
|
||||
f"{shipped} from {scalar}, or clear the table.")
|
||||
|
||||
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
|
||||
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
|
||||
@@ -1403,10 +1397,9 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
# observe. §14's warning box governs CONTRADICTIONS, and this is
|
||||
# not one.
|
||||
_LOG.warning(
|
||||
"%s carries negative levies %s EUR/kWh. That is accepted, but "
|
||||
"a tax component below zero is usually a sign error: the "
|
||||
"add-on will SUBTRACT it from the published price in those "
|
||||
"hours.", TAXES_PROFILE_OPTION, negative)
|
||||
"%s has negative levies %s EUR/kWh, which are subtracted from "
|
||||
"the price. This is allowed, but it is usually a sign error.",
|
||||
TAXES_PROFILE_OPTION, negative)
|
||||
|
||||
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
||||
# STARTUP RATHER THAN AT 03:00.
|
||||
@@ -1459,11 +1452,9 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
# silent loser this module's case (a) is about, so it is said out
|
||||
# loud rather than left to be discovered from a bill.
|
||||
_LOG.warning(
|
||||
"price_formula is filled in but price_provider is %r, so the "
|
||||
"coefficients decide NOTHING - only 'custom_formula' reads "
|
||||
"them. Set price_provider to 'custom_formula' to use them, or "
|
||||
"clear price_formula so the configuration says what the "
|
||||
"add-on is doing.", pid)
|
||||
"price_formula is filled in, but price_provider is %r, so the "
|
||||
"formula is not used. Set price_provider to 'custom_formula' "
|
||||
"or clear price_formula.", pid)
|
||||
if spec is not None and pid == "custom_formula":
|
||||
taxes = _number(opts, "price_taxes_eur_kwh",
|
||||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||||
@@ -1552,20 +1543,18 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
# bounds in `implausible_injection()` are applied, and the
|
||||
# operator is told which of the two checks did NOT run.
|
||||
_LOG.warning(
|
||||
"%s has no \"card\" block, so its coefficients are "
|
||||
"UNGRADED: nothing recomputed a printed price from them. "
|
||||
"That is accepted because it is the real market - Eneco "
|
||||
"prints its injection formula with no price beside it, so "
|
||||
"there is nothing to grade against on that card - and "
|
||||
"refusing would make the published line inexpressible. "
|
||||
"What IS checked is the shape of the coefficients: the "
|
||||
"pass-through (%.4g x the wholesale price) and the offset "
|
||||
"(%+.5f EUR/kWh). If YOUR card prints an injection "
|
||||
"estimate, add {\"month\": ..., \"index_eur_per_mwh\": "
|
||||
"..., \"expect_incl_vat\": ...} and the add-on will "
|
||||
"refuse to start unless your numbers reproduce it.",
|
||||
"%s has no \"card\" block, so its numbers are not checked "
|
||||
"against a printed price, only their shape (pass-through "
|
||||
"%.4g, offset %+.5f EUR/kWh).",
|
||||
EXPORT_FORMULA_OPTION, xspec.passthrough,
|
||||
xspec.offset_eur_kwh)
|
||||
_LOG.debug(
|
||||
"Accepted because some suppliers (Eneco) print the "
|
||||
"injection formula with no price beside it. If your card "
|
||||
"prints an injection estimate, add {\"month\": ..., "
|
||||
"\"index_eur_per_mwh\": ..., \"expect_incl_vat\": ...} "
|
||||
"and the add-on refuses to start unless your numbers "
|
||||
"reproduce it.")
|
||||
else:
|
||||
xaged = stale_card(xspec)
|
||||
if xaged:
|
||||
@@ -1578,11 +1567,9 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
# every install TARIFF-02 shipped, none of which could have had one.
|
||||
if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
|
||||
_LOG.warning(
|
||||
"price_provider is %r but static_profile is empty. §11.4's "
|
||||
"degradation row is 'no price data -> fall back to the static "
|
||||
"profile'; with none configured, a feed outage leaves this site "
|
||||
"with no tariff at all rather than with a coarse one. Not refused, "
|
||||
"but filling it in is what makes that row work.", pid)
|
||||
"price_provider is %r but static_profile is empty, so a price "
|
||||
"feed outage leaves no tariff at all. Fill in static_profile as a "
|
||||
"fallback.", pid)
|
||||
|
||||
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
|
||||
# rather than logged, because a strategy that is armed in the UI and cannot
|
||||
@@ -1598,7 +1585,7 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
if thresh is None or thresh < 0:
|
||||
problems.append(
|
||||
f"{S('cycle_cost_threshold_eur')} is "
|
||||
f"{opts.get('cycle_cost_threshold_eur')!r}. Set it to 0 or more, "
|
||||
f"{messages.shown(opts.get('cycle_cost_threshold_eur'))}. Set it to 0 or more, "
|
||||
"in EUR/kWh.")
|
||||
|
||||
if arbitrage_on(opts):
|
||||
@@ -1655,11 +1642,9 @@ def refuse(problems: list[str]) -> None:
|
||||
at all and even the exit status distinguishes the two.
|
||||
"""
|
||||
_LOG.error("=" * 72)
|
||||
_LOG.error("REFUSING TO START: the add-on configuration is not usable.")
|
||||
_LOG.error("This is a CONFIGURATION problem, not a crash. Nothing is broken;")
|
||||
_LOG.error("the add-on will start as soon as the settings below are fixed.")
|
||||
_LOG.error("Fix them in Settings > Add-ons > GoodWe RS485 Controller >")
|
||||
_LOG.error("Configuration, then start the add-on again.")
|
||||
_LOG.error("The add-on did not start, because its settings have problems.")
|
||||
_LOG.error("Nothing is broken. Fix the settings below, then start it again.")
|
||||
_LOG.error("They are on the add-on's Configuration tab.")
|
||||
_LOG.error("-" * 72)
|
||||
for i, problem in enumerate(problems, 1):
|
||||
_LOG.error(" %d. %s", i, problem)
|
||||
|
||||
@@ -31,7 +31,7 @@ import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import selfconfig
|
||||
from . import messages, selfconfig
|
||||
from .config_page import CONFIG_PAGE
|
||||
from .configui import changed_keys, field_model, problems_by_field
|
||||
|
||||
@@ -125,11 +125,8 @@ refresh(); setInterval(refresh, 3000);
|
||||
# them too, and says so, because a green page that is green for a reason the
|
||||
# operator cannot see is the exact failure this add-on keeps refusing to ship.
|
||||
DEVICE_NOTE = (
|
||||
"%s did not report a range, so the two checks that compare a configured "
|
||||
"ceiling against what the device itself accepts were skipped - here and at "
|
||||
"startup, identically. Everything else was checked. If the device answers "
|
||||
"later and disagrees, the add-on refuses to start then, with the same "
|
||||
"message you would have seen here.")
|
||||
"%s did not report its power range, so two checks were skipped, here and "
|
||||
"at start. If they fail later, the add-on will not start then.")
|
||||
|
||||
|
||||
def build_app(controller, selfcfg=None) -> web.Application:
|
||||
@@ -264,7 +261,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
_LOG.error("saving options failed: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
controller.log_event("configuration saved from the panel - "
|
||||
"not applied until the add-on restarts")
|
||||
"not applied until the add-on restarts",
|
||||
hint=messages.hint("settings_saved"))
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
async def config_restart(request):
|
||||
@@ -288,7 +286,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
_LOG.error("restart request failed: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
controller.log_event("restart requested from the configuration panel")
|
||||
controller.log_event("restart requested from the configuration panel",
|
||||
hint=messages.hint("restart_requested"))
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
app = web.Application()
|
||||
|
||||
Reference in New Issue
Block a user