OPT-17: the solar forecast can come from several entities, summed
checks / unit (pull_request) Successful in 36m13s
infra-pin / fresh (pull_request) Successful in 1s

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:
glenn schrooyen
2026-10-11 02:55:58 +02:00
co-authored by Claude Opus 5.5
parent e36565fe39
commit 9b1fa758ad
13 changed files with 878 additions and 48 deletions
+14
View File
@@ -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",