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
@@ -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