OPT-17: the solar forecast can come from several entities, summed
New list options pv_forecast_entities and pv_forecast_tomorrow_entities
(same shape as pv_entities); the single-entity keys are unchanged.
- Each member is read in its own unit (Wh/kWh/MWh) in one gather and the
day is summed. Hourly splits are merged when every member has one, else
the total goes on the bell.
- A member missing, unavailable, stale, non-energy or bad makes that
day's forecast unusable (no partial sum). A caution row names it by
position; the action log names the entity once per change.
- Single key and list both set for one day: refused at start.
- A one-member list behaves exactly like the single key (test_opt17.py);
the single-key path is byte-identical to e36565f over 24 scenarios.
- Supervisor shape measured: tools/supervisor_shape_probe.py section J.
DECISIONS/OPT-17-several-forecast-entities.md has the rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e36565fe39
commit
9b1fa758ad
@@ -992,12 +992,17 @@ class Controller:
|
||||
self._charge_blocked_q = None
|
||||
self._ref_cheap = None
|
||||
# OPT-11 P4: the solar forecast and the day plan. Only with battery
|
||||
# modes on AND `pv_forecast_entity` set; otherwise none of this runs
|
||||
# and every mode acts as in P3 option A.
|
||||
self.fc_entity = (opts.get("pv_forecast_entity") or None) \
|
||||
if self.modes_on else None
|
||||
self.fc_tomorrow_entity = opts.get("pv_forecast_tomorrow_entity") \
|
||||
or None
|
||||
# modes on AND a forecast for today set; otherwise none of this runs
|
||||
# and every mode acts as in P3 option A. OPT-17: each day may name
|
||||
# several entities, summed (`solarfc.entities()`); `fc_entity` is
|
||||
# the first, and None means no forecast.
|
||||
self.fc_entities = (solarfc.entities(opts) if self.modes_on
|
||||
else ())
|
||||
self.fc_tomorrow_entities = solarfc.entities(opts, tomorrow=True)
|
||||
self.fc_entity = self.fc_entities[0] if self.fc_entities else None
|
||||
# OPT-17: the members that are not usable, as `solarfc.read_sum()`
|
||||
# gives them, for the status page and the action log.
|
||||
self.solar_fc_gaps = ()
|
||||
self.solar_trust_pct = opts.get("solar_trust_pct",
|
||||
dayplan.DEFAULT_TRUST_PCT)
|
||||
self.solar_fc = None
|
||||
@@ -3246,18 +3251,31 @@ class Controller:
|
||||
async def _read_forecast(self) -> None:
|
||||
"""OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A
|
||||
failed or stale read is no forecast, never a zero."""
|
||||
today = await self.hass.state(self.fc_entity)
|
||||
tomorrow = (await self.hass.state(self.fc_tomorrow_entity)
|
||||
if self.fc_tomorrow_entity else None)
|
||||
# OPT-17: every member of both days in one gather.
|
||||
ents = self.fc_entities + self.fc_tomorrow_entities
|
||||
states = await asyncio.gather(*(self.hass.state(e) for e in ents))
|
||||
n = len(self.fc_entities)
|
||||
now = datetime.now(timezone.utc)
|
||||
fc, why = solarfc.read(today, tomorrow, now)
|
||||
if (fc is None) != (self.solar_fc is None) or why != self.solar_fc_why:
|
||||
fc, why, gaps = solarfc.read_sum(
|
||||
list(zip(self.fc_entities, states[:n])),
|
||||
list(zip(self.fc_tomorrow_entities, states[n:])), now)
|
||||
# A member is named only in a sum of several; one entity reads as
|
||||
# before. Logged once per episode: on a change, never every read.
|
||||
gaps = tuple(g for g in gaps if len(
|
||||
self.fc_entities if g[0] == "today"
|
||||
else self.fc_tomorrow_entities) > 1)
|
||||
if (fc is None) != (self.solar_fc is None) \
|
||||
or why != self.solar_fc_why or gaps != self.solar_fc_gaps:
|
||||
self.log_event(
|
||||
"solar forecast: " + ("read" if fc is not None else
|
||||
f"none ({why})"),
|
||||
hint=messages.hint("forecast_read" if fc is not None
|
||||
else "forecast_none"))
|
||||
f"none ({why})")
|
||||
+ "".join(f", {d} {e} not usable ({w})"
|
||||
for d, _i, e, w in gaps),
|
||||
hint=messages.hint(
|
||||
"forecast_none" if fc is None else
|
||||
"forecast_no_tomorrow" if gaps else "forecast_read"))
|
||||
self.solar_fc, self.solar_fc_why = fc, why
|
||||
self.solar_fc_gaps = gaps
|
||||
|
||||
async def read_inputs(self, now_mono: float | None = None) -> None:
|
||||
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
|
||||
@@ -6998,6 +7016,17 @@ class Controller:
|
||||
# is both is a check nobody reads.
|
||||
out.append({"ok": False, "warn": True, "id": "peak_forecast",
|
||||
"text": messages.row_no_peak_forecast()})
|
||||
# OPT-17: a forecast summed from several entities names, by position,
|
||||
# each member with no usable reading. A caution: control goes on.
|
||||
gaps = getattr(self, "solar_fc_gaps", ())
|
||||
for day, ents in (("today", getattr(self, "fc_entities", ())),
|
||||
("tomorrow",
|
||||
getattr(self, "fc_tomorrow_entities", ()))):
|
||||
gone = [i for d, i, _e, _w in gaps if d == day]
|
||||
if gone:
|
||||
out.append({"ok": False, "warn": True, "id": "solar_forecast",
|
||||
"text": messages.row_forecast_gap(
|
||||
day, gone, len(ents))})
|
||||
# --- TEL-11 ----------------------------------------------------------
|
||||
# ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY, AND THAT IS A DELIBERATE
|
||||
# DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. That one
|
||||
|
||||
@@ -754,6 +754,15 @@ def row_pv_partial(missing, count) -> str:
|
||||
f"reading")
|
||||
|
||||
|
||||
def row_forecast_gap(day, missing, count) -> str:
|
||||
"""OPT-17. `day` is "today" or "tomorrow"; `missing` are the 1-based
|
||||
positions of the forecast entities with no usable reading."""
|
||||
which = names([str(n) for n in missing])
|
||||
many = len(missing) > 1
|
||||
return (f"Solar forecast for {day}: not used, source{'s' if many else ''} "
|
||||
f"{which} of {count} {'have' if many else 'has'} no reading")
|
||||
|
||||
|
||||
def row_pv_single_ignored() -> str:
|
||||
return f"Solar production: clear {PV_ONE}, the per-inverter list replaces it"
|
||||
|
||||
@@ -953,6 +962,7 @@ HINTS = {
|
||||
"trade_off": "Trading: waiting for a better price",
|
||||
"forecast_read": "Solar forecast read, the day plan uses it",
|
||||
"forecast_none": "No solar forecast, so Leftover solar takes solar only",
|
||||
"forecast_no_tomorrow": "Tomorrow's solar forecast is not used, a source has no reading",
|
||||
"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",
|
||||
@@ -1114,6 +1124,8 @@ def status_samples() -> list:
|
||||
"entity - it cannot be read in watts")),
|
||||
("row_pv_partial", AMBER, row_pv_partial([2, 3], 3)),
|
||||
("row_pv_partial", AMBER, row_pv_partial([2], 2)),
|
||||
("row_forecast_gap", AMBER, row_forecast_gap("today", [2], 3)),
|
||||
("row_forecast_gap", AMBER, row_forecast_gap("tomorrow", [1, 2], 2)),
|
||||
("row_pv_single_ignored", AMBER, row_pv_single_ignored()),
|
||||
("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)),
|
||||
("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
|
||||
@@ -1232,6 +1244,8 @@ LABELS = {
|
||||
"pv_entities": "Solar production entities, per inverter",
|
||||
"pv_forecast_entity": "Solar forecast, today",
|
||||
"pv_forecast_tomorrow_entity": "Solar forecast, tomorrow",
|
||||
"pv_forecast_entities": "Several solar forecasts, today",
|
||||
"pv_forecast_tomorrow_entities": "Several solar forecasts, tomorrow",
|
||||
"solar_trust_pct": "Trust in the forecast",
|
||||
"p1_phase_import_entities": "Import entities per phase",
|
||||
"p1_phase_export_entities": "Export entities per phase",
|
||||
|
||||
@@ -27,6 +27,11 @@ Without an hourly split, the day's energy is spread over a fixed bell from
|
||||
A reading older than `FRESH_S`, or from before local midnight, is stale,
|
||||
and a stale or missing forecast is no forecast: the day plan is off and
|
||||
every mode acts as in P3 option A.
|
||||
|
||||
OPT-17: each day may instead name a list (`pv_forecast_entities`,
|
||||
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
|
||||
not usable makes that day's sum unusable, never a partial sum.
|
||||
DECISIONS/OPT-17-several-forecast-entities.md.
|
||||
"""
|
||||
|
||||
import math
|
||||
@@ -49,6 +54,12 @@ BELL_FROM_H, BELL_TO_H = 6.0, 20.0
|
||||
NOT_SET, MISSING, UNIT, STALE, BAD = (
|
||||
"not_set", "missing", "unit", "stale", "bad")
|
||||
|
||||
# OPT-17: (single key, list key) per day. A set list replaces the single key;
|
||||
# the validator refuses both set for the same day.
|
||||
TODAY_KEYS = ("pv_forecast_entity", "pv_forecast_entities")
|
||||
TOMORROW_KEYS = ("pv_forecast_tomorrow_entity",
|
||||
"pv_forecast_tomorrow_entities")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Day:
|
||||
@@ -223,6 +234,52 @@ def _reported(state):
|
||||
return max(ts) if ts else None
|
||||
|
||||
|
||||
def entities(opts: dict, *, tomorrow=False) -> tuple:
|
||||
"""The forecast entities one day names, in configured order: the list
|
||||
if it has any entry, else the single key, else (). A repeated id is
|
||||
dropped, as `pv.entities()` does: it would count one forecast twice."""
|
||||
one, many = TOMORROW_KEYS if tomorrow else TODAY_KEYS
|
||||
listed = [e for e in (str(x or "").strip()
|
||||
for x in (opts.get(many) or [])) if e]
|
||||
if listed:
|
||||
return tuple(dict.fromkeys(listed))
|
||||
e = str(opts.get(one) or "").strip()
|
||||
return (e,) if e else ()
|
||||
|
||||
|
||||
def _bounds(now):
|
||||
here = now.astimezone(BILLING_TZ)
|
||||
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
|
||||
nd = d0.date() + timedelta(days=1)
|
||||
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
|
||||
return d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _today(state, day0, now):
|
||||
"""`(Day, at, None)` or `(None, None, why)` for one today sensor."""
|
||||
today, why = _day(state, day0, now, today=True)
|
||||
if today is None:
|
||||
return None, None, why
|
||||
at = _reported(state)
|
||||
# A reading from before local midnight is yesterday's "left today" (0
|
||||
# after sunset) until the integration refreshes: stale, not a dark day.
|
||||
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
|
||||
or (at - now).total_seconds() > 300:
|
||||
return None, None, STALE
|
||||
return today, at, None
|
||||
|
||||
|
||||
def _tomorrow(state, day0, day1, now):
|
||||
"""`(Day, None)` or `(None, why)` for one tomorrow sensor."""
|
||||
t, why = _day(state, day1, now, today=False)
|
||||
if t is None:
|
||||
return None, why
|
||||
t_at = _reported(state)
|
||||
if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S:
|
||||
return None, STALE
|
||||
return t, None
|
||||
|
||||
|
||||
def read(today_state, tomorrow_state, now: datetime, *,
|
||||
configured=True):
|
||||
"""`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None
|
||||
@@ -230,25 +287,64 @@ def read(today_state, tomorrow_state, now: datetime, *,
|
||||
covers today only."""
|
||||
if not configured:
|
||||
return None, NOT_SET
|
||||
here = now.astimezone(BILLING_TZ)
|
||||
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
|
||||
nd = d0.date() + timedelta(days=1)
|
||||
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
|
||||
day0, day1 = d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
|
||||
today, why = _day(today_state, day0, now, today=True)
|
||||
day0, day1 = _bounds(now)
|
||||
today, at, why = _today(today_state, day0, now)
|
||||
if today is None:
|
||||
return None, why
|
||||
at = _reported(today_state)
|
||||
# A reading from before local midnight is yesterday's "left today" (0
|
||||
# after sunset) until the integration refreshes: stale, not a dark day.
|
||||
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
|
||||
or (at - now).total_seconds() > 300:
|
||||
return None, STALE
|
||||
tomorrow = None
|
||||
if tomorrow_state is not None:
|
||||
t, _w = _day(tomorrow_state, day1, now, today=False)
|
||||
t_at = _reported(tomorrow_state)
|
||||
if t is not None and t_at is not None and t_at >= day0 \
|
||||
and (now - t_at).total_seconds() <= FRESH_S:
|
||||
tomorrow = t
|
||||
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
|
||||
return Forecast(today, tomorrow, at), None
|
||||
|
||||
|
||||
def _sum(days):
|
||||
"""One Day from several members' Days. The hourly split is kept only if
|
||||
every member has one: the splits are then joined (`_day_between()`
|
||||
adds overlapping periods). Else the total goes on the bell."""
|
||||
if len(days) == 1:
|
||||
return days[0]
|
||||
periods = ()
|
||||
if all(d.periods for d in days):
|
||||
periods = tuple(sorted(p for d in days for p in d.periods))
|
||||
return Day(days[0].day0, sum(d.kwh for d in days), periods)
|
||||
|
||||
|
||||
def read_sum(today, tomorrow, now: datetime):
|
||||
"""OPT-17: `(Forecast or None, why, gaps)` over lists of
|
||||
`(entity, state)`, one per configured member, in configured order.
|
||||
|
||||
Each member is read exactly as `read()` reads one sensor, in its own
|
||||
unit. A member that is missing, unavailable, stale, not in an energy
|
||||
unit or bad makes that DAY unusable, as one such sensor does today: no
|
||||
today means no forecast, no tomorrow means today only. A partial sum
|
||||
would under-count the sun and mislead the day plan.
|
||||
|
||||
`gaps` is `((day, position, entity, why), ...)` for every unusable
|
||||
member (`day` "today" or "tomorrow", `position` 1-based), so the page
|
||||
and the log can name it. With one member per day the forecast is what
|
||||
`read()` gives (test_opt17.py proves it)."""
|
||||
if not today:
|
||||
return None, NOT_SET, ()
|
||||
day0, day1 = _bounds(now)
|
||||
gaps, days, ats, why0 = [], [], [], None
|
||||
for i, (ent, st) in enumerate(today, 1):
|
||||
d, at, why = _today(st, day0, now)
|
||||
if d is None:
|
||||
gaps.append(("today", i, ent, why))
|
||||
why0 = why0 or why
|
||||
else:
|
||||
days.append(d)
|
||||
ats.append(at)
|
||||
tdays = []
|
||||
for i, (ent, st) in enumerate(tomorrow, 1):
|
||||
t, why = _tomorrow(st, day0, day1, now)
|
||||
if t is None:
|
||||
gaps.append(("tomorrow", i, ent, why))
|
||||
else:
|
||||
tdays.append(t)
|
||||
gaps = tuple(gaps)
|
||||
if why0 is not None:
|
||||
return None, why0, gaps
|
||||
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
|
||||
# The oldest member's report is when the sum was last fully known.
|
||||
return Forecast(_sum(days), tday, min(ats)), None, gaps
|
||||
|
||||
@@ -135,6 +135,10 @@ GROUPS = (
|
||||
Field("pv_forecast_entity", ENTITY, kind="energy", simple=True,
|
||||
note="forecast"),
|
||||
Field("pv_forecast_tomorrow_entity", ENTITY, kind="energy"),
|
||||
# OPT-17: several forecast sensors per day, summed.
|
||||
Field("pv_forecast_entities", ENTITY_LIST, kind="energy"),
|
||||
Field("pv_forecast_tomorrow_entities", ENTITY_LIST,
|
||||
kind="energy"),
|
||||
Field("solar_trust_pct", SLIDER, "%", step=5),
|
||||
)),
|
||||
)),
|
||||
|
||||
@@ -1802,12 +1802,13 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} does not read in kWh, so pick a solar "
|
||||
"forecast sensor in kWh.")
|
||||
if any(opts.get(k) for k in FORECAST_KEYS) and \
|
||||
today = solarfc.entities(opts)
|
||||
tomorrow = solarfc.entities(opts, tomorrow=True)
|
||||
if (today or tomorrow) and \
|
||||
opts.get("battery_modes_enabled") is not True:
|
||||
_advise(notes, "info", ("pv_forecast_entity",),
|
||||
"The solar forecast is used only with battery modes on.")
|
||||
if opts.get("pv_forecast_tomorrow_entity") and \
|
||||
not opts.get("pv_forecast_entity"):
|
||||
if tomorrow and not today:
|
||||
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
|
||||
"Tomorrow's forecast is used only with today's, so set "
|
||||
f"{S('pv_forecast_entity')} too.")
|
||||
@@ -2069,12 +2070,13 @@ def _margin_problems(opts: dict) -> list[str]:
|
||||
TRUST_RANGE_PCT = (30, 100)
|
||||
# A Home Assistant sensor id; it goes into the /api/states/<id> path.
|
||||
_SENSOR_ID = re.compile(r"sensor\.[a-z0-9_]+")
|
||||
FORECAST_KEYS = ("pv_forecast_entity", "pv_forecast_tomorrow_entity")
|
||||
FORECAST_KEYS = solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS
|
||||
|
||||
|
||||
def _forecast_problems(opts: dict) -> list[str]:
|
||||
"""Rule 13 (OPT-11 P4): the trust is a whole percentage in range, and
|
||||
each forecast entity set is a sensor."""
|
||||
each forecast entity set is a sensor. OPT-17: a day names its forecast
|
||||
in the single key or in the list, not both."""
|
||||
out = []
|
||||
raw = opts.get("solar_trust_pct")
|
||||
trust = _number(opts, "solar_trust_pct", dayplan.DEFAULT_TRUST_PCT)
|
||||
@@ -2082,14 +2084,30 @@ def _forecast_problems(opts: dict) -> list[str]:
|
||||
if trust is None or trust != int(trust) or not lo <= trust <= hi:
|
||||
out.append(f"{S('solar_trust_pct')} must be a whole number from {lo} "
|
||||
f"to {hi} %. It is {messages.shown(raw)}.")
|
||||
for key in FORECAST_KEYS:
|
||||
e = opts.get(key)
|
||||
if e is None or e == "":
|
||||
continue
|
||||
if not isinstance(e, str) or not _SENSOR_ID.fullmatch(e):
|
||||
out.append(f"{S(key)} must be a sensor, like "
|
||||
for one, many in (solarfc.TODAY_KEYS, solarfc.TOMORROW_KEYS):
|
||||
e = opts.get(one)
|
||||
if e is not None and e != "" and (
|
||||
not isinstance(e, str) or not _SENSOR_ID.fullmatch(e)):
|
||||
out.append(f"{S(one)} must be a sensor, like "
|
||||
f"sensor.energy_production_today_remaining. It is "
|
||||
f"{messages.shown(e)}.")
|
||||
raw = opts.get(many)
|
||||
if raw is None:
|
||||
raw = []
|
||||
if not isinstance(raw, list):
|
||||
out.append(f"{S(many)} must be a list of sensors. It is "
|
||||
f"{messages.shown(raw)}.")
|
||||
continue
|
||||
listed = [x for x in raw if x is not None
|
||||
and not (isinstance(x, str) and not x.strip())]
|
||||
for x in listed:
|
||||
if not isinstance(x, str) or not _SENSOR_ID.fullmatch(x):
|
||||
out.append(f"{S(many)} must list only sensors, like "
|
||||
f"sensor.energy_production_today_remaining. "
|
||||
f"It has {messages.shown(x)}.")
|
||||
if e and listed:
|
||||
out.append(f"Set {S(one)} or {S(many)}, not both. Keep the "
|
||||
f"list and clear the single one.")
|
||||
return out
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user