Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc6d744c6c | ||
|
|
79b94024cb | ||
|
|
1145a931ac | ||
|
|
56cafefa79 | ||
|
|
3f69c174af | ||
|
|
d77f1b1fdf | ||
|
|
b171ada894 | ||
|
|
0b96e3191c | ||
|
|
35cdffe766 | ||
|
|
a692560960 | ||
|
|
fbd884797d | ||
|
|
aabc2a27fd | ||
|
|
eb10537aee |
@@ -75,3 +75,55 @@ P1 is its section 6 P1, and the pull request lists what was measured.
|
||||
P2's 19.33; Solar first + Cheap hours only 19.57.
|
||||
- Not done in P3: the forecast (P4), and a headroom too short for the run
|
||||
moving the rest to the next cheap periods.
|
||||
|
||||
## P4: the solar forecast and the day plan (2026-09-29)
|
||||
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
|
||||
today; `pv_forecast_tomorrow_entity` optional), read by `app/solarfc.py`.
|
||||
Supported shapes: Forecast.Solar (state only), Solcast (`detailedForecast`)
|
||||
and Open-Meteo Solar Forecast (`wh_period`). The unit is read, not guessed;
|
||||
older than 3 h is stale (one missed hourly poll must not flag it).
|
||||
- Trust: `solar_trust_pct`, default 70 (owner answer 5).
|
||||
- House use: nothing the add-on recorded fitted. `tariff.db` holds grid
|
||||
import per quarter, which the batteries shape. So `dayplan.HouseProfile`
|
||||
keeps grid + battery per local hour for 7 days in the store
|
||||
(`house_profile`), median per hour. It is net of the solar the house used,
|
||||
so the solar sum never takes it below the night median. Solar readings are
|
||||
not used (TEL-11). The first plan needs one day of it.
|
||||
- Verdict (`dayplan.plan()`): trust x sum max(0, forecast - house) x 0.95
|
||||
until the expensive run the cheap run is for. Solar first takes it first.
|
||||
- Leftover solar may then charge from the grid up to min(ceiling, floor +
|
||||
the house's use in the expensive runs / 0.95), less its sun share: only
|
||||
the shortfall.
|
||||
- Cheap hours only ignores the plan and fills to the ceiling (design
|
||||
section 1; review of #156, see the replay below).
|
||||
- Solar first never charges from the grid (owner, 2026-09-29).
|
||||
- The level is kept unless it moves by min(1 kWh, 10 %). With no plan
|
||||
(no, stale or failed forecast, no house profile, no capacity) every mode
|
||||
is P3 option A exactly: `test_modes_p4.py` part 1 pins 12 digests from
|
||||
5a4cdc2 (modes on, with and without an unavailable forecast).
|
||||
- The plan is made with the runs (every 15 min, or at once when the forecast
|
||||
comes or goes). A lost forecast drops the levels at the next cycle.
|
||||
- Not done: the design's scaling by measured solar from 10:00 (it needs the
|
||||
solar readings TEL-11 keeps out), the HA `goodwe_<inv>_plan` sensor, and
|
||||
FCST-01.
|
||||
- Replay (`tools/opt11p4_replay.py`, 16 recorded days, Solar first + Leftover
|
||||
solar, net EUR, lower is better; site solar rebuilt from the recording,
|
||||
forecast errors from Elia's day-ahead Belgian solar forecast):
|
||||
- year-earlier hourly prices: P2 19.33, P3 option A 19.44, old one-period
|
||||
trade 18.14, P4 19.15 (perfect forecast 19.14, 1.5 x too high 19.16,
|
||||
missing 19.44);
|
||||
- the days' real 2026 quarter-hourly prices (held out): P2 32.26, option A
|
||||
32.01, old trade 31.40, P4 29.19 (perfect 29.21, too high 29.28, missing
|
||||
32.01).
|
||||
- Solar first + Cheap hours only, skipping its sun share (the first
|
||||
build): 19.57 (same as P3) and 29.72 against P3's 29.41. The shorter,
|
||||
moved runs left B lower, so later midday top-ups rested A while the sun
|
||||
shone. Cheap hours now ignores the plan, which is P3 exactly.
|
||||
- A forecast of no sun at all gives 19.14 and 29.33. So nearly all of the
|
||||
gain is Leftover solar buying the evening's shortfall in the cheap run;
|
||||
the forecast itself was worth 0.14 EUR in 16 September days.
|
||||
- Bias: the site's solar is rebuilt from the same net use the replay
|
||||
plays, and "recorded" keeps its hourly shape and adds only the national
|
||||
daily error. Both favour P4; "national" (no house leakage) gives 29.25.
|
||||
No threshold was tuned to these days. The 2026-09-24 PV recording could not
|
||||
be replayed: it is only in the live Home Assistant.
|
||||
|
||||
@@ -35,9 +35,8 @@ direction. Opposite splits stay forbidden.
|
||||
(`tools/safety78_identity.py`, which hashes the claims too).
|
||||
|
||||
**Not changed (measured in the PR #154 review, follow-ups).**
|
||||
- At N=1 a drain (60) outranks the peak floor (50), so the guard does not add
|
||||
to it: a 5000 W house imports 3000 W against a 2500 W limit, a 6000 W house
|
||||
4000 W. Pre-existing, kept by the N=1 identity.
|
||||
- At N=1 a drain (60) outranked the peak floor (50): a 5000 W house imported
|
||||
3000 W against a 2500 W limit. Fixed by [[SAFETY-83]].
|
||||
- At N=2 a maintenance CHARGE in a cheap window has the same stall: the
|
||||
charging pack's `hi = -floor` against the loop asking the other to give is
|
||||
refused on 141 of 261 cycles. The mirror fix is a site `hi`, which can meet
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# SAFETY-83 - the peak guard outranks the maintenance drain at N=1
|
||||
|
||||
**Status: DECIDED 2026-09-29 (owner go, P1).** Follows SAFETY-78's "Not
|
||||
changed" list. Tests: `test_safety83.py`.
|
||||
|
||||
**The defect.** At N=1 the drain is the site SET at `P_MAINTENANCE` (60). The
|
||||
peak floor is a limit at `P_PEAK` (50), and clause 2 applies only limits at or
|
||||
above the winner, so the floor was ignored and the house imported what the
|
||||
drain left. A 5000 W house under a 2500 W limit and a 2000 W drain banked
|
||||
3000 W quarters; on the owner's settings (limit 3500, drain 2500, margin 50) a
|
||||
breach starts at about 5950 W.
|
||||
|
||||
**The fix.** While a drain owns the setpoint at N=1 and the peak floor asks
|
||||
for more than the drain rate, `main.py` (`_peak_over_drain()`) refiles that
|
||||
peak claim at `P_MAINTENANCE`. The arbiter then applies it: the target is the
|
||||
floor, still inside the safety limits, and a safety SET (100) still parks
|
||||
everything. Where the floor is at or below the drain rate nothing changes.
|
||||
|
||||
- The order in effect is now the same at N=1 and N=2: safety limits and the
|
||||
SAFETY-73 refusal > peak guard (with the SAFETY-79 margin) > maintenance >
|
||||
modes > trade > loop (`arbiter.py`, beside the priorities).
|
||||
- The drain keeps counting: the battery discharges at least the drain rate on
|
||||
every cycle, so the cycle is not reset or stalled; it only ends sooner.
|
||||
- Below the drain rate the rest is still exported (owner, 2026-09-29).
|
||||
- Identity: `tools/safety78_identity.py --diff` against 35cdffe changes only
|
||||
cycles where the guard asked for more than the drain; after such an episode
|
||||
the commands are identical again and only the log and the idle loop's
|
||||
integrator differ. N=2 is unchanged.
|
||||
|
||||
**Not changed.**
|
||||
- Between the drain rate and the peak aim, the house still imports what the
|
||||
drain leaves (N=1 behaviour kept by the identity). At N=2 the loop covers it.
|
||||
- A maintenance HOLD (0 W, 120 min, battery full) still outranks the peak
|
||||
guard at N=1. Discharging there spends the charge the BMS balances on; it is
|
||||
an owner question, not this fix.
|
||||
@@ -184,6 +184,9 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| Hardware · Inverter list | Usable battery capacity (kWh) (optional) | `inverters.capacity_kwh` | This battery's usable capacity from its datasheet. Fill it in on every inverter or on none. The add-on then shares the work by the energy each battery has left. |
|
||||
| Hardware | RS485 emergency stop is fitted | `estop_fitted` | Switch this on if the emergency stop is installed. It only changes a warning on the status page. |
|
||||
| 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 | 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. |
|
||||
| Hardware | Reversed-sign solar entities | `pv_invert_entities` | Optional. Add each solar entity above that reads negative while producing, spelled the same way. Its sign is flipped before adding up. |
|
||||
@@ -522,7 +525,7 @@ Off by default. Turn it on, restart the add-on, and the Batteries screen (and Se
|
||||
- **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover.
|
||||
- **Mode.**
|
||||
- *Solar first* charges from spare solar before the other batteries and covers the house at any hour. It never charges from the grid and never sells.
|
||||
- *Leftover solar* charges from the solar the others cannot take, never from the grid (until the solar forecast arrives). It saves its power for the expensive hours and covers the house then.
|
||||
- *Leftover solar* charges from the solar the others cannot take. With a solar forecast it may also charge from the grid, but only what the sun will not give (see below). It saves its power for the expensive hours and covers the house then.
|
||||
- *Cheap hours only* charges from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then.
|
||||
- **Selling** is off until it can be judged on the price the grid pays (owner decision 2026-09-29, option A).
|
||||
|
||||
@@ -535,6 +538,16 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
- A carried (not yet published) price is never used. A run that has started runs to its end.
|
||||
- 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.
|
||||
- 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.
|
||||
- *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 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 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.
|
||||
|
||||
**What always comes first.**
|
||||
- The peak limit, the safety stops and the monthly cycle come before a battery's mode.
|
||||
- A grid charge is sized so the 15-minute period stays under the peak limit less its margin (`peak_margin_w`). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period.
|
||||
@@ -1626,7 +1639,7 @@ help - see "Why the tuning is what it is".
|
||||
| `maintenance_enabled` | false | Enable the monthly cycle |
|
||||
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
|
||||
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
||||
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
|
||||
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
|
||||
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
|
||||
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys |
|
||||
| `maintenance_soc_target` | 99 | Charge target |
|
||||
@@ -1651,6 +1664,8 @@ maintenance schedule.
|
||||
With two or more batteries, the one being emptied gives its drain rate and the
|
||||
others only add power when the house needs more; none charges while it drains
|
||||
(SAFETY-78). Below the drain rate the rest is exported, as with one battery.
|
||||
If the house would still go over the peak limit, the battery being emptied
|
||||
gives more than its drain rate, with one battery too (SAFETY-83).
|
||||
|
||||
#### Capacity-tariff exposure of the grid-following loop
|
||||
|
||||
|
||||
@@ -76,6 +76,10 @@ from . import allocator
|
||||
P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits
|
||||
P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold)
|
||||
P_PEAK = 50 # capacity-tariff peak shaving - costs real money
|
||||
# The order in effect: safety limits and the SAFETY-73 refusal > the peak
|
||||
# guard > maintenance > modes > trade > loop. A peak floor above a drain still
|
||||
# binds: the `drain` site floor at N>=2 (SAFETY-78), refiled at P_MAINTENANCE
|
||||
# at N=1 (SAFETY-83). Not a maintenance HOLD at N=1 (DECISIONS/SAFETY-83).
|
||||
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
|
||||
# OPT-04. §10.1 priority 3 - price arbitrage. ⚠️ ITS POSITION IS THE SPEC, NOT A
|
||||
# preference: P_PEAK (50) is ABOVE it so clause 2 lets the capacity-tariff floor
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""OPT-11 P4: the day plan. Will the sun fill each battery before the
|
||||
expensive hours? If not, how much should it take from the grid?
|
||||
|
||||
Pure: the forecast (`solarfc`), the house profile, the price runs and the
|
||||
batteries in; a level per battery (`Plan.targets`) out. `modetrade.charge_to()`
|
||||
turns the level into permission to charge from the grid; main.py rebuilds the
|
||||
plan with the price runs (at most every 15 minutes) and keeps a level unless
|
||||
it moves by `HYST_KWH` or `HYST_FRAC` (`hold()`).
|
||||
|
||||
The sums (OPT-11-design section 1, "Excess solar", with the owner's answers):
|
||||
* `sun`: trust x the solar the house will not use, between now and the
|
||||
expensive run the cheap run is for, x the charge efficiency:
|
||||
`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
|
||||
shared by the Leftover solar batteries by their room.
|
||||
* 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
|
||||
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
|
||||
shortfall, not up to the ceiling.
|
||||
* Solar first never charges from the grid (owner, 2026-09-29).
|
||||
|
||||
The house profile is the add-on's own measurement, grid + battery power, per
|
||||
local hour, over the last `PROFILE_DAYS` days; the estimate for an hour is the
|
||||
median of that hour. It is net of the solar the house used itself, so for
|
||||
the solar sum it is never taken below the night-time median (`base()`).
|
||||
The site's own solar readings are not used: TEL-11 keeps them out of every
|
||||
control decision.
|
||||
"""
|
||||
|
||||
import statistics
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .tariff import BILLING_TZ
|
||||
|
||||
EFF = 0.95 # charge (and discharge) efficiency
|
||||
HYST_KWH = 1.0 # a kept level moves only by this much...
|
||||
HYST_FRAC = 0.10 # ...or this share of the battery's shortfall
|
||||
PROFILE_DAYS = 7
|
||||
MIN_HOURS = 20 # hours of a day recorded before it counts
|
||||
NIGHT_HOURS = (0, 1, 2, 3, 4, 5, 22, 23)
|
||||
DEFAULT_TRUST_PCT = 70
|
||||
|
||||
# Site verdicts (the page's forecast line).
|
||||
SUNNY, PARTLY, DARK = "sunny", "partly", "dark"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The house profile
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _local(t: datetime):
|
||||
return t.astimezone(BILLING_TZ)
|
||||
|
||||
|
||||
class HouseProfile:
|
||||
"""Hourly means of the house's net use (grid + battery power), kept per
|
||||
local day and hour: `{"YYYY-MM-DD": {hour: [mean_w, samples]}}`. The
|
||||
store holds `dump()`; `load()` takes it back and drops what is bad."""
|
||||
|
||||
def __init__(self, data=None):
|
||||
self.days = {}
|
||||
self._cur = None # (day, hour, sum, n)
|
||||
if isinstance(data, dict):
|
||||
self.load(data)
|
||||
|
||||
def load(self, data):
|
||||
for day, hours in data.items():
|
||||
try:
|
||||
datetime.strptime(str(day), "%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(hours, dict):
|
||||
continue
|
||||
row = {}
|
||||
for h, v in hours.items():
|
||||
try:
|
||||
hi, w = int(h), float(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if 0 <= hi < 24 and abs(w) < 1e6:
|
||||
row[hi] = w
|
||||
if row:
|
||||
self.days[str(day)] = row
|
||||
|
||||
def dump(self) -> dict:
|
||||
return {d: {str(h): round(w, 1) for h, w in sorted(r.items())}
|
||||
for d, r in sorted(self.days.items())}
|
||||
|
||||
def add(self, now: datetime, house_w) -> bool:
|
||||
"""One reading. Returns True when an hour closed (then store it)."""
|
||||
lt = _local(now)
|
||||
key = (lt.strftime("%Y-%m-%d"), lt.hour)
|
||||
closed = False
|
||||
if self._cur is not None and self._cur[:2] != key:
|
||||
day, hour, s, n = self._cur
|
||||
if n >= 60: # about 5 minutes of readings
|
||||
self.days.setdefault(day, {})[hour] = s / n
|
||||
closed = True
|
||||
self._cur = None
|
||||
self._prune(lt)
|
||||
if house_w is None:
|
||||
return closed
|
||||
if self._cur is None:
|
||||
self._cur = (key[0], key[1], 0.0, 0)
|
||||
d, h, s, n = self._cur
|
||||
self._cur = (d, h, s + float(house_w), n + 1)
|
||||
return closed
|
||||
|
||||
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()
|
||||
today = lt.date().isoformat()
|
||||
for d in [d for d in self.days if d < keep or d > today]:
|
||||
del self.days[d]
|
||||
|
||||
def ready(self) -> bool:
|
||||
"""At least one day with `MIN_HOURS` hours recorded."""
|
||||
return any(len(r) >= MIN_HOURS for r in self.days.values())
|
||||
|
||||
def hour_w(self, hour: int):
|
||||
vals = [r[hour] for r in self.days.values() if hour in r]
|
||||
return statistics.median(vals) if vals else None
|
||||
|
||||
def base(self):
|
||||
vals = [w for h in NIGHT_HOURS if (w := self.hour_w(h)) is not None]
|
||||
return statistics.median(vals) if vals else None
|
||||
|
||||
def estimator(self):
|
||||
"""`f(hour) -> W`, or None when not ready. Fixed at call time."""
|
||||
if not self.ready():
|
||||
return None
|
||||
base = self.base() or 0.0
|
||||
table = {}
|
||||
for h in range(24):
|
||||
w = self.hour_w(h)
|
||||
table[h] = base if w is None else w
|
||||
return lambda h: table[h], max(0.0, base)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The plan
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class Plan:
|
||||
verdict: str # SUNNY / PARTLY / DARK
|
||||
sun_kwh: float # solar the batteries can count on
|
||||
fc_kwh: float # the forecast over the same span
|
||||
until: datetime # the expensive run the plan is for
|
||||
targets: dict # name -> level % (Leftover solar only)
|
||||
need_kwh: dict # name -> kWh from the grid (0: none)
|
||||
share_kwh: dict # name -> kWh of solar counted for it
|
||||
|
||||
|
||||
def _hour_slices(t0, t1):
|
||||
"""Local-hour slices of [t0, t1): (start, end, local hour)."""
|
||||
out, t = [], t0
|
||||
while t < t1:
|
||||
lt = _local(t)
|
||||
nxt = (lt.replace(minute=0, second=0, microsecond=0)
|
||||
+ timedelta(hours=1)).astimezone(timezone.utc)
|
||||
e = min(nxt, t1)
|
||||
out.append((t, e, lt.hour))
|
||||
t = e
|
||||
return out
|
||||
|
||||
|
||||
def _room(p, to_pct):
|
||||
if p.capacity_kwh is None or p.soc is None:
|
||||
return 0.0
|
||||
return max(0.0, float(to_pct) - float(p.soc)) / 100.0 * float(
|
||||
p.capacity_kwh)
|
||||
|
||||
|
||||
def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
||||
ceiling_pct):
|
||||
"""The day plan, or None when there is none to make: no forecast, no
|
||||
house profile yet, no price plan, or no expensive run after the cheap
|
||||
run. `house` is `HouseProfile.estimator()`'s answer. `packs` are
|
||||
`modetrade.Pack`s."""
|
||||
if forecast is None or house is None or runs is None or not runs.on \
|
||||
or runs.cheap is None:
|
||||
return None
|
||||
house_w, base_w = house
|
||||
until = next((g for g in runs.give if g.start >= runs.cheap.end), None)
|
||||
if until is None:
|
||||
return None
|
||||
trust = max(0.0, min(1.0, float(trust_pct) / 100.0))
|
||||
sun = fc_sum = 0.0
|
||||
for s, e, h in _hour_slices(now, until.start):
|
||||
pv = forecast.between(s, e)
|
||||
if pv is None:
|
||||
return None # the span reaches a day with no forecast
|
||||
hrs = (e - s).total_seconds() / 3600.0
|
||||
use = max(house_w(h), base_w) * hrs / 1000.0
|
||||
sun += max(0.0, pv - use)
|
||||
fc_sum += pv
|
||||
sun *= trust * EFF
|
||||
|
||||
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]
|
||||
sf = [p for p in usable if p.mode == SOLAR_FIRST]
|
||||
price = [p for p in usable if p.mode == LEFTOVER_SOLAR]
|
||||
sf_room = sum(_room(p, ceiling_pct) for p in sf)
|
||||
excess = max(0.0, sun - sf_room)
|
||||
rooms = {p.name: _room(p, ceiling_pct) for p in price}
|
||||
total_room = sum(rooms.values())
|
||||
|
||||
# What the house draws in the expensive runs from the one this plan is
|
||||
# for until the next cheap run (or a day), from the profile.
|
||||
horizon = until.start + timedelta(hours=24)
|
||||
use_kwh = 0.0
|
||||
for g in runs.give:
|
||||
if g.start < until.start or g.start >= horizon:
|
||||
continue
|
||||
for s, e, h in _hour_slices(g.start, g.end):
|
||||
use_kwh += max(0.0, house_w(h)) * (
|
||||
e - s).total_seconds() / 3600.0 / 1000.0
|
||||
caps = {p.name: float(p.capacity_kwh) for p in price}
|
||||
cap_sum = sum(caps.values()) or 1.0
|
||||
|
||||
targets, need, share = {}, {}, {}
|
||||
for p in price:
|
||||
sh = excess * rooms[p.name] / total_room if total_room > 0 else 0.0
|
||||
sh_pct = sh / caps[p.name] * 100.0
|
||||
want = use_kwh * caps[p.name] / cap_sum / EFF
|
||||
top = min(float(ceiling_pct),
|
||||
float(floor_pct) + want / caps[p.name] * 100.0)
|
||||
level = max(0.0, top - sh_pct)
|
||||
targets[p.name] = round(level, 2)
|
||||
need[p.name] = round(_room(p, level), 3)
|
||||
share[p.name] = round(sh, 3)
|
||||
if sun < sf_room:
|
||||
verdict = DARK
|
||||
elif any(v > 0.05 for v in need.values()):
|
||||
verdict = PARTLY
|
||||
else:
|
||||
verdict = SUNNY
|
||||
return Plan(verdict, round(sun, 3), round(fc_sum, 3), until.start,
|
||||
targets, need, share)
|
||||
|
||||
|
||||
def hold(old: dict | None, new: dict, capacity: dict) -> dict:
|
||||
"""Hysteresis: a battery keeps its old level unless the new one differs
|
||||
by at least `HYST_KWH`, or by `HYST_FRAC` of the larger level's energy
|
||||
(whichever is less). A battery new to the plan takes its level at once;
|
||||
one that left the plan is dropped at once."""
|
||||
out = {}
|
||||
for name, level in new.items():
|
||||
was = (old or {}).get(name)
|
||||
cap = float(capacity.get(name) or 0.0)
|
||||
if was is None or cap <= 0:
|
||||
out[name] = level
|
||||
continue
|
||||
moved = abs(level - was) / 100.0 * cap
|
||||
step = min(HYST_KWH, HYST_FRAC * max(was, level) / 100.0 * cap)
|
||||
out[name] = level if moved >= step else was
|
||||
return out
|
||||
@@ -345,7 +345,10 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
else (soc - floor)) / 100.0 * cap)
|
||||
return kwh
|
||||
blocks = []
|
||||
if rp.charge_useful() and pm.mode in GRID_CHARGE_MODES:
|
||||
# P4: with a day plan, the grid block is the planned shortfall.
|
||||
need = plan_need(c, inv.name)
|
||||
if rp.charge_useful() and (need > 0.0 if need is not None
|
||||
else pm.mode in GRID_CHARGE_MODES):
|
||||
blocks.append(("grid", rp.cheap))
|
||||
sell = rp.sell if pm.sell and pm.can_sell else None
|
||||
for g in rp.give:
|
||||
@@ -358,6 +361,8 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
continue
|
||||
hrs = (run.end - run.start).total_seconds() / 3600.0
|
||||
kwh = room(kind, hrs)
|
||||
if kind == "grid" and need is not None:
|
||||
kwh = min(kwh, need)
|
||||
if kwh < MIN_KWH:
|
||||
continue
|
||||
price = run.mean_eur_kwh
|
||||
@@ -382,6 +387,56 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
return out, None
|
||||
|
||||
|
||||
def plan_need(c, name):
|
||||
"""P4: kWh this battery buys from the grid by the day plan, or None when
|
||||
there is no plan for it (then P3 option A applies)."""
|
||||
targets = c.plan_targets() if hasattr(c, "plan_targets") else None
|
||||
if not targets or name not in targets:
|
||||
return None
|
||||
inv = next((i for i in c.inverters if i.name == name), None)
|
||||
if inv is None or inv.soc is None or not inv.capacity_kwh:
|
||||
return None
|
||||
return max(0.0, targets[name] - float(inv.soc)) / 100.0 * float(
|
||||
inv.capacity_kwh)
|
||||
|
||||
|
||||
def plan_state(c):
|
||||
"""P4: `"plan"` (a day plan is made), `"learning"` (no house profile
|
||||
yet), `"no_forecast"` (set but missing or stale), or None (not set, or
|
||||
battery modes off)."""
|
||||
if not getattr(c, "modes_on", False) or \
|
||||
getattr(c, "fc_entity", None) is None:
|
||||
return None
|
||||
if getattr(c, "solar_fc", None) is None:
|
||||
return "no_forecast"
|
||||
hp = getattr(c, "house_profile", None)
|
||||
if hp is not None and not hp.ready():
|
||||
return "learning"
|
||||
return "plan" if getattr(c, "day_plan", None) is not None else None
|
||||
|
||||
|
||||
def solar_block(c, now):
|
||||
"""P4: the solar forecast line for the Batteries screen and the Simple
|
||||
settings view: `{state, fc_kwh, count_kwh, updated, line}`."""
|
||||
st = plan_state(c)
|
||||
out = {"state": st, "fc_kwh": None, "count_kwh": None, "updated": None,
|
||||
"line": ""}
|
||||
if st is None and getattr(c, "solar_fc", None) is None:
|
||||
return out
|
||||
fc = getattr(c, "solar_fc", None)
|
||||
if fc is not None:
|
||||
trust = float(getattr(c, "solar_trust_pct", 70)) / 100.0
|
||||
out.update(fc_kwh=round(fc.today.kwh, 1),
|
||||
count_kwh=round(fc.today.kwh * trust, 1),
|
||||
updated=hhmm(fc.at))
|
||||
if st in ("no_forecast", "learning"):
|
||||
out["line"] = _t("solar." + st)
|
||||
elif fc is not None:
|
||||
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
|
||||
out["updated"])
|
||||
return out
|
||||
|
||||
|
||||
def maintenance_runs(c, day0, day1, now, shadow):
|
||||
"""The monthly battery cycle: under way now, or starting later today.
|
||||
The cycle starts in the hour `maintenance_start_hour` names, read as the
|
||||
@@ -664,7 +719,9 @@ def battery_rows(c, shadow, running, held=False):
|
||||
refused=refusing(c, inv), peak=peak,
|
||||
giving=flow(inv.batt) == "giving", other_refuses=other,
|
||||
sf_ahead=sf_ahead, role=role, plan=plan, has_at=bool(at),
|
||||
other_cycle=cycling)
|
||||
other_cycle=cycling,
|
||||
day=None if pm is None or pm.mode not in PRICE_MODES
|
||||
else day_key(c, inv.name))
|
||||
rows.append({
|
||||
"name": inv.name,
|
||||
"level_pct": None if inv.soc is None else float(inv.soc),
|
||||
@@ -699,10 +756,25 @@ def battery_rows(c, shadow, running, held=False):
|
||||
return rows
|
||||
|
||||
|
||||
def day_key(c, name):
|
||||
"""P4: the day plan's word for one Leftover solar or Cheap hours
|
||||
battery: `sun_fills`, `tops_up`, `no_forecast`, or None."""
|
||||
st = plan_state(c)
|
||||
if st == "no_forecast":
|
||||
mode = c.pack_mode(next(i for i in c.inverters if i.name == name))
|
||||
return "no_forecast" if mode.mode == LEFTOVER_SOLAR else None
|
||||
if st != "plan":
|
||||
return None
|
||||
need = plan_need(c, name)
|
||||
if need is None:
|
||||
return None
|
||||
return "tops_up" if need >= MIN_KWH else "sun_fills"
|
||||
|
||||
|
||||
def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
|
||||
held, refused, peak, sf_ahead, giving=False,
|
||||
other_refuses=None, role=None, plan=None,
|
||||
has_at=False, other_cycle=None) -> str:
|
||||
has_at=False, other_cycle=None, day=None) -> str:
|
||||
"""The key of one battery's "why" line (`home_words.why`), most urgent
|
||||
first. Pure, so every branch is tested without a controller."""
|
||||
if not reading:
|
||||
@@ -729,6 +801,8 @@ def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
|
||||
if not modes_on:
|
||||
return "modes_off"
|
||||
# OPT-11 P3: the battery's role in the price runs this cycle.
|
||||
if role == "hold" and day is not None:
|
||||
return day # P4: what the day plan decided
|
||||
if role in ROLE_WHY:
|
||||
if role == "hold" and not has_at:
|
||||
return "saves_later"
|
||||
@@ -876,6 +950,7 @@ def _build(c, *, level, checks, now):
|
||||
modes_on=bool(getattr(c, "modes_on", False)),
|
||||
running=running, held=held, shadow=shadow)
|
||||
return {
|
||||
"solar": solar_block(c, now),
|
||||
"state": {"kind": kind, "pill": pill(kind),
|
||||
"ran": ctl.get("text", "") if ctl else "",
|
||||
"sub": banner_sub(kind)},
|
||||
@@ -1172,6 +1247,10 @@ def run_house_amount() -> str:
|
||||
return _t("run.house_amount")
|
||||
|
||||
|
||||
def line_solar(fc_kwh, count_kwh, at) -> str:
|
||||
return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at)
|
||||
|
||||
|
||||
def line_no_trade(key) -> str:
|
||||
return "" if key == "off" else _t("no_trade." + key)
|
||||
|
||||
@@ -1314,6 +1393,8 @@ def _samples() -> list:
|
||||
off=off, peak=peak)))
|
||||
out += [("plan", why_words(k, name="Battery A")) for k in words["why"]]
|
||||
out += [("plan", today_words(k)) for k in words["today"]]
|
||||
out += [("plan", line_solar(9.2, 6.4, "10:15")),
|
||||
("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning"))]
|
||||
out += [("plan", line_no_trade(k)) for k in
|
||||
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
|
||||
"no_level")]
|
||||
|
||||
+140
-12
@@ -25,6 +25,7 @@ battery to zero on its own. Never "hold the last value to be safe".
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
@@ -41,9 +42,11 @@ from .arbiter import (
|
||||
P_SAFETY, SET, Claim, Resolution, actuator_for, pre_resolve, resolve,
|
||||
)
|
||||
from . import allocator
|
||||
from . import dayplan
|
||||
from . import modetrade
|
||||
from . import packmode
|
||||
from . import priceruns
|
||||
from . import solarfc
|
||||
from . import control as _ctl
|
||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from .delivery import (
|
||||
@@ -234,6 +237,11 @@ def load_options() -> dict:
|
||||
# valued at (`priceruns.plan(ref_eur_kwh=)`): the rest of that day.
|
||||
REF_CHEAP_KEEP = timedelta(hours=12)
|
||||
|
||||
# OPT-11 P4: the store key of the house profile (dayplan.HouseProfile), and
|
||||
# how often the forecast sensors are read (they change hourly at most).
|
||||
HOUSE_PROFILE_KEY = "house_profile"
|
||||
FORECAST_READ_S = 60.0
|
||||
|
||||
|
||||
def why_raised(resolution, claims) -> bool:
|
||||
"""F2 (OPT-11 P1 test): did the limit that bound the site target push it
|
||||
@@ -947,6 +955,25 @@ class Controller:
|
||||
self._mode_trade_was = False
|
||||
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
|
||||
self.solar_trust_pct = opts.get("solar_trust_pct",
|
||||
dayplan.DEFAULT_TRUST_PCT)
|
||||
self.solar_fc = None
|
||||
self.solar_fc_why = (solarfc.NOT_SET if self.fc_entity is None
|
||||
else solarfc.MISSING)
|
||||
self._fc_read_mono = None
|
||||
self.day_plan = None
|
||||
self._plan_targets = None
|
||||
self.house_profile = None
|
||||
if self.fc_entity is not None:
|
||||
self.house_profile = dayplan.HouseProfile(
|
||||
(getattr(store, "data", None) or {}).get(HOUSE_PROFILE_KEY))
|
||||
# OPT-11 P2: (topic, reason) of MQTT commands refused and logged once.
|
||||
self._mqtt_refused = set()
|
||||
self._mqtt_refused_more = 0
|
||||
@@ -2081,10 +2108,14 @@ class Controller:
|
||||
plan = self.fresh_tariff_plan(now)
|
||||
if plan is None or not plan.has_prices:
|
||||
self.mode_runs, self._runs_key = None, None
|
||||
self.day_plan = None
|
||||
return None
|
||||
slot = int(now.timestamp() // 900)
|
||||
key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell)
|
||||
for p in packs))
|
||||
if self.fc_entity is not None:
|
||||
# P4: a forecast arriving or going rebuilds the plan at once.
|
||||
key += (self.solar_fc is None,)
|
||||
if key != self._runs_key:
|
||||
done_c = self._run_done.get("charge", (None, set()))[1]
|
||||
done_s = self._run_done.get("sell", (None, set()))[1]
|
||||
@@ -2102,6 +2133,9 @@ class Controller:
|
||||
charge_kwh=e[0], charge_kw=e[1], sell_kwh=e[2],
|
||||
sell_kw=e[3],
|
||||
ref_eur_kwh=None if ref is None else ref.mean_eur_kwh)
|
||||
if self.fc_entity is not None:
|
||||
new = self._day_plan(now, packs, floor_pct, ceiling_pct,
|
||||
new, plan, required, ref, done_c, e)
|
||||
self.mode_runs = priceruns.keep_started(self.mode_runs, new, now)
|
||||
self._runs_key = key
|
||||
runs = self.mode_runs
|
||||
@@ -2116,6 +2150,42 @@ class Controller:
|
||||
self._ref_cheap = runs.cheap
|
||||
return runs
|
||||
|
||||
def _day_plan(self, now, packs, floor_pct, ceiling_pct, runs, plan,
|
||||
required, ref, done_c, e):
|
||||
"""OPT-11 P4: the day plan (`dayplan.plan()`) on the runs just made,
|
||||
its levels held by the hysteresis (`dayplan.hold()`), then the runs
|
||||
made again with each battery's shortfall as the charge, so the cheap
|
||||
run is only as long as the shortfall needs. With no plan (no or stale
|
||||
forecast, no house profile yet) the levels are dropped: P3 option A.
|
||||
Returns the runs to keep."""
|
||||
dp = dayplan.plan(
|
||||
self.solar_fc,
|
||||
None if self.house_profile is None
|
||||
else self.house_profile.estimator(),
|
||||
priceruns.keep_started(self.mode_runs, runs, now), now, packs,
|
||||
trust_pct=self.solar_trust_pct, floor_pct=floor_pct,
|
||||
ceiling_pct=ceiling_pct)
|
||||
self.day_plan = dp
|
||||
if dp is None:
|
||||
self._plan_targets = None
|
||||
return runs
|
||||
self._plan_targets = dayplan.hold(
|
||||
self._plan_targets, dp.targets,
|
||||
{p.name: p.capacity_kwh for p in packs})
|
||||
e2 = modetrade.energy(packs, floor_pct, ceiling_pct, done_c, (),
|
||||
targets=self._plan_targets)
|
||||
return priceruns.plan(
|
||||
plan.read.points, now, required_eur_kwh=required,
|
||||
charge_kwh=e2[0], charge_kw=e2[1], sell_kwh=e[2], sell_kw=e[3],
|
||||
ref_eur_kwh=None if ref is None else ref.mean_eur_kwh)
|
||||
|
||||
def plan_targets(self):
|
||||
"""P4: each battery's planned level, or None (no day plan). Dropped
|
||||
at once when the forecast goes (stale or missing)."""
|
||||
if self.solar_fc is None:
|
||||
return None
|
||||
return self._plan_targets
|
||||
|
||||
def _mode_roles(self, now, maint_results):
|
||||
"""OPT-11 P3: each battery's role and range this tick. Grid charging
|
||||
and selling need a fresh plan that `TariffPlan.arbitrage()` allows
|
||||
@@ -2154,9 +2224,22 @@ class Controller:
|
||||
price_now = None
|
||||
house = (None if self.grid is None or self.site_batt_w is None
|
||||
else float(self.grid) + float(self.site_batt_w))
|
||||
done_c = self._run_mark("charge", runs and runs.cheap, packs,
|
||||
lambda p: p.soc is not None
|
||||
and p.soc >= ceiling_pct)
|
||||
if self.house_profile is not None and \
|
||||
self.house_profile.add(now, house):
|
||||
self.store.set(HOUSE_PROFILE_KEY, self.house_profile.dump())
|
||||
targets = self.plan_targets()
|
||||
if targets is None:
|
||||
done_c = self._run_mark("charge", runs and runs.cheap, packs,
|
||||
lambda p: p.soc is not None
|
||||
and p.soc >= ceiling_pct)
|
||||
else:
|
||||
# P4: out of the run at its planned level. A level the
|
||||
# hysteresis moved opens the run to it again (a new mark key).
|
||||
def full(p):
|
||||
to = modetrade.charge_to(p, ceiling_pct, targets)
|
||||
return p.soc is not None and (to is None or p.soc >= to)
|
||||
done_c = self._run_mark("charge", runs and runs.cheap, packs,
|
||||
full, tuple(sorted(targets.items())))
|
||||
done_s = self._run_mark("sell", runs and runs.sell, packs,
|
||||
lambda p: p.soc is not None
|
||||
and p.soc <= floor_pct)
|
||||
@@ -2169,7 +2252,7 @@ class Controller:
|
||||
headroom_w=self.arb_headroom_w, floor_pct=floor_pct,
|
||||
ceiling_pct=ceiling_pct, house_w=house, charging_now=was,
|
||||
blocked=self._charge_blocked_q == quarter,
|
||||
done_charge=done_c, done_sell=done_s,
|
||||
done_charge=done_c, done_sell=done_s, targets=targets,
|
||||
draining=any(r.phase == DRAIN for r in maint_results or ()))
|
||||
# A charge the guard or the headroom stopped stays off this period.
|
||||
if was and self.mode_tick.action != modetrade.CHARGE and \
|
||||
@@ -2177,10 +2260,13 @@ class Controller:
|
||||
self._charge_blocked_q = quarter
|
||||
return self.mode_tick
|
||||
|
||||
def _run_mark(self, kind, run, packs, out) -> set:
|
||||
def _run_mark(self, kind, run, packs, out, extra=None) -> set:
|
||||
"""The batteries that left run `run` (full, or at the floor); they
|
||||
stay out until it ends, so a level edge does not re-arm them."""
|
||||
stay out until it ends, so a level edge does not re-arm them.
|
||||
`extra` (P4: the planned levels) is part of the run's key."""
|
||||
key = None if not run else (run.start, run.end)
|
||||
if key is not None and extra is not None:
|
||||
key += (extra,)
|
||||
was_key, done = self._run_done.get(kind, (None, set()))
|
||||
if key != was_key:
|
||||
done = set()
|
||||
@@ -2428,6 +2514,25 @@ class Controller:
|
||||
self._drain_waits = waits
|
||||
return floor if floor > 0.0 else None
|
||||
|
||||
def _peak_over_drain(self, claims, maint_results) -> None:
|
||||
"""SAFETY-83: at N=1 a peak floor above the drain rate outranks it.
|
||||
|
||||
The drain is the site SET at P_MAINTENANCE, above the peak floor
|
||||
(P_PEAK), so the floor did not bind and the house imported what the
|
||||
drain left. Where the floor asks for more than the drain rate, it is
|
||||
refiled at P_MAINTENANCE and wins; nothing else changes. Safety claims
|
||||
(P_SAFETY) still outrank it. At N>=2 the `drain` floor does this.
|
||||
"""
|
||||
if len(self.inverters) != 1:
|
||||
return
|
||||
res = maint_results[0]
|
||||
if res.phase != DRAIN or not res.owns_setpoint:
|
||||
return
|
||||
rate = float(res.setpoint_w or 0.0)
|
||||
for i, c in enumerate(claims):
|
||||
if c.source == peakguard.SOURCE and c.lo > rate:
|
||||
claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE)
|
||||
|
||||
def _maint_stop_off(self, machine, now) -> None:
|
||||
"""OPT-11: stop this battery's maintenance cycle because it is off."""
|
||||
if machine.phase == IDLE:
|
||||
@@ -2532,6 +2637,22 @@ class Controller:
|
||||
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
|
||||
list(zip(roster, states)), pv_read.inverted(self.o))
|
||||
|
||||
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)
|
||||
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:
|
||||
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"))
|
||||
self.solar_fc, self.solar_fc_why = fc, why
|
||||
|
||||
async def read_inputs(self, now_mono: float | None = None) -> None:
|
||||
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
|
||||
|
||||
@@ -2583,6 +2704,14 @@ class Controller:
|
||||
_reads = [self._read_unit(inv, now_mono) for inv in self.inverters]
|
||||
if self.pv_roster:
|
||||
_reads.append(self._read_pv())
|
||||
# OPT-11 P4: the solar forecast, in the same gather (a slow sensor
|
||||
# must not delay control), at most once per FORECAST_READ_S.
|
||||
if self.fc_entity is not None and (
|
||||
self._fc_read_mono is None
|
||||
or now_mono - self._fc_read_mono >= FORECAST_READ_S
|
||||
or now_mono < self._fc_read_mono):
|
||||
self._fc_read_mono = now_mono
|
||||
_reads.append(self._read_forecast())
|
||||
await asyncio.gather(*_reads)
|
||||
if o.get("peak_forecast_entity"):
|
||||
raw = await self.hass.number(o["peak_forecast_entity"])
|
||||
@@ -3740,12 +3869,10 @@ class Controller:
|
||||
# claim is not sticky. Filed once and skipped next cycle, the floor
|
||||
# lapses and the actuator goes silently back to whatever was pushing the
|
||||
# peak. Which of the higher-priority claims outranks it is arbiter.py's
|
||||
# business, not this block's - and the answer is already right: a
|
||||
# maintenance DRAIN or HOLD owns the setpoint at P_MAINTENANCE and
|
||||
# commands discharge or zero, neither of which can create offtake, while
|
||||
# a maintenance CHARGE is only ever a P_MAINT_SHAPE limit (40) and is
|
||||
# therefore bound by this floor. That is §9.1's "money outranks the
|
||||
# maintenance schedule", now as a consequence of the priorities.
|
||||
# business, not this block's: a maintenance DRAIN or HOLD owns the
|
||||
# setpoint at P_MAINTENANCE, and a maintenance CHARGE is only ever a
|
||||
# P_MAINT_SHAPE limit (40), bound by this floor. A floor above the drain
|
||||
# rate still wins (SAFETY-78 at N>=2, `_peak_over_drain()` at N=1).
|
||||
#
|
||||
# ⚠️ AND IT IS NOT RUN AT ALL WITHOUT P1. `meter_source: off` is the
|
||||
# legacy single-entity path: there is no telegram stream, so
|
||||
@@ -4145,6 +4272,7 @@ class Controller:
|
||||
claims.append(Claim.limit("drain", P_MAINTENANCE, lo=drain,
|
||||
reason="maintenance drain",
|
||||
actuator=self.actuator_of("drain")))
|
||||
self._peak_over_drain(claims, maint_results)
|
||||
# --- OPT-11 P3: the site trade, after every unit claim --------------
|
||||
# Sized from what the batteries may place with their modes and hard
|
||||
# limits (the loop bound), so none is asked for watts it may not give.
|
||||
|
||||
@@ -906,6 +906,8 @@ HINTS = {
|
||||
"trade_charge": "Trading: charging while the price is low",
|
||||
"trade_give": "Trading: giving power while the price is high",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1173,6 +1175,9 @@ LABELS = {
|
||||
"batt_entity": "Battery power entity",
|
||||
"pv_entity": "Solar production entity",
|
||||
"pv_entities": "Solar production entities, per inverter",
|
||||
"pv_forecast_entity": "Solar forecast, today",
|
||||
"pv_forecast_tomorrow_entity": "Solar forecast, tomorrow",
|
||||
"solar_trust_pct": "Trust in the forecast",
|
||||
"p1_phase_import_entities": "Import entities per phase",
|
||||
"p1_phase_export_entities": "Export entities per phase",
|
||||
}
|
||||
|
||||
@@ -23,7 +23,11 @@ Roles (the page's Why line names them):
|
||||
|
||||
Rules that keep this safe (tests: test_modes_p3.py):
|
||||
* every range holds 0 W (a mode may forbid a direction, never force one);
|
||||
* a Solar first battery never charges from the grid (no forecast yet, P4);
|
||||
* a Solar first battery never charges from the grid;
|
||||
* P4: with a day plan (`targets`), a Leftover solar battery charges from
|
||||
the grid only up to its planned level, never above the ceiling; with
|
||||
none it takes solar only (P3 option A). Cheap hours only fills to the
|
||||
ceiling either way (`charge_to()`);
|
||||
* 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
|
||||
`peak_cap_w - peak_margin_w` (SAFETY-79). It starts only at START_W or
|
||||
@@ -47,7 +51,7 @@ Rules that keep this safe (tests: test_modes_p3.py):
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR,
|
||||
SOLAR_FIRST)
|
||||
PLAN_CHARGE_MODES, SOLAR_FIRST)
|
||||
|
||||
OFF, HOUSE, HOLD, GIVE, SELL, CHARGE, REST = (
|
||||
"off", "house", "hold", "give", "sell", "charge", "rest")
|
||||
@@ -90,13 +94,35 @@ def _room_kwh(p: Pack, pct: float) -> float:
|
||||
return max(0.0, abs(pct - p.soc)) / 100.0 * float(p.capacity_kwh)
|
||||
|
||||
|
||||
def chargers(packs, ceiling_pct, done=()) -> list:
|
||||
"""Batteries that may charge from the grid in a cheap run: the modes in
|
||||
`packmode.GRID_CHARGE_MODES` (Cheap hours only until P4's forecast)."""
|
||||
return [p for p in packs
|
||||
if p.use and p.idle and p.live and p.mode in GRID_CHARGE_MODES
|
||||
and p.soc is not None and p.soc < ceiling_pct
|
||||
and p.refused >= 0 and p.name not in done]
|
||||
def charge_to(p: Pack, ceiling_pct, targets=None):
|
||||
"""The level this battery may charge to from the grid, 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
|
||||
this is P3 option A exactly: Cheap hours to the ceiling, the others never.
|
||||
With a plan, a Leftover solar battery charges only to its planned level
|
||||
(the sun fills the rest); a battery the plan leaves out, Cheap hours only
|
||||
included, gets the no-plan answer. Solar first never charges."""
|
||||
if p.mode in GRID_CHARGE_MODES:
|
||||
return float(ceiling_pct)
|
||||
if targets is None or p.name not in targets \
|
||||
or p.mode not in PLAN_CHARGE_MODES:
|
||||
return None
|
||||
t = targets[p.name]
|
||||
return None if t is None else min(float(ceiling_pct), float(t))
|
||||
|
||||
|
||||
def chargers(packs, ceiling_pct, done=(), targets=None) -> list:
|
||||
"""Batteries that may charge from the grid in a cheap run: below the
|
||||
level `charge_to()` allows them (P4: the day plan's)."""
|
||||
out = []
|
||||
for p in packs:
|
||||
to = charge_to(p, ceiling_pct, targets)
|
||||
if (to is not None and p.use and p.idle and p.live
|
||||
and p.soc is not None and p.soc < to
|
||||
and p.refused >= 0 and p.name not in done):
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def sellers(packs, floor_pct, done=()) -> list:
|
||||
@@ -108,11 +134,15 @@ def sellers(packs, floor_pct, done=()) -> list:
|
||||
and p.refused <= 0 and p.name not in done]
|
||||
|
||||
|
||||
def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=()):
|
||||
"""`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`."""
|
||||
ch = chargers(packs, ceiling_pct, done_charge)
|
||||
def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(),
|
||||
targets=None):
|
||||
"""`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`.
|
||||
With a day plan the charge is each battery's shortfall, so the cheap run
|
||||
is only as long as the shortfall needs."""
|
||||
ch = chargers(packs, ceiling_pct, done_charge, targets)
|
||||
se = sellers(packs, floor_pct, done_sell)
|
||||
return (sum(_room_kwh(p, ceiling_pct) for p in ch),
|
||||
return (sum(_room_kwh(p, charge_to(p, ceiling_pct, targets))
|
||||
for p in ch),
|
||||
sum(abs(p.rating_w) for p in ch) / 1000.0,
|
||||
sum(_room_kwh(p, floor_pct) for p in se),
|
||||
sum(abs(p.rating_w) for p in se) / 1000.0)
|
||||
@@ -121,7 +151,7 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=()):
|
||||
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
|
||||
blocked=False, done_charge=(), done_sell=(),
|
||||
draining=False) -> Tick:
|
||||
targets=None, draining=False) -> Tick:
|
||||
"""This tick's ranges, roles and site ask.
|
||||
|
||||
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
|
||||
@@ -132,8 +162,9 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
`house_w` is the measured house (grid + batteries), or None.
|
||||
`charging_now` says last tick charged (then any headroom above 0 keeps
|
||||
it going); `blocked` that the guard or the headroom stopped a charge in
|
||||
this 15-minute period. `draining` says a battery drains for its
|
||||
monthly cycle, so none may charge from the grid (why "cycle").
|
||||
this 15-minute period. `targets` is the day plan (`charge_to()`).
|
||||
`draining` says a battery drains for its monthly cycle, so none may
|
||||
charge from the grid (why "cycle").
|
||||
"""
|
||||
plan_on = runs is not None and runs.on
|
||||
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
|
||||
@@ -141,7 +172,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
in_sell = plan_on and runs.sell is not None and runs.sell.holds(now)
|
||||
|
||||
why = None
|
||||
ch = chargers(packs, ceiling_pct, done_charge) if in_cheap else []
|
||||
ch = chargers(packs, ceiling_pct, done_charge, targets) if in_cheap \
|
||||
else []
|
||||
if ch and draining:
|
||||
ch, why = [], "cycle"
|
||||
charging = False
|
||||
|
||||
@@ -46,10 +46,13 @@ SELL_KEY = "pack_sell" # OPT-11 P3: sell in the sell run
|
||||
# refused; the sell code in modetrade/priceruns stays, unreachable.
|
||||
SELLING_ENABLED = False
|
||||
|
||||
# The modes that may charge from the grid in a cheap run. Leftover solar joins
|
||||
# when the solar forecast exists (P4); until then it takes solar only (owner
|
||||
# decision 2026-09-29, option A).
|
||||
# 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).
|
||||
GRID_CHARGE_MODES = (CHEAP_HOURS,)
|
||||
# OPT-11 P4: with a fresh solar forecast, the day plan (dayplan.py) lets
|
||||
# Leftover solar charge from the grid, by the shortfall only. Cheap hours only
|
||||
# ignores the plan (fills to the ceiling); Solar first never grid-charges.
|
||||
PLAN_CHARGE_MODES = (LEFTOVER_SOLAR,)
|
||||
|
||||
|
||||
def enabled(opts) -> bool:
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""OPT-11 P4: the solar forecast, read from a Home Assistant sensor.
|
||||
|
||||
Pure: HA state dicts and the time in, a `Forecast` (or None and why) out.
|
||||
main.py reads the entities; dayplan.py uses the answer. FCST-01 replaces
|
||||
this later with the add-on's own forecast (owner answer, 2026-09-27).
|
||||
|
||||
Two options name the sensors: `pv_forecast_entity`, the solar energy still
|
||||
to come TODAY, and `pv_forecast_tomorrow_entity`, TOMORROW's (optional; it
|
||||
is only needed for a cheap run before midnight). Supported, by the entities
|
||||
they create (DOCS.md lists them):
|
||||
|
||||
* Forecast.Solar (HA core): `sensor.energy_production_today_remaining` and
|
||||
`sensor.energy_production_tomorrow`. The state only; no hourly split.
|
||||
* Solcast PV Forecast (HACS `solcast_solar`):
|
||||
`sensor.solcast_pv_forecast_forecast_remaining_today` and
|
||||
`..._forecast_tomorrow`, with `detailedForecast` (or `detailedHourly`):
|
||||
`[{period_start, pv_estimate}]`, kW per period.
|
||||
* Open-Meteo Solar Forecast (HACS `open_meteo_solar_forecast`):
|
||||
`sensor.energy_production_today_remaining` / `..._tomorrow`, with
|
||||
`wh_period`: `{period start: Wh}`.
|
||||
|
||||
Any other sensor whose state is energy in Wh, kWh or MWh works too, as a
|
||||
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
|
||||
06:00 to 20:00 local time (`BELL_FROM_H`, `BELL_TO_H`).
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .hass import BAD_STATES
|
||||
from .tariff import BILLING_TZ
|
||||
|
||||
KWH_PER = {"Wh": 0.001, "kWh": 1.0, "MWh": 1000.0}
|
||||
|
||||
# Stale after 3 h. The integrations above refresh at least hourly, so a
|
||||
# 2 h limit would flag a site whose hourly poll was missed once.
|
||||
FRESH_S = 3 * 3600.0
|
||||
|
||||
# The bell a state-only forecast is spread over, in local hours.
|
||||
BELL_FROM_H, BELL_TO_H = 6.0, 20.0
|
||||
|
||||
# Why there is no forecast (the page words are in ui_text `home_words`).
|
||||
NOT_SET, MISSING, UNIT, STALE, BAD = (
|
||||
"not_set", "missing", "unit", "stale", "bad")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Day:
|
||||
"""One day's forecast: `kwh` still to come (today) or in all (tomorrow),
|
||||
and the hourly split if the sensor has one: `((start, end, kwh), ...)`."""
|
||||
day0: datetime # local midnight, as UTC
|
||||
kwh: float
|
||||
periods: tuple = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Forecast:
|
||||
today: Day
|
||||
tomorrow: Day | None
|
||||
at: datetime # when the sensor last reported
|
||||
|
||||
def between(self, t0: datetime, t1: datetime) -> float | None:
|
||||
"""kWh the sun should give between `t0` (now or later) and `t1`.
|
||||
None when the span reaches the sunny hours of a day with no
|
||||
forecast (tomorrow, when its sensor is not set)."""
|
||||
if t1 <= t0:
|
||||
return 0.0
|
||||
last = self.tomorrow or self.today
|
||||
if t1 > last.day0 + timedelta(days=1, hours=BELL_FROM_H):
|
||||
return None
|
||||
total = 0.0
|
||||
for day in (self.today, self.tomorrow):
|
||||
if day is None:
|
||||
continue
|
||||
a = max(t0, day.day0)
|
||||
b = min(t1, day.day0 + timedelta(days=1))
|
||||
if b > a:
|
||||
total += _day_between(day, a, b, t0)
|
||||
return total
|
||||
|
||||
|
||||
def _local_hour(t: datetime) -> float:
|
||||
lt = t.astimezone(BILLING_TZ)
|
||||
return lt.hour + lt.minute / 60.0 + lt.second / 3600.0
|
||||
|
||||
|
||||
def _bell(h: float) -> float:
|
||||
"""Cumulative share of a day's sun by local hour `h` (0..1)."""
|
||||
if h <= BELL_FROM_H:
|
||||
return 0.0
|
||||
if h >= BELL_TO_H:
|
||||
return 1.0
|
||||
x = (h - BELL_FROM_H) / (BELL_TO_H - BELL_FROM_H)
|
||||
return (1.0 - math.cos(math.pi * x)) / 2.0
|
||||
|
||||
|
||||
def _hours_since(day0, t):
|
||||
return (t - day0).total_seconds() / 3600.0
|
||||
|
||||
|
||||
def _day_between(day: Day, a: datetime, b: datetime, now: datetime) -> float:
|
||||
if day.periods:
|
||||
out = 0.0
|
||||
for s, e, kwh in day.periods:
|
||||
lo, hi = max(a, s), min(b, e)
|
||||
if hi > lo:
|
||||
out += kwh * (hi - lo).total_seconds() / (e - s).total_seconds()
|
||||
return out
|
||||
# State only: `kwh` is what is left from `max(now, day0)` to the day's
|
||||
# end, spread over the bell.
|
||||
h_from = _hours_since(day.day0, max(now, day.day0))
|
||||
left = 1.0 - _bell(h_from)
|
||||
if left <= 1e-9:
|
||||
return 0.0
|
||||
share = (_bell(_hours_since(day.day0, b))
|
||||
- _bell(_hours_since(day.day0, a))) / left
|
||||
return day.kwh * max(0.0, share)
|
||||
|
||||
|
||||
def _parse_time(v):
|
||||
try:
|
||||
t = datetime.fromisoformat(str(v).replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return t if t.tzinfo else t.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _periods(attrs: dict, day0: datetime) -> tuple:
|
||||
"""The hourly split, if the sensor has one, for the day at `day0`."""
|
||||
d1 = day0 + timedelta(days=1)
|
||||
out = []
|
||||
for key in ("detailedForecast", "detailedHourly"):
|
||||
rows = attrs.get(key)
|
||||
if isinstance(rows, list) and rows:
|
||||
starts = []
|
||||
for r in rows:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
t = _parse_time(r.get("period_start"))
|
||||
try:
|
||||
kw = float(r.get("pv_estimate"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if t is not None and math.isfinite(kw) and kw >= 0:
|
||||
starts.append((t, kw))
|
||||
starts.sort()
|
||||
for i, (t, kw) in enumerate(starts):
|
||||
step = (starts[i + 1][0] - t if i + 1 < len(starts)
|
||||
else timedelta(minutes=30 if key ==
|
||||
"detailedForecast" else 60))
|
||||
if step <= timedelta(0) or step > timedelta(hours=1):
|
||||
step = timedelta(minutes=30 if key == "detailedForecast"
|
||||
else 60)
|
||||
if day0 <= t < d1:
|
||||
out.append((t, t + step,
|
||||
kw * step.total_seconds() / 3600.0))
|
||||
if out:
|
||||
return tuple(out)
|
||||
wh = attrs.get("wh_period")
|
||||
if isinstance(wh, dict) and wh:
|
||||
rows = []
|
||||
for k, v in wh.items():
|
||||
t = _parse_time(k)
|
||||
try:
|
||||
e = float(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if t is not None and math.isfinite(e) and e >= 0:
|
||||
rows.append((t, e / 1000.0))
|
||||
rows.sort()
|
||||
for i, (t, kwh) in enumerate(rows):
|
||||
nxt = rows[i + 1][0] if i + 1 < len(rows) else t + timedelta(
|
||||
hours=1)
|
||||
if nxt - t > timedelta(hours=1) or nxt <= t:
|
||||
nxt = t + timedelta(hours=1)
|
||||
if day0 <= t < d1:
|
||||
out.append((t, nxt, kwh))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _day(state, day0, now, *, today):
|
||||
"""`(Day, None)` or `(None, why)` from one sensor's state dict."""
|
||||
if state is None:
|
||||
return None, MISSING
|
||||
raw = str(state.get("state", "")).strip()
|
||||
if raw.lower() in BAD_STATES:
|
||||
return None, MISSING
|
||||
try:
|
||||
v = float(raw)
|
||||
except ValueError:
|
||||
return None, BAD
|
||||
attrs = state.get("attributes") or {}
|
||||
unit = str(attrs.get("unit_of_measurement") or "").strip()
|
||||
if unit not in KWH_PER:
|
||||
return None, UNIT
|
||||
kwh = v * KWH_PER[unit]
|
||||
if not math.isfinite(kwh) or kwh < 0:
|
||||
return None, BAD
|
||||
periods = _periods(attrs, day0)
|
||||
if periods and today:
|
||||
# The split is the whole day; keep what is still to come, scaled so
|
||||
# it adds up to the state (the state is what the integration says
|
||||
# is left).
|
||||
left = sum(k * max(0.0, min(1.0, (e - max(s, now)).total_seconds()
|
||||
/ (e - s).total_seconds()))
|
||||
for s, e, k in periods)
|
||||
if left > 1e-6:
|
||||
f = kwh / left
|
||||
periods = tuple((s, e, k * f) for s, e, k in periods)
|
||||
return Day(day0, kwh, periods), None
|
||||
|
||||
|
||||
def _reported(state):
|
||||
ts = [_parse_time(state.get(k)) for k in
|
||||
("last_reported", "last_updated", "last_changed")]
|
||||
ts = [t for t in ts if t is not None]
|
||||
return max(ts) if ts else None
|
||||
|
||||
|
||||
def read(today_state, tomorrow_state, now: datetime, *,
|
||||
configured=True):
|
||||
"""`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None
|
||||
(the option is not set, or the sensor is missing): the forecast then
|
||||
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)
|
||||
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
|
||||
return Forecast(today, tomorrow, at), None
|
||||
@@ -403,6 +403,9 @@
|
||||
else $("#batt-banner").replaceChildren();
|
||||
keepFocus(() => {
|
||||
$("#bcards").replaceChildren(...S.batteries.map(batteryCard));
|
||||
// OPT-11 P4: the solar forecast line under the cards.
|
||||
const sl = $("#batt-solar"), line = modes && S.solar ? S.solar.line : "";
|
||||
if (sl) { sl.textContent = line || ""; sl.hidden = !line; }
|
||||
if (simpleHost && simpleHost.isConnected) {
|
||||
simpleHost.replaceChildren(...(modes ? S.batteries.map(simpleRow)
|
||||
: [h("p", { class: "small muted", style: "margin:4px 0" }, t("batteries.modes_off.name") + ". " + t("batteries.description"))]));
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
<a class="btn" href="#settings" data-t="batteries.settings"></a>
|
||||
</div>
|
||||
<div id="bcards"></div>
|
||||
<p class="small muted" id="batt-solar" hidden></p>
|
||||
<details class="grp" id="batt-how" hidden>
|
||||
<summary><span class="grow"><strong data-t="batteries.how.name"></strong><span class="small muted gdesc" data-t="batteries.how.description"></span></span><span class="chev" aria-hidden="true">›</span></summary>
|
||||
<div class="body small">
|
||||
|
||||
@@ -446,7 +446,17 @@
|
||||
e.textContent = ok ? e.dataset.kwNote.replace("{kw}", window.UX13.kw(mp.peak_w) + " kW") : "";
|
||||
});
|
||||
}
|
||||
// OPT-11 P4: the solar forecast card shows the add-on's forecast line.
|
||||
function fillSolar() {
|
||||
const st = window.UX13.status();
|
||||
const line = st && st.solar ? st.solar.line : "";
|
||||
document.querySelectorAll("#simple [data-solar-note]").forEach((e) => {
|
||||
e.textContent = line || "";
|
||||
e.hidden = !line;
|
||||
});
|
||||
}
|
||||
document.addEventListener("ux13:status", fillNotes);
|
||||
document.addEventListener("ux13:status", fillSolar);
|
||||
function simpleField(f) {
|
||||
const row = fieldRow(f, null, "simple");
|
||||
row.querySelector(".head").append(chip(f.applies));
|
||||
@@ -458,6 +468,7 @@
|
||||
const help = row.querySelector(".help");
|
||||
if (help) help.textContent = n.description;
|
||||
}
|
||||
if (f.note === "forecast") row.append(h("div", { class: "small muted", "data-solar-note": "", hidden: true }));
|
||||
return h("div", { class: "card" }, row);
|
||||
}
|
||||
function renderSimple() {
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
"description": "Every setting, grouped by task.",
|
||||
"view": {"name": "Settings view", "simple": "Simple", "installer": "Installer"},
|
||||
"simple": {"description": "The choices a home owner makes.",
|
||||
"note": {"month_peak": {"description": "This month's peak so far: {kw}."}, "shadow": {"description": "Use Always on while you test a new installation."}},
|
||||
"note": {"month_peak": {"description": "This month's peak so far: {kw}."}, "shadow": {"description": "Use Always on while you test a new installation."}, "forecast": {"description": "With a forecast, a battery buys only what the sun will not give."}},
|
||||
"batteries": {"name": "Batteries"},
|
||||
"more": {"description": "Looking for the meter, inverters or prices?"},
|
||||
"installer": "Open the installer view"},
|
||||
@@ -335,8 +335,12 @@
|
||||
"saves": "Saves its power for {at}.",
|
||||
"saves_later": "Saves its power for higher prices.",
|
||||
"no_prices": "No prices, so it follows the house.",
|
||||
"no_spread": "Prices differ too little, so it follows the house."
|
||||
"no_spread": "Prices differ too little, so it follows the house.",
|
||||
"sun_fills": "Enough sun is forecast, so it does not charge 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."
|
||||
},
|
||||
"solar": {"line": "Solar still to come 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": {
|
||||
"not_planned": "Not planned yet.",
|
||||
"no_runs": "No cheap or expensive hours left today.",
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
"description": "Elke instelling, per taak.",
|
||||
"view": {"name": "Weergave van de instellingen", "simple": "Eenvoudig", "installer": "Installateur"},
|
||||
"simple": {"description": "De keuzes die een eigenaar maakt.",
|
||||
"note": {"month_peak": {"description": "De piek van deze maand tot nu: {kw}."}, "shadow": {"description": "Kies Altijd aan terwijl u een nieuwe installatie test."}},
|
||||
"note": {"month_peak": {"description": "De piek van deze maand tot nu: {kw}."}, "shadow": {"description": "Kies Altijd aan terwijl u een nieuwe installatie test."}, "forecast": {"description": "Met een prognose koopt een batterij alleen wat de zon niet geeft."}},
|
||||
"batteries": {"name": "Batterijen"},
|
||||
"more": {"description": "Zoekt u de meter, de omvormers of de prijzen?"},
|
||||
"installer": "De installateursweergave openen"},
|
||||
@@ -277,8 +277,12 @@
|
||||
"saves": "Spaart haar stroom voor {at}.",
|
||||
"saves_later": "Spaart haar stroom voor hogere prijzen.",
|
||||
"no_prices": "Geen prijzen, dus ze volgt het huis.",
|
||||
"no_spread": "De prijzen verschillen te weinig, dus ze volgt het huis."
|
||||
"no_spread": "De prijzen verschillen te weinig, dus ze volgt het huis.",
|
||||
"sun_fills": "Er is genoeg zon voorspeld, dus ze laadt niet 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."
|
||||
},
|
||||
"solar": {"line": "Nog verwachte zon 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": {
|
||||
"not_planned": "Nog niet gepland.",
|
||||
"no_runs": "Vandaag geen goedkope of dure uren meer.",
|
||||
|
||||
@@ -129,7 +129,14 @@ GROUPS = (
|
||||
wizard=3),)),
|
||||
# OPT-11: the switch that turns battery modes on. Each battery's mode
|
||||
# and on/off switch are runtime state on the Batteries screen.
|
||||
Part("modes", (Field("battery_modes_enabled", TOGGLE),)),
|
||||
Part("modes", (
|
||||
Field("battery_modes_enabled", TOGGLE),
|
||||
# OPT-11 P4: the solar forecast; today's also on Settings, Simple.
|
||||
Field("pv_forecast_entity", ENTITY, kind="energy", simple=True,
|
||||
note="forecast"),
|
||||
Field("pv_forecast_tomorrow_entity", ENTITY, kind="energy"),
|
||||
Field("solar_trust_pct", SLIDER, "%", step=5),
|
||||
)),
|
||||
)),
|
||||
Group("tariff", (
|
||||
Part("", (
|
||||
|
||||
@@ -246,6 +246,10 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
12. `peak_margin_w` outside 0-500 W, not whole, or not below `peak_cap_w`.
|
||||
SAFETY-79; the Supervisor checks the range too, but not for sim/inject.py.
|
||||
|
||||
13. OPT-11 P4: `solar_trust_pct` outside 30-100 or not whole, or a solar
|
||||
forecast entity that is not a `sensor.` id. A trust of 0 or 500 % would
|
||||
make the day plan buy everything or nothing, which no one means.
|
||||
|
||||
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
|
||||
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
|
||||
@@ -324,9 +328,10 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import NamedTuple
|
||||
|
||||
from . import messages, pv
|
||||
from . import dayplan, messages, pv, solarfc
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||
from .custom_formula import (
|
||||
@@ -1199,6 +1204,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
||||
|
||||
# --- 12. SAFETY-79: the margin the peak guard keeps under the limit -----
|
||||
problems += _margin_problems(opts)
|
||||
problems += _forecast_problems(opts)
|
||||
|
||||
# --- warnings: real, but not worth refusing over -------------------------
|
||||
integ = opts.get("integrator_max_w")
|
||||
@@ -1751,6 +1757,8 @@ def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
|
||||
add("pv_entities" if solar else "pv_entity", list(pv.entities(opts)))
|
||||
for key in ("peak_forecast_entity", "price_now_entity", "price_avg_entity"):
|
||||
add(key, opts.get(key))
|
||||
for key in FORECAST_KEYS:
|
||||
add(key, opts.get(key))
|
||||
return out
|
||||
|
||||
|
||||
@@ -1790,6 +1798,19 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
||||
"taken as watts, so pick a sensor in W.")
|
||||
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
|
||||
_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 \
|
||||
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"):
|
||||
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
|
||||
"Tomorrow's forecast is used only with today's, so set "
|
||||
f"{S('pv_forecast_entity')} too.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -2045,6 +2066,33 @@ def _margin_problems(opts: dict) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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."""
|
||||
out = []
|
||||
raw = opts.get("solar_trust_pct")
|
||||
trust = _number(opts, "solar_trust_pct", dayplan.DEFAULT_TRUST_PCT)
|
||||
lo, hi = TRUST_RANGE_PCT
|
||||
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 "
|
||||
f"sensor.energy_production_today_remaining. It is "
|
||||
f"{messages.shown(e)}.")
|
||||
return out
|
||||
|
||||
|
||||
def _number(opts: dict, key, default):
|
||||
raw = opts.get(key, default)
|
||||
if raw is None or raw == "":
|
||||
|
||||
@@ -151,6 +151,13 @@ options:
|
||||
# switch and a mode on the Batteries screen, kept in the add-on's store.
|
||||
# DECISIONS/OPT-11-battery-modes.md.
|
||||
battery_modes_enabled: false
|
||||
# OPT-11 P4: a Home Assistant solar forecast (kWh left today; tomorrow's is
|
||||
# optional) and how much of it to count on. Read only with battery modes
|
||||
# on; empty means no day plan (P3 option A). app/solarfc.py lists the
|
||||
# integrations whose sensors fit.
|
||||
pv_forecast_entity: ""
|
||||
pv_forecast_tomorrow_entity: ""
|
||||
solar_trust_pct: 70
|
||||
|
||||
# TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT
|
||||
# DECIDES ANYTHING. Optional, empty on every install that does not set it, and
|
||||
@@ -951,6 +958,9 @@ schema:
|
||||
|
||||
estop_fitted: bool
|
||||
battery_modes_enabled: bool
|
||||
pv_forecast_entity: str?
|
||||
pv_forecast_tomorrow_entity: 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
|
||||
# `price_provider` is `off` and make the add-on REFUSE TO START otherwise.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -318,6 +318,15 @@ _cases = [
|
||||
(dict(role="charge", refused=-1), "refuses_charge"),
|
||||
(dict(role="sell", peak=True), "peak"),
|
||||
(dict(role="hold", off=True), "off"),
|
||||
# OPT-11 P4: the day plan's word, while holding only.
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", has_at=True,
|
||||
day="sun_fills"), "sun_fills"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="tops_up"),
|
||||
"tops_up"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="no_forecast"),
|
||||
"no_forecast"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="charge", day="tops_up"),
|
||||
"grid_charge"),
|
||||
# SAFETY-78: another battery drains for its monthly cycle.
|
||||
(dict(other_cycle="A"), "other_cycle"),
|
||||
(dict(other_cycle="A", modes_on=False, giving=True), "other_cycle"),
|
||||
@@ -326,6 +335,8 @@ _cases = [
|
||||
(dict(other_cycle="A", peak=True), "peak"),
|
||||
(dict(other_cycle="A", refused=-1), "refuses_charge"),
|
||||
(dict(other_cycle="A", off=True), "off"),
|
||||
(dict(other_cycle="A", mode=packmode.LEFTOVER_SOLAR, role="hold",
|
||||
day="tops_up"), "other_cycle"),
|
||||
]
|
||||
_wrong = [(kw, want, why(**kw)) for kw, want in _cases if why(**kw) != want]
|
||||
print(f" {_wrong}")
|
||||
|
||||
@@ -0,0 +1,800 @@
|
||||
"""Runnable check for OPT-11 P4, the solar forecast and the day plan.
|
||||
`python3 test_modes_p4.py`
|
||||
|
||||
Drives the pure modules (solarfc, dayplan, modetrade) and the REAL Controller
|
||||
(tools/opt11p3_replay.py's engine, with a forecast sensor in the fake Home
|
||||
Assistant), in simulated time. DECISIONS/OPT-11-battery-modes.md has the
|
||||
record.
|
||||
|
||||
1. No usable forecast (none set, or set and unavailable): every command,
|
||||
trade and role is byte-identical to release/1.0 5a4cdc2 (digests measured
|
||||
there), and the harness sees a change there.
|
||||
2. The forecast sensors: the three supported shapes, units, stale, missing.
|
||||
3. The house profile: hours, days kept, the night floor, ready.
|
||||
4. The day plan: sunny, partly, dark; Solar first never gets a level; a
|
||||
Leftover solar level is the shortfall; hysteresis.
|
||||
5. modetrade with a plan: a fuzz against the invariants, and no plan = P3.
|
||||
6. The real controller over a night and a day: sunny (no grid charge),
|
||||
dark (Leftover solar buys the shortfall only, and stops there), Cheap
|
||||
hours skips the part the sun covers, a forecast that goes away mid-run,
|
||||
and every P1-P3 and SAFETY-79 invariant on every cycle.
|
||||
7. The Batteries screen and the Simple view: the forecast line and Why.
|
||||
8. Options: validation of the trust and the entities.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
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)
|
||||
|
||||
from named_absence import nth, present # noqa: E402
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from app import arbitrage, dayplan, homeview, modetrade, packmode # noqa
|
||||
from app import priceruns, solarfc, validate # noqa: E402
|
||||
from app.tariff import BILLING_TZ # 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))
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
D0 = datetime(2026, 9, 21, tzinfo=L)
|
||||
UTC0 = D0.astimezone(timezone.utc)
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. no usable forecast: byte-identical to 5a4cdc2 (P3 option A)")
|
||||
# Printed by tools/opt11p4_identity.py on release/1.0 5a4cdc2 (2026-09-29),
|
||||
# goodwe-addon-checks:1. 1080 cycles each, battery modes on.
|
||||
BASE_DIGESTS = {
|
||||
"n1_ls":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch":
|
||||
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
|
||||
"n2_sf_ls":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls":
|
||||
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
|
||||
"n2_ls_ls":
|
||||
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
|
||||
"n2_sf_ch":
|
||||
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
|
||||
"n1_ls_fc_down":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch_fc_down":
|
||||
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
|
||||
"n2_sf_ls_fc_down":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls_fc_down":
|
||||
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
|
||||
"n2_ls_ls_fc_down":
|
||||
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
|
||||
"n2_sf_ch_fc_down":
|
||||
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
|
||||
}
|
||||
import opt11p4_identity as ident # noqa: E402
|
||||
|
||||
got = ident.digests()
|
||||
for k, v in BASE_DIGESTS.items():
|
||||
check(f"{k}: same digest as 5a4cdc2", got.get(k) == v)
|
||||
check("every scenario is pinned", set(got) == set(BASE_DIGESTS))
|
||||
for k in [k for k in got if k.endswith("_fc_down")]:
|
||||
check(f"{k}: a set but unavailable forecast equals no forecast",
|
||||
got[k] == got[k[:-len('_fc_down')]])
|
||||
|
||||
|
||||
def _moved(scn, patch, undo):
|
||||
patch()
|
||||
try:
|
||||
return ident.p3.digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
!= BASE_DIGESTS[scn]
|
||||
finally:
|
||||
undo()
|
||||
|
||||
|
||||
_gcm = modetrade.GRID_CHARGE_MODES
|
||||
check("the harness bites: Leftover solar grid-charging with no plan moves "
|
||||
"n2_sf_ls",
|
||||
_moved("n2_sf_ls",
|
||||
lambda: setattr(modetrade, "GRID_CHARGE_MODES",
|
||||
(packmode.LEFTOVER_SOLAR, packmode.CHEAP_HOURS)),
|
||||
lambda: setattr(modetrade, "GRID_CHARGE_MODES", _gcm)))
|
||||
_read = solarfc.read
|
||||
|
||||
|
||||
def _as_dark(today, tomorrow, now, **kw):
|
||||
return _read({"state": "0", "attributes": {"unit_of_measurement":
|
||||
"kWh"},
|
||||
"last_updated": now.isoformat()}, None, 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)))
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. the forecast sensors")
|
||||
NOW = datetime(2026, 9, 21, 9, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
|
||||
|
||||
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()}
|
||||
|
||||
|
||||
fc, why = solarfc.read(st("12.5"), None, NOW)
|
||||
check("Forecast.Solar shape (state only, kWh): read", fc is not None
|
||||
and abs(fc.today.kwh - 12.5) < 1e-9 and fc.today.periods == ())
|
||||
check("...the rest of the day adds up to the state",
|
||||
abs(fc.between(NOW, UTC0 + timedelta(days=1)) - 12.5) < 1e-6)
|
||||
check("...and nothing comes after sunset (20:00 local)",
|
||||
fc.between(datetime(2026, 9, 21, 20, tzinfo=L), UTC0
|
||||
+ timedelta(days=1)) < 1e-9)
|
||||
fc2, _ = solarfc.read(st("12500", unit="Wh"), None, NOW)
|
||||
fc3, _ = solarfc.read(st("0.0125", unit="MWh"), None, NOW)
|
||||
check("Wh and MWh are converted, never guessed",
|
||||
abs(fc2.today.kwh - 12.5) < 1e-9 and abs(fc3.today.kwh - 12.5) < 1e-9)
|
||||
check("a sensor with no unit, or in W, is refused (unit)",
|
||||
solarfc.read(st("12.5", unit=None), None, NOW) == (None, solarfc.UNIT)
|
||||
and solarfc.read(st("12.5", unit="W"), None, NOW)[1] == solarfc.UNIT)
|
||||
check("unavailable, unknown and a missing entity are no forecast",
|
||||
solarfc.read(st("unavailable"), None, NOW)[1] == solarfc.MISSING
|
||||
and solarfc.read(st("unknown"), None, NOW)[1] == solarfc.MISSING
|
||||
and solarfc.read(None, None, NOW)[1] == solarfc.MISSING)
|
||||
check("a negative or non-numeric state is no forecast",
|
||||
solarfc.read(st("-1"), None, NOW)[1] == solarfc.BAD
|
||||
and solarfc.read(st("lots"), None, NOW)[1] == solarfc.BAD)
|
||||
old = NOW - timedelta(seconds=solarfc.FRESH_S + 1)
|
||||
check("older than 3 h is stale, and stale is no forecast",
|
||||
solarfc.read(st("12.5", at=old), None, NOW) == (None, solarfc.STALE)
|
||||
and solarfc.read(st("12.5", at=NOW - timedelta(hours=2, minutes=59)),
|
||||
None, NOW)[0] is not None)
|
||||
check("a report from the future is stale too",
|
||||
solarfc.read(st("12.5", at=NOW + timedelta(hours=1)), None,
|
||||
NOW)[1] == solarfc.STALE)
|
||||
check("not configured says so", solarfc.read(None, None, NOW,
|
||||
configured=False)[1]
|
||||
== solarfc.NOT_SET)
|
||||
# Solcast: detailedForecast, kW per 30 min, the whole day.
|
||||
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)]
|
||||
fc4, _ = solarfc.read(st("8.0", detailedForecast=sol), None, NOW)
|
||||
# 10:00-18:00 local at 2 kW = 16 kWh in all; from 09:00 all 16 is to come,
|
||||
# scaled to the state's 8.
|
||||
check("Solcast shape: periods read and scaled to the state (kWh left)",
|
||||
fc4.today.periods and abs(fc4.between(NOW, UTC0 + timedelta(days=1))
|
||||
- 8.0) < 1e-6)
|
||||
check("...the split is used: 10:00-12:00 is a quarter of the day's sun",
|
||||
abs(fc4.between(datetime(2026, 9, 21, 10, tzinfo=L),
|
||||
datetime(2026, 9, 21, 12, tzinfo=L)) - 2.0) < 1e-6)
|
||||
# Open-Meteo: wh_period, Wh per hour.
|
||||
wh = {(UTC0 + timedelta(hours=h)).isoformat(): (1000.0 if 8 <= h < 16
|
||||
else 0.0)
|
||||
for h in range(24)}
|
||||
fc5, _ = solarfc.read(st("7.0", wh_period=wh), None, NOW)
|
||||
check("Open-Meteo shape: wh_period read (8 x 1 kWh, 7 left at 09:00)",
|
||||
abs(fc5.between(NOW, UTC0 + timedelta(days=1)) - 7.0) < 1e-6
|
||||
and abs(fc5.between(datetime(2026, 9, 21, 14, tzinfo=L),
|
||||
datetime(2026, 9, 21, 16, tzinfo=L)) - 2.0) < 1e-6)
|
||||
TOM = UTC0 + timedelta(days=1)
|
||||
check("without tomorrow's sensor, a span into tomorrow's sun is unknown",
|
||||
fc.between(NOW, TOM + timedelta(hours=9)) is None
|
||||
and fc.between(NOW, TOM + timedelta(hours=5)) is not None)
|
||||
fct, _ = solarfc.read(st("12.5"), st("20.0"), NOW)
|
||||
check("with it, tomorrow's sun is counted",
|
||||
abs(fct.between(TOM, TOM + timedelta(days=1)) - 20.0) < 1e-6)
|
||||
fcs, _ = solarfc.read(st("12.5"), st("20.0", at=old), NOW)
|
||||
check("a stale tomorrow is dropped, today is kept",
|
||||
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")
|
||||
|
||||
|
||||
def profile(day_w=None, days=1, evening=None, start=UTC0 - timedelta(days=1)):
|
||||
hp = dayplan.HouseProfile()
|
||||
t = start
|
||||
end = start + timedelta(days=days)
|
||||
while t < end:
|
||||
h = t.astimezone(L).hour
|
||||
w = (evening if evening is not None and 17 <= h < 22 else
|
||||
day_w if day_w is not None else 600.0)
|
||||
hp.add(t, w)
|
||||
t += timedelta(seconds=5)
|
||||
hp.add(t, None)
|
||||
return hp
|
||||
|
||||
|
||||
hp = profile(day_w=600.0, evening=3000.0)
|
||||
check("a full day recorded is ready, with 24 hours",
|
||||
hp.ready() and sum(len(r) for r in hp.days.values()) == 24)
|
||||
check("an hour's figure is its mean", abs(hp.hour_w(18) - 3000.0) < 1e-6
|
||||
and abs(hp.hour_w(3) - 600.0) < 1e-6)
|
||||
hp_half = dayplan.HouseProfile()
|
||||
for i in range(12 * 60 * 10):
|
||||
hp_half.add(UTC0 + timedelta(seconds=5 * i), 500.0)
|
||||
check("ten hours are not ready (MIN_HOURS)", not hp_half.ready())
|
||||
neg = profile(day_w=-2000.0)
|
||||
f, base = neg.estimator()
|
||||
check("a sunny net use (negative) is lifted to the night floor for the "
|
||||
"solar sum", f(12) == -2000.0 and base == -2000.0 or base >= 0.0)
|
||||
hp9 = profile(days=9)
|
||||
check("only the last 7 days are kept", len(hp9.days) <= 8)
|
||||
hp_rt = dayplan.HouseProfile(hp.dump())
|
||||
check("dump and load give the same figures", hp_rt.hour_w(18) == round(
|
||||
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",
|
||||
not dayplan.HouseProfile({"x": "y", "2026-09-20": {"99": 1,
|
||||
"a": "b"}}).ready())
|
||||
|
||||
# ============================================================================ #
|
||||
print("4. the day plan")
|
||||
P = modetrade.Pack
|
||||
|
||||
|
||||
def pk(name, mode, soc, cap=10.0, **kw):
|
||||
return P(name, True, mode, False, 2500.0, soc, cap, **kw)
|
||||
|
||||
|
||||
# Prices: cheap 01:00-03:00, dear 19:00-20:00 local (as in test_modes_p3).
|
||||
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)]
|
||||
from app.price import PricePoint # noqa: E402
|
||||
|
||||
|
||||
def runs_at(now):
|
||||
pts = [PricePoint(start=s, eur_per_kwh=rp.all_in(p), provisional=False)
|
||||
for s, _n, p in WPTS]
|
||||
return priceruns.plan(pts, now, required_eur_kwh=0.30, charge_kwh=6.0,
|
||||
charge_kw=2.5, sell_kwh=0.0, sell_kw=0.0)
|
||||
|
||||
|
||||
T0 = datetime(2026, 9, 21, 0, 30, tzinfo=L).astimezone(timezone.utc)
|
||||
R0 = runs_at(T0)
|
||||
check("the price plan has a cheap run at night and a dear one at 19:00",
|
||||
R0.on and R0.cheap.holds(datetime(2026, 9, 21, 1, 30, tzinfo=L))
|
||||
and any(g.start.astimezone(L).hour == 19 for g in R0.give))
|
||||
|
||||
|
||||
def fc_of(kwh, now=T0):
|
||||
wh = {(UTC0 + timedelta(hours=h)).isoformat():
|
||||
(kwh * 1000.0 / 10 if 8 <= h < 18 else 0.0) for h in range(24)}
|
||||
return solarfc.read(st(f"{kwh}", wh_period=wh, at=now), None, now)[0]
|
||||
|
||||
|
||||
EST = profile(day_w=600.0, evening=3000.0).estimator()
|
||||
PACKS = [pk(A, packmode.SOLAR_FIRST, 60.0), pk(B, packmode.LEFTOVER_SOLAR,
|
||||
30.0)]
|
||||
|
||||
|
||||
def dplan(kwh, packs=PACKS, trust=70, est=EST):
|
||||
return dayplan.plan(fc_of(kwh), est, R0, T0, packs, trust_pct=trust,
|
||||
floor_pct=20.0, ceiling_pct=95.0)
|
||||
|
||||
|
||||
sunny, partly, dark = dplan(40.0), dplan(12.0), dplan(1.0)
|
||||
print(f" sunny {sunny.verdict} {sunny.targets}; partly {partly.verdict} "
|
||||
f"{partly.targets}; dark {dark.verdict} {dark.targets}")
|
||||
check("40 kWh forecast: sunny, and B needs nothing from the grid",
|
||||
sunny.verdict == dayplan.SUNNY and sunny.need_kwh[B] == 0.0)
|
||||
check("1 kWh forecast: dark (not even A fills from the sun)",
|
||||
dark.verdict == dayplan.DARK)
|
||||
check("Solar first never gets a level (never charges from the grid)",
|
||||
all(A not in p.targets for p in (sunny, partly, dark)))
|
||||
_use = 3000.0 / 1000.0 # the 19:00-20:00 run at 3 kW
|
||||
_want = 20.0 + _use / dayplan.EFF / 10.0 * 100.0
|
||||
check("dark: B's level is what the house draws in the dear run (floor + "
|
||||
f"use / eff = {_want:.1f} %), not the 95 % ceiling",
|
||||
abs(dark.targets[B] - _want) < 0.1 and dark.targets[B] < 95.0)
|
||||
check("...so it buys only the shortfall", abs(dark.need_kwh[B] - (
|
||||
_want - 30.0) / 10.0) < 0.01)
|
||||
check("the level falls as the forecast rises (partly between dark and "
|
||||
"sunny)", sunny.targets[B] <= partly.targets[B] <= dark.targets[B])
|
||||
ch = dplan(12.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
|
||||
pk(B, packmode.CHEAP_HOURS, 30.0)])
|
||||
ch_dark = dplan(1.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
|
||||
pk(B, packmode.CHEAP_HOURS, 30.0)])
|
||||
check("...and on a partly sunny day too: Cheap hours ignores the plan "
|
||||
"(design section 1; review of PR #156)", B not in ch.targets
|
||||
and B not in ch_dark.targets
|
||||
and modetrade.charge_to(pk(B, packmode.CHEAP_HOURS, 30.0), 95.0,
|
||||
{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",
|
||||
dplan(12.0, trust=100).targets[B] < partly.targets[B])
|
||||
check("...and the sun counted scales with the trust exactly",
|
||||
abs(dplan(12.0, trust=100).sun_kwh * 0.7 - partly.sun_kwh) < 0.01)
|
||||
_room_a = (95.0 - 60.0) / 10.0 * 10.0 / 10.0 # A: 60 % -> 95 % of 10 kWh
|
||||
check("Solar first takes the sun first: B's share is what is left after "
|
||||
f"A's {_room_a:.1f} kWh of room",
|
||||
abs(partly.share_kwh[B] - max(0.0, partly.sun_kwh - _room_a)) < 0.01
|
||||
and partly.share_kwh[B] < partly.sun_kwh)
|
||||
_flat = (lambda h: 600.0, 600.0)
|
||||
_sunny_net = (lambda h: -2000.0 if 9 <= h < 17 else 600.0, 600.0)
|
||||
check("the house's net use in sunny hours (negative) is lifted to the night "
|
||||
"floor: the same sun as a flat 600 W house",
|
||||
abs(dplan(12.0, est=_flat).sun_kwh - dplan(12.0, est=_sunny_net)
|
||||
.sun_kwh) < 1e-6)
|
||||
check("no forecast, no house profile or no price plan: no plan",
|
||||
dayplan.plan(None, EST, R0, T0, PACKS, trust_pct=70, floor_pct=20,
|
||||
ceiling_pct=95) is None
|
||||
and dplan(12.0, est=None) is None
|
||||
and dayplan.plan(fc_of(12.0), EST, None, T0, PACKS, trust_pct=70,
|
||||
floor_pct=20, ceiling_pct=95) is None)
|
||||
# A plan for a run beyond today's forecast (after 20:00, cheap run tomorrow
|
||||
# night, give run tomorrow evening) with no tomorrow sensor: no plan.
|
||||
T_EVE = datetime(2026, 9, 21, 21, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
check("the sun before the dear run is unknown (no tomorrow sensor): no plan",
|
||||
dayplan.plan(fc_of(12.0, T_EVE), EST, runs_at(T_EVE), T_EVE, PACKS,
|
||||
trust_pct=70, floor_pct=20, ceiling_pct=95) is None)
|
||||
off = [pk(A, packmode.SOLAR_FIRST, 60.0),
|
||||
P(B, False, packmode.LEFTOVER_SOLAR, False, 2500.0, 30.0, 10.0)]
|
||||
check("an Off battery gets no level", B not in dplan(1.0, packs=off).targets)
|
||||
maint = [pk(A, packmode.SOLAR_FIRST, 60.0),
|
||||
pk(B, packmode.LEFTOVER_SOLAR, 30.0, idle=False)]
|
||||
check("a battery in maintenance gets no level",
|
||||
B not in dplan(1.0, packs=maint).targets)
|
||||
# Hysteresis.
|
||||
cap = {B: 10.0}
|
||||
check("hysteresis: a 0.3 kWh move on a 40 % level is ignored",
|
||||
dayplan.hold({B: 40.0}, {B: 43.0}, cap) == {B: 40.0})
|
||||
check("...a 1 kWh move is taken", dayplan.hold({B: 40.0}, {B: 50.0}, cap)
|
||||
== {B: 50.0})
|
||||
check("...10 % of a small level is enough (0.3 kWh on a 25 % level)",
|
||||
dayplan.hold({B: 25.0}, {B: 28.0}, cap) == {B: 28.0})
|
||||
check("...a battery new to the plan takes its level, one gone is dropped",
|
||||
dayplan.hold({A: 50.0}, {B: 33.0}, cap) == {B: 33.0})
|
||||
_flips = 0
|
||||
_kept = None
|
||||
rnd = random.Random(4)
|
||||
for i in range(400):
|
||||
lvl = 40.0 + rnd.uniform(-2.0, 2.0) # a noisy forecast
|
||||
new = dayplan.hold(_kept, {B: lvl}, cap)
|
||||
if _kept is not None and new != _kept:
|
||||
_flips += 1
|
||||
_kept = new
|
||||
check(f"a forecast wobbling by +-0.2 kWh moves the level rarely "
|
||||
f"({_flips} of 400)", _flips <= 8)
|
||||
|
||||
# ============================================================================ #
|
||||
print("5. modetrade with a plan")
|
||||
|
||||
|
||||
def p3_chargers(packs, ceiling, done=()):
|
||||
"""P3 option A's rule, as 5a4cdc2 wrote it."""
|
||||
return [p for p in packs if p.use and p.idle and p.live
|
||||
and p.mode in packmode.GRID_CHARGE_MODES and p.soc is not None
|
||||
and p.soc < ceiling and p.refused >= 0 and p.name not in done]
|
||||
|
||||
|
||||
rnd = random.Random(11)
|
||||
bad = []
|
||||
same = 0
|
||||
for i in range(20000):
|
||||
packs = [P(n, rnd.random() > 0.1, rnd.choice(packmode.MODES), False,
|
||||
2500.0, rnd.choice([None, rnd.uniform(0, 100)]),
|
||||
rnd.choice([None, 10.0, 5.0]), live=rnd.random() > 0.05,
|
||||
idle=rnd.random() > 0.1, refused=rnd.choice([0, 0, 0, 1, -1]))
|
||||
for n in (A, B, "c")]
|
||||
ceiling = rnd.choice([90.0, 95.0, 100.0])
|
||||
if [p.name for p in modetrade.chargers(packs, ceiling)] == \
|
||||
[p.name for p in p3_chargers(packs, ceiling)]:
|
||||
same += 1
|
||||
targets = {p.name: rnd.uniform(0, 120) for p in packs
|
||||
if rnd.random() > 0.3}
|
||||
ch = modetrade.chargers(packs, ceiling, targets=targets)
|
||||
for p in ch:
|
||||
to = modetrade.charge_to(p, ceiling, targets)
|
||||
if p.mode == packmode.SOLAR_FIRST or to is None or to > ceiling \
|
||||
or p.soc >= to or p.refused < 0 or not (p.use and p.idle
|
||||
and p.live):
|
||||
bad.append((p, targets, ceiling))
|
||||
if p.mode == packmode.LEFTOVER_SOLAR and p.name not in targets:
|
||||
bad.append(("LS without a level", p))
|
||||
check("no plan: chargers() is P3 option A's rule on 20 000 random rosters",
|
||||
same == 20000)
|
||||
check("with a plan: never Solar first, never above the ceiling or the "
|
||||
"level, never refusing, Off, not live or in maintenance, and Leftover "
|
||||
f"solar only with a level ({len(bad)} bad)", not bad)
|
||||
# decide() with a plan, in the cheap run: every range holds 0, only chargers
|
||||
# charge, SF never charges.
|
||||
now_c = R0.cheap.start + timedelta(minutes=5)
|
||||
bad = []
|
||||
for i in range(5000):
|
||||
packs = [P(n, rnd.random() > 0.1, rnd.choice(packmode.MODES), False,
|
||||
2500.0, rnd.uniform(0, 100), 10.0, live=rnd.random() > 0.05,
|
||||
idle=rnd.random() > 0.1, refused=rnd.choice([0, 0, 1, -1]))
|
||||
for n in (A, B)]
|
||||
targets = {p.name: rnd.uniform(0, 100) for p in packs}
|
||||
t = modetrade.decide(R0, now_c, packs, charge_ok=True, sell_ok=True,
|
||||
trade_why=None, price_now=0.1, headroom_w=3000.0,
|
||||
floor_pct=20.0, ceiling_pct=95.0, house_w=500.0,
|
||||
targets=targets)
|
||||
for p in packs:
|
||||
lo, hi = t.ranges.get(p.name, (0.0, 0.0))
|
||||
if not lo <= 0.0 <= hi:
|
||||
bad.append(("range without 0", p, lo, hi))
|
||||
if t.roles[p.name] == modetrade.CHARGE and (
|
||||
p.mode == packmode.SOLAR_FIRST
|
||||
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))
|
||||
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)",
|
||||
not bad)
|
||||
t_ls = modetrade.decide(R0, now_c, [pk(A, packmode.SOLAR_FIRST, 50.0),
|
||||
pk(B, packmode.LEFTOVER_SOLAR, 30.0)],
|
||||
charge_ok=True, sell_ok=True, trade_why=None,
|
||||
price_now=0.1, headroom_w=3000.0, floor_pct=20.0,
|
||||
ceiling_pct=95.0, house_w=500.0,
|
||||
targets={B: 50.0})
|
||||
check("a Leftover solar battery below its level charges in the cheap run, "
|
||||
"Solar first rests", t_ls.roles == {A: modetrade.REST,
|
||||
B: modetrade.CHARGE})
|
||||
e = modetrade.energy([pk(B, packmode.LEFTOVER_SOLAR, 30.0)], 20.0, 95.0,
|
||||
targets={B: 45.0})
|
||||
check("the cheap run is sized by the shortfall (1.5 kWh), not the room to "
|
||||
"95 % (6.5 kWh)", abs(e[0] - 1.5) < 1e-9)
|
||||
|
||||
# ============================================================================ #
|
||||
print("6. the real controller over a night and a day")
|
||||
AIM = rp.PEAK_CAP - 50
|
||||
FC_KWH = [None] # today's forecast in the fake sensor (None: down)
|
||||
|
||||
|
||||
async def _sensor(self, e):
|
||||
now = rp_start[0] + timedelta(seconds=self.t)
|
||||
kwh = FC_KWH[0](now) if callable(FC_KWH[0]) else FC_KWH[0]
|
||||
if kwh is None:
|
||||
return {"state": "unavailable", "attributes": {},
|
||||
"last_updated": now.isoformat()}
|
||||
if e.endswith("tomorrow"):
|
||||
return None
|
||||
loc = now.astimezone(L)
|
||||
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
wh = {(d0 + timedelta(hours=h)).astimezone(timezone.utc).isoformat():
|
||||
(kwh * 100.0 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
|
||||
return {"state": f"{left:.3f}",
|
||||
"attributes": {"unit_of_measurement": "kWh", "wh_period": wh},
|
||||
"last_updated": now.isoformat(), "last_reported": now.isoformat()}
|
||||
|
||||
|
||||
rp.Hass.state = _sensor
|
||||
rp_start = [UTC0]
|
||||
FC_OPTS = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
|
||||
"cycle_cost_threshold_eur": 0.20}
|
||||
|
||||
|
||||
def seed_profile(c):
|
||||
if c.house_profile is not None:
|
||||
c.house_profile = profile(day_w=600.0, evening=3000.0)
|
||||
|
||||
|
||||
def house_flat(w):
|
||||
return [(UTC0.timestamp() - 10, float(w))]
|
||||
|
||||
|
||||
def run_day(choice, *, fc, soc0, hours=21.0, start_h=0.75, house_w=800.0,
|
||||
refuse=(), extra=None, patch=seed_profile):
|
||||
FC_KWH[0] = fc
|
||||
start = UTC0 + timedelta(hours=start_h)
|
||||
rp_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(dict(
|
||||
now=now, grid=h.grid(), shares=sh,
|
||||
action=getattr(c.arb, "action", None),
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
soc={u: p.soc for u, p in h.plants.items()},
|
||||
targets=c.plan_targets(), plan=c.day_plan, runs=c.mode_runs))
|
||||
st_, c = rp.run("p3_ls", house_flat(house_w), WPTS, start, 3600 * hours,
|
||||
check=cb, soc0=soc0, choice=choice, refuse=refuse,
|
||||
extra=dict(FC_OPTS, **(extra or {})), patch=patch)
|
||||
return rows, st_, c
|
||||
|
||||
|
||||
SF_LS = {"a": (True, "solar_first", False), "b": (True, "leftover_solar",
|
||||
False)}
|
||||
SF_CH = {"a": (True, "solar_first", False), "b": (True, "cheap_hours",
|
||||
False)}
|
||||
|
||||
|
||||
def invariants(tag, rows, c):
|
||||
charge = [r for r in rows if r["action"] == arbitrage.CHARGE]
|
||||
mixed = [r for r in rows if any(v > 0 for v in r["shares"].values())
|
||||
and any(v < 0 for v in r["shares"].values())]
|
||||
check(f"{tag}: no opposite-direction split on any cycle", not mixed)
|
||||
check(f"{tag}: Solar first never charges from the grid (A's share >= 0 "
|
||||
"while the site charges)",
|
||||
all(r["shares"].get(A, 0) >= 0 for r in charge))
|
||||
q = {int(r["now"].timestamp() // 900) for r in charge}
|
||||
banked = [b.offtake_avg_w for b in c.p1.blocks
|
||||
if int(b.start.timestamp() // 900) in q]
|
||||
check(f"{tag}: every banked quarter with a grid charge <= cap - margin "
|
||||
f"(max {max(banked, default=0):.1f} W, aim {AIM})",
|
||||
not banked or max(banked) <= AIM + 0.5)
|
||||
return charge
|
||||
|
||||
|
||||
# Sunny: 40 kWh forecast. B (Leftover solar) at 30 % needs nothing.
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
|
||||
charge = invariants("sunny", rows, c)
|
||||
_last = nth(rows, -1, "a cycle")
|
||||
print(f" sunny: verdict {present(_last) and _last['plan'] and _last['plan'].verdict}, "
|
||||
f"{len(charge)} charge cycles")
|
||||
check("sunny: a day plan is made, and says sunny",
|
||||
any(r["plan"] is not None and r["plan"].verdict == dayplan.SUNNY
|
||||
for r in rows))
|
||||
check("sunny: no grid charge at all", not charge)
|
||||
|
||||
# Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run.
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0})
|
||||
charge = invariants("dark", 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)
|
||||
print(f" dark: B level {tgt:.1f} %, B reached {top:.1f} %, "
|
||||
f"{len(charge)} charge cycles")
|
||||
check("dark: Leftover solar charges from the grid in the cheap run",
|
||||
charge and all(r["runs"].cheap.holds(r["now"]) for r in charge)
|
||||
and all(r["roles"][B] == modetrade.CHARGE for r in charge))
|
||||
check("dark: ...only up to its level (the shortfall), not 95 %",
|
||||
present(tgt) and tgt < 60.0 and top <= tgt + 2.0)
|
||||
check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST
|
||||
for r in charge))
|
||||
|
||||
# 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})
|
||||
charge = invariants("cheap hours, partly", rows, c)
|
||||
top = max(r["soc"]["b"] for r in rows)
|
||||
print(f" cheap hours partly: B reached {top:.1f} %")
|
||||
check("Cheap hours gets no planned level",
|
||||
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})
|
||||
check("Cheap hours with a sun forecast charges as with none (P3)",
|
||||
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).
|
||||
DROP = UTC0 + timedelta(hours=1, minutes=30)
|
||||
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
|
||||
soc0={"a": 60.0, "b": 10.0})
|
||||
invariants("forecast lost mid-run", rows, c)
|
||||
before = [r for r in rows if r["now"] < DROP and r["action"]
|
||||
== arbitrage.CHARGE]
|
||||
late = [r for r in rows if r["now"] > DROP + timedelta(
|
||||
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE]
|
||||
check("forecast lost mid-run: Leftover solar was charging, and stops within "
|
||||
"a forecast read (60 s) and a cycle", before and not late)
|
||||
|
||||
# The forecast is far too high (40 kWh, and there is no sun at all): no grid
|
||||
# charge, so B covers less of the evening - a money risk, never a safety one.
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0})
|
||||
invariants("forecast too high", rows, c)
|
||||
check("forecast too high: no grid charge, and B still keeps its power for "
|
||||
"the dear run", not [r for r in rows if r["action"]
|
||||
== arbitrage.CHARGE]
|
||||
and all(r["roles"].get(B) != modetrade.GIVE for r in rows
|
||||
if r["now"].astimezone(L).hour not in (19,)))
|
||||
|
||||
# A refusal wins over the plan.
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
|
||||
refuse=("b",))
|
||||
invariants("refusal", rows, c)
|
||||
check("refusal: B refuses to charge, so the plan's charge ends and B is "
|
||||
"never a charger after it", rows and not [
|
||||
r for r in rows[len(rows) // 3:]
|
||||
if r["roles"].get(B) == modetrade.CHARGE])
|
||||
|
||||
# Off wins over the plan.
|
||||
rows, st_, c = run_day({"a": (True, "solar_first", False),
|
||||
"b": (False, "leftover_solar", False)},
|
||||
fc=1.0, soc0={"a": 60.0, "b": 30.0})
|
||||
check("Off: an Off battery never charges from the grid, whatever the plan",
|
||||
not [r for r in rows if r["action"] == arbitrage.CHARGE]
|
||||
and all(abs(r["shares"].get(B, 0)) < 1e-6 for r in rows))
|
||||
|
||||
# No house profile yet (first day): no plan, P3 option A.
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
|
||||
patch=None)
|
||||
check("no house profile yet: no plan, and Leftover solar takes solar only",
|
||||
all(r["plan"] is None for r in rows)
|
||||
and not [r for r in rows if r["action"] == arbitrage.CHARGE])
|
||||
check("...while the profile learns and the store keeps each closed hour",
|
||||
c.house_profile is not None and len(
|
||||
c.store.data.get("house_profile") or {}) >= 1)
|
||||
check("selling stays off (option A)", packmode.SELLING_ENABLED is False)
|
||||
|
||||
# A forecast arriving mid-period makes the plan at once, not at the next
|
||||
# 15-minute period.
|
||||
COME = datetime(2026, 9, 21, 1, 3, tzinfo=L).astimezone(timezone.utc)
|
||||
rows, st_, c = run_day(SF_LS, fc=lambda now: None if now < COME else 1.0,
|
||||
soc0={"a": 60.0, "b": 30.0}, hours=0.5)
|
||||
first = next((r["now"] for r in rows if r["targets"]), None)
|
||||
check("a forecast that arrives at 01:03 makes the plan before 01:15",
|
||||
first is not None and first < COME + timedelta(minutes=3))
|
||||
|
||||
# Battery modes off: the forecast is never read.
|
||||
READS = [0]
|
||||
_real_sensor = rp.Hass.state
|
||||
|
||||
|
||||
async def _counted(self, e):
|
||||
READS[0] += 1
|
||||
return await _real_sensor(self, e)
|
||||
rp.Hass.state = _counted
|
||||
FC_KWH[0] = 1.0
|
||||
rp.run("modes_off", house_flat(800.0), WPTS, UTC0 + timedelta(hours=1),
|
||||
600.0, extra=dict(FC_OPTS))
|
||||
rp.Hass.state = _real_sensor
|
||||
check("battery modes off: the forecast sensor is never read", READS[0] == 0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("7. the Batteries screen and the Simple view")
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
|
||||
hours=0.6)
|
||||
rp.m.datetime = datetime
|
||||
sb = homeview.solar_block(c, c.solar_fc.at)
|
||||
check("the forecast line names the forecast, what is counted and when",
|
||||
sb["line"].startswith("Solar still to come today:")
|
||||
and "we count on" in sb["line"] and sb["state"] == "plan")
|
||||
why = homeview.day_key(c, B)
|
||||
check("B's Why key (plan, dark) is tops_up", why == "tops_up")
|
||||
check("...and says so in plain words", homeview.why_words(why)
|
||||
== "Too little sun is forecast, so it charges some from the grid.")
|
||||
_d0, _d1 = homeview.local_day(c.solar_fc.at)
|
||||
_blocks, _ = homeview.mode_runs(c, _d0, _d1, c.solar_fc.at, False)
|
||||
_grid = [b for b in _blocks if b["kind"] == "grid" and b["battery"] == 1]
|
||||
_need = homeview.plan_need(c, B)
|
||||
check(f"Today: B's grid block is the planned shortfall ({_need:.2f} kWh), "
|
||||
"not its room to 95 %", _grid and abs(_grid[0]["kwh"] - _need) < 0.01
|
||||
and _need < 3.0)
|
||||
_run = c.mode_runs.cheap
|
||||
_packs = c._mode_packs()
|
||||
_m1 = c._run_mark("charge", _run, _packs, lambda p: True, ((B, 40.0),))
|
||||
_m2 = c._run_mark("charge", _run, _packs, lambda p: False, ((B, 50.0),))
|
||||
check("a level the hysteresis moved opens the run to that battery again",
|
||||
_m1 and not _m2)
|
||||
c.solar_fc = None
|
||||
check("the levels are dropped as soon as the forecast goes, even before "
|
||||
"the plan is rebuilt", c._plan_targets and c.plan_targets() is None)
|
||||
check("a lost forecast says so: no forecast, Leftover solar solar only",
|
||||
homeview.solar_block(c, datetime.now(timezone.utc))["state"]
|
||||
== "no_forecast" and homeview.day_key(c, B) == "no_forecast")
|
||||
check("the Why line wins over 'saves its power' only while holding",
|
||||
homeview.battery_why(modes_on=True, mode="leftover_solar", off=False,
|
||||
reading=True, phase="idle", shadow=False,
|
||||
running=True, held=False, refused=0, peak=False,
|
||||
sf_ahead=True, role="hold", has_at=True,
|
||||
day="sun_fills") == "sun_fills"
|
||||
and homeview.battery_why(modes_on=True, mode="leftover_solar",
|
||||
off=False, reading=True, phase="idle",
|
||||
shadow=False, running=True, held=False,
|
||||
refused=0, peak=False, sf_ahead=True,
|
||||
role="charge", has_at=True, day="tops_up")
|
||||
== "grid_charge")
|
||||
for lang in ("en", "nl"):
|
||||
homeview._LANG[0] = lang
|
||||
t = [homeview.why_words(k) for k in ("sun_fills", "tops_up",
|
||||
"no_forecast")]
|
||||
t += [homeview._t("solar.no_forecast"), homeview._t("solar.learning"),
|
||||
homeview.line_solar(9.2, 6.4, "10:15")]
|
||||
check(f"{lang}: every P4 sentence is there and short (<= 2 x 12 words)",
|
||||
all(t) and all(len(s.split()) <= 24 for s in t))
|
||||
homeview._LANG[0] = "en"
|
||||
from app import uilayout # noqa: E402
|
||||
simple = [f.key for _g, _p, f in uilayout.fields() if f.simple]
|
||||
check("the Simple view has the solar forecast card",
|
||||
"pv_forecast_entity" in simple)
|
||||
|
||||
# ============================================================================ #
|
||||
print("8. options")
|
||||
BASE_OPTS = {"setpoint_entity": "number.x", "max_w": 2500}
|
||||
for v, ok in ((70, True), (30, True), (100, True), (29, False), (101, False),
|
||||
(70.5, False), ("x", False)):
|
||||
probs = validate._forecast_problems(dict(solar_trust_pct=v))
|
||||
check(f"solar_trust_pct {v!r}: {'accepted' if ok else 'refused'}",
|
||||
(not probs) == ok)
|
||||
check("a forecast entity that is not a sensor is refused, in plain words",
|
||||
validate._forecast_problems({"pv_forecast_entity": "number.x"})
|
||||
and "must be a sensor" in validate._forecast_problems(
|
||||
{"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(
|
||||
{"pv_forecast_entity": "", "pv_forecast_tomorrow_entity": ""}))
|
||||
notes = []
|
||||
validate._entity_notes(notes, {"pv_forecast_entity": "sensor.f",
|
||||
"battery_modes_enabled": True},
|
||||
{"sensor.f": {"unit": "W"}})
|
||||
check("a forecast sensor not in kWh gets a warning",
|
||||
any("kWh" in n.text for n in notes))
|
||||
notes = []
|
||||
validate._entity_notes(notes, {"pv_forecast_entity": "sensor.f"},
|
||||
{"sensor.f": {"unit": "kWh"}})
|
||||
check("a forecast with battery modes off gets a note that it is unused",
|
||||
any("battery modes on" in n.text for n in notes))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -15,7 +15,7 @@ DECISIONS/SAFETY-78-drain-at-two.md has the record.
|
||||
3. Battery modes on, a Cheap hours battery in a cheap run during the drain:
|
||||
no grid charge, and its card says why.
|
||||
4. The peak guard binds during a drain, and the arbiter's order holds.
|
||||
5. N=1 is byte-identical to release/1.0 5a4cdc2, and the harness bites.
|
||||
5. The N=1 and N=2 digests hold, and the harness bites.
|
||||
6. A seeded sweep of the floor against the loop, trades, the peak floor, a
|
||||
safety stop, refusals and an Off battery.
|
||||
|
||||
@@ -326,26 +326,32 @@ check("⭐ the order holds: a safety stop parks everything; the peak floor "
|
||||
and _got["a grid charge (trade)"][1] == {A: 2000, B: 0})
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. N=1: byte-identical to release/1.0 5a4cdc2")
|
||||
# Printed by `python3 tools/safety78_identity.py` on release/1.0 5a4cdc2 (the
|
||||
# same file copied in, nothing else changed), goodwe-addon-checks:1,
|
||||
print("\n5. the N=1 and N=2 digests")
|
||||
# Printed by `python3 tools/safety78_identity.py`, goodwe-addon-checks:1,
|
||||
# 2026-09-29. 480 cycles each, draining throughout, every claim and the
|
||||
# newest action-log line hashed.
|
||||
AT_5A4CDC2 = {
|
||||
# newest action-log line hashed. N=2: the same on release/1.0 35cdffe. N=1:
|
||||
# re-pinned by SAFETY-83, whose `--diff` against 35cdffe changes only cycles
|
||||
# where the peak guard asked for more than the drain (test_safety83.py).
|
||||
PINNED = {
|
||||
"n1_drain":
|
||||
"55fcfc4a0b9251746f315cfdea607a33640d3a81faf6604c4718a04bbc7dd776",
|
||||
"b0f9ef44149163bc0acece537fe047871c00f0d9ea213e7a7fc8efc4ad3aa500",
|
||||
"n1_drain_modes":
|
||||
"93867eb4ee4132b47c030668bd7f07587a353bced2461524e09421125c3c4edf",
|
||||
"95ab82426e23bce308db3edebe18b1ab37d956952e9f521adce37150ce87ee8a",
|
||||
"n1_drain_owner":
|
||||
"10f7f42a6a952e899d739eb10c674874215c440f41ead7a9f2408bc9b4cb9ea4",
|
||||
"n2_drain":
|
||||
"055d83de0552d3a014dc73d87fdd697dde8b5933b8032f9b3a464ff0d08795fb",
|
||||
"n2_drain_owner":
|
||||
"05750347f1829c249f99154613f068f442a59ef59f409093907f6d0add78dd4c",
|
||||
}
|
||||
_now = s78.digests()
|
||||
for k in AT_5A4CDC2:
|
||||
print(f" {k} {'same' if _now[k] == AT_5A4CDC2[k] else 'DIFFERS'} "
|
||||
for k in PINNED:
|
||||
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'} "
|
||||
f"{_now[k][:16]}")
|
||||
check("⭐ at one inverter a drain under a house below, at and above its "
|
||||
"rate, a surplus and a load over the peak limit, with battery modes "
|
||||
"off and on, is byte-identical to 5a4cdc2: commands, reasons, roles, "
|
||||
"trades and every claim",
|
||||
_now == AT_5A4CDC2)
|
||||
check("⭐ a drain under a house below, at and above its rate, a surplus and a "
|
||||
"load over the peak limit, at N=1 (modes off and on) and N=2, matches "
|
||||
"the pinned digests: commands, reasons, roles, trades and every claim",
|
||||
_now == PINNED)
|
||||
Controller._drain_floor = lambda self, claims, results: (
|
||||
2000.0 if any(r.phase == DRAIN for r in results) else None)
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Runnable check for SAFETY-83: the peak guard outranks the drain at N=1.
|
||||
`python3 test_safety83.py`
|
||||
|
||||
At one inverter the monthly drain is the site SET at P_MAINTENANCE, above the
|
||||
peak floor, so the house imported what the drain left and quarters went over
|
||||
the peak limit. Now a peak floor above the drain rate is refiled at
|
||||
P_MAINTENANCE and wins. Drives the REAL Controller (tools/opt11_identity.py's
|
||||
harness on a P1 meter). DECISIONS/SAFETY-83-peak-over-drain.md has the record.
|
||||
|
||||
1. A house of 5000, 6000 and 7000 W during a drain, limits 2500 and 3500,
|
||||
margins 0 and 50: every quarter banks at or under the aim, the drain keeps
|
||||
running, no 'Load too high' alarm, and without the fix the same runs go over.
|
||||
Under a month peak banked above the limit the aim is that peak - margin.
|
||||
2. The drain never makes the banked quarter worse than no drain, below and
|
||||
above the drain rate; below it the rest is still exported.
|
||||
3. The drain still ends, and the order holds: a lost reading parks the
|
||||
battery, the rating caps the floor without a clash.
|
||||
4. Only cycles where the guard wanted more than the drain change, and N=2 is
|
||||
untouched.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
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 opt11_identity as rig # noqa: E402
|
||||
import safety78_identity as s78 # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
from app import tariff # noqa: E402
|
||||
from app.arbiter import (Claim, P_MAINTENANCE, P_PEAK, P_SAFETY, # noqa
|
||||
resolve)
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import CHARGE, DRAIN, MaintResult # noqa: E402
|
||||
|
||||
fails = []
|
||||
TOL = 1.0 # watts over the aim a banked quarter may show
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def run(house, cap, margin, drain_w, *, maint=True, dur=2700.0, soc=None,
|
||||
before=None, store=None, month_peak=None, **extra):
|
||||
"""N=1 on a P1 meter; returns (rows, controller, banked quarters).
|
||||
`month_peak` banks that quarter earlier in the month."""
|
||||
opts = dict(s79.P1, peak_cap_w=cap, peak_margin_w=margin, **extra)
|
||||
if maint:
|
||||
opts.update(maintenance_enabled=True, maintenance_start_hour=11,
|
||||
maintenance_discharge_w=drain_w)
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s83-"), "tariff.db")
|
||||
if month_peak is not None:
|
||||
tariff.PeakStore(db)._db.execute(
|
||||
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
|
||||
(tariff.month_key(rig.BASE), month_peak,
|
||||
"2026-09-02T10:00:00+00:00")).connection.commit()
|
||||
real = rig.Controller
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
try:
|
||||
rows, c = rig.run(1, extra=opts, dur=dur,
|
||||
load=house if callable(house) else lambda t: house,
|
||||
soc=soc or (lambda u, t: 60.0),
|
||||
cut=lambda u, t: False, before=before or s79._feed,
|
||||
store=store)
|
||||
finally:
|
||||
rig.Controller = real
|
||||
return rows, c, {b.start.strftime("%H:%M"): b.offtake_avg_w
|
||||
for b in c.p1.blocks}
|
||||
|
||||
|
||||
def without_fix(fn):
|
||||
real = Controller._peak_over_drain
|
||||
Controller._peak_over_drain = lambda self, claims, results: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._peak_over_drain = real
|
||||
|
||||
|
||||
def cmd(r):
|
||||
return dict(r[2])["a"]
|
||||
|
||||
|
||||
def alarmed(c):
|
||||
"""Did SAFETY-81's 'Load too high' alarm fire on this run?"""
|
||||
return any("PEAK_GUARD_SATURATED" in e for e in c.events)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. banked quarters during a drain, N=1")
|
||||
CASES = [(cap, drain, m, h) for cap, drain in ((2500, 2000), (3500, 2500))
|
||||
for m in (0, 50) for h in (5000.0, 6000.0, 7000.0)]
|
||||
RES, OLD = {}, {}
|
||||
for k in CASES:
|
||||
cap, drain, m, h = k
|
||||
RES[k] = run(h, cap, m, drain)
|
||||
OLD[k] = without_fix(lambda: run(h, cap, m, drain))
|
||||
print(f" limit {cap} drain {drain} margin {m:>2} house {h:.0f}: "
|
||||
f"banked {[round(v) for v in RES[k][2].values()]} W "
|
||||
f"(before the fix {[round(v) for v in OLD[k][2].values()]} W)")
|
||||
check("⭐ every quarter banks at or under the aim (limit - margin) plus 1 W, "
|
||||
"for a 5000, 6000 and 7000 W house, limits 2500 and 3500, margins 0 "
|
||||
"and 50",
|
||||
all(len(b) >= 2 and max(b.values()) <= k[0] - k[2] + TOL
|
||||
for k, (_r, _c, b) in RES.items()))
|
||||
check("⭐ the drain keeps running: the phase is drain on every run and the "
|
||||
"battery gives at least the drain rate on every cycle",
|
||||
all(c.maints[0].phase == DRAIN and all(cmd(r) >= k[1] for r in rows)
|
||||
for k, (rows, c, _b) in RES.items()))
|
||||
check("...and where the house is over drain + aim, the battery gives more "
|
||||
"than the drain rate, bound by the peak floor",
|
||||
all(any(cmd(r) > k[1] and "limited by peak" in r[6] for r in rows)
|
||||
for k, (rows, _c, _b) in RES.items() if k[3] - k[1] > k[0] - k[2]))
|
||||
check("non-vacuous: without the fix the same runs bank over the limit "
|
||||
"wherever the house is above drain + aim (the reported defect: 3000 W "
|
||||
"at a 5000 W house against 2500 W)",
|
||||
all(max(b.values()) > k[0] + 100.0 for k, (_r, _c, b) in OLD.items()
|
||||
if k[3] - k[1] > k[0])
|
||||
and round(OLD[(2500, 2000, 50, 5000.0)][2]["11:15"]) == 3000)
|
||||
# A hob cycling: one 5 s spike to 11000 W every 150 s on a 6000 W house. Each
|
||||
# spike saturates the guard for one cycle, shorter than ALARM_HOLD_S.
|
||||
_sat = []
|
||||
|
||||
|
||||
def _spiky_feed(c, t):
|
||||
s79._feed(c, t)
|
||||
_sat.append(bool(c.peak_guard is not None and c.peak_guard.saturated))
|
||||
|
||||
|
||||
_k_rows, _k, _kb = run(lambda t: 11000.0 if t % 150 < 5 else 6000.0,
|
||||
3500, 50, 2500, before=_spiky_feed)
|
||||
check("⭐ a drain the guard raises gives no 'Load too high' alarm (SAFETY-81), "
|
||||
"steady or with 5 s spikes that saturate the guard",
|
||||
not any(alarmed(c) for _r, c, _b in RES.values())
|
||||
and any(_sat) and not alarmed(_k))
|
||||
|
||||
# Owner (SAFETY-79): under a month peak banked above the limit, the aim is that
|
||||
# peak - margin, during a drain too. Owner's settings, peak banked at 4500 W.
|
||||
BANK = {h: (run(h, 3500, 50, 2500, month_peak=4500.0),
|
||||
without_fix(lambda: run(h, 3500, 50, 2500, month_peak=4500.0)))
|
||||
for h in (6500.0, 7500.0)}
|
||||
for h, (new, old) in BANK.items():
|
||||
print(f" month peak 4500, house {h:.0f}: banked "
|
||||
f"{[round(v) for v in new[2].values()]} W (before the fix "
|
||||
f"{[round(v) for v in old[2].values()]} W)")
|
||||
check("⭐ under a month peak of 4500 W: a 7500 W house banks at 4450 W "
|
||||
"(peak - margin, not the 3450 W cap aim), was 5000 W; a 6500 W house "
|
||||
"stays under the drain alone at 4000 W",
|
||||
all(4430.0 <= v <= 4450.0 + TOL for v in BANK[7500.0][0][2].values())
|
||||
and max(BANK[7500.0][1][2].values()) > 4900.0
|
||||
and all(round(v) in (4000, 4014)
|
||||
for v in BANK[6500.0][0][2].values()))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the drain never makes the banked quarter worse, N=1")
|
||||
CAP, DRAIN_W, MARGIN = 3500, 2500, 50 # the owner's settings
|
||||
AIM = CAP - MARGIN
|
||||
WORSE = {}
|
||||
for h in (1200.0, 3000.0, 5000.0, 6500.0, 7000.0):
|
||||
_rows, _c, b = run(h, CAP, MARGIN, DRAIN_W)
|
||||
_r0, _c0, b0 = run(h, CAP, MARGIN, DRAIN_W, maint=False)
|
||||
WORSE[h] = (b, b0, _rows)
|
||||
print(f" house {h:.0f}: with the drain {[round(v) for v in b.values()]}"
|
||||
f" W, without {[round(v) for v in b0.values()]} W")
|
||||
check("⭐ below and above the drain rate, every quarter with the drain banks "
|
||||
"no more than the larger of the aim and the same quarter without it",
|
||||
all(b[q] <= max(AIM, b0[q]) + TOL for b, b0, _r in WORSE.values()
|
||||
for q in b))
|
||||
_r12 = WORSE[1200.0][2]
|
||||
check("below the drain rate the battery gives the drain rate and exports the "
|
||||
"rest (owner, 2026-09-29): 2500 W against a 1200 W house",
|
||||
all(cmd(r) == DRAIN_W for r in _r12[1:]))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. the drain ends, and the order holds")
|
||||
_e_rows, _e, _eb = run(6500.0, CAP, MARGIN, DRAIN_W, dur=1200.0,
|
||||
soc=lambda u, t: max(5.0, 60.0 - t / 20.0))
|
||||
check("⭐ with the guard binding, the drain still reaches its floor and moves "
|
||||
"to the charge phase, with no abort",
|
||||
_e.maints[0].phase == CHARGE
|
||||
and not any("abort" in e.lower() for e in _e.events))
|
||||
|
||||
|
||||
def blind_at(t0):
|
||||
"""The battery power reading goes missing at `t0`: a safety SET."""
|
||||
def before(c, t):
|
||||
s79._feed(c, t)
|
||||
if t >= t0 and not getattr(c.hass, "_blind", False):
|
||||
real = c.hass.number
|
||||
|
||||
async def number(e, invert=False):
|
||||
return None if e == "sensor.batt_a" else await real(e, invert)
|
||||
c.hass.number, c.hass._blind = number, True
|
||||
return before
|
||||
|
||||
|
||||
_s_rows, _s, _sb = run(6500.0, CAP, MARGIN, DRAIN_W, dur=600.0,
|
||||
before=blind_at(300.0))
|
||||
print(f" reading lost at 300 s: last {cmd(_s_rows[-1]):+.0f} W, "
|
||||
f"{_s_rows[-1][6]}")
|
||||
check("⭐ a safety SET outranks the refiled peak floor: before the reading "
|
||||
"goes the battery gives above the drain rate, after it it is at 0 W",
|
||||
any(cmd(r) > DRAIN_W for r in _s_rows if r[0] < 300.0)
|
||||
and all(cmd(r) == 0.0 for r in _s_rows if r[0] >= 400.0)
|
||||
and _s_rows[-1][6].startswith("safety -> 0 W"))
|
||||
_m_rows, _m, _mb = run(7000.0, 2500, 50, 2000, dur=900.0, max_w=3000)
|
||||
print(f" max_w 3000, house 7000: last {cmd(_m_rows[-1]):+.0f} W, "
|
||||
f"{_m_rows[-1][6]}")
|
||||
check("⭐ the rating caps the floor without a clash: at max_w 3000 the "
|
||||
"battery gives 3000 W, never 0 W, and nothing contradicts",
|
||||
all(cmd(r) >= 2000.0 for r in _m_rows)
|
||||
and cmd(_m_rows[-1]) == 3000.0
|
||||
and not any("CONTRADICTION" in (r[6] or "") for r in _m_rows))
|
||||
check("...and there, with the battery at its rating and the house still over, "
|
||||
"the 'Load too high' alarm does fire", alarmed(_m))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. only the cycles where the guard wanted more change")
|
||||
|
||||
|
||||
def promoted(row):
|
||||
return [c for c in row[-1][0] if c[0] == "peak"]
|
||||
|
||||
|
||||
_id = {k: s78.run(**v)[0] for k, v in s78.SCENARIOS.items()}
|
||||
_bad = []
|
||||
for k, rows in _id.items():
|
||||
for r in rows:
|
||||
claims, guard = r[-1][0], r[-1][1]
|
||||
rate = [c[3] for c in claims if c[0] == "maintenance" and c[2] == "set"]
|
||||
pk = promoted(r)
|
||||
if k.startswith("n2"):
|
||||
ok = all(c[1] == P_PEAK for c in pk)
|
||||
elif s78.wanted_more(r):
|
||||
ok = ([c[1] for c in pk] == [P_MAINTENANCE]
|
||||
and r[1] == guard[1] and "limited by peak" in r[6])
|
||||
else:
|
||||
ok = (all(c[1] == P_PEAK for c in pk)
|
||||
and (not rate or r[1] == rate[0]))
|
||||
if not ok:
|
||||
_bad.append((k, r[0], r[1], r[6], pk))
|
||||
_bad1 = [b for b in _bad if b[0].startswith("n1")]
|
||||
_bad2 = [b for b in _bad if b[0].startswith("n2")]
|
||||
_more = {k: sum(map(s78.wanted_more, rows)) for k, rows in _id.items()}
|
||||
print(f" cycles with the guard above the drain: {_more}; bad {_bad[:3]}")
|
||||
check("⭐ at N=1, on every cycle where the guard asks for more than the drain "
|
||||
"the peak claim is refiled at P_MAINTENANCE and the target is its floor; "
|
||||
"on every other cycle the peak claim stays at P_PEAK and the target is "
|
||||
"the drain rate",
|
||||
not _bad1 and all(_more[k] > 50 for k in _id if k.startswith("n1")))
|
||||
check("⭐ at N=2 the peak claim is never refiled (the SAFETY-78 floor does "
|
||||
"that job), and test_safety78.py part 5 holds N=2 to the digests of "
|
||||
"release/1.0 35cdffe", not _bad2)
|
||||
|
||||
|
||||
# The rule itself, at the boundary: a floor AT the drain rate changes nothing.
|
||||
_one = types.SimpleNamespace(inverters=[None])
|
||||
_edge = {}
|
||||
for lo in (1999.5, 2000.0, 2000.5):
|
||||
cl = [Claim.set("maintenance", P_MAINTENANCE, 2000.0, DRAIN),
|
||||
Claim.limit("peak", P_PEAK, lo=lo, reason="quarter peak")]
|
||||
Controller._peak_over_drain(_one, cl, [MaintResult(DRAIN, True, 2000.0,
|
||||
False, [])])
|
||||
_edge[lo] = cl[1].priority
|
||||
check("at the boundary: a floor at or below the 2000 W drain stays at P_PEAK, "
|
||||
"a floor 0.5 W above it is refiled at P_MAINTENANCE",
|
||||
_edge == {1999.5: P_PEAK, 2000.0: P_PEAK, 2000.5: P_MAINTENANCE})
|
||||
|
||||
_stop = [Claim.set("maintenance", P_MAINTENANCE, 2000.0, DRAIN),
|
||||
Claim.limit("peak", P_PEAK, lo=4000.0, reason="quarter peak"),
|
||||
Claim.set("safety", P_SAFETY, 0.0, "inputs missing")]
|
||||
Controller._peak_over_drain(_one, _stop, [MaintResult(DRAIN, True, 2000.0,
|
||||
False, [])])
|
||||
check("⭐ and a refiled floor stays under a safety SET: the site resolves "
|
||||
"to 0 W", _stop[1].priority == P_MAINTENANCE
|
||||
and resolve(_stop).target_w == 0.0)
|
||||
|
||||
|
||||
def store_hold():
|
||||
st = rig._Store()
|
||||
st.data["phase"], st.data["phase_started"] = "hold", rig.BASE.isoformat()
|
||||
return st
|
||||
|
||||
|
||||
_h_rows, _h, _hb = run(6000.0, 2500, 50, 2000, dur=600.0, store=store_hold())
|
||||
check("a maintenance HOLD at N=1 is not changed (DECISIONS/SAFETY-83, not "
|
||||
"changed): the battery stays at 0 W with the house over the limit",
|
||||
_h.maints[0].phase == "hold" and _h.peak_guard.engaged
|
||||
and _h.peak_guard.floor_w > 0.0
|
||||
and all(cmd(r) == 0.0 for r in _h_rows))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OPT-11 P4: with no usable solar forecast, is every command byte-identical
|
||||
to release/1.0 5a4cdc2 (P3, owner option A)?
|
||||
|
||||
python3 tools/opt11p4_identity.py # one digest per scenario
|
||||
|
||||
tools/opt11p3_identity.py's harness (the REAL Controller on a P1 meter, a
|
||||
cheap slot, a dear slot, a house over the limit, `arbitrage_enabled`), now
|
||||
with battery modes ON and each battery's mode set in the store, so P3's run
|
||||
code runs on every cycle. Each row adds the trade and the roles. Scenarios:
|
||||
* `ls`, `ch`, `ls_ls`, `ch_ls`: no `pv_forecast_entity` (the day plan never
|
||||
runs); N=1 and N=2.
|
||||
* `*_fc_down`: `pv_forecast_entity` set but the sensor reads `unavailable`,
|
||||
with a ready house profile in the store (the plan falls back); 5a4cdc2
|
||||
ignores both keys, so the digest must match the scenario without them.
|
||||
Uses only the API 5a4cdc2 has; test_modes_p4.py part 1 holds this tree to the
|
||||
digests printed on 5a4cdc2.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import opt11p3_identity as p3 # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
FC = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
|
||||
"pv_forecast_tomorrow_entity": "sensor.energy_production_tomorrow"}
|
||||
|
||||
|
||||
async def _unavailable(self, e):
|
||||
return {"state": "unavailable", "attributes": {},
|
||||
"last_updated": "2026-09-20T11:00:00+00:00"}
|
||||
|
||||
|
||||
rig._Hass.state = _unavailable
|
||||
|
||||
SCENARIOS = {
|
||||
"n1_ls": dict(n=1, modes={"inverter-a": "leftover_solar"}),
|
||||
"n1_ch": dict(n=1, modes={"inverter-a": "cheap_hours"}),
|
||||
"n2_sf_ls": dict(n=2, modes={"inverter-a": "solar_first",
|
||||
"inverter-b": "leftover_solar"}),
|
||||
"n2_ch_ls": dict(n=2, modes={"inverter-a": "cheap_hours",
|
||||
"inverter-b": "leftover_solar"}),
|
||||
"n2_ls_ls": dict(n=2, modes={"inverter-a": "leftover_solar",
|
||||
"inverter-b": "leftover_solar"}),
|
||||
"n2_sf_ch": dict(n=2, modes={"inverter-a": "solar_first",
|
||||
"inverter-b": "cheap_hours"}),
|
||||
}
|
||||
for _k, _v in list(SCENARIOS.items()):
|
||||
SCENARIOS[_k + "_fc_down"] = dict(_v, extra=FC)
|
||||
|
||||
|
||||
def run(n, *, modes, extra=None, dur=p3.DUR):
|
||||
opts = dict(s79.P1, **p3.ARB, battery_modes_enabled=True,
|
||||
**(extra or {}))
|
||||
if n == 2:
|
||||
# A capacity on each battery, so a day plan could size a level;
|
||||
# small, so the cheap run is the one cheap slot.
|
||||
opts["inverters"] = [
|
||||
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
|
||||
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
|
||||
"max_w": 2500, "bms_soc_floor": 10, "capacity_kwh": 1.0}
|
||||
for u in "ab"]
|
||||
store = rig._Store()
|
||||
# At N=1 the one battery is named after the site; the key is per name.
|
||||
for name, mode in modes.items():
|
||||
store.data[unit_key(name, "pack_mode")] = mode
|
||||
if extra:
|
||||
# A ready house profile (a day, 600 W, 3 kW 17:00-22:00), so only
|
||||
# the missing forecast stops a plan here. 5a4cdc2 ignores the key.
|
||||
store.data["house_profile"] = {"2026-09-19": {
|
||||
str(h): (3000.0 if 17 <= h < 22 else 600.0) for h in range(24)}}
|
||||
trade = []
|
||||
|
||||
def on_cycle(c, t):
|
||||
a, tick = c.arb, getattr(c, "mode_tick", None)
|
||||
trade.append((None if a is None else (a.action, a.power_w, a.reason),
|
||||
None if tick is None else
|
||||
tuple(sorted(tick.roles.items()))))
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="p4id-"), "tariff.db")
|
||||
real = rig.Controller
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
try:
|
||||
rows, c = rig.run(n, extra=opts, dur=dur, load=p3.load, soc=p3.soc,
|
||||
cut=lambda u, t: False, before=p3._feed,
|
||||
on_cycle=on_cycle, store=store)
|
||||
finally:
|
||||
rig.Controller = real
|
||||
return [r + (x,) for r, x in zip(rows, trade)], c
|
||||
|
||||
|
||||
def digests() -> dict:
|
||||
return {k: p3.digest(run(**v)[0]) for k, v in SCENARIOS.items()}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--summary" in sys.argv:
|
||||
for k, v in SCENARIOS.items():
|
||||
rows = run(**v)[0]
|
||||
acts = sorted({str(r[-1][0][0]) if r[-1][0] else "None"
|
||||
for r in rows})
|
||||
roles = sorted({x for r in rows if r[-1][1]
|
||||
for x in (y for _n, y in r[-1][1])})
|
||||
print(f"{k}: {len(rows)} cycles, trade {acts}, roles {roles}")
|
||||
for k, v in digests().items():
|
||||
print(f"{k} {v}")
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OPT-11 P4: does the solar forecast pay? Replays recorded days through the
|
||||
REAL Controller with tools/opt11p3_replay.py's engine (house, batteries,
|
||||
prices, peak guard), plus a solar forecast sensor in Home Assistant.
|
||||
|
||||
python3 tools/opt11p4_replay.py --variant p4_ls --fc recorded
|
||||
python3 tools/opt11p4_replay.py --variant p3_ls --prices real2026
|
||||
|
||||
Runs unchanged on release/1.0 7a00f12 (P2) and 5a4cdc2 (P3): an older tree
|
||||
ignores `pv_forecast_entity` and never reads the sensor.
|
||||
|
||||
HOW THE FORECAST IS MADE (the site's own solar was not recorded for these
|
||||
days; the 2026-09-24 PV recording is only in the live Home Assistant):
|
||||
* The site's solar, hour by hour, is rebuilt from the recording: the house's
|
||||
net use (grid + battery) is house - solar, so solar = max(0, H - net),
|
||||
where H is the median net use of the night hours (22:00-06:00) over all
|
||||
days (`--fc perfect` feeds exactly this).
|
||||
* `recorded`: that solar times the day's error of the real Belgian day-ahead
|
||||
solar forecast (Elia, via energy-charts): forecast / actual, per day.
|
||||
* `national`: the Belgian day-ahead forecast itself, scaled to the site
|
||||
(one factor: the site's total over the national total, all days). It
|
||||
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
|
||||
`unavailable`. The sensor has Open-Meteo's shape (state = kWh left today,
|
||||
`wh_period` = Wh per hour); `--shape state` drops `wh_period`
|
||||
(Forecast.Solar's shape).
|
||||
|
||||
MONEY: as opt11p3_replay.py (all-in import (wholesale + 0.12) x 1.06, export
|
||||
at wholesale), plus `stored_eur`: the change in usable energy (above the
|
||||
10 % cut-out) from start to end, x 0.95, at the median all-in import price
|
||||
of the run. `net_after_eur` = net - stored_eur.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import bisect
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
|
||||
EC = os.path.join(HERE, "scenarios",
|
||||
"energy-charts-be-solar-price-2026-09-01_2026-09-17.json")
|
||||
FC_ENTITY = "sensor.energy_production_today_remaining"
|
||||
FC_TOMORROW = "sensor.energy_production_tomorrow"
|
||||
|
||||
rp.VARIANTS.update({
|
||||
"p4_ls": (True, False, {"a": (True, "solar_first", False),
|
||||
"b": (True, "leftover_solar", False)}),
|
||||
"p4_ch": (True, False, {"a": (True, "solar_first", False),
|
||||
"b": (True, "cheap_hours", False)}),
|
||||
})
|
||||
LOCAL = timezone(timedelta(hours=2)) # CEST for every replayed day
|
||||
|
||||
|
||||
def ec():
|
||||
return json.load(open(EC))
|
||||
|
||||
|
||||
def real2026_points():
|
||||
"""The real day-ahead prices of the replayed days (15-minute)."""
|
||||
d = ec()["price"]
|
||||
return [(datetime.fromtimestamp(t, timezone.utc), 900, p / 1000.0)
|
||||
for t, p in zip(d["unix_seconds"], d["eur_mwh"]) if p is not None]
|
||||
|
||||
|
||||
def site_solar(house):
|
||||
"""{hour start (UTC epoch): site solar kWh}, and H (W)."""
|
||||
by_h = {}
|
||||
for t, w in house:
|
||||
by_h.setdefault(int(t // 3600) * 3600, []).append(w)
|
||||
net = {h: statistics.fmean(v) for h, v in by_h.items() if len(v) > 20}
|
||||
night = [w for h, w in net.items()
|
||||
if datetime.fromtimestamp(h, LOCAL).hour in
|
||||
(22, 23, 0, 1, 2, 3, 4, 5)]
|
||||
base = statistics.median(night)
|
||||
return {h: max(0.0, base - w) / 1000.0 for h, w in net.items()}, base
|
||||
|
||||
|
||||
def national():
|
||||
s = ec()["solar"]
|
||||
act = dict(zip(s["unix_seconds"], s["actual_mw"]))
|
||||
fc = dict(zip(s["dayahead_unix_seconds"], s["dayahead_mw"]))
|
||||
return act, fc
|
||||
|
||||
|
||||
def forecast_table(kind, house, days):
|
||||
"""{hour start epoch: forecast kWh}, and a report of its accuracy."""
|
||||
pv, base = site_solar(house)
|
||||
act, fc = national()
|
||||
day_of = {h: datetime.fromtimestamp(h, LOCAL).date() for h in pv}
|
||||
if kind in ("perfect", "missing"):
|
||||
out = dict(pv)
|
||||
elif kind == "zero":
|
||||
out = {h: 0.0 for h in pv}
|
||||
elif kind == "recorded":
|
||||
ratio = {}
|
||||
for d in set(day_of.values()):
|
||||
hs = [h for h in pv if day_of[h] == d]
|
||||
a = sum(act.get(h) or 0.0 for h in hs)
|
||||
f = sum(fc.get(h) or 0.0 for h in hs)
|
||||
ratio[d] = f / a if a > 0 else 1.0
|
||||
out = {h: pv[h] * ratio[day_of[h]] for h in pv}
|
||||
else:
|
||||
k = sum(pv.values()) / max(1e-9, sum(act.get(h) or 0.0 for h in pv))
|
||||
out = {h: (fc.get(h) or 0.0) * k * (1.5 if kind == "high" else 1.0)
|
||||
for h in pv}
|
||||
rep = []
|
||||
for d in sorted(set(day_of.values())):
|
||||
a = sum(pv[h] for h in pv if day_of[h] == d)
|
||||
f = sum(out[h] for h in out if day_of[h] == d)
|
||||
rep.append((d.isoformat(), round(a, 1), round(f, 1)))
|
||||
return out, base, rep
|
||||
|
||||
|
||||
def install_sensor(table, kind, shape):
|
||||
"""Give opt11p3_replay's Hass a `state()` for the forecast sensors."""
|
||||
hours = sorted(table)
|
||||
t0 = rp_start[0]
|
||||
|
||||
def day_bounds(now):
|
||||
loc = now.astimezone(LOCAL)
|
||||
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return d0, d0 + timedelta(days=1)
|
||||
|
||||
def sensor(now, tomorrow):
|
||||
d0, d1 = day_bounds(now)
|
||||
if tomorrow:
|
||||
d0, d1 = d1, d1 + timedelta(days=1)
|
||||
a, b = d0.timestamp(), d1.timestamp()
|
||||
i, j = bisect.bisect_left(hours, a), bisect.bisect_left(hours, b)
|
||||
rows = [(h, table[h]) for h in hours[i:j]]
|
||||
if not rows:
|
||||
return None
|
||||
n = now.timestamp()
|
||||
left = 0.0
|
||||
for h, kwh in rows:
|
||||
if tomorrow or h >= n:
|
||||
left += kwh
|
||||
elif h + 3600 > n:
|
||||
left += kwh * (h + 3600 - n) / 3600.0
|
||||
attrs = {"unit_of_measurement": "kWh"}
|
||||
if shape == "periods":
|
||||
attrs["wh_period"] = {
|
||||
datetime.fromtimestamp(h, timezone.utc).isoformat():
|
||||
round(kwh * 1000.0, 1) for h, kwh in rows}
|
||||
# The forecast refreshes every hour.
|
||||
rep = datetime.fromtimestamp(int(n // 3600) * 3600, timezone.utc)
|
||||
return {"state": f"{left:.3f}", "attributes": attrs,
|
||||
"last_updated": rep.isoformat(),
|
||||
"last_reported": rep.isoformat()}
|
||||
|
||||
async def state(self, e):
|
||||
now = t0 + timedelta(seconds=self.t)
|
||||
if kind == "missing":
|
||||
return {"state": "unavailable", "attributes": {},
|
||||
"last_updated": now.isoformat()}
|
||||
if e == FC_ENTITY:
|
||||
return sensor(now, False)
|
||||
if e == FC_TOMORROW:
|
||||
return sensor(now, True)
|
||||
return None
|
||||
rp.Hass.state = state
|
||||
|
||||
|
||||
rp_start = [None]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--variant", default="p4_ls")
|
||||
ap.add_argument("--fc", default="recorded",
|
||||
choices=("none", "recorded", "national", "perfect",
|
||||
"high", "missing", "zero"))
|
||||
ap.add_argument("--prices", default="epex2025",
|
||||
choices=("epex2025", "real2026"))
|
||||
ap.add_argument("--shape", default="periods",
|
||||
choices=("periods", "state"))
|
||||
ap.add_argument("--first", type=int, default=2)
|
||||
ap.add_argument("--days", type=int, default=16)
|
||||
ap.add_argument("--trust", type=int, default=None)
|
||||
ap.add_argument("--report", action="store_true",
|
||||
help="print the forecast's accuracy and exit")
|
||||
a = ap.parse_args()
|
||||
days = [f"2026-09-{d:02d}" for d in range(a.first, a.first + a.days)]
|
||||
house = rp.load_house(days)
|
||||
pts = rp.epex_points() if a.prices == "epex2025" else real2026_points()
|
||||
start = datetime.fromisoformat(days[0] + "T00:00:00+02:00") \
|
||||
.astimezone(timezone.utc)
|
||||
rp_start[0] = start
|
||||
extra = {}
|
||||
if a.fc != "none":
|
||||
table, base, rep = forecast_table(a.fc, house, days)
|
||||
if a.report:
|
||||
print(json.dumps({"fc": a.fc, "night_base_w": round(base),
|
||||
"days": rep}))
|
||||
return
|
||||
install_sensor(table, a.fc, a.shape)
|
||||
extra = {"pv_forecast_entity": FC_ENTITY,
|
||||
"pv_forecast_tomorrow_entity": FC_TOMORROW}
|
||||
if a.trust is not None:
|
||||
extra["solar_trust_pct"] = a.trust
|
||||
socs = {}
|
||||
|
||||
def check(c, hass, now, pr):
|
||||
if "start" not in socs:
|
||||
socs["start"] = {u: p.soc for u, p in hass.plants.items()}
|
||||
socs["end"] = {u: p.soc for u, p in hass.plants.items()}
|
||||
dp = getattr(c, "day_plan", None)
|
||||
if dp is not None:
|
||||
socs.setdefault("verdicts", {})[
|
||||
now.astimezone(LOCAL).date().isoformat()] = dp.verdict
|
||||
t0 = time.time()
|
||||
stats, _c = rp.run(a.variant, house, pts, start, 86400.0 * len(days),
|
||||
extra=extra, check=check)
|
||||
out = rp.summary(stats)
|
||||
prices = sorted(rp.all_in(p) for _s, _n, p in pts
|
||||
if start <= _s < start + timedelta(days=len(days)))
|
||||
med = prices[len(prices) // 2]
|
||||
|
||||
def usable(soc):
|
||||
return sum(max(0.0, v - 10.0) / 100.0 * rp.CAP_KWH
|
||||
for v in soc.values())
|
||||
d_kwh = usable(socs["end"]) - usable(socs["start"])
|
||||
out["stored_change_kwh"] = round(d_kwh, 2)
|
||||
out["stored_eur"] = round(d_kwh * 0.95 * med, 2)
|
||||
out["net_after_eur"] = round(out["net_eur"] - out["stored_eur"], 2)
|
||||
out["end_soc"] = {u: round(v, 1) for u, v in socs["end"].items()}
|
||||
out.update(variant=a.variant, fc=a.fc, prices=a.prices, shape=a.shape,
|
||||
first=days[0], days=len(days),
|
||||
verdicts=socs.get("verdicts", {}),
|
||||
wall_s=round(time.time() - t0, 1))
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -13,11 +13,18 @@ no watt still moves the digest. The `_modes` scenarios run battery modes on,
|
||||
Cheap hours only, over opt11p3_identity's prices. Uses only the API release/1.0 5a4cdc2 has, so the same file runs on
|
||||
both trees; test_safety78.py part 5 holds this tree to the digests printed
|
||||
there.
|
||||
|
||||
SAFETY-83 adds the owner's settings (limit 3500, drain 2500, margin 50) with a
|
||||
house that goes over drain + aim and back, and N=2 runs of both. At N=1 the
|
||||
peak floor now outranks the drain where it asks for more, so the N=1 digests
|
||||
moved; `--dump FILE` on the base tree and `--diff FILE` here list every
|
||||
changed cycle and whether the peak guard wanted more than the drain on it.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import os
|
||||
import pickle
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
@@ -47,14 +54,25 @@ def store_cheap(n):
|
||||
return s
|
||||
|
||||
|
||||
def load_owner(t):
|
||||
"""Below the 2500 W drain, above it, over drain + aim (5950 W), back."""
|
||||
return (1200.0, 3000.0, 6500.0, 3000.0, -1500.0)[min(int(t // 480), 4)]
|
||||
|
||||
|
||||
OWNER = {"peak_cap_w": 3500, "peak_margin_w": 50,
|
||||
"maintenance_discharge_w": 2500}
|
||||
|
||||
SCENARIOS = {
|
||||
"n1_drain": dict(n=1),
|
||||
"n1_drain_modes": dict(n=1, modes=True),
|
||||
"n1_drain_owner": dict(n=1, load=load_owner, extra=OWNER),
|
||||
"n2_drain": dict(n=2),
|
||||
"n2_drain_owner": dict(n=2, load=load_owner, extra=OWNER),
|
||||
}
|
||||
|
||||
|
||||
def run(n, *, modes=False, dur=DUR):
|
||||
extra = dict(s79.P1, **DRAIN)
|
||||
def run(n, *, modes=False, dur=DUR, load=load, extra=None):
|
||||
extra = dict(s79.P1, **DRAIN, **(extra or {}))
|
||||
if modes:
|
||||
extra.update(p3.ARB, battery_modes_enabled=True)
|
||||
seen, extra_rows = [], []
|
||||
@@ -99,7 +117,65 @@ def digests() -> dict:
|
||||
return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
|
||||
|
||||
|
||||
FIELDS = ("t", "target", "commands", "loop reason", "integrator",
|
||||
"saturation", "why", "allocation")
|
||||
XFIELDS = ("claims", "guard", "roles", "trade", "phase", "log length",
|
||||
"newest log line")
|
||||
|
||||
|
||||
def wanted_more(row) -> bool:
|
||||
"""Did the peak guard ask for more than the drain rate on this cycle?"""
|
||||
claims, guard = row[-1][0] or (), row[-1][1]
|
||||
rate = [c[3] for c in claims if c[0] == "maintenance" and c[2] == "set"]
|
||||
return (bool(rate) and guard is not None and bool(guard[0])
|
||||
and guard[1] is not None and guard[1] > max(rate))
|
||||
|
||||
|
||||
def diff(base: dict, now: dict) -> dict:
|
||||
"""Per scenario: (cycles, changed, changed where the guard wanted more on
|
||||
either tree, changed cycles whose commands differ without that)."""
|
||||
out = {}
|
||||
for k, rows in now.items():
|
||||
old = base.get(k)
|
||||
if old is None:
|
||||
continue
|
||||
ch = [i for i, (a, b) in enumerate(zip(old, rows)) if a != b]
|
||||
ch += list(range(min(len(old), len(rows)), max(len(old), len(rows))))
|
||||
more = [i for i in ch if i < min(len(old), len(rows))
|
||||
and (wanted_more(old[i]) or wanted_more(rows[i]))]
|
||||
cmd = [i for i in ch if i not in more
|
||||
and (i >= min(len(old), len(rows))
|
||||
or old[i][1:3] != rows[i][1:3])]
|
||||
fields = set()
|
||||
for i in set(ch) - set(more) - set(cmd):
|
||||
fields |= {FIELDS[j] for j in range(8) if old[i][j] != rows[i][j]}
|
||||
fields |= {XFIELDS[j] for j in range(7)
|
||||
if old[i][8][j] != rows[i][8][j]}
|
||||
out[k] = (len(rows), ch, more, cmd, sorted(fields))
|
||||
return out
|
||||
|
||||
|
||||
def all_rows() -> dict:
|
||||
return {k: run(**v)[0] for k, v in SCENARIOS.items()}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--dump" in sys.argv:
|
||||
with open(sys.argv[sys.argv.index("--dump") + 1], "wb") as f:
|
||||
pickle.dump(all_rows(), f)
|
||||
sys.exit(0)
|
||||
if "--diff" in sys.argv:
|
||||
with open(sys.argv[sys.argv.index("--diff") + 1], "rb") as f:
|
||||
base = pickle.load(f)
|
||||
for k, (n, ch, more, cmd, fields) in diff(base, all_rows()).items():
|
||||
rest = sorted(set(ch) - set(more))
|
||||
print(f"{k}: {n} cycles, {len(ch)} changed, {len(more)} with the "
|
||||
f"guard above the drain, {len(rest)} others "
|
||||
f"({len(cmd)} with a different target or command"
|
||||
+ (f"; the rest differ only in {', '.join(fields)}"
|
||||
if fields else "") + ")"
|
||||
+ (f", first changed t={ch[0] * rig.TICK:.0f} s" if ch else ""))
|
||||
sys.exit(0)
|
||||
if "--summary" in sys.argv:
|
||||
for k, v in SCENARIOS.items():
|
||||
rows = run(**v)[0]
|
||||
|
||||
@@ -85,6 +85,15 @@ configuration:
|
||||
battery_modes_enabled:
|
||||
name: "Hardware · Battery modes"
|
||||
description: "Give each battery an on and off switch and a mode, set on the Batteries screen."
|
||||
pv_forecast_entity:
|
||||
name: "Hardware · Solar forecast, today (kWh)"
|
||||
description: "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."
|
||||
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."
|
||||
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."
|
||||
pv_entity:
|
||||
name: "Hardware · Solar production entity (W)"
|
||||
description: "Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control."
|
||||
|
||||
@@ -66,6 +66,15 @@ configuration:
|
||||
battery_modes_enabled:
|
||||
name: "Hardware · Batterijmodi"
|
||||
description: "Geef elke batterij een aan- en uitschakelaar en een modus, in te stellen op het scherm Batterijen."
|
||||
pv_forecast_entity:
|
||||
name: "Hardware · Zonneprognose, vandaag (kWh)"
|
||||
description: "Optioneel. Een prognosesensor met de zonne-energie die vandaag nog komt, zoals Forecast.Solar of Solcast. Een batterij koopt dan alleen wat de zon niet geeft."
|
||||
pv_forecast_tomorrow_entity:
|
||||
name: "Hardware · Zonneprognose, morgen (kWh)"
|
||||
description: "Optioneel. Dezelfde prognose voor morgen. Ze helpt een goedkoop uur voor middernacht te plannen."
|
||||
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."
|
||||
pv_entity:
|
||||
name: "Hardware · Entiteit zonneproductie (W)"
|
||||
description: "Optioneel. De vermogenssensor van uw zonne-omvormer, positief tijdens productie. De add-on houdt ze bij en regelt er niet mee."
|
||||
|
||||
Reference in New Issue
Block a user