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
|
||||
|
||||
Reference in New Issue
Block a user