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
@@ -0,0 +1,78 @@
|
||||
# OPT-17 - the solar forecast from several entities, summed
|
||||
|
||||
**Status: for review.** Tests: `test_opt17.py`. Two options added.
|
||||
|
||||
A site whose forecast comes as several Home Assistant sensors (one per
|
||||
inverter or roof side) had to build a template sensor to sum them. Now each
|
||||
day can list them.
|
||||
|
||||
## Options
|
||||
|
||||
- `pv_forecast_entities` and `pv_forecast_tomorrow_entities`: lists of
|
||||
sensors, default `[]`, the same shape as `pv_entities`.
|
||||
- `pv_forecast_entity` and `pv_forecast_tomorrow_entity` are unchanged. An
|
||||
install that uses them needs no edit.
|
||||
- **Why new keys, not "string or list" in the old ones.** Measured on
|
||||
Supervisor 2026.08.0 by `tools/supervisor_shape_probe.py` §J, as TEL-12's
|
||||
§G did for `pv_entity`. Declared as a list, each forecast key refuses the
|
||||
string an install already stores (`Invalid list for option
|
||||
'pv_forecast_entity'`), and a `[]` default does not rescue it. The one
|
||||
spelling that takes both, `match(.*)?`, also takes the integer 417 and
|
||||
shows a plain text box, so a second sensor cannot be added in the UI.
|
||||
- §J also shows: a 0.17.0 blob validates unchanged with the lists at `[]`;
|
||||
the lists render as repeatable fields; a downgrade drops them and keeps
|
||||
the single keys.
|
||||
- `[]` in `options:` is load-bearing, as for `pv_entities`.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **One key per day.** Setting the single key and the list for the same day
|
||||
is refused at start, with one message: keep the list, clear the single one.
|
||||
The list can hold that entity too. (TEL-12 lets `pv_entities` replace
|
||||
`pv_entity` with a caution row. Here the brief asked for a refusal: a
|
||||
doubled or dropped forecast changes what a battery buys, and no install
|
||||
has both keys yet, so the refusal breaks nothing.)
|
||||
2. **Each member in its own unit.** Every member is read as one forecast
|
||||
sensor is today (`solarfc._day()`): Wh, kWh or MWh, never guessed. Then the
|
||||
day's kWh are added up. A repeated id counts once.
|
||||
3. **One member not usable means the day is not usable.** Missing,
|
||||
unavailable, unknown, stale, not an energy unit, or a bad number: that
|
||||
day's sum is not used, exactly as when the single sensor reads that way.
|
||||
No today means no forecast (P3 option A). No tomorrow means today only.
|
||||
4. **Hourly split.** If every member has a split, the splits are joined
|
||||
(overlapping periods add up). If any member has none, the sum is spread
|
||||
over the bell, as for a state-only sensor.
|
||||
5. **Time.** The forecast is as old as its oldest member.
|
||||
6. **One read.** All members of both days are read in one gather, inside the
|
||||
gather that reads the inverters, so a slow sensor does not delay control.
|
||||
7. **Shown and logged once.** With two or more members, each one not usable
|
||||
is named on the status page by its place in the list (STYLE.md: no entity
|
||||
ids on the page), as a caution row. The action log names the entity id,
|
||||
once when the set of failing members changes.
|
||||
8. **One member is the single key.** A one-member list reads, plans, shows
|
||||
and logs exactly as the single key with that entity.
|
||||
|
||||
## Why
|
||||
|
||||
- A partial sum is a smaller number that looks real. Two of three inverters'
|
||||
forecasts read as a duller day, and Leftover solar would then buy grid
|
||||
energy the sun was going to give. TEL-12 took the same rule for the same
|
||||
reason. No forecast is a known state with a tested fallback.
|
||||
- The bell for a mixed list: joining one member's hours with another's total
|
||||
has no honest shape, and the bell is the existing state-only rule.
|
||||
|
||||
## Cost
|
||||
|
||||
- One flaky member costs the whole day's forecast. A site with three
|
||||
sensors loses the plan more often than one with a template sum that hides
|
||||
gaps.
|
||||
- Two more options (85).
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- No weighting or calibration per member; FCST-01 is still the own forecast.
|
||||
- Members on different integrations with different refresh times: each must
|
||||
be fresh (3 h) on its own; there is no wait for a lagging one.
|
||||
- The day plan itself, the trust and the house profile are unchanged.
|
||||
- The Batteries screen still says only "no solar forecast now". The member
|
||||
is named on the status page.
|
||||
@@ -186,6 +186,8 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| Hardware | Battery modes | `battery_modes_enabled` | Give each battery an on and off switch and a mode, set on the Batteries screen. |
|
||||
| Hardware | Solar forecast, today (kWh) | `pv_forecast_entity` | Optional. A forecast sensor with today's remaining solar energy, such as Forecast.Solar or Solcast. A battery then buys only what the sun will not give. |
|
||||
| Hardware | Solar forecast, tomorrow (kWh) | `pv_forecast_tomorrow_entity` | Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight. |
|
||||
| Hardware | Several solar forecasts, today (kWh) | `pv_forecast_entities` | Optional. One row per forecast sensor, such as one per inverter, used instead of the single one above. They are added up. |
|
||||
| Hardware | Several solar forecasts, tomorrow (kWh) | `pv_forecast_tomorrow_entities` | Optional. The same list for tomorrow. If one sensor has no reading, that day's forecast is not used. |
|
||||
| Hardware | Trust in the forecast (%) | `solar_trust_pct` | Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast. |
|
||||
| Hardware | Solar production entity (W) | `pv_entity` | Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control. |
|
||||
| Hardware | Solar production entities, per inverter (W) | `pv_entities` | Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded. |
|
||||
@@ -539,6 +541,9 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
- With no prices, or too small a gap, every battery follows the house.
|
||||
|
||||
**Solar forecast** (`pv_forecast_entity`, optional). A Home Assistant sensor with the solar energy still to come today, in kWh. These work: Forecast.Solar (`sensor.energy_production_today_remaining`), Solcast PV Forecast (`sensor.solcast_pv_forecast_forecast_remaining_today`) and Open-Meteo Solar Forecast (`sensor.energy_production_today_remaining`). Set `pv_forecast_tomorrow_entity` to the matching *tomorrow* sensor to plan a cheap run before midnight.
|
||||
- **Several forecasts.** If your forecast comes as several sensors (one per inverter or roof side), list them in `pv_forecast_entities` and, for tomorrow, `pv_forecast_tomorrow_entities`, instead of the single key. They are added up, each in its own unit (Wh, kWh or MWh). Set the list or the single key for a day, not both.
|
||||
- If one of them has no reading, is stale or is not in an energy unit, that day's forecast is not used at all. A part of the sun is not counted as all of it. The status page names the source by its place in the list.
|
||||
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
|
||||
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
|
||||
- *Leftover solar* then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level.
|
||||
- *Cheap hours only* ignores the forecast and fills to the top level.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -157,6 +157,11 @@ options:
|
||||
# integrations whose sensors fit.
|
||||
pv_forecast_entity: ""
|
||||
pv_forecast_tomorrow_entity: ""
|
||||
# OPT-17: several forecast sensors per day (one per inverter or string),
|
||||
# summed. Use the list OR the single key above, not both. `[]` is
|
||||
# load-bearing, as for `pv_entities`.
|
||||
pv_forecast_entities: []
|
||||
pv_forecast_tomorrow_entities: []
|
||||
solar_trust_pct: 70
|
||||
|
||||
# TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT
|
||||
@@ -961,6 +966,13 @@ schema:
|
||||
battery_modes_enabled: bool
|
||||
pv_forecast_entity: str?
|
||||
pv_forecast_tomorrow_entity: str?
|
||||
# OPT-17: new list keys beside the single ones. A list schema on the old
|
||||
# keys refuses the string an install stores (tools/supervisor_shape_probe.py
|
||||
# section J); DECISIONS/OPT-17-several-forecast-entities.md.
|
||||
pv_forecast_entities:
|
||||
- str
|
||||
pv_forecast_tomorrow_entities:
|
||||
- str
|
||||
solar_trust_pct: int(30,100)
|
||||
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
|
||||
# `price_now_entity`/`price_avg_entity`, which are legal only while
|
||||
|
||||
@@ -113,19 +113,21 @@ check("the harness bites: Leftover solar grid-charging with no plan moves "
|
||||
lambda: setattr(modetrade, "GRID_CHARGE_MODES",
|
||||
(packmode.LEFTOVER_SOLAR, packmode.CHEAP_HOURS)),
|
||||
lambda: setattr(modetrade, "GRID_CHARGE_MODES", _gcm)))
|
||||
_read = solarfc.read
|
||||
# OPT-17: the controller reads through `read_sum()`, so that is the seam.
|
||||
_read = solarfc.read_sum
|
||||
|
||||
|
||||
def _as_dark(today, tomorrow, now, **kw):
|
||||
return _read({"state": "0", "attributes": {"unit_of_measurement":
|
||||
"kWh"},
|
||||
"last_updated": now.isoformat()}, None, now)
|
||||
return _read([("sensor.f", {"state": "0", "attributes": {
|
||||
"unit_of_measurement": "kWh"}, "last_updated": now.isoformat()})],
|
||||
[], now)
|
||||
|
||||
|
||||
check("...and an unavailable forecast read as a dark one moves "
|
||||
"n2_sf_ls_fc_down (Leftover solar would buy its shortfall)",
|
||||
_moved("n2_sf_ls_fc_down", lambda: setattr(solarfc, "read", _as_dark),
|
||||
lambda: setattr(solarfc, "read", _read)))
|
||||
_moved("n2_sf_ls_fc_down",
|
||||
lambda: setattr(solarfc, "read_sum", _as_dark),
|
||||
lambda: setattr(solarfc, "read_sum", _read)))
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. the forecast sensors")
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
"""Runnable check for OPT-17, the solar forecast summed from several entities.
|
||||
`python3 test_opt17.py`
|
||||
|
||||
1. `solarfc.read_sum()`: sums in mixed units, refusals, a member down or
|
||||
stale makes that day unusable (never a partial sum), hourly splits, today
|
||||
and tomorrow, an empty list, and one member equal to `read()`.
|
||||
2. `solarfc.entities()`: which key a day uses.
|
||||
3. Options: the validator, the settings notes and config.yaml.
|
||||
4. The REAL controller (tools/opt11p3_replay.py): a one-member list runs
|
||||
exactly like the single key, two halves like one whole, a member down
|
||||
like a forecast down; the status row and the action log name the member
|
||||
once; every member is read in one gather; modes off reads nothing.
|
||||
|
||||
DECISIONS/OPT-17-several-forecast-entities.md has the rules. Mutations
|
||||
(MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from app import dayplan, messages, solarfc, validate # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=2))
|
||||
UTC0 = datetime(2026, 9, 21, tzinfo=L).astimezone(timezone.utc)
|
||||
TOM = UTC0 + timedelta(days=1)
|
||||
NOW = datetime(2026, 9, 21, 9, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
END = UTC0 + timedelta(days=1)
|
||||
OLD = NOW - timedelta(seconds=solarfc.FRESH_S + 1)
|
||||
|
||||
|
||||
def st(state, unit="kWh", at=NOW, **attrs):
|
||||
a = dict(attrs)
|
||||
if unit is not None:
|
||||
a["unit_of_measurement"] = unit
|
||||
return {"state": state, "attributes": a,
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
def members(*states):
|
||||
return [(f"sensor.fc_{i}", s) for i, s in enumerate(states, 1)]
|
||||
|
||||
|
||||
def near(a, b, eps=1e-6):
|
||||
return a is not None and b is not None and abs(a - b) < eps
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. read_sum")
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("7500", "Wh")), [], NOW)
|
||||
check("two members, kWh + Wh: 5 + 7.5 = 12.5 kWh",
|
||||
fc is not None and near(fc.today.kwh, 12.5) and why is None
|
||||
and gaps == ())
|
||||
check("...and the rest of the day adds up to the sum",
|
||||
near(fc.between(NOW, END), 12.5))
|
||||
fc, why, gaps = solarfc.read_sum(
|
||||
members(st("5"), st("2500", "Wh"), st("0.005", "MWh")), [], NOW)
|
||||
check("three members, kWh + Wh + MWh: 5 + 2.5 + 5 = 12.5 kWh",
|
||||
fc is not None and near(fc.today.kwh, 12.5))
|
||||
for unit in ("W", None, "kW", "%"):
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("5", unit)), [],
|
||||
NOW)
|
||||
check(f"a member in {unit or 'no unit'} makes today unusable (unit), "
|
||||
f"and is named",
|
||||
fc is None and why == solarfc.UNIT
|
||||
and gaps == (("today", 2, "sensor.fc_2", solarfc.UNIT),))
|
||||
for bad, w in (("unavailable", solarfc.MISSING), ("unknown", solarfc.MISSING),
|
||||
(None, solarfc.MISSING), ("-1", solarfc.BAD),
|
||||
("lots", solarfc.BAD)):
|
||||
s2 = None if bad is None else st(bad)
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), s2, st("4")), [], NOW)
|
||||
check(f"a member that reads {bad!r}: no forecast at all ({w}), not "
|
||||
f"the 9 kWh of the others",
|
||||
fc is None and why == w
|
||||
and gaps == (("today", 2, "sensor.fc_2", w),))
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4", at=OLD)), [], NOW)
|
||||
check("a stale member (older than 3 h): no forecast (stale), named",
|
||||
fc is None and why == solarfc.STALE
|
||||
and gaps == (("today", 2, "sensor.fc_2", solarfc.STALE),))
|
||||
EVE = datetime(2026, 9, 20, 23, 30, tzinfo=L).astimezone(timezone.utc)
|
||||
MID = datetime(2026, 9, 21, 0, 10, tzinfo=L).astimezone(timezone.utc)
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5", at=MID), st("0", at=EVE)),
|
||||
[], MID)
|
||||
check("a member from before local midnight is stale, not a dark member",
|
||||
fc is None and why == solarfc.STALE)
|
||||
fc, why, gaps = solarfc.read_sum(members(st("unavailable"), st("4", at=OLD)),
|
||||
[], NOW)
|
||||
check("two members down: both named, the first reason is the day's",
|
||||
fc is None and why == solarfc.MISSING and [g[1] for g in gaps] == [1, 2])
|
||||
check("an empty list is not set", solarfc.read_sum([], [], NOW)
|
||||
== (None, solarfc.NOT_SET, ())
|
||||
and solarfc.read_sum([], members(st("5")), NOW)[1] == solarfc.NOT_SET)
|
||||
fc, _w, _g = solarfc.read_sum(members(st("5", at=NOW - timedelta(hours=2)),
|
||||
st("4")), [], NOW)
|
||||
check("the forecast's time is the oldest member's report",
|
||||
fc.at == NOW - timedelta(hours=2))
|
||||
|
||||
# Tomorrow.
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4")),
|
||||
members(st("12"), st("8000", "Wh")), NOW)
|
||||
check("tomorrow: 12 + 8 = 20 kWh, and today is 9",
|
||||
fc is not None and near(fc.tomorrow.kwh, 20.0) and near(fc.today.kwh,
|
||||
9.0)
|
||||
and near(fc.between(TOM, TOM + timedelta(days=1)), 20.0))
|
||||
for name, t2, w in (("unavailable", st("unavailable"), solarfc.MISSING),
|
||||
("stale", st("8", at=OLD), solarfc.STALE),
|
||||
("in W", st("8", "W"), solarfc.UNIT)):
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4")),
|
||||
members(st("12"), t2), NOW)
|
||||
check(f"a tomorrow member {name}: tomorrow is not used (not 12 kWh), "
|
||||
f"today is kept, the member is named",
|
||||
fc is not None and fc.tomorrow is None and near(fc.today.kwh, 9.0)
|
||||
and why is None
|
||||
and gaps == (("tomorrow", 2, "sensor.fc_2", w),)
|
||||
and fc.between(NOW, TOM + timedelta(hours=9)) is None)
|
||||
fc, _w, gaps = solarfc.read_sum(members(st("5"), st("4")), [], NOW)
|
||||
check("no tomorrow list: today only, nothing named",
|
||||
fc.tomorrow is None and gaps == ())
|
||||
|
||||
# Hourly splits.
|
||||
sol = [{"period_start": (UTC0 + timedelta(minutes=30 * i)).isoformat(),
|
||||
"pv_estimate": 2.0 if 20 <= i < 36 else 0.0} for i in range(48)]
|
||||
wh = {(UTC0 + timedelta(hours=h)).isoformat(): (1000.0 if 8 <= h < 16
|
||||
else 0.0)
|
||||
for h in range(24)}
|
||||
fc, _w, _g = solarfc.read_sum(members(st("8.0", detailedForecast=sol),
|
||||
st("7.0", wh_period=wh)), [], NOW)
|
||||
one_s = solarfc.read(st("8.0", detailedForecast=sol), None, NOW)[0]
|
||||
one_w = solarfc.read(st("7.0", wh_period=wh), None, NOW)[0]
|
||||
spans = [(NOW, END)] + [
|
||||
(datetime(2026, 9, 21, h, tzinfo=L), datetime(2026, 9, 21, h + 1,
|
||||
tzinfo=L))
|
||||
for h in range(9, 20)]
|
||||
check("every member has a split: the splits are merged (hour by hour, the "
|
||||
"sum of each member's own split)",
|
||||
fc.today.periods and near(fc.today.kwh, 15.0)
|
||||
and all(near(fc.between(a, b), one_s.between(a, b)
|
||||
+ one_w.between(a, b)) for a, b in spans))
|
||||
check("...so 10:00-12:00 is 2 (Solcast) + 2 (Open-Meteo) = 4 kWh",
|
||||
near(fc.between(datetime(2026, 9, 21, 10, tzinfo=L),
|
||||
datetime(2026, 9, 21, 12, tzinfo=L)), 4.0))
|
||||
fc, _w, _g = solarfc.read_sum(members(st("8.0", detailedForecast=sol),
|
||||
st("7.0")), [], NOW)
|
||||
bell = solarfc.read(st("15.0"), None, NOW)[0]
|
||||
check("one member without a split: the sum goes on the bell, as one "
|
||||
"state-only sensor of 15 kWh would",
|
||||
fc.today.periods == () and near(fc.today.kwh, 15.0)
|
||||
and all(near(fc.between(a, b), bell.between(a, b)) for a, b in spans))
|
||||
wht = {(TOM + timedelta(hours=h)).isoformat(): (500.0 if 8 <= h < 16 else 0)
|
||||
for h in range(24)}
|
||||
fc, _w, _g = solarfc.read_sum(members(st("5")),
|
||||
members(st("4", wh_period=wht),
|
||||
st("4", wh_period=wht)), NOW)
|
||||
check("tomorrow's splits are merged too (8 x 0.5 kWh each, 8 in all)",
|
||||
fc.tomorrow.periods and near(fc.between(TOM, TOM + timedelta(days=1)),
|
||||
8.0)
|
||||
and near(fc.between(TOM + timedelta(hours=8),
|
||||
TOM + timedelta(hours=10)), 2.0))
|
||||
fc, _w, _g = solarfc.read_sum(members(st("5")),
|
||||
members(st("4", wh_period=wht), st("4")), NOW)
|
||||
check("...and fall back to the bell when one has none",
|
||||
fc.tomorrow.periods == () and near(fc.tomorrow.kwh, 8.0))
|
||||
|
||||
# One member is read().
|
||||
TODAYS = [st("12.5"), st("12500", "Wh"), st("0.0125", "MWh"), st("12.5", "W"),
|
||||
st("12.5", None), st("unavailable"), st("unknown"), None, st("-1"),
|
||||
st("lots"), st("12.5", at=OLD), st("12.5", at=NOW + timedelta(
|
||||
hours=1)), st("8.0", detailedForecast=sol),
|
||||
st("7.0", wh_period=wh), st("0")]
|
||||
TOMS = [None, st("20"), st("20", at=OLD), st("unavailable"), st("20", "W"),
|
||||
st("4", wh_period=wht)]
|
||||
WHENS = [NOW, MID, datetime(2026, 9, 21, 19, 30, tzinfo=L)]
|
||||
same, n = True, 0
|
||||
for now in WHENS:
|
||||
for a in TODAYS:
|
||||
for b in TOMS:
|
||||
n += 1
|
||||
one = solarfc.read(a, b, now)
|
||||
got = solarfc.read_sum([("sensor.f", a)],
|
||||
[("sensor.t", b)] if b is not None
|
||||
else [], now)
|
||||
same = same and got[:2] == one
|
||||
check(f"one member per day gives exactly what read() gives ({n} cases: "
|
||||
f"units, refusals, stale, splits, tomorrow)", same and n == 270)
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. entities")
|
||||
E = solarfc.entities
|
||||
check("the single key alone", E({"pv_forecast_entity": "sensor.a"})
|
||||
== ("sensor.a",))
|
||||
check("the list alone, in order", E({"pv_forecast_entities": [
|
||||
"sensor.b", "sensor.a"]}) == ("sensor.b", "sensor.a"))
|
||||
check("a list with an entry replaces the single key",
|
||||
E({"pv_forecast_entity": "sensor.a",
|
||||
"pv_forecast_entities": ["sensor.b"]}) == ("sensor.b",))
|
||||
check("an empty list or blank rows use the single key",
|
||||
E({"pv_forecast_entity": "sensor.a", "pv_forecast_entities": []})
|
||||
== ("sensor.a",)
|
||||
and E({"pv_forecast_entity": "sensor.a",
|
||||
"pv_forecast_entities": ["", " "]}) == ("sensor.a",))
|
||||
check("a repeated id counts once (it would double that forecast)",
|
||||
E({"pv_forecast_entities": ["sensor.a", "sensor.b", "sensor.a"]})
|
||||
== ("sensor.a", "sensor.b"))
|
||||
check("tomorrow reads its own keys",
|
||||
E({"pv_forecast_entities": ["sensor.a"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.t1", "sensor.t2"]},
|
||||
tomorrow=True) == ("sensor.t1", "sensor.t2")
|
||||
and E({"pv_forecast_tomorrow_entity": "sensor.t"}, tomorrow=True)
|
||||
== ("sensor.t",))
|
||||
check("nothing set: ()", E({}) == () and E({}, tomorrow=True) == ())
|
||||
|
||||
# ============================================================================ #
|
||||
print("3. options")
|
||||
F = validate._forecast_problems
|
||||
for one, many in (solarfc.TODAY_KEYS, solarfc.TOMORROW_KEYS):
|
||||
p = F({one: "sensor.a", many: ["sensor.b", "sensor.c"]})
|
||||
check(f"{one} and {many} both set: refused, one plain message that "
|
||||
f"says which to keep",
|
||||
len(p) == 1 and "not both" in p[0] and "Keep the list" in p[0]
|
||||
and one in p[0] and many in p[0] and len(p[0].split()) <= 30)
|
||||
check(f"...{many} alone is fine, and so is {one} alone",
|
||||
not F({many: ["sensor.b", "sensor.c"]}) and not F({one: "sensor.a"})
|
||||
and not F({one: "sensor.a", many: []})
|
||||
and not F({one: "", many: ["sensor.b"]}))
|
||||
p = F({many: ["sensor.b", "number.x"]})
|
||||
check(f"...an entry in {many} that is not a sensor is refused",
|
||||
len(p) == 1 and "only sensors" in p[0] and "number.x" in p[0])
|
||||
check(f"...and {many} that is not a list is refused",
|
||||
F({many: "sensor.b"}) and "list of sensors" in F({many: "x"})[0])
|
||||
with open(os.path.join(HERE, "translations", "en.yaml"),
|
||||
encoding="utf-8") as fh:
|
||||
EN = fh.read()
|
||||
for k in solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS:
|
||||
name = re.search(rf"^ {k}:\n name: \"Hardware · ([^\"(]+?)"
|
||||
rf"(?: \(kWh\))?\"$", EN, re.M)
|
||||
check(f"the refusal names {k} by its label on the tab",
|
||||
name is not None
|
||||
and f"“{name.group(1)}” ({k})" in validate.S(k))
|
||||
print(" " + F({"pv_forecast_entity": "sensor.a",
|
||||
"pv_forecast_entities": ["sensor.b"]})[0])
|
||||
VBASE = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000, "battery_modes_enabled": True}
|
||||
check("the start-up check refuses both keys set for a day",
|
||||
any("not both" in p for p in validate.validate_startup_config(
|
||||
dict(VBASE, pv_forecast_tomorrow_entity="sensor.a",
|
||||
pv_forecast_tomorrow_entities=["sensor.b"])))
|
||||
and validate.validate_startup_config(
|
||||
dict(VBASE, pv_forecast_entities=["sensor.a", "sensor.b"],
|
||||
pv_forecast_tomorrow_entities=["sensor.c", "sensor.d"]))
|
||||
== [])
|
||||
notes = []
|
||||
validate._entity_notes(notes, dict(VBASE, pv_forecast_entities=[
|
||||
"sensor.a", "sensor.b", "sensor.gone"]),
|
||||
{"sensor.a": {"unit": "kWh"}, "sensor.b": {"unit": "W"}})
|
||||
check("settings notes: a listed sensor not in kWh, and one Home Assistant "
|
||||
"does not have, are each named",
|
||||
any("sensor.b" in n.text and "kWh" in n.text for n in notes)
|
||||
and any("sensor.gone" in n.text for n in notes)
|
||||
and not any("sensor.a " in n.text for n in notes))
|
||||
notes = []
|
||||
validate._entity_notes(notes, dict(VBASE, pv_forecast_tomorrow_entities=[
|
||||
"sensor.t"]), {"sensor.t": {"unit": "kWh"}})
|
||||
check("a tomorrow list without today's forecast gets the same warning as "
|
||||
"the single key", any("used only with today's" in n.text
|
||||
for n in notes))
|
||||
notes = []
|
||||
validate._entity_notes(notes, {"pv_forecast_entities": ["sensor.a"]},
|
||||
{"sensor.a": {"unit": "kWh"}})
|
||||
check("a list with battery modes off gets the 'unused' note",
|
||||
any("battery modes on" in n.text for n in notes))
|
||||
with open(os.path.join(HERE, "config.yaml"), encoding="utf-8") as fh:
|
||||
CFG = fh.read()
|
||||
for k in ("pv_forecast_entities", "pv_forecast_tomorrow_entities"):
|
||||
check(f"config.yaml: {k} defaults to [] (load-bearing) and is a list "
|
||||
f"of str",
|
||||
re.search(rf"^ {k}: \[\]$", CFG, re.M) is not None
|
||||
and re.search(rf"^ {k}:\n - str$", CFG, re.M) is not None)
|
||||
for k in solarfc.TODAY_KEYS[:1] + solarfc.TOMORROW_KEYS[:1]:
|
||||
check(f"config.yaml: {k} is unchanged (str?, default \"\")",
|
||||
re.search(rf"^ {k}: \"\"$", CFG, re.M) is not None
|
||||
and re.search(rf"^ {k}: str\?$", CFG, re.M) is not None)
|
||||
|
||||
# ============================================================================ #
|
||||
print("4. the real controller")
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
# Prices as in test_modes_p4: cheap 01:00-03:00, dear 19:00-20:00 local.
|
||||
DAYW = [0.20] * 96 * 2
|
||||
for i in list(range(4, 12)) + list(range(100, 108)):
|
||||
DAYW[i] = 0.02
|
||||
for i in list(range(76, 80)) + list(range(172, 176)):
|
||||
DAYW[i] = 0.40
|
||||
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p)
|
||||
for i, p in enumerate(DAYW)]
|
||||
SF_LS = {"a": (True, "solar_first", False),
|
||||
"b": (True, "leftover_solar", False)}
|
||||
# Each fake sensor: (share of the day's sun, has a split, status).
|
||||
SENSORS = {}
|
||||
FC_KWH = [1.0]
|
||||
START = [UTC0]
|
||||
CALLS = []
|
||||
|
||||
|
||||
async def _sensor(self, e):
|
||||
CALLS.append(("start", e))
|
||||
await asyncio.sleep(0)
|
||||
CALLS.append(("end", e))
|
||||
now = START[0] + timedelta(seconds=self.t)
|
||||
share, split, status = SENSORS[e]
|
||||
if status == "down":
|
||||
return {"state": "unavailable", "attributes": {},
|
||||
"last_updated": now.isoformat()}
|
||||
at = now - timedelta(hours=4) if status == "stale" else now
|
||||
loc = now.astimezone(L)
|
||||
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if e.startswith("sensor.tom"):
|
||||
d0 += timedelta(days=1)
|
||||
wh = {(d0 + timedelta(hours=h)).astimezone(timezone.utc).isoformat():
|
||||
(FC_KWH[0] * 100.0 * share if 8 <= h < 18 else 0.0)
|
||||
for h in range(24)}
|
||||
left = sum(v for k, v in wh.items()
|
||||
if datetime.fromisoformat(k) >= now) / 1000.0
|
||||
attrs = {"unit_of_measurement": "kWh"}
|
||||
if split:
|
||||
attrs["wh_period"] = wh
|
||||
return {"state": f"{left:.6f}", "attributes": attrs,
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
rp.Hass.state = _sensor
|
||||
|
||||
|
||||
def seed(c):
|
||||
if c.house_profile is not None:
|
||||
hp = dayplan.HouseProfile()
|
||||
t = UTC0 - timedelta(days=1)
|
||||
while t < UTC0:
|
||||
h = t.astimezone(L).hour
|
||||
hp.add(t, 3000.0 if 17 <= h < 22 else 600.0)
|
||||
t += timedelta(seconds=5)
|
||||
hp.add(t, None)
|
||||
c.house_profile = hp
|
||||
|
||||
|
||||
def run(opts, hours=4.0, modes=True):
|
||||
start = UTC0 + timedelta(hours=0.75)
|
||||
START[0] = start
|
||||
rows = []
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
sh = dict(c.allocation.shares) if c.allocation is not None and \
|
||||
c.allocation.shares else {}
|
||||
rows.append((now, round(h.grid(), 6), tuple(sorted(sh.items())),
|
||||
getattr(c.arb, "action", None),
|
||||
tuple(sorted(c.mode_tick.roles.items()))
|
||||
if c.mode_tick else (),
|
||||
tuple(sorted((u, round(p.soc, 6))
|
||||
for u, p in h.plants.items())),
|
||||
tuple(sorted((c.plan_targets() or {}).items())),
|
||||
None if c.day_plan is None else c.day_plan.verdict))
|
||||
_st, c = rp.run("p3_ls" if modes else "modes_off", [(UTC0.timestamp()
|
||||
- 10, 800.0)],
|
||||
WPTS, start, 3600 * hours, check=cb,
|
||||
soc0={"a": 60.0, "b": 30.0}, choice=SF_LS,
|
||||
extra=dict({"cycle_cost_threshold_eur": 0.20}, **opts),
|
||||
patch=seed)
|
||||
return rows, c
|
||||
|
||||
|
||||
def fc_rows(c):
|
||||
return [r for r in c.checks() if r["id"] == "solar_forecast"]
|
||||
|
||||
|
||||
def fc_events(c):
|
||||
return [e for e in c.events if "solar forecast" in e]
|
||||
|
||||
|
||||
SENSORS.update({
|
||||
"sensor.all": (1.0, True, "ok"), "sensor.h1": (0.5, True, "ok"),
|
||||
"sensor.h2": (0.5, True, "ok"), "sensor.h2_flat": (0.5, False, "ok"),
|
||||
"sensor.down": (0.5, True, "down"), "sensor.stale": (0.5, True, "stale"),
|
||||
"sensor.tom_all": (1.0, True, "ok"), "sensor.tom_h1": (0.5, True, "ok"),
|
||||
"sensor.tom_down": (0.5, True, "down"), "sensor.none": (1.0, True, "down"),
|
||||
})
|
||||
single, c_single = run({"pv_forecast_entity": "sensor.all"})
|
||||
listed, c_listed = run({"pv_forecast_entities": ["sensor.all"]})
|
||||
plans = [r for r in single if r[6]]
|
||||
check(f"the run makes a day plan with a level ({len(plans)} of "
|
||||
f"{len(single)} cycles), so the forecast steers something",
|
||||
plans and any(r[7] is not None for r in single))
|
||||
check("a one-member list runs exactly like the single key: every grid "
|
||||
"figure, share, action, role, level and plan, cycle by cycle",
|
||||
single == listed and len(single) > 2000)
|
||||
check("...and says the same on the status page and in the action log",
|
||||
fc_rows(c_single) == fc_rows(c_listed) == []
|
||||
and fc_events(c_single) == fc_events(c_listed))
|
||||
halves, c_halves = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"]})
|
||||
check("two members of half the sun each run like one sensor of all of it",
|
||||
[r[:6] + (r[7],) for r in halves] == [r[:6] + (r[7],) for r in single]
|
||||
and all(len(a[6]) == len(b[6]) and all(
|
||||
k1 == k2 and abs(v1 - v2) < 1e-6 for (k1, v1), (k2, v2)
|
||||
in zip(a[6], b[6])) for a, b in zip(halves, single)))
|
||||
none_, c_none = run({"pv_forecast_entity": "sensor.none"})
|
||||
check("(the comparison bites: no forecast runs differently)",
|
||||
[r[:6] for r in none_] != [r[:6] for r in single])
|
||||
for name, mem in (("unavailable", "sensor.down"), ("stale", "sensor.stale")):
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1", mem]})
|
||||
check(f"a member {name}: the controller runs exactly as with the single "
|
||||
f"forecast unavailable (no partial sum)", rows == none_)
|
||||
check(f"...the status page names it by place, in one short line",
|
||||
[r["text"] for r in fc_rows(c)] == [
|
||||
"Solar forecast for today: not used, source 2 of 2 has no "
|
||||
"reading"] and fc_rows(c)[0]["warn"])
|
||||
ev = fc_events(c)
|
||||
check(f"...and the action log names it once in {len(single)} cycles",
|
||||
len(ev) == 1 and mem in ev[0] and "today" in ev[0])
|
||||
one_down, c_one_down = run({"pv_forecast_entities": ["sensor.none"]})
|
||||
check("a one-member list that is down runs, shows and logs exactly like the "
|
||||
"single key down", one_down == none_
|
||||
and fc_rows(c_one_down) == fc_rows(c_none)
|
||||
and fc_events(c_one_down) == fc_events(c_none))
|
||||
# A forecast missing from the start logs nothing (the start state is
|
||||
# already "missing"); that is release/1.0's behaviour, kept.
|
||||
check("the single key down: no new row and no new log line",
|
||||
fc_rows(c_none) == [] and fc_events(c_none) == [])
|
||||
CALLS.clear()
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.tom_h1",
|
||||
"sensor.tom_down"]},
|
||||
hours=0.2)
|
||||
check("a tomorrow member down: today is used, tomorrow is not, and the row "
|
||||
"says so",
|
||||
c.solar_fc is not None and c.solar_fc.tomorrow is None
|
||||
and [r["text"] for r in fc_rows(c)] == [
|
||||
"Solar forecast for tomorrow: not used, source 2 of 2 has no "
|
||||
"reading"]
|
||||
and len(fc_events(c)) == 1 and "sensor.tom_down" in fc_events(c)[0])
|
||||
first = CALLS[:8]
|
||||
check("every member of both days is read in one gather (all four start "
|
||||
"before any ends)",
|
||||
[k for k, _e in first] == ["start"] * 4 + ["end"] * 4
|
||||
and sorted(e for _k, e in first[:4]) == sorted(
|
||||
["sensor.h1", "sensor.h2", "sensor.tom_h1", "sensor.tom_down"]))
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.tom_all"]},
|
||||
hours=0.2)
|
||||
check("tomorrow: a one-member list is read and counted",
|
||||
c.solar_fc is not None and c.solar_fc.tomorrow is not None)
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2_flat"]},
|
||||
hours=0.2)
|
||||
check("a member without a split: the controller's forecast is on the bell",
|
||||
c.solar_fc is not None and c.solar_fc.today.periods == ())
|
||||
CALLS.clear()
|
||||
run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"]}, hours=0.2,
|
||||
modes=False)
|
||||
check("battery modes off: no forecast member is read", CALLS == [])
|
||||
check("the status row is a STYLE status row (<= 15 words, no entity id)",
|
||||
all(len(t.split()) <= 15 and "sensor." not in t for t in (
|
||||
messages.row_forecast_gap("today", [2, 3], 3),
|
||||
messages.row_forecast_gap("tomorrow", [1], 2))))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -767,6 +767,79 @@ ok_i4, out_i4 = validate(dict(defaults, **{_mk: 24}))
|
||||
show("the range is unchanged: 24 is refused (`at most 23`)",
|
||||
(not ok_i4) and "at most 23" in str(out_i4), out_i4)
|
||||
|
||||
print("\n== J. OPT-17: several solar forecast entities per day ==")
|
||||
# The same question as §G, for `pv_forecast_entity` /
|
||||
# `pv_forecast_tomorrow_entity` (`str?` since 0.15.0). Each refusal is
|
||||
# varied from the accepted baseline and asserted on the key's name.
|
||||
# DECISIONS/OPT-17-several-forecast-entities.md.
|
||||
FC_STORED = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
|
||||
"pv_forecast_tomorrow_entity": "sensor.energy_production_tomorrow"}
|
||||
_fc_old = {k: v for k, v in defaults.items()
|
||||
if k not in ("pv_forecast_entities", "pv_forecast_tomorrow_entities")}
|
||||
_fc_base = MERGER.merge(copy.deepcopy(defaults),
|
||||
dict(_fc_old, **FC_STORED))
|
||||
ok_j, out_j = validate(_fc_base)
|
||||
show("a 0.17.0 blob (single forecast keys set, no list keys) validates "
|
||||
"unchanged, and both new keys default to []",
|
||||
ok_j and out_j.get("pv_forecast_entity") == FC_STORED["pv_forecast_entity"]
|
||||
and out_j.get("pv_forecast_entities") == []
|
||||
and out_j.get("pv_forecast_tomorrow_entities") == [],
|
||||
"" if ok_j else out_j)
|
||||
for _k in ("pv_forecast_entity", "pv_forecast_tomorrow_entity"):
|
||||
_s1 = dict(schema, **{_k: ["str"]})
|
||||
ok_j1, out_j1 = validate_with(_s1, _fc_base)
|
||||
show(f"`{_k}` declared as a LIST refuses the string an install stores",
|
||||
(not ok_j1) and f"Invalid list for option '{_k}'" in str(out_j1),
|
||||
out_j1)
|
||||
_m1 = MERGER.merge(copy.deepcopy(dict(defaults, **{_k: []})),
|
||||
copy.deepcopy(FC_STORED))
|
||||
ok_j2, out_j2 = validate_with(_s1, _m1)
|
||||
show("...and a `[]` default does not rescue it (list merge is override)",
|
||||
_m1[_k] == FC_STORED[_k] and not ok_j2, out_j2)
|
||||
_sm = dict(schema, **{_k: "match(.*)?"})
|
||||
_ok_s, _ = validate_with(_sm, _fc_base)
|
||||
_ok_l, _ = validate_with(_sm, dict(_fc_base, **{_k: ["sensor.a",
|
||||
"sensor.b"]}))
|
||||
_ok_n, _ = validate_with(_sm, dict(_fc_base, **{_k: 417}))
|
||||
_ui_m = _ui_of(_sm, _k)
|
||||
show(f"...`match(.*)?` on `{_k}` takes a string AND a list, but also the "
|
||||
"integer 417, and renders as a plain text box (no second row)",
|
||||
_ok_s and _ok_l and _ok_n and _ui_m
|
||||
and _ui_m[0].get("multiple") is not True,
|
||||
f"string={_ok_s} list={_ok_l} integer-417={_ok_n} "
|
||||
f"ui={json.dumps(_ui_m)}")
|
||||
_two = ["sensor.fc_east", "sensor.fc_west"]
|
||||
ok_j3, out_j3 = validate(MERGER.merge(copy.deepcopy(defaults), dict(
|
||||
_fc_old, pv_forecast_entities=_two,
|
||||
pv_forecast_tomorrow_entities=_two)))
|
||||
show("⭐ SHIPPED: two-entity lists for today and tomorrow validate",
|
||||
ok_j3 and out_j3.get("pv_forecast_entities") == _two
|
||||
and out_j3.get("pv_forecast_tomorrow_entities") == _two,
|
||||
"" if ok_j3 else out_j3)
|
||||
for _k in ("pv_forecast_entities", "pv_forecast_tomorrow_entities"):
|
||||
ok_j4, out_j4 = validate({k: v for k, v in defaults.items() if k != _k})
|
||||
show(f"...and the `options:` block with ONLY `{_k}` removed is refused "
|
||||
"by name, so its `[]` default is load-bearing",
|
||||
(not ok_j4) and f"Missing option '{_k}' in root" in str(out_j4),
|
||||
out_j4)
|
||||
_ui_j = _ui_of(schema, _k)
|
||||
show(f"...and `{_k}` renders as a repeatable string field",
|
||||
len(_ui_j) == 1 and _ui_j[0].get("multiple") is True
|
||||
and _ui_j[0].get("type") == "string", json.dumps(_ui_j))
|
||||
_flat_j = {k: v for k, v in schema.items()
|
||||
if k not in ("pv_forecast_entities", "pv_forecast_tomorrow_entities")}
|
||||
try:
|
||||
_rj = vol.Schema(vol.All(dict, AppOptions(
|
||||
None, _flat_j, cfg["name"], "local_goodwe_controller")))(
|
||||
MERGER.merge(copy.deepcopy(defaults), dict(
|
||||
FC_STORED, pv_forecast_entities=_two)))
|
||||
show("a DOWNGRADE to 0.17.0's schema drops the list keys and keeps the "
|
||||
"single ones",
|
||||
"pv_forecast_entities" not in _rj
|
||||
and _rj.get("pv_forecast_entity") == FC_STORED["pv_forecast_entity"])
|
||||
except vol.Invalid as e:
|
||||
show("a DOWNGRADE to 0.17.0's schema drops the list keys", False, str(e))
|
||||
|
||||
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
|
||||
# and still succeeds, and a transcript is only evidence if a wrong answer is
|
||||
# distinguishable from a right one by something other than reading it.
|
||||
|
||||
@@ -91,6 +91,12 @@ configuration:
|
||||
pv_forecast_tomorrow_entity:
|
||||
name: "Hardware · Solar forecast, tomorrow (kWh)"
|
||||
description: "Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight."
|
||||
pv_forecast_entities:
|
||||
name: "Hardware · Several solar forecasts, today (kWh)"
|
||||
description: "Optional. One row per forecast sensor, such as one per inverter, used instead of the single one above. They are added up."
|
||||
pv_forecast_tomorrow_entities:
|
||||
name: "Hardware · Several solar forecasts, tomorrow (kWh)"
|
||||
description: "Optional. The same list for tomorrow. If one sensor has no reading, that day's forecast is not used."
|
||||
solar_trust_pct:
|
||||
name: "Hardware · Trust in the forecast (%)"
|
||||
description: "Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast."
|
||||
|
||||
@@ -72,6 +72,12 @@ configuration:
|
||||
pv_forecast_tomorrow_entity:
|
||||
name: "Hardware · Zonneprognose, morgen (kWh)"
|
||||
description: "Optioneel. Dezelfde prognose voor morgen. Ze helpt een goedkoop uur voor middernacht te plannen."
|
||||
pv_forecast_entities:
|
||||
name: "Hardware · Meerdere zonneprognoses, vandaag (kWh)"
|
||||
description: "Optioneel. Eén rij per prognosesensor, bijvoorbeeld per omvormer, in plaats van de losse sensor hierboven. Ze worden opgeteld."
|
||||
pv_forecast_tomorrow_entities:
|
||||
name: "Hardware · Meerdere zonneprognoses, morgen (kWh)"
|
||||
description: "Optioneel. Dezelfde lijst voor morgen. Heeft één sensor geen meting, dan wordt de prognose voor die dag niet gebruikt."
|
||||
solar_trust_pct:
|
||||
name: "Hardware · Vertrouwen in de prognose (%)"
|
||||
description: "Prognoses zijn vaak te zonnig. Bij 70 % plant de add-on met 7 van elke 10 kWh uit de prognose."
|
||||
|
||||
Reference in New Issue
Block a user