OPT-11 P4 review: Cheap hours ignores the plan; midnight-stale forecast; stricter ids
- Cheap hours only fills to the ceiling with or without a forecast, as the design (section 1) says. Skipping the sun's share cost 0.31 EUR on real 2026 prices (29.72 against P3's 29.41) and saved nothing on the P3 set. - A forecast reading from before local midnight is stale: until the integration refreshes, "left today" is still yesterday's 0 and "tomorrow" is really today. - Forecast entity ids must be sensor.[a-z0-9_]+ (they go into the /api/states/<id> path). - House profile: a stored day that is not a date, or is after today, is dropped instead of kept forever. - Text: the forecast line says "for the rest of today"; the learning line no longer promises "tomorrow" (a plan needs one full day). - Tests for four surviving review mutants (hour sample floor, the dear run after the cheap run, share by room, daytime use) and the fixes above. - Replay tool: `--fc zero` (a forecast of no sun). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
aabc2a27fd
commit
fbd884797d
@@ -545,7 +545,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
|||||||
- *Solar first* never charges from the grid.
|
- *Solar first* never charges from the grid.
|
||||||
- The house use is the add-on's own measurement (grid + battery power) per hour, over the last 7 days. It needs one day of it before the first plan.
|
- The house use is the add-on's own measurement (grid + battery power) per hour, over the last 7 days. It needs one day of it before the first plan.
|
||||||
- The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it.
|
- The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it.
|
||||||
- No forecast, one older than 3 hours, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so.
|
- No forecast, one older than 3 hours or from before midnight, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so.
|
||||||
- A battery needs its capacity (`inverters.capacity_kwh`) for a plan.
|
- A battery needs its capacity (`inverters.capacity_kwh`) for a plan.
|
||||||
|
|
||||||
**What always comes first.**
|
**What always comes first.**
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ The sums (OPT-11-design section 1, "Excess solar", with the owner's answers):
|
|||||||
expensive run the cheap run is for, x the charge efficiency:
|
expensive run the cheap run is for, x the charge efficiency:
|
||||||
`trust x sum_h max(0, forecast_h - house_h) x EFF`.
|
`trust x sum_h max(0, forecast_h - house_h) x EFF`.
|
||||||
* Solar first batteries take it first, up to the ceiling. What is left is
|
* Solar first batteries take it first, up to the ceiling. What is left is
|
||||||
shared by the Leftover solar and Cheap hours batteries by their room.
|
shared by the Leftover solar batteries by their room.
|
||||||
* A Cheap hours battery charges from the grid up to `ceiling - its share`.
|
* Cheap hours only ignores the plan and fills to the ceiling, as in P3 and
|
||||||
|
the design (section 1). Skipping the sun's share cost money in the replay
|
||||||
|
(PR #156 review: 29.72 against 29.41 EUR on real 2026 prices).
|
||||||
* A Leftover solar battery charges only up to what the house will draw from
|
* A Leftover solar battery charges only up to what the house will draw from
|
||||||
it in the expensive runs before the next cheap run, less its share:
|
it in the expensive runs before the next cheap run, less its share:
|
||||||
`min(ceiling, floor + house use / EFF) - share`. So it buys only the
|
`min(ceiling, floor + house use / EFF) - share`. So it buys only the
|
||||||
@@ -32,7 +34,7 @@ import statistics
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from .packmode import CHEAP_HOURS, LEFTOVER_SOLAR, SOLAR_FIRST
|
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
|
||||||
from .tariff import BILLING_TZ
|
from .tariff import BILLING_TZ
|
||||||
|
|
||||||
EFF = 0.95 # charge (and discharge) efficiency
|
EFF = 0.95 # charge (and discharge) efficiency
|
||||||
@@ -67,6 +69,10 @@ class HouseProfile:
|
|||||||
|
|
||||||
def load(self, data):
|
def load(self, data):
|
||||||
for day, hours in data.items():
|
for day, hours in data.items():
|
||||||
|
try:
|
||||||
|
datetime.strptime(str(day), "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
if not isinstance(hours, dict):
|
if not isinstance(hours, dict):
|
||||||
continue
|
continue
|
||||||
row = {}
|
row = {}
|
||||||
@@ -105,8 +111,11 @@ class HouseProfile:
|
|||||||
return closed
|
return closed
|
||||||
|
|
||||||
def _prune(self, lt):
|
def _prune(self, lt):
|
||||||
|
"""Keep the last `PROFILE_DAYS` days; a day after today (the clock
|
||||||
|
was wrong once) would never age out, so it goes too."""
|
||||||
keep = (lt.date() - timedelta(days=PROFILE_DAYS)).isoformat()
|
keep = (lt.date() - timedelta(days=PROFILE_DAYS)).isoformat()
|
||||||
for d in [d for d in self.days if d < keep]:
|
today = lt.date().isoformat()
|
||||||
|
for d in [d for d in self.days if d < keep or d > today]:
|
||||||
del self.days[d]
|
del self.days[d]
|
||||||
|
|
||||||
def ready(self) -> bool:
|
def ready(self) -> bool:
|
||||||
@@ -142,7 +151,7 @@ class Plan:
|
|||||||
sun_kwh: float # solar the batteries can count on
|
sun_kwh: float # solar the batteries can count on
|
||||||
fc_kwh: float # the forecast over the same span
|
fc_kwh: float # the forecast over the same span
|
||||||
until: datetime # the expensive run the plan is for
|
until: datetime # the expensive run the plan is for
|
||||||
targets: dict # name -> level % (LS and CH only)
|
targets: dict # name -> level % (Leftover solar only)
|
||||||
need_kwh: dict # name -> kWh from the grid (0: none)
|
need_kwh: dict # name -> kWh from the grid (0: none)
|
||||||
share_kwh: dict # name -> kWh of solar counted for it
|
share_kwh: dict # name -> kWh of solar counted for it
|
||||||
|
|
||||||
@@ -195,7 +204,7 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
|||||||
usable = [p for p in packs if p.use and p.live and p.idle
|
usable = [p for p in packs if p.use and p.live and p.idle
|
||||||
and p.soc is not None and p.capacity_kwh]
|
and p.soc is not None and p.capacity_kwh]
|
||||||
sf = [p for p in usable if p.mode == SOLAR_FIRST]
|
sf = [p for p in usable if p.mode == SOLAR_FIRST]
|
||||||
price = [p for p in usable if p.mode in (LEFTOVER_SOLAR, CHEAP_HOURS)]
|
price = [p for p in usable if p.mode == LEFTOVER_SOLAR]
|
||||||
sf_room = sum(_room(p, ceiling_pct) for p in sf)
|
sf_room = sum(_room(p, ceiling_pct) for p in sf)
|
||||||
excess = max(0.0, sun - sf_room)
|
excess = max(0.0, sun - sf_room)
|
||||||
rooms = {p.name: _room(p, ceiling_pct) for p in price}
|
rooms = {p.name: _room(p, ceiling_pct) for p in price}
|
||||||
@@ -218,9 +227,6 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
|||||||
for p in price:
|
for p in price:
|
||||||
sh = excess * rooms[p.name] / total_room if total_room > 0 else 0.0
|
sh = excess * rooms[p.name] / total_room if total_room > 0 else 0.0
|
||||||
sh_pct = sh / caps[p.name] * 100.0
|
sh_pct = sh / caps[p.name] * 100.0
|
||||||
if p.mode == CHEAP_HOURS:
|
|
||||||
top = float(ceiling_pct)
|
|
||||||
else:
|
|
||||||
want = use_kwh * caps[p.name] / cap_sum / EFF
|
want = use_kwh * caps[p.name] / cap_sum / EFF
|
||||||
top = min(float(ceiling_pct),
|
top = min(float(ceiling_pct),
|
||||||
float(floor_pct) + want / caps[p.name] * 100.0)
|
float(floor_pct) + want / caps[p.name] * 100.0)
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ Roles (the page's Why line names them):
|
|||||||
Rules that keep this safe (tests: test_modes_p3.py):
|
Rules that keep this safe (tests: test_modes_p3.py):
|
||||||
* every range holds 0 W (a mode may forbid a direction, never force one);
|
* every range holds 0 W (a mode may forbid a direction, never force one);
|
||||||
* a Solar first battery never charges from the grid;
|
* a Solar first battery never charges from the grid;
|
||||||
* P4: with a day plan (`targets`), a Leftover solar or Cheap hours battery
|
* P4: with a day plan (`targets`), a Leftover solar battery charges from
|
||||||
charges from the grid only up to its planned level, never above the
|
the grid only up to its planned level, never above the ceiling; with
|
||||||
ceiling; with none, Cheap hours fills to the ceiling and Leftover solar
|
none it takes solar only (P3 option A). Cheap hours only fills to the
|
||||||
takes solar only (P3 option A, `charge_to()`);
|
ceiling either way (`charge_to()`);
|
||||||
* grid charging needs a measured peak guard that is clear and a measured
|
* grid charging needs a measured peak guard that is clear and a measured
|
||||||
headroom (SAFETY-45); the trade is sized to that headroom, whose aim is
|
headroom (SAFETY-45); the trade is sized to that headroom, whose aim is
|
||||||
`peak_cap_w - peak_margin_w` (SAFETY-79). It starts only at START_W or
|
`peak_cap_w - peak_margin_w` (SAFETY-79). It starts only at START_W or
|
||||||
@@ -98,12 +98,13 @@ def charge_to(p: Pack, ceiling_pct, targets=None):
|
|||||||
`targets` is the day plan (`dayplan.plan()`: name -> level), or None
|
`targets` is the day plan (`dayplan.plan()`: name -> level), or None
|
||||||
when there is none (no forecast, stale, or not configured). With no plan
|
when there is none (no forecast, stale, or not configured). With no plan
|
||||||
this is P3 option A exactly: Cheap hours to the ceiling, the others never.
|
this is P3 option A exactly: Cheap hours to the ceiling, the others never.
|
||||||
With a plan, a Leftover solar or Cheap hours battery charges only to its
|
With a plan, a Leftover solar battery charges only to its planned level
|
||||||
planned level (the sun fills the rest), and a battery the plan leaves out
|
(the sun fills the rest); a battery the plan leaves out, Cheap hours only
|
||||||
falls back to the no-plan answer. Solar first never charges."""
|
included, gets the no-plan answer. Solar first never charges."""
|
||||||
if targets is None or p.name not in targets:
|
if p.mode in GRID_CHARGE_MODES:
|
||||||
return float(ceiling_pct) if p.mode in GRID_CHARGE_MODES else None
|
return float(ceiling_pct)
|
||||||
if p.mode not in PLAN_CHARGE_MODES:
|
if targets is None or p.name not in targets \
|
||||||
|
or p.mode not in PLAN_CHARGE_MODES:
|
||||||
return None
|
return None
|
||||||
t = targets[p.name]
|
t = targets[p.name]
|
||||||
return None if t is None else min(float(ceiling_pct), float(t))
|
return None if t is None else min(float(ceiling_pct), float(t))
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ SELLING_ENABLED = False
|
|||||||
# The modes that may charge from the grid in a cheap run with no day plan.
|
# The modes that may charge from the grid in a cheap run with no day plan.
|
||||||
# Leftover solar takes solar only then (owner decision 2026-09-29, option A).
|
# Leftover solar takes solar only then (owner decision 2026-09-29, option A).
|
||||||
GRID_CHARGE_MODES = (CHEAP_HOURS,)
|
GRID_CHARGE_MODES = (CHEAP_HOURS,)
|
||||||
# OPT-11 P4: with a fresh solar forecast, the day plan (dayplan.py) may also
|
# OPT-11 P4: with a fresh solar forecast, the day plan (dayplan.py) lets
|
||||||
# let Leftover solar charge from the grid, by the shortfall only. Solar first
|
# Leftover solar charge from the grid, by the shortfall only. Cheap hours only
|
||||||
# never charges from the grid.
|
# ignores the plan (fills to the ceiling); Solar first never grid-charges.
|
||||||
PLAN_CHARGE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
|
PLAN_CHARGE_MODES = (LEFTOVER_SOLAR,)
|
||||||
|
|
||||||
|
|
||||||
def enabled(opts) -> bool:
|
def enabled(opts) -> bool:
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ state only. The unit is read, never guessed (the rule `pv.watts()` has).
|
|||||||
Without an hourly split, the day's energy is spread over a fixed bell from
|
Without an hourly split, the day's energy is spread over a fixed bell from
|
||||||
06:00 to 20:00 local time (`BELL_FROM_H`, `BELL_TO_H`).
|
06:00 to 20:00 local time (`BELL_FROM_H`, `BELL_TO_H`).
|
||||||
|
|
||||||
A reading older than `FRESH_S` is stale, and a stale or missing forecast is
|
A reading older than `FRESH_S`, or from before local midnight, is stale,
|
||||||
no forecast: the day plan is off and every mode acts as in P3 option A.
|
and a stale or missing forecast is no forecast: the day plan is off and
|
||||||
|
every mode acts as in P3 option A.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
@@ -238,14 +239,16 @@ def read(today_state, tomorrow_state, now: datetime, *,
|
|||||||
if today is None:
|
if today is None:
|
||||||
return None, why
|
return None, why
|
||||||
at = _reported(today_state)
|
at = _reported(today_state)
|
||||||
if at is None or (now - at).total_seconds() > FRESH_S \
|
# 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:
|
or (at - now).total_seconds() > 300:
|
||||||
return None, STALE
|
return None, STALE
|
||||||
tomorrow = None
|
tomorrow = None
|
||||||
if tomorrow_state is not None:
|
if tomorrow_state is not None:
|
||||||
t, _w = _day(tomorrow_state, day1, now, today=False)
|
t, _w = _day(tomorrow_state, day1, now, today=False)
|
||||||
t_at = _reported(tomorrow_state)
|
t_at = _reported(tomorrow_state)
|
||||||
if t is not None and t_at is not None \
|
if t is not None and t_at is not None and t_at >= day0 \
|
||||||
and (now - t_at).total_seconds() <= FRESH_S:
|
and (now - t_at).total_seconds() <= FRESH_S:
|
||||||
tomorrow = t
|
tomorrow = t
|
||||||
return Forecast(today, tomorrow, at), None
|
return Forecast(today, tomorrow, at), None
|
||||||
|
|||||||
@@ -339,7 +339,7 @@
|
|||||||
"tops_up": "Too little sun is forecast, so it charges some from the grid.",
|
"tops_up": "Too little sun is forecast, so it charges some from the grid.",
|
||||||
"no_forecast": "No solar forecast, so it charges from solar only."
|
"no_forecast": "No solar forecast, so it charges from solar only."
|
||||||
},
|
},
|
||||||
"solar": {"line": "Solar forecast today: {kwh}, we count on {count}. Updated {at}.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts tomorrow."},
|
"solar": {"line": "Solar forecast for the rest of today: {kwh}, we count on {count}. Updated {at}.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||||
"today": {
|
"today": {
|
||||||
"not_planned": "Not planned yet.",
|
"not_planned": "Not planned yet.",
|
||||||
"no_runs": "No cheap or expensive hours left today.",
|
"no_runs": "No cheap or expensive hours left today.",
|
||||||
|
|||||||
@@ -281,7 +281,7 @@
|
|||||||
"tops_up": "Te weinig zon voorspeld, dus ze laadt wat van het net.",
|
"tops_up": "Te weinig zon voorspeld, dus ze laadt wat van het net.",
|
||||||
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
|
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
|
||||||
},
|
},
|
||||||
"solar": {"line": "Zonneprognose vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start morgen."},
|
"solar": {"line": "Zonneprognose voor de rest van vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||||
"today": {
|
"today": {
|
||||||
"not_planned": "Nog niet gepland.",
|
"not_planned": "Nog niet gepland.",
|
||||||
"no_runs": "Vandaag geen goedkope of dure uren meer.",
|
"no_runs": "Vandaag geen goedkope of dure uren meer.",
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
from . import dayplan, messages, pv, solarfc
|
from . import dayplan, messages, pv, solarfc
|
||||||
@@ -2066,6 +2067,8 @@ def _margin_problems(opts: dict) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
TRUST_RANGE_PCT = (30, 100)
|
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 = ("pv_forecast_entity", "pv_forecast_tomorrow_entity")
|
||||||
|
|
||||||
|
|
||||||
@@ -2083,7 +2086,7 @@ def _forecast_problems(opts: dict) -> list[str]:
|
|||||||
e = opts.get(key)
|
e = opts.get(key)
|
||||||
if e is None or e == "":
|
if e is None or e == "":
|
||||||
continue
|
continue
|
||||||
if not isinstance(e, str) or not e.strip().startswith("sensor."):
|
if not isinstance(e, str) or not _SENSOR_ID.fullmatch(e):
|
||||||
out.append(f"{S(key)} must be a sensor, like "
|
out.append(f"{S(key)} must be a sensor, like "
|
||||||
f"sensor.energy_production_today_remaining. It is "
|
f"sensor.energy_production_today_remaining. It is "
|
||||||
f"{messages.shown(e)}.")
|
f"{messages.shown(e)}.")
|
||||||
|
|||||||
@@ -116,15 +116,15 @@ check("the harness bites: Leftover solar grid-charging with no plan moves "
|
|||||||
_read = solarfc.read
|
_read = solarfc.read
|
||||||
|
|
||||||
|
|
||||||
def _as_sunny(today, tomorrow, now, **kw):
|
def _as_dark(today, tomorrow, now, **kw):
|
||||||
return _read({"state": "200", "attributes": {"unit_of_measurement":
|
return _read({"state": "0", "attributes": {"unit_of_measurement":
|
||||||
"kWh"},
|
"kWh"},
|
||||||
"last_updated": now.isoformat()}, None, now)
|
"last_updated": now.isoformat()}, None, now)
|
||||||
|
|
||||||
|
|
||||||
check("...and an unavailable forecast read as a sunny one moves "
|
check("...and an unavailable forecast read as a dark one moves "
|
||||||
"n2_sf_ch_fc_down (Cheap hours would skip its charge)",
|
"n2_sf_ls_fc_down (Leftover solar would buy its shortfall)",
|
||||||
_moved("n2_sf_ch_fc_down", lambda: setattr(solarfc, "read", _as_sunny),
|
_moved("n2_sf_ls_fc_down", lambda: setattr(solarfc, "read", _as_dark),
|
||||||
lambda: setattr(solarfc, "read", _read)))
|
lambda: setattr(solarfc, "read", _read)))
|
||||||
|
|
||||||
# ============================================================================ #
|
# ============================================================================ #
|
||||||
@@ -204,6 +204,18 @@ check("with it, tomorrow's sun is counted",
|
|||||||
fcs, _ = solarfc.read(st("12.5"), st("20.0", at=old), NOW)
|
fcs, _ = solarfc.read(st("12.5"), st("20.0", at=old), NOW)
|
||||||
check("a stale tomorrow is dropped, today is kept",
|
check("a stale tomorrow is dropped, today is kept",
|
||||||
fcs is not None and fcs.tomorrow is None)
|
fcs is not None and fcs.tomorrow is None)
|
||||||
|
# Review (PR #156): just after local midnight the integration may not have
|
||||||
|
# refreshed yet, so "left today" is still yesterday's 0 and "tomorrow" is
|
||||||
|
# really today. That is stale, not a dark day.
|
||||||
|
MID = datetime(2026, 9, 22, 0, 10, tzinfo=L).astimezone(timezone.utc)
|
||||||
|
EVE = datetime(2026, 9, 21, 21, 30, tzinfo=L).astimezone(timezone.utc)
|
||||||
|
check("a today reading from before local midnight is stale (not 0 kWh)",
|
||||||
|
solarfc.read(st("0.0", at=EVE), st("15", at=EVE), MID)
|
||||||
|
== (None, solarfc.STALE))
|
||||||
|
fcm, _ = solarfc.read(st("15", at=MID - timedelta(minutes=2)),
|
||||||
|
st("18", at=EVE), MID)
|
||||||
|
check("...and a tomorrow reading from before midnight is dropped",
|
||||||
|
fcm is not None and fcm.tomorrow is None)
|
||||||
|
|
||||||
# ============================================================================ #
|
# ============================================================================ #
|
||||||
print("3. the house profile")
|
print("3. the house profile")
|
||||||
@@ -241,6 +253,14 @@ check("only the last 7 days are kept", len(hp9.days) <= 8)
|
|||||||
hp_rt = dayplan.HouseProfile(hp.dump())
|
hp_rt = dayplan.HouseProfile(hp.dump())
|
||||||
check("dump and load give the same figures", hp_rt.hour_w(18) == round(
|
check("dump and load give the same figures", hp_rt.hour_w(18) == round(
|
||||||
hp.hour_w(18), 1) and hp_rt.ready())
|
hp.hour_w(18), 1) and hp_rt.ready())
|
||||||
|
bogus = dayplan.HouseProfile({"2099-01-01": {str(h): 9e5 for h in range(24)},
|
||||||
|
"zz": {str(h): 9e5 for h in range(24)}})
|
||||||
|
check("a stored day that is not a date is dropped on load",
|
||||||
|
"zz" not in bogus.days)
|
||||||
|
bogus.add(UTC0, 500.0)
|
||||||
|
bogus.add(UTC0 + timedelta(hours=1), 500.0)
|
||||||
|
check("a stored day after today (a wrong clock) is pruned, not kept forever",
|
||||||
|
"2099-01-01" not in bogus.days)
|
||||||
check("a bad stored profile is ignored, not fatal",
|
check("a bad stored profile is ignored, not fatal",
|
||||||
not dayplan.HouseProfile({"x": "y", "2026-09-20": {"99": 1,
|
not dayplan.HouseProfile({"x": "y", "2026-09-20": {"99": 1,
|
||||||
"a": "b"}}).ready())
|
"a": "b"}}).ready())
|
||||||
@@ -316,11 +336,38 @@ ch = dplan(12.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
|
|||||||
pk(B, packmode.CHEAP_HOURS, 30.0)])
|
pk(B, packmode.CHEAP_HOURS, 30.0)])
|
||||||
ch_dark = dplan(1.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
|
ch_dark = dplan(1.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
|
||||||
pk(B, packmode.CHEAP_HOURS, 30.0)])
|
pk(B, packmode.CHEAP_HOURS, 30.0)])
|
||||||
check("Cheap hours: dark fills to the ceiling (as P3)",
|
check("...and on a partly sunny day too: Cheap hours ignores the plan "
|
||||||
abs(ch_dark.targets[B] - 95.0) < 1e-6)
|
"(design section 1; review of PR #156)", B not in ch.targets
|
||||||
check("...and skips the part the sun covers on a partly sunny day",
|
and B not in ch_dark.targets
|
||||||
ch.targets[B] < 95.0 and ch.share_kwh[B] > 0
|
and modetrade.charge_to(pk(B, packmode.CHEAP_HOURS, 30.0), 95.0,
|
||||||
and abs(ch.targets[B] - (95.0 - ch.share_kwh[B] * 10.0)) < 0.01)
|
{B: 40.0}) == 95.0)
|
||||||
|
# Review of PR #156: four mutants that survived.
|
||||||
|
_hi = {h: (3000.0 if 17 <= h < 22 else 2000.0 if 8 <= h < 17 else 600.0)
|
||||||
|
for h in range(24)}
|
||||||
|
check("a house that uses more by day leaves less sun: a higher level",
|
||||||
|
dplan(20.0, est=(lambda h: _hi[h], 600.0)).targets[B]
|
||||||
|
> dplan(20.0).targets[B] + 1.0)
|
||||||
|
_two = dplan(40.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
|
||||||
|
pk(B, packmode.LEFTOVER_SOLAR, 30.0),
|
||||||
|
pk("inverter-c", packmode.LEFTOVER_SOLAR, 80.0)])
|
||||||
|
check("two Leftover solar batteries share the sun by their room (65:15)",
|
||||||
|
abs(_two.share_kwh[B] / _two.share_kwh["inverter-c"] - 6.5 / 1.5)
|
||||||
|
< 0.01)
|
||||||
|
_h = lambda hh: datetime(2026, 9, 21, hh, tzinfo=L).astimezone(timezone.utc)
|
||||||
|
_rp = priceruns.RunPlan(priceruns.Run(_h(13), _h(14), 0.02),
|
||||||
|
(priceruns.Run(_h(8), _h(9), 0.4),
|
||||||
|
priceruns.Run(_h(19), _h(20), 0.4)),
|
||||||
|
None, 0.3, None, True)
|
||||||
|
_pl = dayplan.plan(fc_of(12.0), EST, _rp, T0, PACKS, trust_pct=70,
|
||||||
|
floor_pct=20.0, ceiling_pct=95.0)
|
||||||
|
check("the plan is for the dear run after the cheap run, not an earlier one",
|
||||||
|
_pl is not None and _pl.until == _h(19))
|
||||||
|
_hp = dayplan.HouseProfile()
|
||||||
|
for i in range(30):
|
||||||
|
_hp.add(_h(10) + timedelta(seconds=5 * i), 500.0)
|
||||||
|
_hp.add(_h(11), 500.0)
|
||||||
|
check("an hour with under 5 minutes of readings is not kept",
|
||||||
|
not any(10 in r for r in _hp.days.values()))
|
||||||
check("trust 100 % counts more sun than 70 %: a lower level",
|
check("trust 100 % counts more sun than 70 %: a lower level",
|
||||||
dplan(12.0, trust=100).targets[B] < partly.targets[B])
|
dplan(12.0, trust=100).targets[B] < partly.targets[B])
|
||||||
check("...and the sun counted scales with the trust exactly",
|
check("...and the sun counted scales with the trust exactly",
|
||||||
@@ -437,7 +484,8 @@ for i in range(5000):
|
|||||||
bad.append(("range without 0", p, lo, hi))
|
bad.append(("range without 0", p, lo, hi))
|
||||||
if t.roles[p.name] == modetrade.CHARGE and (
|
if t.roles[p.name] == modetrade.CHARGE and (
|
||||||
p.mode == packmode.SOLAR_FIRST
|
p.mode == packmode.SOLAR_FIRST
|
||||||
or p.soc >= min(95.0, targets[p.name])):
|
or p.soc >= (95.0 if p.mode == packmode.CHEAP_HOURS
|
||||||
|
else min(95.0, targets[p.name]))):
|
||||||
bad.append(("charges past its level", p, targets))
|
bad.append(("charges past its level", p, targets))
|
||||||
check(f"decide() with a plan on 5000 random cases: ranges hold 0, nothing "
|
check(f"decide() with a plan on 5000 random cases: ranges hold 0, nothing "
|
||||||
f"charges at or past its level, Solar first never ({len(bad)} bad)",
|
f"charges at or past its level, Solar first never ({len(bad)} bad)",
|
||||||
@@ -567,17 +615,17 @@ check("dark: ...only up to its level (the shortfall), not 95 %",
|
|||||||
check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST
|
check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST
|
||||||
for r in charge))
|
for r in charge))
|
||||||
|
|
||||||
# Cheap hours on a partly sunny day: skips the part the sun covers.
|
# Cheap hours on a partly sunny day: ignores the plan, fills as in P3.
|
||||||
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0})
|
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0})
|
||||||
charge = invariants("cheap hours, partly", rows, c)
|
charge = invariants("cheap hours, partly", rows, c)
|
||||||
tgt = nth([r["targets"][B] for r in rows if r["targets"]], 0, "a level")
|
|
||||||
top = max(r["soc"]["b"] for r in rows)
|
top = max(r["soc"]["b"] for r in rows)
|
||||||
print(f" cheap hours partly: B level {tgt:.1f} %, reached {top:.1f} %")
|
print(f" cheap hours partly: B reached {top:.1f} %")
|
||||||
check("Cheap hours with sun forecast: charges, but stops below 95 %",
|
check("Cheap hours gets no planned level",
|
||||||
charge and present(tgt) and tgt < 90.0 and top <= tgt + 2.0)
|
not any(r["targets"] and B in r["targets"] for r in rows))
|
||||||
rows_nf, _s, c_nf = run_day(SF_CH, fc=None, soc0={"a": 60.0, "b": 30.0})
|
rows_nf, _s, c_nf = run_day(SF_CH, fc=None, soc0={"a": 60.0, "b": 30.0})
|
||||||
check("...and with the forecast down it fills towards 95 % as in P3",
|
check("Cheap hours with a sun forecast charges as with none (P3)",
|
||||||
max(r["soc"]["b"] for r in rows_nf) > top + 10.0)
|
charge and abs(max(r["soc"]["b"] for r in rows_nf) - top) < 1.0
|
||||||
|
and top > 80.0)
|
||||||
|
|
||||||
# The forecast goes away mid-run: Leftover solar stops at once (P3 option A).
|
# The forecast goes away mid-run: Leftover solar stops at once (P3 option A).
|
||||||
DROP = UTC0 + timedelta(hours=1, minutes=30)
|
DROP = UTC0 + timedelta(hours=1, minutes=30)
|
||||||
@@ -660,8 +708,8 @@ rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
|
|||||||
rp.m.datetime = datetime
|
rp.m.datetime = datetime
|
||||||
sb = homeview.solar_block(c, c.solar_fc.at)
|
sb = homeview.solar_block(c, c.solar_fc.at)
|
||||||
check("the forecast line names the forecast, what is counted and when",
|
check("the forecast line names the forecast, what is counted and when",
|
||||||
sb["line"].startswith("Solar forecast today:") and "we count on" in
|
sb["line"].startswith("Solar forecast for the rest of today:")
|
||||||
sb["line"] and sb["state"] == "plan")
|
and "we count on" in sb["line"] and sb["state"] == "plan")
|
||||||
why = homeview.day_key(c, B)
|
why = homeview.day_key(c, B)
|
||||||
check("B's Why key (plan, dark) is tops_up", why == "tops_up")
|
check("B's Why key (plan, dark) is tops_up", why == "tops_up")
|
||||||
check("...and says so in plain words", homeview.why_words(why)
|
check("...and says so in plain words", homeview.why_words(why)
|
||||||
@@ -723,6 +771,12 @@ check("a forecast entity that is not a sensor is refused, in plain words",
|
|||||||
validate._forecast_problems({"pv_forecast_entity": "number.x"})
|
validate._forecast_problems({"pv_forecast_entity": "number.x"})
|
||||||
and "must be a sensor" in validate._forecast_problems(
|
and "must be a sensor" in validate._forecast_problems(
|
||||||
{"pv_forecast_entity": "number.x"})[0])
|
{"pv_forecast_entity": "number.x"})[0])
|
||||||
|
check("an entity id with a path or odd characters is refused",
|
||||||
|
all(validate._forecast_problems({"pv_forecast_entity": e}) for e in
|
||||||
|
("sensor.x/../config", "sensor.x?y=1", "sensor.X", "sensor.",
|
||||||
|
" sensor.x", "sensor.a b"))
|
||||||
|
and not validate._forecast_problems(
|
||||||
|
{"pv_forecast_entity": "sensor.energy_production_today_remaining"}))
|
||||||
check("empty entities are fine", not validate._forecast_problems(
|
check("empty entities are fine", not validate._forecast_problems(
|
||||||
{"pv_forecast_entity": "", "pv_forecast_tomorrow_entity": ""}))
|
{"pv_forecast_entity": "", "pv_forecast_tomorrow_entity": ""}))
|
||||||
notes = []
|
notes = []
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ days; the 2026-09-24 PV recording is only in the live Home Assistant):
|
|||||||
* `national`: the Belgian day-ahead forecast itself, scaled to the site
|
* `national`: the Belgian day-ahead forecast itself, scaled to the site
|
||||||
(one factor: the site's total over the national total, all days). It
|
(one factor: the site's total over the national total, all days). It
|
||||||
carries local weather differences too, so it is the harder test.
|
carries local weather differences too, so it is the harder test.
|
||||||
|
* `zero`: a forecast of no sun at all (is the gain the forecast's, or only
|
||||||
|
Leftover solar buying the evening's shortfall?).
|
||||||
* `high`: `national` x 1.5. `missing`: the sensor is configured but reads
|
* `high`: `national` x 1.5. `missing`: the sensor is configured but reads
|
||||||
`unavailable`. The sensor has Open-Meteo's shape (state = kWh left today,
|
`unavailable`. The sensor has Open-Meteo's shape (state = kWh left today,
|
||||||
`wh_period` = Wh per hour); `--shape state` drops `wh_period`
|
`wh_period` = Wh per hour); `--shape state` drops `wh_period`
|
||||||
@@ -98,6 +100,8 @@ def forecast_table(kind, house, days):
|
|||||||
day_of = {h: datetime.fromtimestamp(h, LOCAL).date() for h in pv}
|
day_of = {h: datetime.fromtimestamp(h, LOCAL).date() for h in pv}
|
||||||
if kind in ("perfect", "missing"):
|
if kind in ("perfect", "missing"):
|
||||||
out = dict(pv)
|
out = dict(pv)
|
||||||
|
elif kind == "zero":
|
||||||
|
out = {h: 0.0 for h in pv}
|
||||||
elif kind == "recorded":
|
elif kind == "recorded":
|
||||||
ratio = {}
|
ratio = {}
|
||||||
for d in set(day_of.values()):
|
for d in set(day_of.values()):
|
||||||
@@ -176,7 +180,7 @@ def main():
|
|||||||
ap.add_argument("--variant", default="p4_ls")
|
ap.add_argument("--variant", default="p4_ls")
|
||||||
ap.add_argument("--fc", default="recorded",
|
ap.add_argument("--fc", default="recorded",
|
||||||
choices=("none", "recorded", "national", "perfect",
|
choices=("none", "recorded", "national", "perfect",
|
||||||
"high", "missing"))
|
"high", "missing", "zero"))
|
||||||
ap.add_argument("--prices", default="epex2025",
|
ap.add_argument("--prices", default="epex2025",
|
||||||
choices=("epex2025", "real2026"))
|
choices=("epex2025", "real2026"))
|
||||||
ap.add_argument("--shape", default="periods",
|
ap.add_argument("--shape", default="periods",
|
||||||
|
|||||||
Reference in New Issue
Block a user