Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b6f16dba2 | ||
|
|
df154a26d1 | ||
|
|
21599938ea | ||
|
|
3a54b253cb | ||
|
|
8b9680fc35 | ||
|
|
3ce18708b6 | ||
|
|
78acb558ff | ||
|
|
0f14c2a5de |
@@ -1,37 +0,0 @@
|
||||
# MAINT-11 - start or stop the maintenance cycle per battery
|
||||
|
||||
**Status: for review.** Tests: `test_maint11.py`. No option added.
|
||||
|
||||
**The fault.** At two or more batteries the page had one "start a battery cycle"
|
||||
button, and `_maint_button()` chose the battery. An operator could not cycle a
|
||||
chosen one (inverter B, new after storage, needed its first cycle).
|
||||
|
||||
## Rule
|
||||
|
||||
1. `maint_start` and `maint_abort` take an optional `unit`. With no unit they
|
||||
run the old code unchanged. The Batteries screen sends the unit from a
|
||||
per-battery Start cycle and Stop cycle button, at two or more batteries only.
|
||||
2. **A start while any battery holds the maintenance slot is refused, not
|
||||
queued.** The message names the battery that is cycling. One slot per site
|
||||
stays the rule, and the scheduler is untouched.
|
||||
3. Also refused, with a plain message: an unknown unit, a start on a battery
|
||||
that is switched off (battery modes on), and a stop on a battery that is not
|
||||
cycling. Nothing changes on a refusal.
|
||||
4. A stop on the cycling battery is today's abort: the cycle ends, then every
|
||||
inverter goes to 0 W.
|
||||
|
||||
## Why refuse and not queue
|
||||
|
||||
A queued start is a cycle that begins hours later, unseen, after the operator
|
||||
has moved on. A refusal is immediate and says why. The scheduler already
|
||||
queues by waiting time, so nothing is lost.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- Today's bare button never refused a battery that is refusing to give power
|
||||
(SAFETY-73); its drain waits. The per-battery start does the same.
|
||||
- A start on the battery that is already cycling is refused as "busy", where
|
||||
the bare button restarts its drain. The bare button is unchanged.
|
||||
- At one battery, a request that names the battery uses the new path, so a
|
||||
second start is refused instead of restarting the drain. Nothing sends a name
|
||||
at one battery, and the bare request is unchanged.
|
||||
@@ -50,7 +50,7 @@ P1 is its section 6 P1, and the pull request lists what was measured.
|
||||
needs its own run to clear the spread (without: a night charge was spent
|
||||
on flat morning prices, then charging went on at them);
|
||||
- a run edge reseeds the loop at the house's level (a 3 kW import spike
|
||||
removed) and, since OPT-15, holds it there until the meter shows the step.
|
||||
removed; about 0.1 x rating still overshoots as export).
|
||||
- Replay (tools/opt11p3_replay.py, 16 recorded days, EPEX hourly prices a
|
||||
year earlier, modelled batteries), net EUR, lower is better: P2 19.33, P3
|
||||
Solar first + Leftover solar 19.57, with a sale 19.60, the old one-period
|
||||
@@ -75,63 +75,6 @@ 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.
|
||||
- OPT-15 (TEST-23 findings on 5a4cdc2, merged up to e3e4ba6,
|
||||
`test_price_run_fixes.py`):
|
||||
- F1, a charge stopped at a quarter edge and latched for the quarter. The
|
||||
P1 averager opens a quarter only when a sample arrives, and the HA source
|
||||
builds one 0.35 s after the reading, so the cycle on the boundary still
|
||||
saw the old block: "no seconds left", headroom None, "no trade
|
||||
(headroom)", latched for the new quarter. With modes on the guard now
|
||||
projects the quarter `now` is in (`PeakTracker.project(roll=True)`,
|
||||
crediting the held sample as the averager will), and only a measured
|
||||
stop latches. Modes off still reads the old block (identity).
|
||||
Since SAFETY-91, `next_quarter()` does the same for the guard at two
|
||||
inverters (no stop there without the roll, same banked quarters); the
|
||||
roll is what covers one inverter, where the old projection stops the
|
||||
charge at every edge. With modes on at one inverter the roll also removes
|
||||
the 1 s 0 W command at each quarter edge that SAFETY-81's sim showed, so
|
||||
its modes-on digests are re-pinned (modes off equal to e3e4ba6). A
|
||||
rolled projection is not graded by SAFETY-81's alarm (the old edge tick
|
||||
read "no seconds left"), so a standing overload is still notified once
|
||||
per quarter.
|
||||
- F2, -1120 W export at a charge end against -257 W modelled. The replay
|
||||
acted every 5 s on readings of that instant, and by then the lagged
|
||||
battery had nearly arrived, so it never acted on a reading that still
|
||||
showed the trade. The add-on cycles on each meter update (1 s on the rig)
|
||||
on readings 1-2 s old: the two cycles after the reseed saw the trade's
|
||||
+3300 W and slewed E up 555 W each. The loop now holds E until the meter
|
||||
reaches the target (or `EDGE_HOLD_S`, 18 s; to 36 s while the battery
|
||||
still moves). The replay gained `Lag` (dead time, reading age, the P1
|
||||
debounce, 1 s cycles). Charge end, 800 W house: -10 to -51 W over eight
|
||||
lag variants; without the hold -1370 W (meter 2 s). On e3e4ba6 the same
|
||||
numbers hold. The hold gives the loop the target as its grid, so it
|
||||
yields to SAFETY-95's drain tail, and any other claim that moves the
|
||||
command ends it.
|
||||
- F3, a charge refusal in a cheap run was never probed: the SAFETY-73 pin
|
||||
stands only while the site asks that way, and nothing asked to charge.
|
||||
Probing was chosen over changing the card: the probe is 200 W for 34 s
|
||||
each 15 min under the same peak gates, and it wins back the run. In its
|
||||
probe window the refusing battery is a charger, pinned at 200 W by its
|
||||
own claim, and the others rest. A monthly drain (SAFETY-78) still comes
|
||||
first, and with a day plan a probing Leftover solar battery goes only to
|
||||
its planned level. At one inverter SAFETY-52 already ends a charge
|
||||
refusal as soon as the house asks for power.
|
||||
- Not fixed, same on e3e4ba6: with lagged readings a headroom-sized quarter
|
||||
banks up to 1.4 W over the aim (48.6 W under the cap), and a battery
|
||||
reading older than the meter can make a charge start look like a house
|
||||
over the aim, a real guard stop.
|
||||
- Review: an unmeasured stop that never latched let a headroom missing
|
||||
every other cycle flip the charge 1799 times in 30 min, so the second one
|
||||
in a quarter latches (at most 4 changes a quarter). A house that rises
|
||||
1, 2 or 3 kW as the charge ends, from 30 s into the quarter to 15 s
|
||||
before its end (two inverters, lagged readings): the hold adds at most
|
||||
+33 W to that quarter against the loop without it, and the most any
|
||||
quarter banks is 3431 W with the charge sized to the aim (3450 W; the cap
|
||||
is 3500 W), 3456 W with a slow 8 s battery (the lag's own, the same on
|
||||
e3e4ba6). The import during the hold is the rise, below the charge's
|
||||
own, and a rise past that is the peak floor's, which ends the hold.
|
||||
A maintenance drain, hold or charge that starts during the hold ends it
|
||||
on that tick (replayed at edge + 3 s).
|
||||
|
||||
## P4: the solar forecast and the day plan (2026-09-29)
|
||||
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# OPT-17 - the solar forecast from several entities, summed
|
||||
|
||||
**Status: for review.** Tests: `test_opt17.py`. Two options added.
|
||||
|
||||
A site whose forecast comes as several Home Assistant sensors (one per
|
||||
inverter or roof side) had to build a template sensor to sum them. Now each
|
||||
day can list them.
|
||||
|
||||
## Options
|
||||
|
||||
- `pv_forecast_entities` and `pv_forecast_tomorrow_entities`: lists of
|
||||
sensors, default `[]`, the same shape as `pv_entities`.
|
||||
- `pv_forecast_entity` and `pv_forecast_tomorrow_entity` are unchanged. An
|
||||
install that uses them needs no edit.
|
||||
- **Why new keys, not "string or list" in the old ones.** Measured on
|
||||
Supervisor 2026.08.0 by `tools/supervisor_shape_probe.py` §J, as TEL-12's
|
||||
§G did for `pv_entity`. Declared as a list, each forecast key refuses the
|
||||
string an install already stores (`Invalid list for option
|
||||
'pv_forecast_entity'`), and a `[]` default does not rescue it. The one
|
||||
spelling that takes both, `match(.*)?`, also takes the integer 417 and
|
||||
shows a plain text box, so a second sensor cannot be added in the UI.
|
||||
- §J also shows: a 0.17.0 blob validates unchanged with the lists at `[]`;
|
||||
the lists render as repeatable fields; a downgrade drops them and keeps
|
||||
the single keys.
|
||||
- `[]` in `options:` is load-bearing, as for `pv_entities`.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **One key per day.** Setting the single key and the list for the same day
|
||||
is refused at start, with one message: keep the list, clear the single one.
|
||||
The list can hold that entity too. (TEL-12 lets `pv_entities` replace
|
||||
`pv_entity` with a caution row. Here the brief asked for a refusal: a
|
||||
doubled or dropped forecast changes what a battery buys, and no install
|
||||
has both keys yet, so the refusal breaks nothing.)
|
||||
2. **Each member in its own unit.** Every member is read as one forecast
|
||||
sensor is today (`solarfc._day()`): Wh, kWh or MWh, never guessed. Then the
|
||||
day's kWh are added up. A repeated id counts once.
|
||||
3. **One member not usable means the day is not usable.** Missing,
|
||||
unavailable, unknown, stale, not an energy unit, or a bad number: that
|
||||
day's sum is not used, exactly as when the single sensor reads that way.
|
||||
No today means no forecast (P3 option A). No tomorrow means today only.
|
||||
4. **Hourly split.** If every member has a split, the splits are joined
|
||||
(overlapping periods add up). If any member has none, the sum is spread
|
||||
over the bell, as for a state-only sensor.
|
||||
5. **Time.** The forecast is as old as its oldest member.
|
||||
6. **One read.** All members of both days are read in one gather, inside the
|
||||
gather that reads the inverters, so a slow sensor does not delay control.
|
||||
7. **Shown and logged once.** With two or more members, each one not usable
|
||||
is named on the status page by its place in the list (STYLE.md: no entity
|
||||
ids on the page), as a caution row titled "Solar forecast" on Home. The
|
||||
action log names the entity id, once when the set of failing members
|
||||
changes.
|
||||
8. **One member is the single key.** A one-member list reads, plans, shows
|
||||
and logs exactly as the single key with that entity.
|
||||
|
||||
## Why
|
||||
|
||||
- A partial sum is a smaller number that looks real. Two of three inverters'
|
||||
forecasts read as a duller day, and Leftover solar would then buy grid
|
||||
energy the sun was going to give. TEL-12 took the same rule for the same
|
||||
reason. No forecast is a known state with a tested fallback.
|
||||
- The bell for a mixed list: joining one member's hours with another's total
|
||||
has no honest shape, and the bell is the existing state-only rule.
|
||||
|
||||
## Cost
|
||||
|
||||
- One flaky member costs the whole day's forecast. A site with three
|
||||
sensors loses the plan more often than one with a template sum that hides
|
||||
gaps.
|
||||
- Two more options (85).
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- No weighting or calibration per member; FCST-01 is still the own forecast.
|
||||
- Members on different integrations with different refresh times: each must
|
||||
be fresh (3 h) on its own; there is no wait for a lagging one.
|
||||
- The day plan itself, the trust and the house profile are unchanged.
|
||||
- The Batteries screen still says only "no solar forecast now". The member
|
||||
is named on the status page.
|
||||
@@ -1,98 +0,0 @@
|
||||
# SAFETY-112 - control waits for Home Assistant to finish starting
|
||||
|
||||
**Status: for review.** Tests: `test_safety112.py`, `test_messages.py`.
|
||||
Control is unchanged. The add-on now says plainly that it is waiting.
|
||||
|
||||
## Cause
|
||||
|
||||
The Supervisor's proxy refuses every add-on request with 502 until Core's
|
||||
state is `RUNNING`. The add-on reads every battery reading and writes every
|
||||
power request through that proxy, so it cannot control until then. Readings
|
||||
that are already live inside Home Assistant do not help.
|
||||
|
||||
Source, Supervisor 2026.08.0 (the image the live site ran at CFG-04):
|
||||
|
||||
- `supervisor/api/proxy.py`, `APIProxy.api()` (REST, used for reads and
|
||||
`number.set_value`) and `APIProxy.websocket()` both start with
|
||||
`if not await self.sys_homeassistant.api.check_api_state(): raise HTTPBadGateway`.
|
||||
- `supervisor/homeassistant/api.py`, `check_api_state()` returns
|
||||
`state.core_state == "RUNNING" or state.offline_db_migration`. The state
|
||||
comes from Core's `/api/core/state`.
|
||||
- Upstream `main` has the same gate in both handlers (read 2026-10-13).
|
||||
- A Core restart restarts only the Core container
|
||||
(`homeassistant/core.py`, `restart()`). The add-on keeps running.
|
||||
|
||||
The add-on itself has no wait. Nothing in `app/` reads Core's state or waits
|
||||
for an event. The loop reads its inputs every second, a 502 gives `None`,
|
||||
and missing inputs give 0 W. P1 backoff is not involved: the live site reads
|
||||
the meter straight from the HomeWizard (`homewizard_local`), not through Home
|
||||
Assistant.
|
||||
|
||||
## Measured
|
||||
|
||||
Repro stack `safety112-*`, all removed afterwards:
|
||||
|
||||
- Home Assistant 2026.9.4, as live.
|
||||
- A test integration whose `async_setup` sleeps, so Core stays `NOT_RUNNING`.
|
||||
- A fake meter and two fake inverters that update every 2 s from load.
|
||||
- The Supervisor's own `APIProxy` and `HomeAssistantAPI` from the 2026.08.0
|
||||
image. Only the add-on token lookup, Core's access token and the container
|
||||
check are stubbed.
|
||||
- The add-on on `meter_source: homewizard_local` against a fake HomeWizard.
|
||||
|
||||
Times are seconds after `docker restart` of Home Assistant:
|
||||
|
||||
| run | Core `NOT_RUNNING` | entities live in HA | Core `RUNNING` | proxy 502 | first write | wait from live readings |
|
||||
|---|---|---|---|---|---|---|
|
||||
| base 3050fa5, N=2 | 13.1 | 17.1 | 253.1 | 0.9-252.7 (1 132 requests) | 254.7 | 237.6 |
|
||||
| base 3050fa5, N=1 | 13.1 | 18.1 | 256.1 | 1.5-255.1 (573 requests) | 257.2 | 239.1 |
|
||||
| this change, N=2 | 12.2 | 17.2 | 313.2 | 1.4-312.5 (1 406 requests) | 317.6 | 300.4 |
|
||||
| this change, N=1 | 12.2 | 17.2 | 255.2 | 1.2-254.1 (571 requests) | 257.1 | 240.0 |
|
||||
|
||||
The first write comes 1.0-4.4 s after `RUNNING`, so the whole wait is the
|
||||
proxy's. With a 400 s sleep Core moved on at 300.88 s on its own (bootstrap's
|
||||
stage timeout), so one slow integration holds a start stage for about 5
|
||||
minutes. The whole start can take longer: the live one took 414 s.
|
||||
|
||||
Before this change the page said "Not ready — Battery level: no usable
|
||||
reading", which sends the reader to Developer Tools for an entity that is
|
||||
fine.
|
||||
|
||||
## Rule
|
||||
|
||||
1. No way around the Supervisor. The add-on still talks to Home Assistant
|
||||
only through the proxy.
|
||||
2. While the proxy does not answer on the API (`hass.OUTAGE.waiting()`), the
|
||||
first status row is red: "Home Assistant is not answering, perhaps it is
|
||||
still starting. Control resumes by itself when it answers." The banner
|
||||
reads "Not ready — Waiting for Home Assistant to finish starting". The Home
|
||||
pill reads "Waiting for Home Assistant", in English and Dutch.
|
||||
3. A safety stop, a stalled control loop or shadow mode keeps its own banner
|
||||
and state over the wait, because none of them clears by itself.
|
||||
4. The log has one WARNING when Home Assistant stops answering, an INFO line
|
||||
every 5 minutes while it is down, and an INFO line with the length of the
|
||||
wait when it answers again.
|
||||
5. Only the API counts. The meter websocket alone does not, because the meter
|
||||
has its own row.
|
||||
|
||||
## Why it is safe
|
||||
|
||||
The change is text only. `cycle()`, the writes, the heartbeat, the stand-down
|
||||
on shutdown and every watchdog are untouched. `test_safety112.py` part 3 shows
|
||||
the same targets and writes with the flag on and off, for N=1 and N=2.
|
||||
|
||||
## Open, for the owner
|
||||
|
||||
The add-on cannot control while Home Assistant starts. These ways around it
|
||||
were not taken:
|
||||
|
||||
- **Talk to Core directly with a long-lived token** (`http://homeassistant:8123`).
|
||||
Core's own API answers while `NOT_RUNNING`. In the repro `/api/core/state`
|
||||
answered from 12-13 s, and a direct `number.set_value` 10 s into
|
||||
`NOT_RUNNING` returned 200 and set the value. The cost is a secret stored
|
||||
at every client site, which `hass.py` exists to avoid.
|
||||
- **Talk to the control board directly** (ESPHome native API), so control
|
||||
does not need Home Assistant at all. This is a large change.
|
||||
- **Remove the slow start at the site.** On 2026-10-12 the Solis
|
||||
integration's discovery held the start. Fixing or disabling it shortens the
|
||||
wait there, but not on a client site with another slow integration.
|
||||
@@ -186,8 +186,6 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| Hardware | Battery modes | `battery_modes_enabled` | Give each battery an on and off switch and a mode, set on the Batteries screen. |
|
||||
| Hardware | Solar forecast, today (kWh) | `pv_forecast_entity` | Optional. A forecast sensor with today's remaining solar energy, such as Forecast.Solar or Solcast. A battery then buys only what the sun will not give. |
|
||||
| Hardware | Solar forecast, tomorrow (kWh) | `pv_forecast_tomorrow_entity` | Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight. |
|
||||
| Hardware | Several solar forecasts, today (kWh) | `pv_forecast_entities` | Optional. One row per forecast sensor, such as one per inverter, used instead of the single one above. They are added up. |
|
||||
| Hardware | Several solar forecasts, tomorrow (kWh) | `pv_forecast_tomorrow_entities` | Optional. The same list for tomorrow. If one sensor has no reading, that day's forecast is not used. |
|
||||
| Hardware | Trust in the forecast (%) | `solar_trust_pct` | Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast. |
|
||||
| Hardware | Solar production entity (W) | `pv_entity` | Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control. |
|
||||
| Hardware | Solar production entities, per inverter (W) | `pv_entities` | Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded. |
|
||||
@@ -541,9 +539,6 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
- With no prices, or too small a gap, every battery follows the house.
|
||||
|
||||
**Solar forecast** (`pv_forecast_entity`, optional). A Home Assistant sensor with the solar energy still to come today, in kWh. These work: Forecast.Solar (`sensor.energy_production_today_remaining`), Solcast PV Forecast (`sensor.solcast_pv_forecast_forecast_remaining_today`) and Open-Meteo Solar Forecast (`sensor.energy_production_today_remaining`). Set `pv_forecast_tomorrow_entity` to the matching *tomorrow* sensor to plan a cheap run before midnight.
|
||||
- **Several forecasts.** If your forecast comes as several sensors (one per inverter or roof side), list them in `pv_forecast_entities` and, for tomorrow, `pv_forecast_tomorrow_entities`, instead of the single key. They are added up, each in its own unit (Wh, kWh or MWh). Set the list or the single key for a day, not both.
|
||||
- If one of them has no reading, is stale or is not in an energy unit, that day's forecast is not used at all. A part of the sun is not counted as all of it. The status page names the source by its place in the list.
|
||||
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
|
||||
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
|
||||
- *Leftover solar* then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level.
|
||||
- *Cheap hours only* ignores the forecast and fills to the top level.
|
||||
@@ -555,10 +550,10 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
|
||||
**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. The start of a new period does not stop it.
|
||||
- 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.
|
||||
- While one battery charges from the grid, the others rest, and the house uses the grid.
|
||||
- While one battery empties for its monthly cycle, no battery charges from the grid.
|
||||
- A battery that refuses to charge or give power takes no part in a run in that direction. In a cheap run it is still tested every 15 minutes: it charges at 200 W for about half a minute while the others rest.
|
||||
- A battery that refuses to charge or give power takes no part in a run in that direction.
|
||||
|
||||
**In Home Assistant.** With MQTT set up, each battery also gets a *Mode* list and a *Use* switch, for example `select.goodwe_inverter_a_mode` and `switch.goodwe_inverter_a_use`. They do the same as the Batteries screen. Turn battery modes off and restart, and they are removed.
|
||||
|
||||
@@ -566,7 +561,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
|
||||
*For installers:* a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`.
|
||||
|
||||
*For installers:* when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts, and holds there until the meter shows the change (at most 18 s, up to 36 s while the battery is still moving). This removes the ~3 kW import spike after a 2.5 kW charge and the export overshoot that readings a second or two old caused (-1120 W on the test rig); with the hold it stays within 0.15 × the battery's power. The *Mode* list gains *Cheap hours only* in Home Assistant, at the end.
|
||||
*For installers:* when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts. On the replay model that removes a ~3 kW import spike after a 2.5 kW charge and halves the settling time; about 0.1 × the battery's power still overshoots as export. The *Mode* list gains *Cheap hours only* in Home Assistant, at the end.
|
||||
|
||||
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
|
||||
|
||||
@@ -3413,11 +3408,6 @@ day's price runs.
|
||||
An Off battery that the peak limit still needs says so, with the power it
|
||||
gives: "Off, but gives 0.5 kW for the peak limit."
|
||||
|
||||
With two or more batteries, each card also has **Start cycle** and **Stop
|
||||
cycle**, to run the monthly cycle on that battery now. Only one battery cycles
|
||||
at a time: a start while another battery is cycling, or on a battery that is
|
||||
switched off, is refused and the message names the battery.
|
||||
|
||||
**Settings** opens on the **Simple** view, or on the view you last picked:
|
||||
the peak limit, the batteries' switches and modes, and shadow mode, each
|
||||
marked "Applies right away" or "Applies after a restart". **Installer** shows
|
||||
@@ -3483,7 +3473,6 @@ for a new install*.
|
||||
|
||||
| Row starts with | What it tells you |
|
||||
|---|---|
|
||||
| Home Assistant is not answering | Home Assistant is not passing readings or power requests on, usually because it is still starting. The add-on cannot control until then. It resumes by itself. Nothing to do unless it lasts. |
|
||||
| Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. |
|
||||
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. Just after a start it is amber and *waiting for the first reading*. Red means no reading for longer than the limit. |
|
||||
| Stuck-meter check | Whether the add-on can warn you about a stuck meter. |
|
||||
@@ -3542,13 +3531,6 @@ reason code, and its full figures are on the DEBUG line right after it and in
|
||||
(TARIFF-11), in words.
|
||||
- **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The
|
||||
*Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14).
|
||||
- **Home Assistant is not answering** (SAFETY-112): the Supervisor answers
|
||||
502 to every add-on read, write and websocket until Home Assistant is
|
||||
RUNNING, even when its entities are already live. A slow integration at
|
||||
start-up therefore holds control for as long as it holds Home Assistant's
|
||||
start. The log has one WARNING, a "Still waiting" INFO line every 5
|
||||
minutes and "answers again after …" at the end. See
|
||||
`DECISIONS/SAFETY-112-waiting-for-home-assistant.md`.
|
||||
- **Shadow mode** (SAFETY-10): the banner turns red when a latch is up
|
||||
(circulating power, or a site or per-inverter sign trip), and that latch keeps
|
||||
its own red row.
|
||||
|
||||
@@ -11,8 +11,6 @@ import time
|
||||
|
||||
import aiohttp
|
||||
|
||||
from . import messages
|
||||
|
||||
_LOG = logging.getLogger("goodwe.hass")
|
||||
|
||||
CORE_API = "http://supervisor/core/api"
|
||||
@@ -21,7 +19,6 @@ BAD_STATES = ("unknown", "unavailable", "none", "")
|
||||
# What the Supervisor's proxy answers while Home Assistant restarts.
|
||||
UNAVAILABLE = (502, 503, 504)
|
||||
STATES_TIMEOUT_S = 10 # UX-13's entity list, like every other read here
|
||||
STILL_WAITING_S = 300 # SAFETY-112: one reminder line per 5 minutes down
|
||||
|
||||
|
||||
class Outage:
|
||||
@@ -32,30 +29,15 @@ class Outage:
|
||||
("502, message='Bad Gateway', url=URL(...)", "Received message 8:1000 is
|
||||
not str"). Now the first channel to fail says it once at WARNING, the raw
|
||||
text goes to DEBUG, and the last channel to recover says so at INFO.
|
||||
|
||||
SAFETY-112: the Supervisor answers 502 for every read, write and
|
||||
websocket until Home Assistant has finished starting, however live its
|
||||
entities already are (DECISIONS/SAFETY-112-waiting-for-home-assistant.md).
|
||||
So an outage also says, every STILL_WAITING_S, that it is still on, and
|
||||
`waiting()` puts it on the status page.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.down_on = set()
|
||||
self.since = None # monotonic start of this outage
|
||||
self._told = None # when the last "still waiting" line went
|
||||
|
||||
def down(self, channel: str, what: str, detail) -> None:
|
||||
now = time.monotonic()
|
||||
if not self.down_on:
|
||||
_LOG.warning("Home Assistant is not answering (%s), perhaps it is "
|
||||
"still starting. Control waits and resumes by itself.",
|
||||
what)
|
||||
self.since = self._told = now
|
||||
elif now - self._told >= STILL_WAITING_S:
|
||||
self._told = now
|
||||
_LOG.info("Still waiting for Home Assistant to finish starting, "
|
||||
"%s so far.", messages.duration(now - self.since))
|
||||
"restarting. The add-on keeps trying.", what)
|
||||
self.down_on.add(channel)
|
||||
_LOG.debug("Home Assistant not answering on %s: %s", channel, detail)
|
||||
|
||||
@@ -63,13 +45,7 @@ class Outage:
|
||||
if channel in self.down_on:
|
||||
self.down_on.discard(channel)
|
||||
if not self.down_on:
|
||||
_LOG.info("Home Assistant answers again after %s.",
|
||||
messages.duration(time.monotonic() - self.since))
|
||||
|
||||
def waiting(self) -> bool:
|
||||
"""True while Home Assistant's API does not answer. The API, not the
|
||||
meter websocket: every battery reading and every write rides on it."""
|
||||
return "api" in self.down_on
|
||||
_LOG.info("Home Assistant answers again.")
|
||||
|
||||
|
||||
OUTAGE = Outage()
|
||||
|
||||
@@ -909,14 +909,13 @@ def month_peak(c, now):
|
||||
return out
|
||||
|
||||
|
||||
def state_of(c, level, shadow, waiting=False):
|
||||
def state_of(c, level, shadow):
|
||||
"""One word for the pill and the banner's colour: running, shadow,
|
||||
stopped, alarm, waiting or warning. `waiting`: the red headline is
|
||||
Home Assistant still starting (SAFETY-112), which needs nobody."""
|
||||
stopped, alarm or warning."""
|
||||
if shadow:
|
||||
return "shadow"
|
||||
if level == "bad":
|
||||
return "waiting" if waiting else "alarm"
|
||||
return "alarm"
|
||||
if not c.auto:
|
||||
return "stopped"
|
||||
if level == "warn":
|
||||
@@ -925,22 +924,20 @@ def state_of(c, level, shadow, waiting=False):
|
||||
|
||||
|
||||
def build(c, *, level: str, checks: list, now: datetime | None = None,
|
||||
lang: str = "en", held: bool | None = None,
|
||||
waiting: bool = False) -> dict:
|
||||
lang: str = "en", held: bool | None = None) -> dict:
|
||||
"""The structured half of `/status`, in `lang` (UX-13 P5). Pure reads of
|
||||
the controller."""
|
||||
_LANG[0] = uitext.lang_of(lang)
|
||||
try:
|
||||
return _build(c, level=level, checks=checks, now=now, held=held,
|
||||
waiting=waiting)
|
||||
return _build(c, level=level, checks=checks, now=now, held=held)
|
||||
finally:
|
||||
_LANG[0] = "en"
|
||||
|
||||
|
||||
def _build(c, *, level, checks, now, held=None, waiting=False):
|
||||
def _build(c, *, level, checks, now, held=None):
|
||||
now = now or datetime.now(timezone.utc)
|
||||
shadow = bool(c.shadow)
|
||||
kind = state_of(c, level, shadow, waiting)
|
||||
kind = state_of(c, level, shadow)
|
||||
# A red banner means every inverter is at 0 W (the status cascade), so
|
||||
# nothing is planned until it clears. Shadow mode still plans: it is
|
||||
# what the add-on would do. `held` False: an alarm while control runs
|
||||
@@ -1335,7 +1332,7 @@ def _samples() -> list:
|
||||
out += [("title", mode_word(k)) for k in words["modes"]]
|
||||
out += [("title", mode_house()), ("title", attn_shadow()),
|
||||
("line", attn_shadow_body())]
|
||||
for k in ("running", "shadow", "stopped", "alarm", "waiting", "warning"):
|
||||
for k in ("running", "shadow", "stopped", "alarm", "warning"):
|
||||
out += [("title", pill(k)), ("line", banner_sub(k))]
|
||||
for sh in (False, True):
|
||||
out += [("title", tile_solar(v, s)) for v, s in
|
||||
|
||||
+41
-186
@@ -69,7 +69,7 @@ from . import messages
|
||||
from .freeze import (
|
||||
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
|
||||
)
|
||||
from .hass import CORE_API, OUTAGE, HomeAssistant
|
||||
from .hass import CORE_API, HomeAssistant
|
||||
# ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it
|
||||
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
|
||||
# window an operator would look up here is the one the roster actually uses.
|
||||
@@ -238,9 +238,6 @@ def load_options() -> dict:
|
||||
# OPT-11 P3: how long the last cheap run stays the price stored energy is
|
||||
# valued at (`priceruns.plan(ref_eur_kwh=)`): the rest of that day.
|
||||
REF_CHEAP_KEEP = timedelta(hours=12)
|
||||
# OPT-15: the longest a run edge holds the loop at E waiting for the meter to
|
||||
# show the step: the plant's measured step time (control.PLANT_SETTLE_S).
|
||||
EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
|
||||
|
||||
# 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).
|
||||
@@ -971,7 +968,6 @@ class Controller:
|
||||
self.peak_unmeasured_s = 0.0
|
||||
self._peak_alarm_block = None
|
||||
self._peak_high_since = None
|
||||
self._peak_rolled = False # OPT-15
|
||||
self._peak_floor_limited = False
|
||||
# SAFETY-87: the reserve alarm's hold, as SAFETY-81's above.
|
||||
self._reserve_batt_prev = None
|
||||
@@ -994,23 +990,14 @@ class Controller:
|
||||
self._run_done = {}
|
||||
self._mode_trade_was = False
|
||||
self._charge_blocked_q = None
|
||||
self._charge_gap_q = None # OPT-15: an unmeasured stop's quarter
|
||||
self._ref_cheap = None
|
||||
# OPT-15: a run edge the loop holds at E: (E, step sign, until, the
|
||||
# last battery reading).
|
||||
self._edge = None
|
||||
# OPT-11 P4: the solar forecast and the day plan. Only with battery
|
||||
# modes on AND a forecast for today set; otherwise none of this runs
|
||||
# and every mode acts as in P3 option A. OPT-17: each day may name
|
||||
# several entities, summed (`solarfc.entities()`); `fc_entity` is
|
||||
# the first, and None means no forecast.
|
||||
self.fc_entities = (solarfc.entities(opts) if self.modes_on
|
||||
else ())
|
||||
self.fc_tomorrow_entities = solarfc.entities(opts, tomorrow=True)
|
||||
self.fc_entity = self.fc_entities[0] if self.fc_entities else None
|
||||
# OPT-17: the members that are not usable, as `solarfc.read_sum()`
|
||||
# gives them, for the status page and the action log.
|
||||
self.solar_fc_gaps = ()
|
||||
# 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
|
||||
@@ -2224,15 +2211,10 @@ class Controller:
|
||||
or maint_results[i].phase == IDLE)
|
||||
refused = (inv.refused if len(self.inverters) > 1
|
||||
else self.batt_refused) or 0
|
||||
# OPT-15: a charge refusal's probe (SAFETY-73, N>1) as it stood
|
||||
# when last decided; its pin is filed after this, in cycle().
|
||||
probing = (len(self.inverters) > 1 and refused < 0
|
||||
and inv.probe_from is not None)
|
||||
out.append(modetrade.Pack(
|
||||
inv.name, pm.use, pm.mode, pm.sell and pm.can_sell,
|
||||
float(inv.max_w), inv.soc, inv.capacity_kwh,
|
||||
live=inv.name in live, idle=idle, refused=int(refused),
|
||||
probing=probing))
|
||||
live=inv.name in live, idle=idle, refused=int(refused)))
|
||||
return out
|
||||
|
||||
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
|
||||
@@ -2390,17 +2372,9 @@ class Controller:
|
||||
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.
|
||||
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
|
||||
# (None) in a quarter stops this tick only, so a gap cannot cost the
|
||||
# whole quarter. A second one latches, so gaps cannot flip the charge.
|
||||
if was and self.mode_tick.action != modetrade.CHARGE:
|
||||
why = self.mode_tick.why
|
||||
gap = why == "headroom" and self.arb_headroom_w is None
|
||||
if why == "peak" or (why == "headroom" and not gap) or (
|
||||
gap and self._charge_gap_q == quarter):
|
||||
self._charge_blocked_q = quarter
|
||||
elif gap:
|
||||
self._charge_gap_q = quarter
|
||||
if was and self.mode_tick.action != modetrade.CHARGE and \
|
||||
self.mode_tick.why in ("peak", "headroom"):
|
||||
self._charge_blocked_q = quarter
|
||||
return self.mode_tick
|
||||
|
||||
def _run_mark(self, kind, run, packs, out, extra=None) -> set:
|
||||
@@ -2471,18 +2445,18 @@ class Controller:
|
||||
else "trade_off"))
|
||||
return self.arb.action is not None
|
||||
|
||||
def _reseed_run_edge(self, claims, now) -> None:
|
||||
def _reseed_run_edge(self, claims) -> None:
|
||||
"""OPT-11 P3: a mode trade just ended, so the loop takes over from a
|
||||
command no loop chose. Start it at the house's own point E, the
|
||||
command that brings the grid to its target now (`site battery + grid
|
||||
- target`, inside the loop bound), instead of at the trade's watts.
|
||||
OPT-15: then HOLD it at E (`_edge_grid()`) until the meter shows the
|
||||
step or `EDGE_HOLD_S` passes. The readings still describe the trade for
|
||||
a second or two (meter and battery lag), and stepping on them sent
|
||||
E 810 -> 1370 -> 1920 W on TEST-23: -1120 W export at a charge end.
|
||||
Measured: DECISIONS/OPT-11-battery-modes.md.
|
||||
- target`, inside the loop bound), instead of at the trade's watts
|
||||
(the trade began with the same unslewed step). This tick's error still
|
||||
measures the trade's command (it is exactly E - battery), so the
|
||||
integrator is seeded that much short and lands on E after its step.
|
||||
Measured (tools/opt11p3_replay.py, lagged battery, 2.5 kW charge
|
||||
ending under an 800 W house): no reseed -258 W export then a 2976 W
|
||||
import, settled in 55 s; this -257 W / +615 W, settled in 20 s.
|
||||
"""
|
||||
self._edge = None
|
||||
batt = self.site_batt_w
|
||||
if batt is None or self.grid is None:
|
||||
return
|
||||
@@ -2491,34 +2465,8 @@ class Controller:
|
||||
if bound is not None:
|
||||
want = max(bound[0], min(bound[1], want))
|
||||
want = float(round(max(-self.site_max_w, min(self.site_max_w, want))))
|
||||
self.target = self.i_w = want
|
||||
if want != float(batt):
|
||||
self._edge = (want, 1.0 if want > float(batt) else -1.0,
|
||||
now + timedelta(seconds=EDGE_HOLD_S), float(batt))
|
||||
|
||||
def _edge_grid(self, end, now):
|
||||
"""OPT-15: the grid reading the loop acts on this tick. While a run
|
||||
edge is held, the target itself (no error, the command stays at E).
|
||||
The hold ends when the meter reaches the target in the step's
|
||||
direction (or passes it), on `end` (a trade started, or another claim
|
||||
moved the command), or after `EDGE_HOLD_S` - later, up to twice that,
|
||||
while the battery reading is still moving towards E."""
|
||||
e = self._edge
|
||||
if e is None:
|
||||
return self.grid
|
||||
held_w, sign, until, last_b = e
|
||||
tg, db = self.tuning.target_grid_w, self.tuning.deadband_w
|
||||
b = self.site_batt_w
|
||||
moving = (b is not None and sign * (float(b) - last_b) >= db
|
||||
and sign * (held_w - float(b)) >= db
|
||||
and now < until + timedelta(seconds=EDGE_HOLD_S))
|
||||
if (end or (now >= until and not moving)
|
||||
or sign * (float(self.grid) - tg) < db):
|
||||
self._edge = None
|
||||
return self.grid
|
||||
self._edge = (held_w, sign, until, last_b if b is None else float(b))
|
||||
self.i_w = held_w
|
||||
return tg
|
||||
self.target = want
|
||||
self.i_w = want - self.tuning.gain * (want - float(batt))
|
||||
|
||||
def _charge_track(self, claims, maint_results) -> None:
|
||||
"""SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can
|
||||
@@ -2777,43 +2725,6 @@ class Controller:
|
||||
out["sell"] = pm.sell
|
||||
return out
|
||||
|
||||
async def _maint_unit_action(self, what, unit, now) -> dict:
|
||||
"""MAINT-11: start or abort the maintenance cycle of ONE named battery.
|
||||
|
||||
Returns `{"error", "code", "unit"}` for a refusal and changes nothing.
|
||||
The site keeps its one maintenance slot: a start while any battery
|
||||
holds it is refused (no queue), see DECISIONS/MAINT-11. The phase
|
||||
machine is not touched beyond `force_start()` and `abort()`, the same
|
||||
two calls the bare button makes.
|
||||
"""
|
||||
inv = next((i for i in self.inverters if i.name == unit), None)
|
||||
if inv is None:
|
||||
return self._maint_refusal("unit")
|
||||
machine = self.maints[self.inverters.index(inv)]
|
||||
if what == "maint_start":
|
||||
holder = next((i for i, m in zip(self.inverters, self.maints)
|
||||
if m.phase in POWER_MOVING), None)
|
||||
if holder is not None:
|
||||
return self._maint_refusal("busy", holder.name)
|
||||
if self.modes_on and not self.pack_mode(inv).use:
|
||||
return self._maint_refusal("off", inv.name)
|
||||
for msg in machine.force_start(now):
|
||||
self.log_event(msg, hint=messages.hint(
|
||||
"maintenance", phase=machine.phase))
|
||||
return {"unit": inv.name, "phase": machine.phase}
|
||||
if machine.phase not in POWER_MOVING:
|
||||
return self._maint_refusal("idle", inv.name)
|
||||
for msg in machine.abort(now, "operator"):
|
||||
self.log_event(msg, hint=messages.hint("maintenance",
|
||||
phase=machine.phase))
|
||||
await self.command_site_zero()
|
||||
return {"unit": inv.name, "phase": machine.phase}
|
||||
|
||||
@staticmethod
|
||||
def _maint_refusal(code, unit="") -> dict:
|
||||
return {"error": messages.maint_refused(code, unit), "code": code,
|
||||
"unit": unit}
|
||||
|
||||
def mode_values(self) -> dict:
|
||||
"""OPT-11 P2: each battery's mode and switch, for their HA state
|
||||
topics. Read from the store, so they are right after a restart. Empty
|
||||
@@ -3335,31 +3246,18 @@ class Controller:
|
||||
async def _read_forecast(self) -> None:
|
||||
"""OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A
|
||||
failed or stale read is no forecast, never a zero."""
|
||||
# OPT-17: every member of both days in one gather.
|
||||
ents = self.fc_entities + self.fc_tomorrow_entities
|
||||
states = await asyncio.gather(*(self.hass.state(e) for e in ents))
|
||||
n = len(self.fc_entities)
|
||||
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, gaps = solarfc.read_sum(
|
||||
list(zip(self.fc_entities, states[:n])),
|
||||
list(zip(self.fc_tomorrow_entities, states[n:])), now)
|
||||
# A member is named only in a sum of several; one entity reads as
|
||||
# before. Logged once per episode: on a change, never every read.
|
||||
gaps = tuple(g for g in gaps if len(
|
||||
self.fc_entities if g[0] == "today"
|
||||
else self.fc_tomorrow_entities) > 1)
|
||||
if (fc is None) != (self.solar_fc is None) \
|
||||
or why != self.solar_fc_why or gaps != self.solar_fc_gaps:
|
||||
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})")
|
||||
+ "".join(f", {d} {e} not usable ({w})"
|
||||
for d, _i, e, w in gaps),
|
||||
hint=messages.hint(
|
||||
"forecast_none" if fc is None else
|
||||
"forecast_no_tomorrow" if gaps else "forecast_read"))
|
||||
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
|
||||
self.solar_fc_gaps = gaps
|
||||
|
||||
async def read_inputs(self, now_mono: float | None = None) -> None:
|
||||
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
|
||||
@@ -4642,16 +4540,8 @@ class Controller:
|
||||
# is not measured on that path, and the honest report is NOT COVERED -
|
||||
# the same answer, for the same reason, that the status page already
|
||||
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`.
|
||||
# OPT-15: with battery modes on, the quarter `now` is in, even before
|
||||
# its first sample (DECISIONS/OPT-11-battery-modes.md).
|
||||
self._peak_proj = (None if not self.p1_enabled
|
||||
else self.peak.project(self.p1.averager, now,
|
||||
roll=self.modes_on))
|
||||
# OPT-15 x SAFETY-81: see `check_peak_saturated()`.
|
||||
self._peak_rolled = (self._peak_proj is not None
|
||||
and self._peak_proj.block_start is not None
|
||||
and self._peak_proj.block_start
|
||||
!= self.p1.averager.block_start)
|
||||
else self.peak.project(self.p1.averager, now))
|
||||
_reading = self.peak.peak(now) if self.p1_enabled else None
|
||||
# SAFETY-90: seconds a meter gap left uncounted are counted, so the
|
||||
# guard goes on after the readings return.
|
||||
@@ -5094,15 +4984,11 @@ class Controller:
|
||||
# entering failsafe re-seeds the integrator to zero and the first
|
||||
# cycle after release starts from zero instead of dumping the whole
|
||||
# stale period as power.
|
||||
moved = self.loop_w is None or self.target != self.loop_w
|
||||
if moved:
|
||||
if self.loop_w is None or self.target != self.loop_w:
|
||||
self.i_w = self.target
|
||||
# OPT-11 P3: a run edge (a mode trade ended) reseeds the loop, and
|
||||
# OPT-15 holds it there until the meter shows the step.
|
||||
# OPT-11 P3: a run edge (a mode trade ended) reseeds the loop.
|
||||
if trade_was and not mode_trade:
|
||||
self._reseed_run_edge(claims, now)
|
||||
moved = False
|
||||
loop_grid = self._edge_grid(mode_trade or moved, now)
|
||||
self._reseed_run_edge(claims)
|
||||
# SAFETY-52: another writer overrode the loop, so a refusal learned
|
||||
# against the loop's own ask may no longer describe the wire. A gap
|
||||
# (`loop_w` None: blind or stopped) keeps it: the stale-reading stop
|
||||
@@ -5155,10 +5041,8 @@ class Controller:
|
||||
decision = compute(
|
||||
prev_w=loop_prev,
|
||||
# SAFETY-95: a drain's tail is not the loop's to chase.
|
||||
# OPT-15: `loop_grid` is the grid, or the target while a run
|
||||
# edge is held.
|
||||
grid_w=(self._drain_grid_w(_live96) if self._drain_tail
|
||||
else loop_grid),
|
||||
else self.grid),
|
||||
actual_w=site_batt,
|
||||
tuning=self.tuning,
|
||||
sat_count=self.sat_count,
|
||||
@@ -5905,13 +5789,9 @@ class Controller:
|
||||
if prev is not None:
|
||||
batt = min(float(batt), float(prev))
|
||||
p = self._peak_proj
|
||||
# OPT-15 x SAFETY-81: until the new quarter's first sample the old
|
||||
# projection read "no seconds left" (not high), which restarted the
|
||||
# hold; a rolled one (battery modes on) is not graded then, so a
|
||||
# persistent overload is still notified once per quarter.
|
||||
high = bool(
|
||||
g is not None and g.saturated and p is not None
|
||||
and p.block_start is not None and not self._peak_rolled
|
||||
and p.block_start is not None
|
||||
and peakguard.load_too_high(
|
||||
p, batt_w=batt, ceiling_w=g.ceiling_w,
|
||||
achievable_w=self.site_rating_w))
|
||||
@@ -6692,14 +6572,6 @@ class Controller:
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "controller",
|
||||
"text": messages.row_controller_ran(_pass_age)})
|
||||
# SAFETY-112. The headline unless control has stalled: while the
|
||||
# Supervisor gives 502 (Home Assistant not yet RUNNING) no reading or
|
||||
# write gets through, and the rows below that need it follow from it.
|
||||
if OUTAGE.waiting():
|
||||
out.insert(1 if out[0]["ok"] is False else 0,
|
||||
{"ok": False, "warn": False, "id": "home_assistant",
|
||||
"banner": messages.banner_ha_waiting(),
|
||||
"text": messages.row_ha_waiting()})
|
||||
if self.p1_enabled:
|
||||
age = self.p1.published_age_s
|
||||
if (self.p1.stale and self.p1.age_s() is None
|
||||
@@ -7126,17 +6998,6 @@ class Controller:
|
||||
# is both is a check nobody reads.
|
||||
out.append({"ok": False, "warn": True, "id": "peak_forecast",
|
||||
"text": messages.row_no_peak_forecast()})
|
||||
# OPT-17: a forecast summed from several entities names, by position,
|
||||
# each member with no usable reading. A caution: control goes on.
|
||||
gaps = getattr(self, "solar_fc_gaps", ())
|
||||
for day, ents in (("today", getattr(self, "fc_entities", ())),
|
||||
("tomorrow",
|
||||
getattr(self, "fc_tomorrow_entities", ()))):
|
||||
gone = [i for d, i, _e, _w in gaps if d == day]
|
||||
if gone:
|
||||
out.append({"ok": False, "warn": True, "id": "solar_forecast",
|
||||
"text": messages.row_forecast_gap(
|
||||
day, gone, len(ents))})
|
||||
# --- TEL-11 ----------------------------------------------------------
|
||||
# ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY, AND THAT IS A DELIBERATE
|
||||
# DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. That one
|
||||
@@ -7209,7 +7070,7 @@ class Controller:
|
||||
# control goes on, so it neither reads "Not ready" nor holds the page.
|
||||
runs = [c for c in bad if c.get("runs")]
|
||||
bad = [c for c in bad if not c.get("runs")]
|
||||
running_alarm = waiting = False
|
||||
running_alarm = False
|
||||
# --- SAFETY-10, AND IT IS FIRST IN THE CASCADE -----------------------
|
||||
# "The battery is NOT being controlled" is the one sentence on this
|
||||
# page that may never be displaced by another condition. A banner that
|
||||
@@ -7260,10 +7121,6 @@ class Controller:
|
||||
# headline nobody reads. UX-08: without the key, the banner takes
|
||||
# the row's first sentence, so it stays a headline.
|
||||
level, banner = "bad", messages.banner_not_ready(bad[0])
|
||||
# SAFETY-112: nothing to do but wait, so Home says that instead
|
||||
# of "Needs you now". Only when it IS the headline: a safety stop
|
||||
# above outranks it and still needs a person.
|
||||
waiting = bad[0].get("id") == "home_assistant"
|
||||
elif not self.auto:
|
||||
level, banner = "warn", messages.banner_stopped()
|
||||
elif runs:
|
||||
@@ -7410,7 +7267,7 @@ class Controller:
|
||||
# fields are served alone (the page then shows the installer details).
|
||||
try:
|
||||
out.update(homeview.build(self, level=level, checks=checks,
|
||||
lang=lang, held=held, waiting=waiting))
|
||||
lang=lang, held=held))
|
||||
except Exception: # noqa: BLE001
|
||||
# Once per process at ERROR: the page polls every 3 s.
|
||||
if not getattr(self, "_home_failed", False):
|
||||
@@ -7550,10 +7407,6 @@ class Controller:
|
||||
# would read as the stop button not working.
|
||||
await self.command_site_zero()
|
||||
return {"auto": self.auto}
|
||||
if what in ("maint_start", "maint_abort") and unit is not None:
|
||||
# MAINT-11: the operator named the battery. A request with no unit
|
||||
# falls through to the branches below, unchanged.
|
||||
return await self._maint_unit_action(what, unit, now)
|
||||
if what == "maint_start":
|
||||
# ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
|
||||
# ONE LABEL. `_maint_button()` answers it: the pack that holds the
|
||||
@@ -7564,8 +7417,10 @@ class Controller:
|
||||
# picked on its own. At N=1 both branches are `maints[0]` and this
|
||||
# is the shipped call, byte for byte.
|
||||
#
|
||||
# This is the bare request (no unit). A request naming a battery
|
||||
# was handled above by `_maint_unit_action()` (MAINT-11).
|
||||
# A PER-PACK BUTTON IS A UI CARD, NOT THIS ONE. `web.py` posts a
|
||||
# bare `maint_start` with no unit, and inventing a unit parameter
|
||||
# here would ship half a surface: the page would still have one
|
||||
# button and the API would have two meanings.
|
||||
_m = self._maint_button(now)
|
||||
for msg in _m.force_start(now):
|
||||
self.log_event(msg, hint=messages.hint("maintenance",
|
||||
|
||||
@@ -436,17 +436,6 @@ def banner_shadow(latched: bool) -> str:
|
||||
return "Shadow mode: the battery is not controlled. Inverters stay at 0 W."
|
||||
|
||||
|
||||
def banner_ha_waiting() -> str:
|
||||
"""SAFETY-112: the Supervisor passes nothing on until Home Assistant has
|
||||
finished starting, so the add-on can neither read nor write."""
|
||||
return "Waiting for Home Assistant to finish starting"
|
||||
|
||||
|
||||
def row_ha_waiting() -> str:
|
||||
return ("Home Assistant is not answering, perhaps it is still starting. "
|
||||
"Control resumes by itself when it answers.")
|
||||
|
||||
|
||||
# Rows: control and the meter. "Control" is what the add-on does, as in
|
||||
# "start control" (STYLE.md section 4).
|
||||
def row_controller_starting() -> str:
|
||||
@@ -765,15 +754,6 @@ def row_pv_partial(missing, count) -> str:
|
||||
f"reading")
|
||||
|
||||
|
||||
def row_forecast_gap(day, missing, count) -> str:
|
||||
"""OPT-17. `day` is "today" or "tomorrow"; `missing` are the 1-based
|
||||
positions of the forecast entities with no usable reading."""
|
||||
which = names([str(n) for n in missing])
|
||||
many = len(missing) > 1
|
||||
return (f"Solar forecast for {day}: not used, source{'s' if many else ''} "
|
||||
f"{which} of {count} {'have' if many else 'has'} no reading")
|
||||
|
||||
|
||||
def row_pv_single_ignored() -> str:
|
||||
return f"Solar production: clear {PV_ONE}, the per-inverter list replaces it"
|
||||
|
||||
@@ -973,7 +953,6 @@ HINTS = {
|
||||
"trade_off": "Trading: waiting for a better price",
|
||||
"forecast_read": "Solar forecast read, the day plan uses it",
|
||||
"forecast_none": "No solar forecast, so Leftover solar takes solar only",
|
||||
"forecast_no_tomorrow": "Tomorrow's solar forecast is not used, a source has no reading",
|
||||
"split": "The power split between inverters changed",
|
||||
"clash": "Two limits clash, inverters set to 0 W",
|
||||
"no_setpoint": "An inverter has no “Setpoint entity”, inverters stay at 0 W",
|
||||
@@ -1022,21 +1001,6 @@ def pack_refused(why: str) -> str:
|
||||
return PACK_REFUSED[why]
|
||||
|
||||
|
||||
# MAINT-11: why a start or stop of ONE battery's cycle was refused. `{unit}` is
|
||||
# the battery the sentence is about (for "busy", the one that is cycling). The
|
||||
# page shows the same sentences in Dutch from app/ui_text, keyed by the code.
|
||||
MAINT_REFUSED = {
|
||||
"unit": "There is no battery with that name.",
|
||||
"busy": "Only one battery can cycle at a time, and {unit} is cycling now.",
|
||||
"off": "{unit} is switched off, so it cannot start a cycle.",
|
||||
"idle": "{unit} is not running a cycle.",
|
||||
}
|
||||
|
||||
|
||||
def maint_refused(why: str, unit: str = "") -> str:
|
||||
return MAINT_REFUSED[why].format(unit=unit)
|
||||
|
||||
|
||||
def hint(key: str, **kw) -> str:
|
||||
"""The plain words for one event. `w` is formatted as watts, `sw` as
|
||||
watts with their sign (positive gives power, negative charges)."""
|
||||
@@ -1081,10 +1045,6 @@ def status_samples() -> list:
|
||||
("banner_setup_only", None, banner_setup_only()),
|
||||
("row_setup_only", AMBER, row_setup_only()),
|
||||
("banner_shadow", None, banner_shadow(True)),
|
||||
("banner_not_ready", None, banner_not_ready(
|
||||
{"text": row_ha_waiting(), "banner": banner_ha_waiting()})),
|
||||
("banner_ha_waiting", None, banner_ha_waiting()),
|
||||
("row_ha_waiting", RED, row_ha_waiting()),
|
||||
("row_controller_starting", GREEN, row_controller_starting()),
|
||||
("row_controller_ran", GREEN, row_controller_ran(2)),
|
||||
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
|
||||
@@ -1154,8 +1114,6 @@ def status_samples() -> list:
|
||||
"entity - it cannot be read in watts")),
|
||||
("row_pv_partial", AMBER, row_pv_partial([2, 3], 3)),
|
||||
("row_pv_partial", AMBER, row_pv_partial([2], 2)),
|
||||
("row_forecast_gap", AMBER, row_forecast_gap("today", [2], 3)),
|
||||
("row_forecast_gap", AMBER, row_forecast_gap("tomorrow", [1, 2], 2)),
|
||||
("row_pv_single_ignored", AMBER, row_pv_single_ignored()),
|
||||
("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)),
|
||||
("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
|
||||
@@ -1274,8 +1232,6 @@ LABELS = {
|
||||
"pv_entities": "Solar production entities, per inverter",
|
||||
"pv_forecast_entity": "Solar forecast, today",
|
||||
"pv_forecast_tomorrow_entity": "Solar forecast, tomorrow",
|
||||
"pv_forecast_entities": "Several solar forecasts, today",
|
||||
"pv_forecast_tomorrow_entities": "Several solar forecasts, tomorrow",
|
||||
"solar_trust_pct": "Trust in the forecast",
|
||||
"p1_phase_import_entities": "Import entities per phase",
|
||||
"p1_phase_export_entities": "Export entities per phase",
|
||||
|
||||
@@ -21,7 +21,7 @@ Roles (the page's Why line names them):
|
||||
grid the others cannot give power either; the allocator never
|
||||
splits a target both ways).
|
||||
|
||||
Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
Rules that keep this safe (tests: test_modes_p3.py):
|
||||
* every range holds 0 W (a mode may forbid a direction, never force one);
|
||||
* a Solar first battery never charges from the grid;
|
||||
* P4: with a day plan (`targets`), a Leftover solar battery charges from
|
||||
@@ -31,11 +31,11 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
* 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
|
||||
more, and once the guard or a measured headroom stops it, it stays
|
||||
stopped for the rest of that 15-minute period (`blocked`): a charge
|
||||
makes the other batteries stop giving power, so a few watts of charge
|
||||
flipping on and off would swing the house between them and the grid
|
||||
(measured on the 2026-09-09 replay: 13 flips in 4 minutes at 3.5 kW);
|
||||
more, and once the guard or the headroom stops it, it stays stopped for
|
||||
the rest of that 15-minute period (`blocked`): a charge makes the other
|
||||
batteries stop giving power, so a few watts of charge flipping on and
|
||||
off would swing the house between them and the grid (measured on the
|
||||
2026-09-09 replay: 13 flips in 4 minutes at a 3.5 kW house);
|
||||
* selling needs selling on for that battery, the sell run, a price >= 0
|
||||
now, and the battery above `arbitrage_soc_floor`; it stops there. The
|
||||
peak guard never stops a sell (section 9.1: discharge is never capped);
|
||||
@@ -43,10 +43,7 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
* a battery out of a run (full, or at the floor) stays out until the run
|
||||
ends, so the ceiling and the floor do not re-arm within one run;
|
||||
* a battery that refuses a direction (SAFETY-52/73) takes no part in a
|
||||
run in that direction: the refusal wins over the run. The one exception
|
||||
is its SAFETY-73 probe inside a cheap run (OPT-15): it is a charger then,
|
||||
pinned at PROBE_W by its own claim, so the others rest (one sign) and the
|
||||
card's "tries again every 15 minutes" holds in the run too;
|
||||
run in that direction: the refusal wins over the run;
|
||||
* no grid charge while another battery drains for its monthly cycle
|
||||
(`draining`, SAFETY-78): it would charge against the drain.
|
||||
"""
|
||||
@@ -78,7 +75,6 @@ class Pack:
|
||||
live: bool = True
|
||||
idle: bool = True # not in a maintenance phase
|
||||
refused: int = 0 # SAFETY-52/73: +1 refuses to give, -1 to charge
|
||||
probing: bool = False # OPT-15: its SAFETY-73 probe window is open
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -116,18 +112,15 @@ def charge_to(p: Pack, ceiling_pct, targets=None):
|
||||
return None if t is None else min(float(ceiling_pct), float(t))
|
||||
|
||||
|
||||
def chargers(packs, ceiling_pct, done=(), targets=None, probe=False) -> list:
|
||||
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). With `probe`, also
|
||||
one refusing to charge while its SAFETY-73 probe is open: its own P_SAFETY
|
||||
pin keeps it at PROBE_W (OPT-15)."""
|
||||
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 or (probe and p.probing))
|
||||
and p.name not in done):
|
||||
and p.refused >= 0 and p.name not in done):
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
@@ -179,8 +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, targets, probe=True) \
|
||||
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
|
||||
|
||||
@@ -312,12 +312,6 @@ class QuarterAverager:
|
||||
"""
|
||||
return self._last_net
|
||||
|
||||
@property
|
||||
def held_t(self) -> float | None:
|
||||
"""Epoch seconds of the held sample. OPT-15: `PeakTracker.project()`
|
||||
with `roll=True` credits it into the next block as `add()` will."""
|
||||
return self._last_t
|
||||
|
||||
@property
|
||||
def held_since(self) -> datetime | None:
|
||||
"""SAFETY-90: when the held sample arrived (its `ingest_ts`), or None."""
|
||||
|
||||
@@ -27,11 +27,6 @@ Without an hourly split, the day's energy is spread over a fixed bell from
|
||||
A reading older than `FRESH_S`, or from before local midnight, is stale,
|
||||
and a stale or missing forecast is no forecast: the day plan is off and
|
||||
every mode acts as in P3 option A.
|
||||
|
||||
OPT-17: each day may instead name a list (`pv_forecast_entities`,
|
||||
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
|
||||
not usable makes that day's sum unusable, never a partial sum.
|
||||
DECISIONS/OPT-17-several-forecast-entities.md.
|
||||
"""
|
||||
|
||||
import math
|
||||
@@ -54,12 +49,6 @@ BELL_FROM_H, BELL_TO_H = 6.0, 20.0
|
||||
NOT_SET, MISSING, UNIT, STALE, BAD = (
|
||||
"not_set", "missing", "unit", "stale", "bad")
|
||||
|
||||
# OPT-17: (single key, list key) per day. A set list replaces the single key;
|
||||
# the validator refuses both set for the same day.
|
||||
TODAY_KEYS = ("pv_forecast_entity", "pv_forecast_entities")
|
||||
TOMORROW_KEYS = ("pv_forecast_tomorrow_entity",
|
||||
"pv_forecast_tomorrow_entities")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Day:
|
||||
@@ -234,52 +223,6 @@ def _reported(state):
|
||||
return max(ts) if ts else None
|
||||
|
||||
|
||||
def entities(opts: dict, *, tomorrow=False) -> tuple:
|
||||
"""The forecast entities one day names, in configured order: the list
|
||||
if it has any entry, else the single key, else (). A repeated id is
|
||||
dropped, as `pv.entities()` does: it would count one forecast twice."""
|
||||
one, many = TOMORROW_KEYS if tomorrow else TODAY_KEYS
|
||||
listed = [e for e in (str(x or "").strip()
|
||||
for x in (opts.get(many) or [])) if e]
|
||||
if listed:
|
||||
return tuple(dict.fromkeys(listed))
|
||||
e = str(opts.get(one) or "").strip()
|
||||
return (e,) if e else ()
|
||||
|
||||
|
||||
def _bounds(now):
|
||||
here = now.astimezone(BILLING_TZ)
|
||||
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
|
||||
nd = d0.date() + timedelta(days=1)
|
||||
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
|
||||
return d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _today(state, day0, now):
|
||||
"""`(Day, at, None)` or `(None, None, why)` for one today sensor."""
|
||||
today, why = _day(state, day0, now, today=True)
|
||||
if today is None:
|
||||
return None, None, why
|
||||
at = _reported(state)
|
||||
# A reading from before local midnight is yesterday's "left today" (0
|
||||
# after sunset) until the integration refreshes: stale, not a dark day.
|
||||
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
|
||||
or (at - now).total_seconds() > 300:
|
||||
return None, None, STALE
|
||||
return today, at, None
|
||||
|
||||
|
||||
def _tomorrow(state, day0, day1, now):
|
||||
"""`(Day, None)` or `(None, why)` for one tomorrow sensor."""
|
||||
t, why = _day(state, day1, now, today=False)
|
||||
if t is None:
|
||||
return None, why
|
||||
t_at = _reported(state)
|
||||
if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S:
|
||||
return None, STALE
|
||||
return t, None
|
||||
|
||||
|
||||
def read(today_state, tomorrow_state, now: datetime, *,
|
||||
configured=True):
|
||||
"""`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None
|
||||
@@ -287,64 +230,25 @@ def read(today_state, tomorrow_state, now: datetime, *,
|
||||
covers today only."""
|
||||
if not configured:
|
||||
return None, NOT_SET
|
||||
day0, day1 = _bounds(now)
|
||||
today, at, why = _today(today_state, day0, now)
|
||||
here = now.astimezone(BILLING_TZ)
|
||||
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
|
||||
nd = d0.date() + timedelta(days=1)
|
||||
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
|
||||
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:
|
||||
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
|
||||
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
|
||||
|
||||
|
||||
def _sum(days):
|
||||
"""One Day from several members' Days. The hourly split is kept only if
|
||||
every member has one: the splits are then joined (`_day_between()`
|
||||
adds overlapping periods). Else the total goes on the bell."""
|
||||
if len(days) == 1:
|
||||
return days[0]
|
||||
periods = ()
|
||||
if all(d.periods for d in days):
|
||||
periods = tuple(sorted(p for d in days for p in d.periods))
|
||||
return Day(days[0].day0, sum(d.kwh for d in days), periods)
|
||||
|
||||
|
||||
def read_sum(today, tomorrow, now: datetime):
|
||||
"""OPT-17: `(Forecast or None, why, gaps)` over lists of
|
||||
`(entity, state)`, one per configured member, in configured order.
|
||||
|
||||
Each member is read exactly as `read()` reads one sensor, in its own
|
||||
unit. A member that is missing, unavailable, stale, not in an energy
|
||||
unit or bad makes that DAY unusable, as one such sensor does today: no
|
||||
today means no forecast, no tomorrow means today only. A partial sum
|
||||
would under-count the sun and mislead the day plan.
|
||||
|
||||
`gaps` is `((day, position, entity, why), ...)` for every unusable
|
||||
member (`day` "today" or "tomorrow", `position` 1-based), so the page
|
||||
and the log can name it. With one member per day the forecast is what
|
||||
`read()` gives (test_opt17.py proves it)."""
|
||||
if not today:
|
||||
return None, NOT_SET, ()
|
||||
day0, day1 = _bounds(now)
|
||||
gaps, days, ats, why0 = [], [], [], None
|
||||
for i, (ent, st) in enumerate(today, 1):
|
||||
d, at, why = _today(st, day0, now)
|
||||
if d is None:
|
||||
gaps.append(("today", i, ent, why))
|
||||
why0 = why0 or why
|
||||
else:
|
||||
days.append(d)
|
||||
ats.append(at)
|
||||
tdays = []
|
||||
for i, (ent, st) in enumerate(tomorrow, 1):
|
||||
t, why = _tomorrow(st, day0, day1, now)
|
||||
if t is None:
|
||||
gaps.append(("tomorrow", i, ent, why))
|
||||
else:
|
||||
tdays.append(t)
|
||||
gaps = tuple(gaps)
|
||||
if why0 is not None:
|
||||
return None, why0, gaps
|
||||
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
|
||||
# The oldest member's report is when the sum was last fully known.
|
||||
return Forecast(_sum(days), tday, min(ats)), None, gaps
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
}
|
||||
function renderPill() {
|
||||
const st = S.state || null;
|
||||
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad", waiting: " warn" }[st.kind] ?? " warn")
|
||||
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[st.kind] ?? " warn")
|
||||
: (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn");
|
||||
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.setup_only === true ? t("setup_only.name") : S.banner;
|
||||
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
|
||||
@@ -336,39 +336,6 @@
|
||||
finally { packBusy = false; }
|
||||
await refresh(true);
|
||||
}
|
||||
// MAINT-11: start or stop ONE battery's cycle. Two or more batteries only;
|
||||
// the add-on refuses a second cycle and says which battery is cycling.
|
||||
async function cycleAct(what, b) {
|
||||
if (packBusy) return;
|
||||
packBusy = true;
|
||||
try {
|
||||
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: what, unit: b.name }) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
const own = j.code && t("batteries.cycle." + j.code);
|
||||
if (!r.ok) toast(own ? say(own.description, { name: j.unit || b.name }) : (j.error || t("toast.failed")));
|
||||
else toast(say(t("batteries.cycle.toast." + (what === "maint_start" ? "started" : "stopped")), { name: b.name }));
|
||||
} catch (e) { toast(t("toast.failed")); }
|
||||
finally { packBusy = false; }
|
||||
await refresh(true);
|
||||
}
|
||||
// A button that cannot work is disabled (the add-on still refuses it): Stop
|
||||
// needs this battery cycling; Start needs no battery cycling and this one on.
|
||||
function cycleState(b) {
|
||||
const cycling = (x) => x.maintenance !== "idle";
|
||||
return { start: !S.batteries.some(cycling) && b.use !== false, abort: cycling(b) };
|
||||
}
|
||||
function cycleButtons(b, i) {
|
||||
const can = cycleState(b);
|
||||
const ask1 = (key, what) => () => {
|
||||
const w = t("batteries.cycle." + key);
|
||||
ask({ name: say(w.name, { name: b.name }), description: say(w.description, { name: b.name }), yes: w.yes },
|
||||
() => cycleAct(what, b));
|
||||
};
|
||||
return h("div", { class: "cyc", style: "display:flex;gap:8px;flex-wrap:wrap;margin-top:10px" },
|
||||
h("button", { type: "button", class: "btn", id: "bcs-" + i, disabled: !can.start, onclick: ask1("start_dialog", "maint_start") }, t("batteries.cycle.start")),
|
||||
h("button", { type: "button", class: "btn danger", id: "bca-" + i, disabled: !can.abort, onclick: ask1("abort_dialog", "maint_abort") }, t("batteries.cycle.abort")));
|
||||
}
|
||||
function onSwitch(b, i, where, lid) {
|
||||
const sw = h("button", { type: "button", class: "switch", role: "switch", id: "bsw-" + where + i,
|
||||
"aria-checked": String(b.use), "aria-labelledby": lid,
|
||||
@@ -403,8 +370,7 @@
|
||||
h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored),
|
||||
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
|
||||
h("dt", {}, t("batteries.today")), h("dd", {}, todayLines(b)),
|
||||
h("dt", {}, t("batteries.why")), h("dd", { class: "muted" }, b.why || "–")),
|
||||
S.batteries.length > 1 ? cycleButtons(b, i) : null);
|
||||
h("dt", {}, t("batteries.why")), h("dd", { class: "muted" }, b.why || "–")));
|
||||
}
|
||||
function simpleRow(b, i) {
|
||||
const lid = "bsname-" + i;
|
||||
@@ -433,7 +399,7 @@
|
||||
$("#batt-how").hidden = !modes;
|
||||
const docs = $("#batt-docs");
|
||||
if (S.control && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
|
||||
if (S.state && ["shadow", "stopped", "alarm", "waiting"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null);
|
||||
if (S.state && ["shadow", "stopped", "alarm"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null);
|
||||
else $("#batt-banner").replaceChildren();
|
||||
keepFocus(() => {
|
||||
$("#bcards").replaceChildren(...S.batteries.map(batteryCard));
|
||||
|
||||
@@ -776,14 +776,9 @@ class PeakTracker:
|
||||
return r.peak_w / self.cap_w
|
||||
|
||||
@staticmethod
|
||||
def project(averager, now: datetime, roll: bool = False) -> Projection:
|
||||
def project(averager, now: datetime) -> Projection:
|
||||
"""End-of-quarter average if the last sample held for the rest of it.
|
||||
|
||||
`roll` (OPT-15, battery modes only): when `now` is past the open block
|
||||
because no sample has come since the boundary, project the quarter
|
||||
`now` is in, crediting the held sample as `add()` will (`max_hold_s`).
|
||||
Without it that tick reads "no seconds left" and measures nothing.
|
||||
|
||||
⚠️ The two divisors are DIFFERENT and only coincide at the end of the
|
||||
block. `elapsed_w` divides by the seconds actually covered (what the
|
||||
meter has shown so far); `projected_w` divides by the full 900 s and
|
||||
@@ -803,14 +798,6 @@ class PeakTracker:
|
||||
elapsed_wall = max(0.0, (now - start).total_seconds())
|
||||
elapsed = averager.elapsed_s
|
||||
acc = averager.partial_ws
|
||||
if roll and elapsed_wall >= QUARTER_S:
|
||||
t = now.timestamp()
|
||||
block = int(t // QUARTER_S) * QUARTER_S
|
||||
start = datetime.fromtimestamp(block, timezone.utc)
|
||||
held_end = float(averager.held_t) + float(averager.max_hold_s)
|
||||
elapsed = max(0.0, min(t, held_end) - block)
|
||||
elapsed_wall = t - block
|
||||
acc = max(averager.held_net_w, 0.0) * elapsed
|
||||
remaining = max(0.0, QUARTER_S - elapsed_wall)
|
||||
# ⚠️ NO `getattr` DEFAULT, AND THAT IS THE FIX, NOT A TIDY-UP. This read
|
||||
# was `getattr(averager, "_last_net", 0.0)`: a private name with a
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
"off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
|
||||
},
|
||||
"today": "Today", "why": "Why",
|
||||
"cycle": {"start": "Start cycle", "abort": "Stop cycle", "start_dialog": {"name": "Start a cycle on {name}?", "description": "{name} empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"}, "abort_dialog": {"name": "Stop the cycle on {name}?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}, "toast": {"started": "Cycle started on {name}.", "stopped": "Cycle stopped on {name}."}, "unit": {"description": "There is no battery with that name."}, "busy": {"description": "Only one battery can cycle at a time, and {name} is cycling now."}, "off": {"description": "{name} is switched off, so it cannot start a cycle."}, "idle": {"description": "{name} is not running a cycle."}},
|
||||
"modes_off": {"name": "Battery modes are off"},
|
||||
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.",
|
||||
"solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."},
|
||||
@@ -268,7 +267,6 @@
|
||||
"shadow": "Shadow mode",
|
||||
"stopped": "Stopped",
|
||||
"alarm": "Needs you now",
|
||||
"waiting": "Waiting for Home Assistant",
|
||||
"warning": "Running, with a warning"
|
||||
},
|
||||
"banner_sub": {
|
||||
@@ -276,7 +274,6 @@
|
||||
"shadow": "The plans below say what the add-on would do.",
|
||||
"stopped": "Start control below when you are ready.",
|
||||
"alarm": "See Needs attention below for what to do.",
|
||||
"waiting": "Control resumes by itself when Home Assistant answers.",
|
||||
"warning": "See Needs attention below."
|
||||
},
|
||||
"tile": {
|
||||
@@ -434,7 +431,6 @@
|
||||
"stopped": "Control is stopped. Every inverter is at 0 W."
|
||||
},
|
||||
"attn": {
|
||||
"home_assistant": "Home Assistant is not answering",
|
||||
"controller": "Control is not running",
|
||||
"meter": "Meter reading",
|
||||
"meter_frozen": "Meter reading may be stuck",
|
||||
@@ -453,7 +449,6 @@
|
||||
"delivery": "An inverter is not delivering",
|
||||
"estop": "Emergency stop",
|
||||
"pv": "Solar reading",
|
||||
"solar_forecast": "Solar forecast",
|
||||
"none": "Inverter connection"
|
||||
},
|
||||
"months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
"off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."}
|
||||
},
|
||||
"today": "Vandaag", "why": "Waarom",
|
||||
"cycle": {"start": "Cyclus starten", "abort": "Cyclus stoppen", "start_dialog": {"name": "Cyclus starten op {name}?", "description": "{name} loopt leeg, laadt weer op en blijft vol, wat enkele uren duurt.", "yes": "Cyclus starten"}, "abort_dialog": {"name": "Cyclus stoppen op {name}?", "description": "Elke omvormer gaat even naar 0 W, daarna gaat de regeling verder.", "yes": "Cyclus stoppen"}, "toast": {"started": "Cyclus gestart op {name}.", "stopped": "Cyclus gestopt op {name}."}, "unit": {"description": "Er is geen batterij met die naam."}, "busy": {"description": "Er kan maar één batterij tegelijk een cyclus doen, en {name} is nu bezig."}, "off": {"description": "{name} staat uit en kan dus geen cyclus starten."}, "idle": {"description": "{name} doet nu geen cyclus."}},
|
||||
"modes_off": {"name": "Batterijmodi staan uit"},
|
||||
"how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.",
|
||||
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, en dekt het huis op elk uur."},
|
||||
@@ -231,8 +230,8 @@
|
||||
"zero_held": "Op 0 W gehouden",
|
||||
"zero_going": "Gaat naar 0 W",
|
||||
"verdict": {"held": "Op 0 W gehouden", "stopped": "Gestopt", "cycle": "Vandaag maandelijkse cyclus", "trade": "Handelt op prijs", "house": "Volgt het huis"},
|
||||
"pill": {"running": "Actief", "shadow": "Schaduwmodus", "stopped": "Gestopt", "alarm": "Heeft u nu nodig", "waiting": "Wacht op Home Assistant", "warning": "Actief, met een waarschuwing"},
|
||||
"banner_sub": {"running": "De batterijen volgen het plan hieronder.", "shadow": "De plannen hieronder tonen wat de add-on zou doen.", "stopped": "Start de regeling hieronder als u klaar bent.", "alarm": "Zie Vraagt aandacht hieronder voor wat u moet doen.", "waiting": "De regeling gaat vanzelf verder zodra Home Assistant antwoordt.", "warning": "Zie Vraagt aandacht hieronder."},
|
||||
"pill": {"running": "Actief", "shadow": "Schaduwmodus", "stopped": "Gestopt", "alarm": "Heeft u nu nodig", "warning": "Actief, met een waarschuwing"},
|
||||
"banner_sub": {"running": "De batterijen volgen het plan hieronder.", "shadow": "De plannen hieronder tonen wat de add-on zou doen.", "stopped": "Start de regeling hieronder als u klaar bent.", "alarm": "Zie Vraagt aandacht hieronder voor wat u moet doen.", "warning": "Zie Vraagt aandacht hieronder."},
|
||||
"tile": {"not_set_up": "Niet ingesteld", "no_reading": "Geen meting", "producing": "Produceert", "not_producing": "Produceert niet", "needs_solar": "Zonnemeting nodig", "not_adding_up": "Metingen kloppen niet", "using": "Verbruikt", "taking": "Neemt af van het net", "feeding": "Injecteert", "balanced": "In evenwicht", "giving": "Levert stroom", "charging": "Laadt", "resting": "Rust"},
|
||||
"now": {
|
||||
"no_reading": "Geen meting van deze batterij.",
|
||||
@@ -355,7 +354,6 @@
|
||||
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
|
||||
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
|
||||
"attn": {
|
||||
"home_assistant": "Home Assistant antwoordt niet",
|
||||
"controller": "Regeling is niet actief",
|
||||
"meter": "Meting van de meter",
|
||||
"meter_frozen": "Meter staat mogelijk vast",
|
||||
@@ -374,7 +372,6 @@
|
||||
"delivery": "Een omvormer levert niet",
|
||||
"estop": "Noodstop",
|
||||
"pv": "Meting van de zonnepanelen",
|
||||
"solar_forecast": "Zonneprognose",
|
||||
"none": "Verbinding met de omvormer"
|
||||
},
|
||||
"months": ["januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"]
|
||||
|
||||
@@ -135,10 +135,6 @@ GROUPS = (
|
||||
Field("pv_forecast_entity", ENTITY, kind="energy", simple=True,
|
||||
note="forecast"),
|
||||
Field("pv_forecast_tomorrow_entity", ENTITY, kind="energy"),
|
||||
# OPT-17: several forecast sensors per day, summed.
|
||||
Field("pv_forecast_entities", ENTITY_LIST, kind="energy"),
|
||||
Field("pv_forecast_tomorrow_entities", ENTITY_LIST,
|
||||
kind="energy"),
|
||||
Field("solar_trust_pct", SLIDER, "%", step=5),
|
||||
)),
|
||||
)),
|
||||
|
||||
@@ -1802,13 +1802,12 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} does not read in kWh, so pick a solar "
|
||||
"forecast sensor in kWh.")
|
||||
today = solarfc.entities(opts)
|
||||
tomorrow = solarfc.entities(opts, tomorrow=True)
|
||||
if (today or tomorrow) and \
|
||||
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 tomorrow and not today:
|
||||
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.")
|
||||
@@ -2070,13 +2069,12 @@ def _margin_problems(opts: dict) -> list[str]:
|
||||
TRUST_RANGE_PCT = (30, 100)
|
||||
# A Home Assistant sensor id; it goes into the /api/states/<id> path.
|
||||
_SENSOR_ID = re.compile(r"sensor\.[a-z0-9_]+")
|
||||
FORECAST_KEYS = solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS
|
||||
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. OPT-17: a day names its forecast
|
||||
in the single key or in the list, not both."""
|
||||
each forecast entity set is a sensor."""
|
||||
out = []
|
||||
raw = opts.get("solar_trust_pct")
|
||||
trust = _number(opts, "solar_trust_pct", dayplan.DEFAULT_TRUST_PCT)
|
||||
@@ -2084,30 +2082,14 @@ def _forecast_problems(opts: dict) -> list[str]:
|
||||
if trust is None or trust != int(trust) or not lo <= trust <= hi:
|
||||
out.append(f"{S('solar_trust_pct')} must be a whole number from {lo} "
|
||||
f"to {hi} %. It is {messages.shown(raw)}.")
|
||||
for one, many in (solarfc.TODAY_KEYS, solarfc.TOMORROW_KEYS):
|
||||
e = opts.get(one)
|
||||
if e is not None and e != "" and (
|
||||
not isinstance(e, str) or not _SENSOR_ID.fullmatch(e)):
|
||||
out.append(f"{S(one)} must be a sensor, like "
|
||||
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)}.")
|
||||
raw = opts.get(many)
|
||||
if raw is None:
|
||||
raw = []
|
||||
if not isinstance(raw, list):
|
||||
out.append(f"{S(many)} must be a list of sensors. It is "
|
||||
f"{messages.shown(raw)}.")
|
||||
continue
|
||||
listed = [x for x in raw if x is not None
|
||||
and not (isinstance(x, str) and not x.strip())]
|
||||
for x in listed:
|
||||
if not isinstance(x, str) or not _SENSOR_ID.fullmatch(x):
|
||||
out.append(f"{S(many)} must list only sensors, like "
|
||||
f"sensor.energy_production_today_remaining. "
|
||||
f"It has {messages.shown(x)}.")
|
||||
if e and listed:
|
||||
out.append(f"Set {S(one)} or {S(many)}, not both. Keep the "
|
||||
f"list and clear the single one.")
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -60,8 +60,6 @@ _LOG = logging.getLogger("goodwe.web")
|
||||
|
||||
# OPT-11: the per-battery actions, which name a battery and a value.
|
||||
PACK_ACTIONS = ("pack_use", "pack_mode", "pack_sell")
|
||||
# MAINT-11: start or stop one battery's maintenance cycle (the unit is optional).
|
||||
MAINT_ACTIONS = ("maint_start", "maint_abort")
|
||||
|
||||
# UX-13 P1: the page lives in app/static/ as three files - index.html,
|
||||
# app.css, app.js - plus the page words from app/ui_text/<lang>.json (English
|
||||
@@ -165,14 +163,6 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
return web.json_response({"ok": False,
|
||||
"error": result["error"]},
|
||||
status=400)
|
||||
elif what in MAINT_ACTIONS and body.get("unit") is not None:
|
||||
# MAINT-11: one battery's cycle. No unit = the old bare call below.
|
||||
result = await controller.handle_action(what, unit=body["unit"])
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": result["error"],
|
||||
"code": result["code"], "unit": result["unit"]},
|
||||
status=409 if result["code"] != "unit" else 400)
|
||||
else:
|
||||
result = await controller.handle_action(what)
|
||||
if result is None:
|
||||
|
||||
@@ -157,11 +157,6 @@ options:
|
||||
# integrations whose sensors fit.
|
||||
pv_forecast_entity: ""
|
||||
pv_forecast_tomorrow_entity: ""
|
||||
# OPT-17: several forecast sensors per day (one per inverter or string),
|
||||
# summed. Use the list OR the single key above, not both. `[]` is
|
||||
# load-bearing, as for `pv_entities`.
|
||||
pv_forecast_entities: []
|
||||
pv_forecast_tomorrow_entities: []
|
||||
solar_trust_pct: 70
|
||||
|
||||
# TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT
|
||||
@@ -966,13 +961,6 @@ schema:
|
||||
battery_modes_enabled: bool
|
||||
pv_forecast_entity: str?
|
||||
pv_forecast_tomorrow_entity: str?
|
||||
# OPT-17: new list keys beside the single ones. A list schema on the old
|
||||
# keys refuses the string an install stores (tools/supervisor_shape_probe.py
|
||||
# section J); DECISIONS/OPT-17-several-forecast-entities.md.
|
||||
pv_forecast_entities:
|
||||
- str
|
||||
pv_forecast_tomorrow_entities:
|
||||
- str
|
||||
solar_trust_pct: int(30,100)
|
||||
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
|
||||
# `price_now_entity`/`price_avg_entity`, which are legal only while
|
||||
|
||||
@@ -1,405 +0,0 @@
|
||||
"""MAINT-11: start or stop the maintenance cycle of ONE battery.
|
||||
`python3 test_maint11.py`
|
||||
|
||||
Graded here, at two and at three batteries: a start on a chosen battery with the
|
||||
slot free (and it is that battery, not the one the bare button would pick); a
|
||||
start with the slot held, in each moving phase, refused by name; an unknown
|
||||
unit; a battery switched off; a battery that refuses to give power; a stop on a
|
||||
battery that is not cycling; a stop on the cycling one; a request with no unit
|
||||
doing what it always did; the web layer's codes; the words in both languages;
|
||||
and a roster of one, whose requests, log and writes are pinned.
|
||||
See DECISIONS/MAINT-11-cycle-per-battery.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web as aioweb
|
||||
|
||||
from app import messages, packmode, web
|
||||
from app.main import Controller
|
||||
from app.maintenance import CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING
|
||||
from app.store import DEFAULTS
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class MemStore:
|
||||
def __init__(self):
|
||||
self.data = dict(DEFAULTS)
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, k):
|
||||
raw = self.data.get(k)
|
||||
if not raw:
|
||||
return None
|
||||
if isinstance(raw, datetime):
|
||||
return raw
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.astimezone(timezone.utc).isoformat() if when else None
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
class Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
def __init__(self, n):
|
||||
self.numbers = {"sensor.grid": 5000.0}
|
||||
self.limits_by = {}
|
||||
for k in range(n):
|
||||
self.numbers[f"sensor.soc_{k}"] = 80.0
|
||||
self.numbers[f"sensor.batt_{k}"] = 0.0
|
||||
self.limits_by[f"number.sp_{k}"] = (-5000.0, 5000.0, 1.0)
|
||||
self.writes = []
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
v = self.numbers.get(entity)
|
||||
return None if v is None else (-v if invert else v)
|
||||
|
||||
async def limits(self, entity):
|
||||
return self.limits_by.get(entity, (None, None, None))
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append((entity, value))
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
return True
|
||||
|
||||
|
||||
class Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
NAMES = ["inverter-a", "inverter-b", "inverter-c"]
|
||||
OPTS = dict(meter_source="off", meter_entity="sensor.grid", gain=0.6,
|
||||
slew_w=6000, deadband_w=15, target_grid_w=0, step_w=10,
|
||||
heartbeat_s=10, stale_input_s=15, maintenance_discharge_w=2000,
|
||||
maintenance_charge_w=2000)
|
||||
|
||||
|
||||
def controller(n, modes=False):
|
||||
opts = dict(OPTS)
|
||||
opts["inverters"] = [
|
||||
dict(name=NAMES[k], setpoint_entity=f"number.sp_{k}",
|
||||
soc_entity=f"sensor.soc_{k}", batt_entity=f"sensor.batt_{k}",
|
||||
max_w=5000.0) for k in range(n)]
|
||||
if n == 1:
|
||||
del opts["inverters"]
|
||||
opts.update(setpoint_entity="number.sp_0", soc_entity="sensor.soc_0",
|
||||
batt_entity="sensor.batt_0", max_w=5000.0)
|
||||
if modes:
|
||||
opts["battery_modes_enabled"] = True
|
||||
hass = Hass(n)
|
||||
c = Controller(opts, hass, MemStore(), Pub(), stop=asyncio.Event())
|
||||
for inv in c.inverters:
|
||||
inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w
|
||||
c.auto = True
|
||||
return c, hass
|
||||
|
||||
|
||||
def act(c, what, unit=None):
|
||||
if unit is None:
|
||||
return asyncio.run(c.handle_action(what))
|
||||
return asyncio.run(c.handle_action(what, unit=unit))
|
||||
|
||||
|
||||
def phases(c):
|
||||
return [m.phase for m in c.maints]
|
||||
|
||||
|
||||
def snapshot(c, hass):
|
||||
return (phases(c), list(c.events), list(hass.writes),
|
||||
json.dumps(c.store.data, sort_keys=True, default=str))
|
||||
|
||||
|
||||
def settle(c):
|
||||
"""Events carry a clock stamp; compare them without it."""
|
||||
return [e.split(" ", 1)[1] for e in c.events]
|
||||
|
||||
|
||||
for N in (2, 3):
|
||||
print(f"\n{N} batteries")
|
||||
names = NAMES[:N]
|
||||
last = N - 1
|
||||
|
||||
# --- start with the slot free -------------------------------------------
|
||||
c, hass = controller(N)
|
||||
bare_pick = c._maint_button(datetime.now(timezone.utc))
|
||||
check("with every battery never cycled the bare button picks the first "
|
||||
"(this is the card's case)", bare_pick is c.maints[0])
|
||||
r = act(c, "maint_start", names[last])
|
||||
check(f"start on {names[last]}: its cycle starts, nobody else's",
|
||||
r == {"unit": names[last], "phase": DRAIN}
|
||||
and phases(c) == [IDLE] * last + [DRAIN])
|
||||
check("...and the log says a forced start",
|
||||
any("forced start" in e for e in c.events))
|
||||
check("...and the machine holding the slot is the named one",
|
||||
c.maint_index == last)
|
||||
|
||||
c, hass = controller(N)
|
||||
r = act(c, "maint_start", names[0])
|
||||
check("start on the first battery works the same way",
|
||||
r["unit"] == names[0] and phases(c)[0] == DRAIN
|
||||
and phases(c)[1:] == [IDLE] * last)
|
||||
|
||||
# --- start with the slot held --------------------------------------------
|
||||
for phase in POWER_MOVING:
|
||||
for holder in range(N):
|
||||
c, hass = controller(N)
|
||||
c.maints[holder].store.set("phase", phase)
|
||||
before = snapshot(c, hass)
|
||||
bad = []
|
||||
for k in range(N):
|
||||
r = act(c, "maint_start", names[k])
|
||||
bad.append(not (r.get("code") == "busy"
|
||||
and r["unit"] == names[holder]
|
||||
and names[holder] in r["error"]
|
||||
and "error" in r))
|
||||
check(f"slot held by {names[holder]} in {phase}: a start on any "
|
||||
f"battery (itself too) is refused, naming {names[holder]}, "
|
||||
f"and nothing changes", not any(bad)
|
||||
and snapshot(c, hass) == before)
|
||||
|
||||
# --- unknown unit --------------------------------------------------------
|
||||
c, hass = controller(N)
|
||||
before = snapshot(c, hass)
|
||||
rs = [act(c, w, u) for w in ("maint_start", "maint_abort")
|
||||
for u in ("inverter-z", "", "Inverter-A", 7, ["inverter-a"])]
|
||||
check("an unknown unit (wrong name, empty, wrong case, not a string) is "
|
||||
"refused for start and for stop, and nothing changes",
|
||||
all(r.get("code") == "unit" and r["error"]
|
||||
== messages.maint_refused("unit") for r in rs)
|
||||
and snapshot(c, hass) == before)
|
||||
|
||||
# --- a battery switched off ---------------------------------------------
|
||||
c, hass = controller(N, modes=True)
|
||||
c.inverters[last].store_set(packmode.USE_KEY, False)
|
||||
before = snapshot(c, hass)
|
||||
r = act(c, "maint_start", names[last])
|
||||
check("a battery switched off (battery modes on) is refused by name and "
|
||||
"nothing changes", r.get("code") == "off" and r["unit"] == names[last]
|
||||
and names[last] in r["error"] and snapshot(c, hass) == before)
|
||||
r = act(c, "maint_start", names[0])
|
||||
check("...while a battery that is on still starts", r.get("phase") == DRAIN
|
||||
and phases(c)[0] == DRAIN)
|
||||
c, hass = controller(N)
|
||||
c.inverters[last].store_set(packmode.USE_KEY, False)
|
||||
check("with battery modes off the stored switch is not consulted (it is "
|
||||
"not shown, so it cannot refuse)",
|
||||
act(c, "maint_start", names[last]).get("phase") == DRAIN)
|
||||
|
||||
# --- a battery that refuses to give power ---------------------------------
|
||||
c, hass = controller(N)
|
||||
c.inverters[last].refused = 1
|
||||
r = act(c, "maint_start", names[last])
|
||||
check("a battery that refuses to give power starts like the bare button "
|
||||
"would start it (its drain then waits, as before)",
|
||||
r == {"unit": names[last], "phase": DRAIN})
|
||||
|
||||
# --- stop ---------------------------------------------------------------
|
||||
c, hass = controller(N)
|
||||
before = snapshot(c, hass)
|
||||
r = act(c, "maint_abort", names[0])
|
||||
check("a stop on a battery that is not cycling is refused and nothing "
|
||||
"changes (no 0 W written either)", r.get("code") == "idle"
|
||||
and names[0] in r["error"] and snapshot(c, hass) == before)
|
||||
c.maints[last].store.set("phase", CHARGE)
|
||||
before = snapshot(c, hass)
|
||||
r = act(c, "maint_abort", names[0])
|
||||
check(f"a stop on {names[0]} while {names[last]} cycles is refused, and "
|
||||
f"{names[last]}'s cycle is not touched", r.get("code") == "idle"
|
||||
and phases(c)[last] == CHARGE and snapshot(c, hass) == before)
|
||||
|
||||
for phase in POWER_MOVING:
|
||||
c, hass = controller(N)
|
||||
c.maints[last].store.set("phase", phase)
|
||||
r = act(c, "maint_abort", names[last])
|
||||
check(f"a stop on the cycling battery ({phase}) ends its cycle, "
|
||||
f"logs the abort and sets every inverter to 0 W",
|
||||
r == {"unit": names[last], "phase": IDLE}
|
||||
and phases(c) == [IDLE] * N
|
||||
and any("ABORT (operator)" in e for e in c.events)
|
||||
and {w for w in hass.writes}
|
||||
== {(f"number.sp_{k}", 0.0) for k in range(N)}
|
||||
and all(i.target == 0.0 for i in c.inverters))
|
||||
|
||||
# --- the old request ---------------------------------------------------
|
||||
c, hass = controller(N)
|
||||
r = act(c, "maint_start")
|
||||
check("a start with no unit is the old call: the bare button's pick, and "
|
||||
"the old answer", r == {"phase": DRAIN} and phases(c)[0] == DRAIN
|
||||
and any("forced start" in e for e in c.events))
|
||||
r = act(c, "maint_start")
|
||||
check("...a second bare start re-enters the drain of the cycling battery, "
|
||||
"as it always did", r == {"phase": DRAIN} and phases(c)[0] == DRAIN)
|
||||
c.maints[last].store.set("phase", IDLE)
|
||||
r = act(c, "maint_abort")
|
||||
check("...and a bare stop stops the cycling battery and answers as before",
|
||||
r == {"phase": IDLE} and phases(c) == [IDLE] * N
|
||||
and any("ABORT (operator)" in e for e in c.events))
|
||||
c, hass = controller(N)
|
||||
r = act(c, "maint_abort")
|
||||
check("...a bare stop with nothing cycling is still accepted, as before",
|
||||
r == {"phase": IDLE} and hass.writes
|
||||
and {w[1] for w in hass.writes} == {0.0})
|
||||
|
||||
# --- the web layer ----------------------------------------------------------
|
||||
print("\nthe web layer")
|
||||
|
||||
|
||||
async def serve(c, calls):
|
||||
runner = aioweb.AppRunner(web.build_app(c, object()))
|
||||
await runner.setup()
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
await aioweb.TCPSite(runner, "127.0.0.1", port).start()
|
||||
out = []
|
||||
try:
|
||||
async with aiohttp.ClientSession() as s:
|
||||
for body in calls:
|
||||
async with s.post(f"http://127.0.0.1:{port}/action",
|
||||
json=body) as r:
|
||||
out.append((r.status, await r.json(content_type=None)))
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
return out
|
||||
|
||||
|
||||
c, hass = controller(2)
|
||||
rs = asyncio.run(serve(c, [
|
||||
{"action": "maint_start", "unit": "inverter-b"},
|
||||
{"action": "maint_start", "unit": "inverter-a"},
|
||||
{"action": "maint_abort", "unit": "inverter-a"},
|
||||
{"action": "maint_start", "unit": "nope"},
|
||||
{"action": "maint_abort", "unit": "inverter-b"},
|
||||
{"action": "maint_start", "unit": None},
|
||||
{"action": "maint_abort"},
|
||||
{"action": "maint_start", "unit": ""},
|
||||
]))
|
||||
check("start with a unit: 200 and the unit's answer",
|
||||
rs[0] == (200, {"ok": True, "result": {"unit": "inverter-b",
|
||||
"phase": DRAIN}}))
|
||||
check("start while the slot is held: 409, a code, the cycling battery, the "
|
||||
"plain sentence", rs[1][0] == 409 and rs[1][1] == {
|
||||
"ok": False, "code": "busy", "unit": "inverter-b",
|
||||
"error": messages.maint_refused("busy", "inverter-b")})
|
||||
check("stop on a battery that is not cycling: 409 with the sentence",
|
||||
rs[2][0] == 409 and rs[2][1]["code"] == "idle"
|
||||
and rs[2][1]["error"] == messages.maint_refused("idle", "inverter-a"))
|
||||
check("unknown unit: 400 with the sentence", rs[3][0] == 400
|
||||
and rs[3][1]["code"] == "unit"
|
||||
and rs[3][1]["error"] == messages.maint_refused("unit"))
|
||||
check("stop on the cycling battery: 200", rs[4] == (
|
||||
200, {"ok": True, "result": {"unit": "inverter-b", "phase": IDLE}}))
|
||||
check("a null unit is the bare call: 200 and the old answer",
|
||||
rs[5] == (200, {"ok": True, "result": {"phase": DRAIN}}))
|
||||
check("a request with no unit: 200 and the old answer", rs[6] == (
|
||||
200, {"ok": True, "result": {"phase": IDLE}}))
|
||||
check("an empty unit is refused (400), not taken as the bare call",
|
||||
rs[7][0] == 400 and rs[7][1]["code"] == "unit"
|
||||
and phases(c) == [IDLE, IDLE])
|
||||
|
||||
# --- the words --------------------------------------------------------------
|
||||
print("\nthe words")
|
||||
words = {}
|
||||
for lang in ("en", "nl"):
|
||||
with open(os.path.join(HERE, "app", "ui_text", f"{lang}.json"),
|
||||
encoding="utf-8") as f:
|
||||
words[lang] = json.load(f)["home"]["batteries"]["cycle"]
|
||||
for code, sentence in messages.MAINT_REFUSED.items():
|
||||
en = words["en"][code]["description"]
|
||||
check(f"'{code}': the page's English is the API's sentence",
|
||||
en == sentence.replace("{unit}", "{name}"))
|
||||
nl = words["nl"][code]["description"]
|
||||
check(f"'{code}': the Dutch has the same placeholders",
|
||||
sorted(re.findall(r"\{\w+\}", nl))
|
||||
== sorted(re.findall(r"\{\w+\}", en)) and nl != en)
|
||||
check("both languages carry the same keys",
|
||||
json.dumps(words["en"], sort_keys=True).count('":')
|
||||
== json.dumps(words["nl"], sort_keys=True).count('":')
|
||||
and set(words["en"]) == set(words["nl"]))
|
||||
check("every code the server can answer has a page sentence",
|
||||
set(messages.MAINT_REFUSED) <= set(words["en"]))
|
||||
|
||||
# --- the page ----------------------------------------------------------------
|
||||
print("\nthe page")
|
||||
JS = open(os.path.join(HERE, "app", "static", "app.js"),
|
||||
encoding="utf-8").read()
|
||||
check("the buttons are drawn only with two or more batteries",
|
||||
"S.batteries.length > 1 ? cycleButtons(b, i) : null" in JS)
|
||||
check("both buttons post the battery's name and ask first",
|
||||
'JSON.stringify({ action: what, unit: b.name }) });' in JS and 'ask1("start_dialog", "maint_start")' in JS
|
||||
and 'ask1("abort_dialog", "maint_abort")' in JS)
|
||||
check("a refusal is shown in the page's language, by its code",
|
||||
'const own = j.code && t("batteries.cycle." + j.code);' in JS
|
||||
and 'toast(own ? say(own.description' in JS)
|
||||
check("Stop is disabled on a battery that is not cycling",
|
||||
'abort: cycling(b) };' in JS and "disabled: !can.abort" in JS)
|
||||
check("Start is disabled while any battery cycles",
|
||||
"start: !S.batteries.some(cycling) && " in JS
|
||||
and "disabled: !can.start" in JS)
|
||||
check("Start is disabled on a switched-off battery (use false, modes on)",
|
||||
"&& b.use !== false, abort" in JS)
|
||||
check("each button carries its own flag, and only that flag",
|
||||
'id: "bcs-" + i, disabled: !can.start, onclick: ask1("start_dialog"' in JS
|
||||
and 'id: "bca-" + i, disabled: !can.abort, onclick: ask1("abort_dialog"' in JS)
|
||||
check("a battery counts as cycling when its phase is not idle",
|
||||
'cycling = (x) => x.maintenance !== "idle"' in JS)
|
||||
check("the old header buttons still post a bare request",
|
||||
'ask("maint_start", () => simple("maint_start"))' in JS
|
||||
and "body: JSON.stringify({ action: what }) });" in JS)
|
||||
|
||||
# --- a roster of one -----------------------------------------------------------
|
||||
print("\none battery: the requests, the log and the wire stay as they were")
|
||||
c, hass = controller(1)
|
||||
r1 = act(c, "maint_start")
|
||||
r2 = act(c, "maint_start")
|
||||
r3 = act(c, "maint_abort")
|
||||
r4 = act(c, "maint_abort")
|
||||
check("answers", [r1, r2, r3, r4] == [{"phase": DRAIN}, {"phase": DRAIN},
|
||||
{"phase": IDLE}, {"phase": IDLE}])
|
||||
check("log lines (stamp removed), newest first", settle(c) == [
|
||||
"maintenance: ABORT (operator)", "maintenance: ABORT (operator)",
|
||||
"maintenance: forced start -> drain",
|
||||
"maintenance: forced start -> drain"][:len(settle(c))]
|
||||
and len(settle(c)) == 4)
|
||||
check("wire: two stops, each 0 W", hass.writes == [("number.sp_0", 0.0)] * 2)
|
||||
c, hass = controller(1)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -1469,11 +1469,8 @@ _info = [r.getMessage() for r in _out if r.levelno == logging.INFO]
|
||||
check(f"a Home Assistant restart is one plain WARNING line, and one INFO line "
|
||||
f"when both connections are back ({_warn}, {_info})",
|
||||
_warn[:1] == ["Home Assistant is not answering (error 502), perhaps it "
|
||||
"is still starting. Control waits and resumes by itself."]
|
||||
and len(_info) == 1
|
||||
and re.fullmatch(r"Home Assistant answers again after \d+ seconds\.",
|
||||
_info[0]) is not None and len(_warn) == 2)
|
||||
# SAFETY-112's reminder and its timing are graded in test_safety112.py.
|
||||
"is restarting. The add-on keeps trying."]
|
||||
and _info == ["Home Assistant answers again."] and len(_warn) == 2)
|
||||
check("...and the library's own text is kept, at DEBUG",
|
||||
any("Received message 8:1000" in r.getMessage() for r in _out
|
||||
if r.levelno == logging.DEBUG)
|
||||
|
||||
@@ -74,7 +74,7 @@ AT_7A00F12 = {
|
||||
"n1_off": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2_off": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
}
|
||||
_rows = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_7A00F12}
|
||||
_rows = {k: ident.run(**v)[0] for k, v in ident.SCENARIOS.items()}
|
||||
_got = {k: ident.digest(v) for k, v in _rows.items()}
|
||||
for k in AT_7A00F12:
|
||||
print(f" {k} {'same' if _got[k] == AT_7A00F12[k] else 'DIFFERS'} "
|
||||
@@ -482,16 +482,10 @@ _ref = [r for r in ROWS_R if present(_first)
|
||||
print(f" B refuses to charge; after 3 min: trades "
|
||||
f"{sorted({str(r['action']) for r in _ref})}, B roles "
|
||||
f"{sorted({r['roles'].get(B) for r in _ref})}")
|
||||
# OPT-15: B's SAFETY-73 probe (every 15 min, 34 s) is the one grid trade
|
||||
# left: PROBE_W on B alone, A resting.
|
||||
_prb = [r for r in _ref if r["action"] is not None]
|
||||
check("B refusing to charge: no grid trade is filed after the refusal is "
|
||||
"learned but B's probe (PROBE_W on B, A resting), B charges only then, "
|
||||
"and A covers the house",
|
||||
_ref and all(r["shares"].get(B) == -200 and r["shares"].get(A, 0) == 0
|
||||
for r in _prb) and len(_prb) <= 0.1 * len(_ref)
|
||||
and all(r["roles"].get(B) != modetrade.CHARGE
|
||||
for r in _ref if r["action"] is None)
|
||||
"learned, B's role is no longer 'charge', and A covers the house",
|
||||
_ref and all(r["action"] is None for r in _ref)
|
||||
and all(r["roles"].get(B) != modetrade.CHARGE for r in _ref)
|
||||
and sum(r["shares"].get(A, 0) > 0 for r in _ref) > len(_ref) / 2)
|
||||
|
||||
# ============================================================================ #
|
||||
@@ -522,7 +516,7 @@ print("\n7. a run edge reseeds the loop")
|
||||
|
||||
|
||||
def _no_reseed(c):
|
||||
c._reseed_run_edge = lambda claims, now: None
|
||||
c._reseed_run_edge = lambda claims: None
|
||||
|
||||
|
||||
def edge(rows, kind):
|
||||
|
||||
@@ -60,42 +60,37 @@ 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.
|
||||
# OPT-15: the six scenarios with a grid charge (`_ch`) were re-pinned on the
|
||||
# OPT-15 head: a charge end now holds the loop at the house's level (F2). With
|
||||
# that hold and its reseed switched off they equal 5a4cdc2's again; the others
|
||||
# (no charge) never moved.
|
||||
BASE_DIGESTS = {
|
||||
"n1_ls":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch":
|
||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
||||
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
|
||||
"n2_sf_ls":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls":
|
||||
"9c54abddf877ddacbb3511d9cbe5231ddbda74986a89f396b83c5b2d22dc16f6",
|
||||
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
|
||||
"n2_ls_ls":
|
||||
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
|
||||
"n2_sf_ch":
|
||||
"d16d3cac0d9f720b68212c5899624d2a02fb6f7ecae258ddfc8dbba560d47bcf",
|
||||
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
|
||||
"n1_ls_fc_down":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch_fc_down":
|
||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
||||
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
|
||||
"n2_sf_ls_fc_down":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls_fc_down":
|
||||
"9c54abddf877ddacbb3511d9cbe5231ddbda74986a89f396b83c5b2d22dc16f6",
|
||||
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
|
||||
"n2_ls_ls_fc_down":
|
||||
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
|
||||
"n2_sf_ch_fc_down":
|
||||
"d16d3cac0d9f720b68212c5899624d2a02fb6f7ecae258ddfc8dbba560d47bcf",
|
||||
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
|
||||
}
|
||||
import opt11p4_identity as ident # noqa: E402
|
||||
|
||||
got = ident.digests()
|
||||
for k, v in BASE_DIGESTS.items():
|
||||
check(f"{k}: pinned digest (5a4cdc2; OPT-15 for the _ch ones)",
|
||||
got.get(k) == v)
|
||||
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",
|
||||
@@ -118,21 +113,19 @@ check("the harness bites: Leftover solar grid-charging with no plan moves "
|
||||
lambda: setattr(modetrade, "GRID_CHARGE_MODES",
|
||||
(packmode.LEFTOVER_SOLAR, packmode.CHEAP_HOURS)),
|
||||
lambda: setattr(modetrade, "GRID_CHARGE_MODES", _gcm)))
|
||||
# OPT-17: the controller reads through `read_sum()`, so that is the seam.
|
||||
_read = solarfc.read_sum
|
||||
_read = solarfc.read
|
||||
|
||||
|
||||
def _as_dark(today, tomorrow, now, **kw):
|
||||
return _read([("sensor.f", {"state": "0", "attributes": {
|
||||
"unit_of_measurement": "kWh"}, "last_updated": now.isoformat()})],
|
||||
[], now)
|
||||
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_sum", _as_dark),
|
||||
lambda: setattr(solarfc, "read_sum", _read)))
|
||||
_moved("n2_sf_ls_fc_down", lambda: setattr(solarfc, "read", _as_dark),
|
||||
lambda: setattr(solarfc, "read", _read)))
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. the forecast sensors")
|
||||
|
||||
@@ -1,498 +0,0 @@
|
||||
"""Runnable check for OPT-17, the solar forecast summed from several entities.
|
||||
`python3 test_opt17.py`
|
||||
|
||||
1. `solarfc.read_sum()`: sums in mixed units, refusals, a member down or
|
||||
stale makes that day unusable (never a partial sum), hourly splits, today
|
||||
and tomorrow, an empty list, and one member equal to `read()`.
|
||||
2. `solarfc.entities()`: which key a day uses.
|
||||
3. Options: the validator, the settings notes and config.yaml.
|
||||
4. The REAL controller (tools/opt11p3_replay.py): a one-member list runs
|
||||
exactly like the single key, two halves like one whole, a member down
|
||||
like a forecast down; the status row and the action log name the member
|
||||
once; every member is read in one gather; modes off reads nothing.
|
||||
|
||||
DECISIONS/OPT-17-several-forecast-entities.md has the rules. Mutations
|
||||
(MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from app import (dayplan, homeview, messages, solarfc, uitext, # noqa: E402
|
||||
validate)
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=2))
|
||||
UTC0 = datetime(2026, 9, 21, tzinfo=L).astimezone(timezone.utc)
|
||||
TOM = UTC0 + timedelta(days=1)
|
||||
NOW = datetime(2026, 9, 21, 9, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
END = UTC0 + timedelta(days=1)
|
||||
OLD = NOW - timedelta(seconds=solarfc.FRESH_S + 1)
|
||||
|
||||
|
||||
def st(state, unit="kWh", at=NOW, **attrs):
|
||||
a = dict(attrs)
|
||||
if unit is not None:
|
||||
a["unit_of_measurement"] = unit
|
||||
return {"state": state, "attributes": a,
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
def members(*states):
|
||||
return [(f"sensor.fc_{i}", s) for i, s in enumerate(states, 1)]
|
||||
|
||||
|
||||
def near(a, b, eps=1e-6):
|
||||
return a is not None and b is not None and abs(a - b) < eps
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. read_sum")
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("7500", "Wh")), [], NOW)
|
||||
check("two members, kWh + Wh: 5 + 7.5 = 12.5 kWh",
|
||||
fc is not None and near(fc.today.kwh, 12.5) and why is None
|
||||
and gaps == ())
|
||||
check("...and the rest of the day adds up to the sum",
|
||||
near(fc.between(NOW, END), 12.5))
|
||||
fc, why, gaps = solarfc.read_sum(
|
||||
members(st("5"), st("2500", "Wh"), st("0.005", "MWh")), [], NOW)
|
||||
check("three members, kWh + Wh + MWh: 5 + 2.5 + 5 = 12.5 kWh",
|
||||
fc is not None and near(fc.today.kwh, 12.5))
|
||||
for unit in ("W", None, "kW", "%"):
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("5", unit)), [],
|
||||
NOW)
|
||||
check(f"a member in {unit or 'no unit'} makes today unusable (unit), "
|
||||
f"and is named",
|
||||
fc is None and why == solarfc.UNIT
|
||||
and gaps == (("today", 2, "sensor.fc_2", solarfc.UNIT),))
|
||||
for bad, w in (("unavailable", solarfc.MISSING), ("unknown", solarfc.MISSING),
|
||||
(None, solarfc.MISSING), ("-1", solarfc.BAD),
|
||||
("lots", solarfc.BAD)):
|
||||
s2 = None if bad is None else st(bad)
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), s2, st("4")), [], NOW)
|
||||
check(f"a member that reads {bad!r}: no forecast at all ({w}), not "
|
||||
f"the 9 kWh of the others",
|
||||
fc is None and why == w
|
||||
and gaps == (("today", 2, "sensor.fc_2", w),))
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4", at=OLD)), [], NOW)
|
||||
check("a stale member (older than 3 h): no forecast (stale), named",
|
||||
fc is None and why == solarfc.STALE
|
||||
and gaps == (("today", 2, "sensor.fc_2", solarfc.STALE),))
|
||||
EVE = datetime(2026, 9, 20, 23, 30, tzinfo=L).astimezone(timezone.utc)
|
||||
MID = datetime(2026, 9, 21, 0, 10, tzinfo=L).astimezone(timezone.utc)
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5", at=MID), st("0", at=EVE)),
|
||||
[], MID)
|
||||
check("a member from before local midnight is stale, not a dark member",
|
||||
fc is None and why == solarfc.STALE)
|
||||
fc, why, gaps = solarfc.read_sum(members(st("unavailable"), st("4", at=OLD)),
|
||||
[], NOW)
|
||||
check("two members down: both named, the first reason is the day's",
|
||||
fc is None and why == solarfc.MISSING and [g[1] for g in gaps] == [1, 2])
|
||||
check("an empty list is not set", solarfc.read_sum([], [], NOW)
|
||||
== (None, solarfc.NOT_SET, ())
|
||||
and solarfc.read_sum([], members(st("5")), NOW)[1] == solarfc.NOT_SET)
|
||||
fc, _w, _g = solarfc.read_sum(members(st("5", at=NOW - timedelta(hours=2)),
|
||||
st("4")), [], NOW)
|
||||
check("the forecast's time is the oldest member's report",
|
||||
fc.at == NOW - timedelta(hours=2))
|
||||
|
||||
# Tomorrow.
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4")),
|
||||
members(st("12"), st("8000", "Wh")), NOW)
|
||||
check("tomorrow: 12 + 8 = 20 kWh, and today is 9",
|
||||
fc is not None and near(fc.tomorrow.kwh, 20.0) and near(fc.today.kwh,
|
||||
9.0)
|
||||
and near(fc.between(TOM, TOM + timedelta(days=1)), 20.0))
|
||||
for name, t2, w in (("unavailable", st("unavailable"), solarfc.MISSING),
|
||||
("stale", st("8", at=OLD), solarfc.STALE),
|
||||
("in W", st("8", "W"), solarfc.UNIT)):
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4")),
|
||||
members(st("12"), t2), NOW)
|
||||
check(f"a tomorrow member {name}: tomorrow is not used (not 12 kWh), "
|
||||
f"today is kept, the member is named",
|
||||
fc is not None and fc.tomorrow is None and near(fc.today.kwh, 9.0)
|
||||
and why is None
|
||||
and gaps == (("tomorrow", 2, "sensor.fc_2", w),)
|
||||
and fc.between(NOW, TOM + timedelta(hours=9)) is None)
|
||||
fc, _w, gaps = solarfc.read_sum(members(st("5"), st("4")), [], NOW)
|
||||
check("no tomorrow list: today only, nothing named",
|
||||
fc.tomorrow is None and gaps == ())
|
||||
|
||||
# Hourly splits.
|
||||
sol = [{"period_start": (UTC0 + timedelta(minutes=30 * i)).isoformat(),
|
||||
"pv_estimate": 2.0 if 20 <= i < 36 else 0.0} for i in range(48)]
|
||||
wh = {(UTC0 + timedelta(hours=h)).isoformat(): (1000.0 if 8 <= h < 16
|
||||
else 0.0)
|
||||
for h in range(24)}
|
||||
fc, _w, _g = solarfc.read_sum(members(st("8.0", detailedForecast=sol),
|
||||
st("7.0", wh_period=wh)), [], NOW)
|
||||
one_s = solarfc.read(st("8.0", detailedForecast=sol), None, NOW)[0]
|
||||
one_w = solarfc.read(st("7.0", wh_period=wh), None, NOW)[0]
|
||||
spans = [(NOW, END)] + [
|
||||
(datetime(2026, 9, 21, h, tzinfo=L), datetime(2026, 9, 21, h + 1,
|
||||
tzinfo=L))
|
||||
for h in range(9, 20)]
|
||||
check("every member has a split: the splits are merged (hour by hour, the "
|
||||
"sum of each member's own split)",
|
||||
fc.today.periods and near(fc.today.kwh, 15.0)
|
||||
and all(near(fc.between(a, b), one_s.between(a, b)
|
||||
+ one_w.between(a, b)) for a, b in spans))
|
||||
check("...so 10:00-12:00 is 2 (Solcast) + 2 (Open-Meteo) = 4 kWh",
|
||||
near(fc.between(datetime(2026, 9, 21, 10, tzinfo=L),
|
||||
datetime(2026, 9, 21, 12, tzinfo=L)), 4.0))
|
||||
fc, _w, _g = solarfc.read_sum(members(st("8.0", detailedForecast=sol),
|
||||
st("7.0")), [], NOW)
|
||||
bell = solarfc.read(st("15.0"), None, NOW)[0]
|
||||
check("one member without a split: the sum goes on the bell, as one "
|
||||
"state-only sensor of 15 kWh would",
|
||||
fc.today.periods == () and near(fc.today.kwh, 15.0)
|
||||
and all(near(fc.between(a, b), bell.between(a, b)) for a, b in spans))
|
||||
wht = {(TOM + timedelta(hours=h)).isoformat(): (500.0 if 8 <= h < 16 else 0)
|
||||
for h in range(24)}
|
||||
fc, _w, _g = solarfc.read_sum(members(st("5")),
|
||||
members(st("4", wh_period=wht),
|
||||
st("4", wh_period=wht)), NOW)
|
||||
check("tomorrow's splits are merged too (8 x 0.5 kWh each, 8 in all)",
|
||||
fc.tomorrow.periods and near(fc.between(TOM, TOM + timedelta(days=1)),
|
||||
8.0)
|
||||
and near(fc.between(TOM + timedelta(hours=8),
|
||||
TOM + timedelta(hours=10)), 2.0))
|
||||
fc, _w, _g = solarfc.read_sum(members(st("5")),
|
||||
members(st("4", wh_period=wht), st("4")), NOW)
|
||||
check("...and fall back to the bell when one has none",
|
||||
fc.tomorrow.periods == () and near(fc.tomorrow.kwh, 8.0))
|
||||
|
||||
# One member is read().
|
||||
TODAYS = [st("12.5"), st("12500", "Wh"), st("0.0125", "MWh"), st("12.5", "W"),
|
||||
st("12.5", None), st("unavailable"), st("unknown"), None, st("-1"),
|
||||
st("lots"), st("12.5", at=OLD), st("12.5", at=NOW + timedelta(
|
||||
hours=1)), st("8.0", detailedForecast=sol),
|
||||
st("7.0", wh_period=wh), st("0")]
|
||||
TOMS = [None, st("20"), st("20", at=OLD), st("unavailable"), st("20", "W"),
|
||||
st("4", wh_period=wht)]
|
||||
WHENS = [NOW, MID, datetime(2026, 9, 21, 19, 30, tzinfo=L)]
|
||||
same, n = True, 0
|
||||
for now in WHENS:
|
||||
for a in TODAYS:
|
||||
for b in TOMS:
|
||||
n += 1
|
||||
one = solarfc.read(a, b, now)
|
||||
got = solarfc.read_sum([("sensor.f", a)],
|
||||
[("sensor.t", b)] if b is not None
|
||||
else [], now)
|
||||
same = same and got[:2] == one
|
||||
check(f"one member per day gives exactly what read() gives ({n} cases: "
|
||||
f"units, refusals, stale, splits, tomorrow)", same and n == 270)
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. entities")
|
||||
E = solarfc.entities
|
||||
check("the single key alone", E({"pv_forecast_entity": "sensor.a"})
|
||||
== ("sensor.a",))
|
||||
check("the list alone, in order", E({"pv_forecast_entities": [
|
||||
"sensor.b", "sensor.a"]}) == ("sensor.b", "sensor.a"))
|
||||
check("a list with an entry replaces the single key",
|
||||
E({"pv_forecast_entity": "sensor.a",
|
||||
"pv_forecast_entities": ["sensor.b"]}) == ("sensor.b",))
|
||||
check("an empty list or blank rows use the single key",
|
||||
E({"pv_forecast_entity": "sensor.a", "pv_forecast_entities": []})
|
||||
== ("sensor.a",)
|
||||
and E({"pv_forecast_entity": "sensor.a",
|
||||
"pv_forecast_entities": ["", " "]}) == ("sensor.a",))
|
||||
check("a repeated id counts once (it would double that forecast)",
|
||||
E({"pv_forecast_entities": ["sensor.a", "sensor.b", "sensor.a"]})
|
||||
== ("sensor.a", "sensor.b"))
|
||||
check("tomorrow reads its own keys",
|
||||
E({"pv_forecast_entities": ["sensor.a"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.t1", "sensor.t2"]},
|
||||
tomorrow=True) == ("sensor.t1", "sensor.t2")
|
||||
and E({"pv_forecast_tomorrow_entity": "sensor.t"}, tomorrow=True)
|
||||
== ("sensor.t",))
|
||||
check("nothing set: ()", E({}) == () and E({}, tomorrow=True) == ())
|
||||
|
||||
# ============================================================================ #
|
||||
print("3. options")
|
||||
F = validate._forecast_problems
|
||||
for one, many in (solarfc.TODAY_KEYS, solarfc.TOMORROW_KEYS):
|
||||
p = F({one: "sensor.a", many: ["sensor.b", "sensor.c"]})
|
||||
check(f"{one} and {many} both set: refused, one plain message that "
|
||||
f"says which to keep",
|
||||
len(p) == 1 and "not both" in p[0] and "Keep the list" in p[0]
|
||||
and one in p[0] and many in p[0] and len(p[0].split()) <= 30)
|
||||
check(f"...{many} alone is fine, and so is {one} alone",
|
||||
not F({many: ["sensor.b", "sensor.c"]}) and not F({one: "sensor.a"})
|
||||
and not F({one: "sensor.a", many: []})
|
||||
and not F({one: "", many: ["sensor.b"]}))
|
||||
check(f"...blank rows in {many} are ignored, alone or beside {one}",
|
||||
not F({many: ["", None, "sensor.b"]})
|
||||
and not F({one: "sensor.a", many: ["", None]}))
|
||||
p = F({many: ["sensor.b", "number.x"]})
|
||||
check(f"...an entry in {many} that is not a sensor is refused",
|
||||
len(p) == 1 and "only sensors" in p[0] and "number.x" in p[0])
|
||||
check(f"...and {many} that is not a list is refused",
|
||||
F({many: "sensor.b"}) and "list of sensors" in F({many: "x"})[0])
|
||||
with open(os.path.join(HERE, "translations", "en.yaml"),
|
||||
encoding="utf-8") as fh:
|
||||
EN = fh.read()
|
||||
for k in solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS:
|
||||
name = re.search(rf"^ {k}:\n name: \"Hardware · ([^\"(]+?)"
|
||||
rf"(?: \(kWh\))?\"$", EN, re.M)
|
||||
check(f"the refusal names {k} by its label on the tab",
|
||||
name is not None
|
||||
and f"“{name.group(1)}” ({k})" in validate.S(k))
|
||||
print(" " + F({"pv_forecast_entity": "sensor.a",
|
||||
"pv_forecast_entities": ["sensor.b"]})[0])
|
||||
VBASE = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000, "battery_modes_enabled": True}
|
||||
check("the start-up check refuses both keys set for a day",
|
||||
any("not both" in p for p in validate.validate_startup_config(
|
||||
dict(VBASE, pv_forecast_tomorrow_entity="sensor.a",
|
||||
pv_forecast_tomorrow_entities=["sensor.b"])))
|
||||
and validate.validate_startup_config(
|
||||
dict(VBASE, pv_forecast_entities=["sensor.a", "sensor.b"],
|
||||
pv_forecast_tomorrow_entities=["sensor.c", "sensor.d"]))
|
||||
== [])
|
||||
notes = []
|
||||
validate._entity_notes(notes, dict(VBASE, pv_forecast_entities=[
|
||||
"sensor.a", "sensor.b", "sensor.gone"]),
|
||||
{"sensor.a": {"unit": "kWh"}, "sensor.b": {"unit": "W"}})
|
||||
check("settings notes: a listed sensor not in kWh, and one Home Assistant "
|
||||
"does not have, are each named",
|
||||
any("sensor.b" in n.text and "kWh" in n.text for n in notes)
|
||||
and any("sensor.gone" in n.text for n in notes)
|
||||
and not any("sensor.a " in n.text for n in notes))
|
||||
notes = []
|
||||
validate._entity_notes(notes, dict(VBASE, pv_forecast_tomorrow_entities=[
|
||||
"sensor.t"]), {"sensor.t": {"unit": "kWh"}})
|
||||
check("a tomorrow list without today's forecast gets the same warning as "
|
||||
"the single key", any("used only with today's" in n.text
|
||||
for n in notes))
|
||||
notes = []
|
||||
validate._entity_notes(notes, {"pv_forecast_entities": ["sensor.a"]},
|
||||
{"sensor.a": {"unit": "kWh"}})
|
||||
check("a list with battery modes off gets the 'unused' note",
|
||||
any("battery modes on" in n.text for n in notes))
|
||||
with open(os.path.join(HERE, "config.yaml"), encoding="utf-8") as fh:
|
||||
CFG = fh.read()
|
||||
for k in ("pv_forecast_entities", "pv_forecast_tomorrow_entities"):
|
||||
check(f"config.yaml: {k} defaults to [] (load-bearing) and is a list "
|
||||
f"of str",
|
||||
re.search(rf"^ {k}: \[\]$", CFG, re.M) is not None
|
||||
and re.search(rf"^ {k}:\n - str$", CFG, re.M) is not None)
|
||||
for k in solarfc.TODAY_KEYS[:1] + solarfc.TOMORROW_KEYS[:1]:
|
||||
check(f"config.yaml: {k} is unchanged (str?, default \"\")",
|
||||
re.search(rf"^ {k}: \"\"$", CFG, re.M) is not None
|
||||
and re.search(rf"^ {k}: str\?$", CFG, re.M) is not None)
|
||||
|
||||
# ============================================================================ #
|
||||
print("4. the real controller")
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
# Prices as in test_modes_p4: cheap 01:00-03:00, dear 19:00-20:00 local.
|
||||
DAYW = [0.20] * 96 * 2
|
||||
for i in list(range(4, 12)) + list(range(100, 108)):
|
||||
DAYW[i] = 0.02
|
||||
for i in list(range(76, 80)) + list(range(172, 176)):
|
||||
DAYW[i] = 0.40
|
||||
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p)
|
||||
for i, p in enumerate(DAYW)]
|
||||
SF_LS = {"a": (True, "solar_first", False),
|
||||
"b": (True, "leftover_solar", False)}
|
||||
# Each fake sensor: (share of the day's sun, has a split, status).
|
||||
SENSORS = {}
|
||||
FC_KWH = [1.0]
|
||||
START = [UTC0]
|
||||
CALLS = []
|
||||
|
||||
|
||||
async def _sensor(self, e):
|
||||
CALLS.append(("start", e))
|
||||
await asyncio.sleep(0)
|
||||
CALLS.append(("end", e))
|
||||
now = START[0] + timedelta(seconds=self.t)
|
||||
share, split, status = SENSORS[e]
|
||||
if status == "down":
|
||||
return {"state": "unavailable", "attributes": {},
|
||||
"last_updated": now.isoformat()}
|
||||
at = now - timedelta(hours=4) if status == "stale" else now
|
||||
loc = now.astimezone(L)
|
||||
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if e.startswith("sensor.tom"):
|
||||
d0 += timedelta(days=1)
|
||||
wh = {(d0 + timedelta(hours=h)).astimezone(timezone.utc).isoformat():
|
||||
(FC_KWH[0] * 100.0 * share if 8 <= h < 18 else 0.0)
|
||||
for h in range(24)}
|
||||
left = sum(v for k, v in wh.items()
|
||||
if datetime.fromisoformat(k) >= now) / 1000.0
|
||||
attrs = {"unit_of_measurement": "kWh"}
|
||||
if split:
|
||||
attrs["wh_period"] = wh
|
||||
return {"state": f"{left:.6f}", "attributes": attrs,
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
rp.Hass.state = _sensor
|
||||
|
||||
|
||||
def seed(c):
|
||||
if c.house_profile is not None:
|
||||
hp = dayplan.HouseProfile()
|
||||
t = UTC0 - timedelta(days=1)
|
||||
while t < UTC0:
|
||||
h = t.astimezone(L).hour
|
||||
hp.add(t, 3000.0 if 17 <= h < 22 else 600.0)
|
||||
t += timedelta(seconds=5)
|
||||
hp.add(t, None)
|
||||
c.house_profile = hp
|
||||
|
||||
|
||||
def run(opts, hours=4.0, modes=True):
|
||||
start = UTC0 + timedelta(hours=0.75)
|
||||
START[0] = start
|
||||
rows = []
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
sh = dict(c.allocation.shares) if c.allocation is not None and \
|
||||
c.allocation.shares else {}
|
||||
rows.append((now, round(h.grid(), 6), tuple(sorted(sh.items())),
|
||||
getattr(c.arb, "action", None),
|
||||
tuple(sorted(c.mode_tick.roles.items()))
|
||||
if c.mode_tick else (),
|
||||
tuple(sorted((u, round(p.soc, 6))
|
||||
for u, p in h.plants.items())),
|
||||
tuple(sorted((c.plan_targets() or {}).items())),
|
||||
None if c.day_plan is None else c.day_plan.verdict))
|
||||
_st, c = rp.run("p3_ls" if modes else "modes_off", [(UTC0.timestamp()
|
||||
- 10, 800.0)],
|
||||
WPTS, start, 3600 * hours, check=cb,
|
||||
soc0={"a": 60.0, "b": 30.0}, choice=SF_LS,
|
||||
extra=dict({"cycle_cost_threshold_eur": 0.20}, **opts),
|
||||
patch=seed)
|
||||
return rows, c
|
||||
|
||||
|
||||
def fc_rows(c):
|
||||
return [r for r in c.checks() if r["id"] == "solar_forecast"]
|
||||
|
||||
|
||||
def fc_events(c):
|
||||
return [e for e in c.events if "solar forecast" in e]
|
||||
|
||||
|
||||
SENSORS.update({
|
||||
"sensor.all": (1.0, True, "ok"), "sensor.h1": (0.5, True, "ok"),
|
||||
"sensor.h2": (0.5, True, "ok"), "sensor.h2_flat": (0.5, False, "ok"),
|
||||
"sensor.down": (0.5, True, "down"), "sensor.stale": (0.5, True, "stale"),
|
||||
"sensor.tom_all": (1.0, True, "ok"), "sensor.tom_h1": (0.5, True, "ok"),
|
||||
"sensor.tom_down": (0.5, True, "down"), "sensor.none": (1.0, True, "down"),
|
||||
})
|
||||
single, c_single = run({"pv_forecast_entity": "sensor.all"})
|
||||
listed, c_listed = run({"pv_forecast_entities": ["sensor.all"]})
|
||||
plans = [r for r in single if r[6]]
|
||||
check(f"the run makes a day plan with a level ({len(plans)} of "
|
||||
f"{len(single)} cycles), so the forecast steers something",
|
||||
plans and any(r[7] is not None for r in single))
|
||||
check("a one-member list runs exactly like the single key: every grid "
|
||||
"figure, share, action, role, level and plan, cycle by cycle",
|
||||
single == listed and len(single) > 2000)
|
||||
check("...and says the same on the status page and in the action log",
|
||||
fc_rows(c_single) == fc_rows(c_listed) == []
|
||||
and fc_events(c_single) == fc_events(c_listed))
|
||||
halves, c_halves = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"]})
|
||||
check("two members of half the sun each run like one sensor of all of it",
|
||||
[r[:6] + (r[7],) for r in halves] == [r[:6] + (r[7],) for r in single]
|
||||
and all(len(a[6]) == len(b[6]) and all(
|
||||
k1 == k2 and abs(v1 - v2) < 1e-6 for (k1, v1), (k2, v2)
|
||||
in zip(a[6], b[6])) for a, b in zip(halves, single)))
|
||||
none_, c_none = run({"pv_forecast_entity": "sensor.none"})
|
||||
check("(the comparison bites: no forecast runs differently)",
|
||||
[r[:6] for r in none_] != [r[:6] for r in single])
|
||||
for name, mem in (("unavailable", "sensor.down"), ("stale", "sensor.stale")):
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1", mem]})
|
||||
check(f"a member {name}: the controller runs exactly as with the single "
|
||||
f"forecast unavailable (no partial sum)", rows == none_)
|
||||
check(f"...the status page names it by place, in one short line",
|
||||
[r["text"] for r in fc_rows(c)] == [
|
||||
"Solar forecast for today: not used, source 2 of 2 has no "
|
||||
"reading"] and fc_rows(c)[0]["warn"])
|
||||
ev = fc_events(c)
|
||||
check(f"...and the action log names it once in {len(single)} cycles",
|
||||
len(ev) == 1 and mem in ev[0] and "today" in ev[0])
|
||||
one_down, c_one_down = run({"pv_forecast_entities": ["sensor.none"]})
|
||||
check("a one-member list that is down runs, shows and logs exactly like the "
|
||||
"single key down", one_down == none_
|
||||
and fc_rows(c_one_down) == fc_rows(c_none)
|
||||
and fc_events(c_one_down) == fc_events(c_none))
|
||||
# A forecast missing from the start logs nothing (the start state is
|
||||
# already "missing"); that is release/1.0's behaviour, kept.
|
||||
check("the single key down: no new row and no new log line",
|
||||
fc_rows(c_none) == [] and fc_events(c_none) == [])
|
||||
CALLS.clear()
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.tom_h1",
|
||||
"sensor.tom_down"]},
|
||||
hours=0.2)
|
||||
check("a tomorrow member down: today is used, tomorrow is not, and the row "
|
||||
"says so",
|
||||
c.solar_fc is not None and c.solar_fc.tomorrow is None
|
||||
and [r["text"] for r in fc_rows(c)] == [
|
||||
"Solar forecast for tomorrow: not used, source 2 of 2 has no "
|
||||
"reading"]
|
||||
and len(fc_events(c)) == 1 and "sensor.tom_down" in fc_events(c)[0])
|
||||
check("...and the page hint says tomorrow's forecast is not used",
|
||||
(c.hint or "").endswith(messages.hint("forecast_no_tomorrow")))
|
||||
check("...and Home titles the row as the solar forecast, in EN and NL",
|
||||
[a["title"] for a in homeview.attention(fc_rows(c))]
|
||||
== ["Solar forecast"]
|
||||
and uitext.words("nl")["home_words"]["attn"]["solar_forecast"]
|
||||
== "Zonneprognose")
|
||||
first = CALLS[:8]
|
||||
check("every member of both days is read in one gather (all four start "
|
||||
"before any ends)",
|
||||
[k for k, _e in first] == ["start"] * 4 + ["end"] * 4
|
||||
and sorted(e for _k, e in first[:4]) == sorted(
|
||||
["sensor.h1", "sensor.h2", "sensor.tom_h1", "sensor.tom_down"]))
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.tom_all"]},
|
||||
hours=0.2)
|
||||
check("tomorrow: a one-member list is read and counted",
|
||||
c.solar_fc is not None and c.solar_fc.tomorrow is not None)
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2_flat"]},
|
||||
hours=0.2)
|
||||
check("a member without a split: the controller's forecast is on the bell",
|
||||
c.solar_fc is not None and c.solar_fc.today.periods == ())
|
||||
CALLS.clear()
|
||||
run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"]}, hours=0.2,
|
||||
modes=False)
|
||||
check("battery modes off: no forecast member is read", CALLS == [])
|
||||
check("the status row is a STYLE status row (<= 15 words, no entity id)",
|
||||
all(len(t.split()) <= 15 and "sensor." not in t for t in (
|
||||
messages.row_forecast_gap("today", [2, 3], 3),
|
||||
messages.row_forecast_gap("tomorrow", [1], 2))))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1,635 +0,0 @@
|
||||
"""Runnable check for OPT-15, the P3 price-run fixes. `python3 test_price_run_fixes.py`
|
||||
|
||||
The OPT-11 P3 post-merge test on TEST-23 (5a4cdc2) found three defects. This
|
||||
file drives the pure modules and the REAL Controller (tools/opt11p3_replay.py)
|
||||
with the lagged model (`Lag`: command dead time, meter and battery reading
|
||||
age, the P1 debounce, 1 s cycles), which reproduces all three on 5a4cdc2.
|
||||
|
||||
1. F1: a quarter edge while charging does not stop the charge (the guard
|
||||
projects the quarter `now` is in), and a real guard stop still latches.
|
||||
At two inverters SAFETY-91's `next_quarter()` already does that for the
|
||||
guard; at one inverter only the rolled projection does.
|
||||
2. F2: the export overshoot at a charge end stays within 0.15 x rating in
|
||||
the model and in the lagged variants (TEST-23 measured -1120 W).
|
||||
3. F3: a charge refusal inside a cheap run is probed every 15 minutes.
|
||||
4. The P3 and SAFETY-79 invariants on the lagged runs.
|
||||
5. Modes off is byte-identical to release/1.0 e3e4ba6, also with a late P1
|
||||
sample on every quarter boundary, and the harness sees a change there.
|
||||
|
||||
DECISIONS/OPT-11-battery-modes.md has the record. Mutations (MUTATION.md,
|
||||
purged copies, graded over this file and test_modes_p3.py) are listed in the
|
||||
pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_identity as ident # noqa: E402
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from app import arbitrage, modetrade, packmode, peakguard # noqa: E402
|
||||
from app import tariff # noqa: E402
|
||||
from app import control as ctl # noqa: E402
|
||||
from app import main # noqa: E402
|
||||
from app.p1 import QuarterAverager, make_sample # noqa: E402
|
||||
from app.tariff import PeakTracker # noqa: E402
|
||||
from named_absence import nth, present # 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, 9, 0, 0, tzinfo=L)
|
||||
UTC0 = D0.astimezone(timezone.utc)
|
||||
# Cheap 02:00-04:00 local, dear 19:00-20:00, flat otherwise (two days).
|
||||
PRICES = [0.30] * 192
|
||||
for _i in range(8, 16):
|
||||
PRICES[_i] = 0.10
|
||||
for _i in range(76, 80):
|
||||
PRICES[_i] = 0.40
|
||||
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p)
|
||||
for i, p in enumerate(PRICES)]
|
||||
RATING = rp.RATING
|
||||
BOUND = 0.15 * RATING
|
||||
AIM = rp.PEAK_CAP - 50
|
||||
CH_SF = {"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.SOLAR_FIRST, False)}
|
||||
|
||||
|
||||
def at(h):
|
||||
return UTC0 + timedelta(hours=h)
|
||||
|
||||
|
||||
def house(steps, base=800.0):
|
||||
"""[(t, W)] for the replay: `base`, then each (local hour, W) from then."""
|
||||
out = [(UTC0.timestamp() - 10, base)]
|
||||
out += [(at(h).timestamp(), float(w)) for h, w in steps]
|
||||
return out
|
||||
|
||||
|
||||
def run(start_h, hours, *, lag=None, soc_a=20.0, steps=(), choice=None,
|
||||
refuse=(), patch=None, check_cb=None, n=2):
|
||||
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),
|
||||
reason=getattr(c.arb, "reason", None) or "",
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
refused=c.inverters[0].refused,
|
||||
probing=c.inverters[0].probe_from is not None,
|
||||
headroom=c.arb_headroom_w,
|
||||
p={u: p.p for u, p in h.plants.items()}))
|
||||
if check_cb:
|
||||
check_cb(c, h, now, pr)
|
||||
st, c = rp.run("p3_ch_sell", house(steps), WPTS, at(start_h),
|
||||
3600 * hours, check=cb, lag=lag, n=n,
|
||||
soc0={"a": soc_a, "b": 60.0},
|
||||
choice=CH_SF if choice is None else choice,
|
||||
refuse=refuse, patch=patch,
|
||||
extra={"cycle_cost_threshold_eur": 0.10})
|
||||
return rows, c
|
||||
|
||||
|
||||
def stops(rows):
|
||||
"""[(row index, row)] where a grid charge stopped."""
|
||||
return [(i, rows[i]) for i in range(1, len(rows))
|
||||
if rows[i - 1]["action"] == arbitrage.CHARGE
|
||||
and rows[i]["action"] != arbitrage.CHARGE]
|
||||
|
||||
|
||||
def q(when):
|
||||
return int(when.timestamp() // 900)
|
||||
|
||||
|
||||
def banked(c, rows):
|
||||
"""The add-on's own P1 blocks for the quarters that had a grid charge."""
|
||||
chq = {q(r["now"]) for r in rows if r["action"] == arbitrage.CHARGE}
|
||||
return [b.offtake_avg_w for b in c.p1.blocks if q(b.start) in chq]
|
||||
|
||||
|
||||
def mixed(rows):
|
||||
return [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())]
|
||||
|
||||
|
||||
# Close to TEST-23's plant: 1 s cycles, readings 1 s old, a battery that
|
||||
# follows the previous second's command.
|
||||
LAG = rp.Lag(tau_s=0.05, ramp_w_s=1e9)
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. F1: a quarter edge while charging")
|
||||
# 1a. The projection on the edge tick, before the new block's first sample.
|
||||
_avg = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
_edge = datetime(2026, 9, 9, 12, 0, tzinfo=timezone.utc)
|
||||
for _s in range(-900, 1, 1):
|
||||
_t = _edge + timedelta(seconds=_s - 0.35)
|
||||
_avg.add(make_sample("ha_signed", 3300.0, 0.0, phases=1, ingest_ts=_t,
|
||||
ingest_mono=0.0))
|
||||
_now = _edge + timedelta(seconds=0.6)
|
||||
_old = PeakTracker.project(_avg, _now)
|
||||
_new = PeakTracker.project(_avg, _now, roll=True)
|
||||
|
||||
|
||||
def _decide(pr):
|
||||
return peakguard.decide(pr, batt_w=-2500.0, cap_w=3500.0, margin_w=50.0,
|
||||
reading=None, achievable_w=5000.0, soc=50.0,
|
||||
soc_floor=20.0, max_hold_s=30.0)
|
||||
|
||||
|
||||
print(f" edge tick: old block {_old.explain()}; rolled {_new.explain()}")
|
||||
check("on the edge tick the open block is still the old one: the guard reads "
|
||||
"'no seconds left' and the headroom is unmeasured (the F1 cause)",
|
||||
_old.elapsed_s >= 900 and peakguard.charge_headroom_w(
|
||||
_decide(_old)) is None)
|
||||
_later = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
for _s in range(-900, 1, 1):
|
||||
_later.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=_s - 0.35),
|
||||
ingest_mono=0.0))
|
||||
_later.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=0.6),
|
||||
ingest_mono=0.0))
|
||||
_real = PeakTracker.project(_later, _now)
|
||||
_hr = peakguard.charge_headroom_w(_decide(_new))
|
||||
check("rolled, it is the new quarter, measured, and equal to what the "
|
||||
"averager projects once the new block's first sample is in; the "
|
||||
"headroom is the house and the aim (3450 - 800 W)",
|
||||
_new.block_start == _edge == _real.block_start and _new.complete
|
||||
and abs(_new.projected_w - _real.projected_w) < 0.01
|
||||
and abs(_new.elapsed_s - 0.6) < 1e-6
|
||||
and _hr is not None and abs(_hr - 2650.0) < 1.0)
|
||||
_gap = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
_gap.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
|
||||
ingest_ts=_edge - timedelta(seconds=100), ingest_mono=0))
|
||||
_gp = PeakTracker.project(_gap, _edge + timedelta(seconds=40), roll=True)
|
||||
check("...and a sample older than the hold is not credited: past the hold "
|
||||
"the rolled quarter is blind, as the averager would make it",
|
||||
_gp.elapsed_s == 40.0 and _gp.covered_frac == 0.0
|
||||
and peakguard.blindness(_gp, 30.0, False) is not None)
|
||||
check("without `roll` nothing changes (the modes-off path)",
|
||||
PeakTracker.project(_avg, _now, roll=False) == _old)
|
||||
# Review of #157 (X1): a held export is credited as 0 W, as `add()` banks it,
|
||||
# never as a negative that would pull the rolled quarter under the meter.
|
||||
_exp = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
for _s in range(-900, 1, 1):
|
||||
_exp.add(make_sample("ha_signed", 0.0, 1500.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=_s - 0.35),
|
||||
ingest_mono=0.0))
|
||||
_ep = PeakTracker.project(_exp, _now, roll=True)
|
||||
_exp.add(make_sample("ha_signed", 0.0, 1500.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=0.6),
|
||||
ingest_mono=0.0))
|
||||
_er = PeakTracker.project(_exp, _now)
|
||||
check("...and a held export rolls in as 0 W, equal to what the averager "
|
||||
"banks for those seconds",
|
||||
_ep.block_start == _er.block_start and _ep.projected_w == 0.0
|
||||
and _ep.elapsed_w == 0.0 and _er.projected_w == 0.0)
|
||||
|
||||
# 1b. The lagged replay: 02:00-03:30 local, a Cheap hours battery charging
|
||||
# under an 800 W house through five quarter edges.
|
||||
ROWS1, C1 = run(1.9, 1.6, lag=LAG, choice={
|
||||
"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.LEFTOVER_SOLAR, False)})
|
||||
_ch1 = [r for r in ROWS1 if r["action"] == arbitrage.CHARGE]
|
||||
_edges1 = sorted({q(r["now"]) for r in _ch1})
|
||||
_st1 = stops(ROWS1)
|
||||
print(f" {len(_ch1)} charge cycles over {len(_edges1)} quarters; stops "
|
||||
f"{[(r['now'].astimezone(L).strftime('%H:%M:%S'), r['reason'][:40]) for _, r in _st1]}")
|
||||
check("no stop at any quarter edge crossed while charging (two inverters; "
|
||||
"see below for the old projection)",
|
||||
len(_edges1) >= 5 and not _st1)
|
||||
|
||||
|
||||
def _no_roll(c):
|
||||
real = c.peak.project
|
||||
c.peak.project = lambda a, now, roll=False: real(a, now)
|
||||
|
||||
|
||||
_CH1 = {"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.LEFTOVER_SOLAR, False)}
|
||||
# One inverter: SAFETY-91's `next_quarter()` is not applied there, so this is
|
||||
# where the old projection stops the charge at every edge.
|
||||
ROWS1N, C1N = run(1.9, 1.6, lag=LAG, n=1, choice=_CH1)
|
||||
ROWS1M, _ = run(1.9, 0.6, lag=LAG, n=1, patch=_no_roll, choice=_CH1)
|
||||
_m1 = stops(ROWS1M)
|
||||
check("one inverter: no stop at any edge crossed while charging",
|
||||
len({q(r["now"]) for r in ROWS1N if r["action"] == arbitrage.CHARGE})
|
||||
>= 5 and not stops(ROWS1N))
|
||||
check("non-vacuous: with the old projection one inverter stops at every edge "
|
||||
"for want of a measured headroom (5a4cdc2 then latched the quarter; "
|
||||
"the latch fix alone keeps it to that tick)",
|
||||
len(_m1) >= 2 and all("headroom" in r["reason"] and
|
||||
r["now"].astimezone(L).minute % 15 == 0 and
|
||||
r["now"].astimezone(L).second == 0
|
||||
for _, r in _m1))
|
||||
# Two inverters: since SAFETY-91 the guard grades the next quarter past a
|
||||
# block's end (`tariff.next_quarter()`), so the old projection no longer
|
||||
# stops there; the rolled one gives the same quarters.
|
||||
ROWS1O, C1O = run(1.9, 1.6, lag=LAG, patch=_no_roll, choice=_CH1)
|
||||
print(f" two inverters, old projection: {len(stops(ROWS1O))} stops; banked "
|
||||
f"{[round(x) for x in banked(C1O, ROWS1O)]} against "
|
||||
f"{[round(x) for x in banked(C1, ROWS1)]} rolled")
|
||||
check("two inverters: SAFETY-91 already holds the edge (no stop with the old "
|
||||
"projection), and rolling it changes no banked quarter",
|
||||
not stops(ROWS1O) and banked(C1O, ROWS1O) == banked(C1, ROWS1))
|
||||
|
||||
# 1c. A real guard stop still latches, and the 500 W minimum stays.
|
||||
# House: 800 W; 3700 W at 02:20-02:21; 3000 W from 02:26 (headroom 450 W
|
||||
# in the next quarter); 2850 W from 02:40 (600 W).
|
||||
ROWS2, C2 = run(1.9, 0.9, lag=LAG, steps=[
|
||||
(2 + 20 / 60, 3700), (2 + 21 / 60, 800), (2 + 26 / 60, 3000),
|
||||
(2 + 40 / 60, 2850)])
|
||||
_st2 = stops(ROWS2)
|
||||
_first = _st2[0][1] if _st2 else None
|
||||
_q2 = q(_first["now"]) if _first else None
|
||||
_after2 = [r for r in ROWS2 if _first and r["now"] > _first["now"]
|
||||
and q(r["now"]) == _q2]
|
||||
print(f" guard stop at "
|
||||
f"{_first['now'].astimezone(L).strftime('%H:%M:%S') if _first else None}"
|
||||
f" ({_first['reason'][:40] if _first else ''})")
|
||||
check("a mid-quarter 3.7 kW load stops the charge for the peak and it stays "
|
||||
"off for the rest of that quarter, the house back at 800 W",
|
||||
_first is not None and _first["now"].astimezone(L).minute == 20
|
||||
and ("peak" in _first["reason"] or "headroom" in _first["reason"])
|
||||
and _after2 and all(r["action"] is None for r in _after2))
|
||||
_starts = [b for a, b in zip(ROWS1 + ROWS2, (ROWS1 + ROWS2)[1:])
|
||||
if a["action"] != arbitrage.CHARGE
|
||||
and b["action"] == arbitrage.CHARGE]
|
||||
_low = [r for r in ROWS2 if r["headroom"] is not None
|
||||
and 0 < r["headroom"] <= modetrade.START_W and r["action"] is None]
|
||||
print(f" {len(_starts)} charge starts, lowest headroom at a start "
|
||||
f"{min(r['headroom'] for r in _starts):.0f} W; {len(_low)} cycles "
|
||||
f"with 0-500 W of headroom and no charge")
|
||||
check("the 500 W minimum stays: every charge starts with more than 500 W of "
|
||||
"headroom, and a house at 3 kW (450 W) holds it off",
|
||||
_starts and _low and all(r["headroom"] > modetrade.START_W
|
||||
for r in _starts))
|
||||
|
||||
# 1d. An unmeasured headroom stops one tick and does not latch.
|
||||
_c1d = run(1.9, 0.2, soc_a=20.0)[1]
|
||||
_n1d = UTC0 + timedelta(hours=2, minutes=5)
|
||||
_ok1d = False
|
||||
if _c1d.mode_tick is not None and _c1d.mode_tick.action == modetrade.CHARGE:
|
||||
_c1d.arb_headroom_w = None
|
||||
_t1 = _c1d._mode_roles(_n1d, None)
|
||||
_c1d.arb_headroom_w = 3000.0
|
||||
_t2 = _c1d._mode_roles(_n1d + timedelta(seconds=1), None)
|
||||
_c1d.arb_headroom_w = 0.0
|
||||
_t3 = _c1d._mode_roles(_n1d + timedelta(seconds=2), None)
|
||||
_c1d.arb_headroom_w = 3000.0
|
||||
_t4 = _c1d._mode_roles(_n1d + timedelta(seconds=3), None)
|
||||
_ok1d = (_t1.action is None and _t2.action == modetrade.CHARGE
|
||||
and _t3.action is None and _t4.action is None
|
||||
and _c1d._charge_blocked_q == q(_n1d))
|
||||
check("an unmeasured headroom (None) stops the charge that tick only; a "
|
||||
"measured 0 W stops it for the rest of the quarter", _ok1d)
|
||||
|
||||
|
||||
# 1e. Review of #157: gaps cannot flip the charge. A headroom forced to None
|
||||
# every other cycle flipped it 1799 times in 30 min before this; the second
|
||||
# unmeasured stop in a quarter now latches it.
|
||||
def _gaps(c):
|
||||
real, k = c._mode_roles, [0]
|
||||
|
||||
def mr(now, maint):
|
||||
k[0] += 1
|
||||
if k[0] % 2 == 0:
|
||||
c.arb_headroom_w = None
|
||||
return real(now, maint)
|
||||
c._mode_roles = mr
|
||||
|
||||
|
||||
_rows1e = run(2.0, 0.5, lag=LAG, patch=_gaps)[0]
|
||||
_flips = {}
|
||||
_on1e = next((i for i, r in enumerate(_rows1e)
|
||||
if r["action"] == arbitrage.CHARGE), len(_rows1e))
|
||||
for _a, _b in zip(_rows1e[_on1e:], _rows1e[_on1e + 1:]):
|
||||
if (_a["action"] == arbitrage.CHARGE) != (_b["action"] == arbitrage.CHARGE):
|
||||
_flips[q(_b["now"])] = _flips.get(q(_b["now"]), 0) + 1
|
||||
print(f" None every other cycle: charge on/off changes per quarter "
|
||||
f"{sorted(_flips.values())}")
|
||||
check("a headroom unmeasured every other cycle changes the charge at most "
|
||||
"4 times a quarter (restart, stop, restart, stop and latch)",
|
||||
_flips and max(_flips.values()) <= 4)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. F2: the export overshoot at a charge end")
|
||||
# A fills to its 95 % ceiling at about 04:03 under an 800 W house, and
|
||||
# Solar first B takes the house over.
|
||||
VARIANTS = {
|
||||
"model (5 s, instant readings)": None,
|
||||
"model, lagged (1 s cycles)": rp.Lag(),
|
||||
"TEST-23 plant, meter 1 s": rp.Lag(tau_s=0.05, ramp_w_s=1e9),
|
||||
"TEST-23 plant, meter 2 s": rp.Lag(tau_s=0.05, ramp_w_s=1e9,
|
||||
meter_s=2.0),
|
||||
"meter 2 s, battery reading fresh": rp.Lag(tau_s=0.05, ramp_w_s=1e9,
|
||||
meter_s=2.0, batt_s=0.0),
|
||||
"2 s cycles, meter 2 s": rp.Lag(tick=2.0, meter_s=2.0),
|
||||
"5 s meter": rp.Lag(tick=5.0, meter_s=5.0, batt_s=2.0),
|
||||
"slow battery (8 s, 2 s dead), 5 s meter": rp.Lag(
|
||||
tau_s=8.0, dead_s=2.0, tick=5.0, meter_s=5.0, batt_s=2.0),
|
||||
}
|
||||
|
||||
|
||||
def charge_end(rows, lag):
|
||||
"""(min grid, max grid, pre-edge grid, settle s) after the first charge
|
||||
end, over 120 s; settle: from then on within 40 W of the target."""
|
||||
st = stops(rows)
|
||||
if not st:
|
||||
return None
|
||||
i = st[0][0]
|
||||
tick = rp.TICK if lag is None else lag.tick
|
||||
w = [r["grid"] for r in rows[i:i + int(120 / tick)]]
|
||||
settle = next((j * tick for j in range(len(w))
|
||||
if all(abs(x + 10) < 40 for x in w[j:])), None)
|
||||
return min(w), max(w), rows[i - 1]["grid"], settle
|
||||
|
||||
|
||||
F2 = {}
|
||||
for _k, _lag in VARIANTS.items():
|
||||
F2[_k] = charge_end(run(3.9, 0.5, lag=_lag, soc_a=86.0)[0], _lag)
|
||||
print(f" {_k}: {F2[_k] and tuple(round(x) if x is not None else x for x in F2[_k])}")
|
||||
check(f"every variant: the charge ends, the export overshoot stays within "
|
||||
f"0.15 x rating ({BOUND:.0f} W), no import above the charge's own, and "
|
||||
f"the grid settles within 45 s (90 s for the slow battery)",
|
||||
all(v is not None and v[0] >= -BOUND and v[1] <= v[2] + 50
|
||||
and v[3] is not None and v[3] <= (90 if k.startswith("slow")
|
||||
else 45)
|
||||
for k, v in F2.items()))
|
||||
|
||||
|
||||
def _no_hold(c):
|
||||
c._edge_grid = lambda end, now: c.grid
|
||||
|
||||
|
||||
_pre = charge_end(run(3.9, 0.5, lag=VARIANTS["TEST-23 plant, meter 2 s"],
|
||||
soc_a=86.0, patch=_no_hold)[0],
|
||||
VARIANTS["TEST-23 plant, meter 2 s"])
|
||||
print(f" without the hold (5a4cdc2's reseed), meter 2 s: "
|
||||
f"{_pre and tuple(round(x) if x is not None else x for x in _pre)}")
|
||||
check("non-vacuous: without the hold the lagged model shows TEST-23's "
|
||||
"overshoot (over 0.4 x rating), so it cannot hide again",
|
||||
_pre is not None and _pre[0] < -0.4 * RATING)
|
||||
|
||||
# The hold must not hide a real load change at the edge.
|
||||
_down = run(3.9, 0.5, lag=LAG, soc_a=86.0, steps=[])[0]
|
||||
_i_end = stops(_down)[0][0] if stops(_down) else None
|
||||
_edge_t = _down[_i_end]["now"] if _i_end else None
|
||||
|
||||
|
||||
def _edge_step(w):
|
||||
"""The same charge end with the house stepping to `w` at the edge:
|
||||
(seconds until the loop moves B off E, lowest grid, highest grid after
|
||||
the first 3 s)."""
|
||||
if _edge_t is None:
|
||||
return None
|
||||
h = (_edge_t - UTC0).total_seconds() / 3600
|
||||
rows = run(3.9, 0.5, lag=LAG, soc_a=86.0, steps=[(h, w)])[0]
|
||||
after = [r for r in rows if r["now"] >= _edge_t]
|
||||
first = nth(after, 0, "the edge cycle")
|
||||
if not present(first):
|
||||
return None
|
||||
e = first["shares"].get(B)
|
||||
moved = next(((r["now"] - _edge_t).total_seconds() for r in after
|
||||
if r["shares"].get(B) != e), None)
|
||||
return moved, min(r["grid"] for r in after[:60]), max(
|
||||
r["grid"] for r in after[3:60]), [r["grid"] for r in after[:60]]
|
||||
|
||||
|
||||
_fall, _rise = _edge_step(200.0), _edge_step(1800.0)
|
||||
print(f" house 800 -> 200 W at the edge: loop moves after {_fall[0]} s, "
|
||||
f"lowest {_fall[1]:.0f} W; 800 -> 1800 W: after {_rise[0]} s, highest "
|
||||
f"{_rise[2]:.0f} W")
|
||||
check("the hold does not hide a load change: a house that falls at the edge "
|
||||
"ends it within 5 s, with no export beyond the fall and the bound; one "
|
||||
"that rises is followed by EDGE_HOLD_S, with no import beyond the rise",
|
||||
_fall and _rise and _fall[0] is not None and _fall[0] <= 5
|
||||
and _fall[1] >= -600 - BOUND
|
||||
and _rise[0] is not None and _rise[0] <= main.EDGE_HOLD_S + 2
|
||||
and _rise[2] <= 1000 + 50)
|
||||
_peak = _edge_step(4500.0)
|
||||
_g10 = nth(_peak[3], 10, "10 s after the edge") if _peak else None
|
||||
print(f" 800 -> 4500 W at the edge: grid 10 s later {_g10} W")
|
||||
check("...and when the peak floor takes the command during the hold, the "
|
||||
"hold ends: 10 s after the edge B gives its rating (grid 4500 - 2500 W), "
|
||||
"not the aim the floor holds", present(_g10) and _g10 <= 2100)
|
||||
|
||||
# Review of #157: the hold's own rules, on `_reseed_run_edge()` and
|
||||
# `_edge_grid()` directly (target -10 W, deadband 15 W, EDGE_HOLD_S 18 s).
|
||||
_T0 = UTC0
|
||||
|
||||
|
||||
def _held(grid, batt, s, last_b=-2500.0, end=False):
|
||||
"""(what the loop reads, still held) for a charge end E=800 W at +s."""
|
||||
f = SimpleNamespace(
|
||||
_edge=(800.0, 1.0, _T0 + timedelta(seconds=main.EDGE_HOLD_S), last_b),
|
||||
tuning=ctl.Tuning(target_grid_w=-10.0), grid=grid,
|
||||
site_batt_w=batt, i_w=0.0)
|
||||
g = main.Controller._edge_grid(f, end, _T0 + timedelta(seconds=s))
|
||||
return g, f._edge is not None
|
||||
|
||||
|
||||
_hs = main.EDGE_HOLD_S
|
||||
check("the hold: the loop reads the target while the meter still shows the "
|
||||
"step; it ends on a new trade or claim, when the meter comes within the "
|
||||
"deadband of the target, and at EDGE_HOLD_S (later only while the "
|
||||
"battery still moves to E, and never past twice that)",
|
||||
_held(3300.0, -2500.0, 1) == (-10.0, True)
|
||||
and _held(3300.0, -2500.0, 1, end=True) == (3300.0, False)
|
||||
and _held(5.0, -2500.0, 1) == (-10.0, True)
|
||||
and _held(4.0, -2500.0, 1) == (4.0, False)
|
||||
and _held(3300.0, -2500.0, _hs + 1) == (3300.0, False)
|
||||
and _held(3300.0, -2000.0, _hs + 1) == (-10.0, True)
|
||||
and _held(3300.0, -2000.0, 2 * _hs + 1) == (3300.0, False))
|
||||
# Review of #157 (X4): a reading that has passed E is not "still moving".
|
||||
check("...and a battery reading already past E does not extend the hold",
|
||||
_held(3300.0, 900.0, _hs + 1, last_b=700.0) == (3300.0, False))
|
||||
_f = SimpleNamespace(site_batt_w=500.0, grid=90.0, _edge=None,
|
||||
tuning=ctl.Tuning(target_grid_w=-10.0), site_max_w=5000.0,
|
||||
loop_bound=lambda claims: None, target=None, i_w=None)
|
||||
main.Controller._reseed_run_edge(_f, [], _T0)
|
||||
check("...and a small step is held too (E 600 W from 500 W)",
|
||||
_f.target == 600.0 and _f._edge is not None and _f._edge[:2] == (
|
||||
600.0, 1.0))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. F3: a charge refusal inside a cheap run is probed")
|
||||
# A refuses to charge from its first charge; the plant accepts again at
|
||||
# 02:40. The cheap run lasts to 04:45.
|
||||
|
||||
|
||||
def _accept(c, h, now, pr):
|
||||
if now >= at(2 + 40 / 60):
|
||||
h.plants["a"].refuse_charge = False
|
||||
|
||||
|
||||
ROWS3, C3 = run(1.9, 1.2, soc_a=30.0, refuse=("a",), check_cb=_accept)
|
||||
_ref3 = [r for r in ROWS3 if r["refused"]]
|
||||
_pr3 = [r for r in ROWS3 if r["probing"] and r["action"] == arbitrage.CHARGE]
|
||||
_probe_q = sorted({r["now"].astimezone(L).strftime("%H:%M") for r in _pr3})
|
||||
_end3 = next((r for r in ROWS3 if _ref3 and r["now"] > _ref3[-1]["now"]),
|
||||
None)
|
||||
print(f" refused {len(_ref3)} cycles; charge probes at {_probe_q}; "
|
||||
f"after: {_end3 and _end3['now'].astimezone(L).strftime('%H:%M:%S')}"
|
||||
f" {_end3 and _end3['action']}")
|
||||
check("while A refuses, it is probed every 15 minutes: at PROBE_W exactly, "
|
||||
"with B resting (no share on B, one sign)",
|
||||
len(_probe_q) >= 2 and all(
|
||||
r["shares"].get(A) == -ctl.PROBE_W and r["shares"].get(B, 0) == 0
|
||||
for r in _pr3))
|
||||
check("...outside a probe the refusal wins: no grid trade and A never "
|
||||
"charges",
|
||||
all(r["action"] is None and r["shares"].get(A, 0) >= 0
|
||||
for r in _ref3 if not r["probing"]))
|
||||
check("...and the probe after the plant accepts again ends the refusal and "
|
||||
"the charge resumes in the same run",
|
||||
_end3 is not None and _end3["now"] < at(3)
|
||||
and any(r["action"] == arbitrage.CHARGE and r["shares"].get(A, 0)
|
||||
< -1000 for r in ROWS3 if r["now"] > _end3["now"]))
|
||||
# Review of #157 (M15): outside its probe a refuser is no charger, so the
|
||||
# others are not made to rest. With solar to spare, Solar first B takes it.
|
||||
_rows3s = run(1.9, 0.5, soc_a=30.0, refuse=("a",), steps=[(2, -1500)])[0]
|
||||
_pt = [r["now"] for r in _rows3s if r["probing"]]
|
||||
_sun = [r for r in _rows3s if r["refused"] and not r["probing"]
|
||||
and r["now"] >= at(2 + 5 / 60) and not any(
|
||||
timedelta(0) < r["now"] - t <= timedelta(seconds=30) for t in _pt)]
|
||||
_bsun = sorted(r["shares"].get(B, 0) for r in _sun)
|
||||
print(f" solar 1500 W over the house, A refusing: B's share outside probes "
|
||||
f"{_bsun[0] if _bsun else None} .. {_bsun[-1] if _bsun else None} W")
|
||||
check("...outside a probe the others are not held at rest: Solar first B "
|
||||
"takes the spare solar (from 30 s after a probe)",
|
||||
_sun and all(r["shares"].get(B, 0) < -1000 for r in _sun))
|
||||
_pk = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0,
|
||||
10.0, refused=-1, probing=True)
|
||||
_runs = SimpleNamespace(on=True, cheap=SimpleNamespace(holds=lambda n: True),
|
||||
charge_useful=lambda: True, give_at=lambda n: None,
|
||||
sell=None)
|
||||
|
||||
|
||||
def _dec(packs, **kw):
|
||||
args = dict(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=800.0)
|
||||
args.update(kw)
|
||||
return modetrade.decide(_runs, UTC0, packs, **args)
|
||||
|
||||
|
||||
_pb = modetrade.Pack(B, True, packmode.SOLAR_FIRST, False, 2500.0, 60.0,
|
||||
10.0)
|
||||
check("modetrade: a probing charge refuser is a charger, the others rest; "
|
||||
"not while in maintenance, not on Leftover solar or Solar first, not "
|
||||
"latched, not under the 500 W minimum, not with the guard engaged, "
|
||||
"and never in energy() (the plan's sizing)",
|
||||
_dec([_pk, _pb]).action == modetrade.CHARGE
|
||||
and _dec([_pk, _pb]).roles == {A: modetrade.CHARGE, B: modetrade.REST}
|
||||
and _dec([replace(_pk, idle=False), _pb]).action is None
|
||||
and all(_dec([replace(_pk, mode=m), _pb]).action is None
|
||||
for m in (packmode.LEFTOVER_SOLAR, packmode.SOLAR_FIRST))
|
||||
and _dec([_pk, _pb], blocked=True).action is None
|
||||
and _dec([_pk, _pb], headroom_w=450.0).action is None
|
||||
and _dec([_pk, _pb], charge_ok=False, trade_why="peak").action is None
|
||||
and modetrade.energy([_pk], 20.0, 95.0)[1] == 0.0
|
||||
and _dec([replace(_pk, probing=False), _pb]).action is None)
|
||||
|
||||
# On the new base (release/1.0 e3e4ba6): SAFETY-78's drain comes before the
|
||||
# probe, and P4's day plan sets how far a probing Leftover solar battery goes.
|
||||
_ls = replace(_pk, mode=packmode.LEFTOVER_SOLAR)
|
||||
_dr = _dec([_pk, _pb], draining=True)
|
||||
check("a monthly drain comes before the probe: no grid charge while another "
|
||||
"battery drains, the probing refuser included (why 'cycle')",
|
||||
_dr.action is None and _dr.why == "cycle"
|
||||
and _dr.roles.get(A) != modetrade.CHARGE
|
||||
and _dec([_pk, _pb], draining=False).action == modetrade.CHARGE)
|
||||
check("with a day plan a probing Leftover solar battery is a charger up to "
|
||||
"its planned level and no further; without a level for it, it is not",
|
||||
_dec([_ls, _pb], targets={A: 60.0}).action == modetrade.CHARGE
|
||||
and _dec([_ls, _pb], targets={A: 25.0}).action is None
|
||||
and _dec([_ls, _pb], targets={B: 60.0}).action is None
|
||||
and _dec([_ls, _pb]).action is None
|
||||
and modetrade.energy([_ls], 20.0, 95.0, targets={A: 60.0})[1] == 0.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. invariants on these runs (and part 1c's house in the model)")
|
||||
_rows4, _c4 = run(1.9, 0.9, steps=[
|
||||
(2 + 20 / 60, 3700), (2 + 21 / 60, 800), (2 + 26 / 60, 3000),
|
||||
(2 + 40 / 60, 2850)])
|
||||
_all = ROWS1 + ROWS2 + ROWS3 + _rows4
|
||||
check("no opposite-direction split on any cycle", not mixed(_all))
|
||||
_ceil = peakguard.quarter_ceiling_w(rp.PEAK_CAP, None)[0]
|
||||
_bk0 = banked(_c4, _rows4)
|
||||
_bk = banked(C1, ROWS1) + banked(C2, ROWS2)
|
||||
print(f" banked quarters with a grid charge: model {len(_bk0)}, highest "
|
||||
f"{max(_bk0):.2f} W; lagged {len(_bk)}, highest {max(_bk):.2f} W "
|
||||
f"(e3e4ba6: the same 3451.37 W); ceiling - margin {_ceil - 50:.0f} W")
|
||||
check("every banked 15-minute period with a grid charge is at most "
|
||||
"quarter_ceiling_w() - margin (SAFETY-79) in the model; lagged, the "
|
||||
"battery's lag may use 2 W of the 50 W margin, as on e3e4ba6",
|
||||
_bk0 and max(_bk0) <= _ceil - 50 + 0.5
|
||||
and _bk and max(_bk) <= _ceil - 50 + 2.0)
|
||||
check("Solar first and Leftover solar never charge from the grid: no charge "
|
||||
"role and no charge share on B while the site charges",
|
||||
all(r["roles"].get(B) != modetrade.CHARGE for r in _all)
|
||||
and all(r["shares"].get(B, 0) >= 0 for r in _all
|
||||
if r["action"] == arbitrage.CHARGE))
|
||||
check("selling is off: no sale on any cycle",
|
||||
packmode.SELLING_ENABLED is False
|
||||
and all(r["action"] != arbitrage.DISCHARGE for r in _all))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. modes off: byte-identical to release/1.0 e3e4ba6")
|
||||
# Printed by tools/opt11p3_identity.py on release/1.0 e3e4ba6 (2026-10-13),
|
||||
# goodwe-addon-checks:1, with this branch's harness (same API). n1 and n2 are
|
||||
# also 5a4cdc2's; the lag ones moved with SAFETY-90/91 (n2_lag now equals n2:
|
||||
# the next-quarter grading removed the edge tick's blindness at N=2).
|
||||
AT_BASE = {
|
||||
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
"n1_lag": "86ef0f5ad8da1498ff7dcf59262a177bef847a0b201ce804e947c8eab5f07190",
|
||||
"n2_lag": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
}
|
||||
_got = {k: ident.digest(ident.run(**ident.SCENARIOS[k])[0])
|
||||
for k in AT_BASE}
|
||||
for _k in AT_BASE:
|
||||
print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} "
|
||||
f"{_got[_k][:16]}")
|
||||
check("with battery modes off every command, integrator, allocation and "
|
||||
"trade sentence equals e3e4ba6's, also when every quarter boundary's "
|
||||
"cycle still sees the old P1 block", _got == AT_BASE)
|
||||
|
||||
_real_project = tariff.PeakTracker.project
|
||||
tariff.PeakTracker.project = staticmethod(
|
||||
lambda a, now, roll=False: _real_project(a, now, roll=True))
|
||||
try:
|
||||
_moved = ident.digest(ident.run(**ident.SCENARIOS["n1_lag"])[0]) \
|
||||
!= AT_BASE["n1_lag"]
|
||||
finally:
|
||||
tariff.PeakTracker.project = _real_project
|
||||
check("non-vacuous: the rolled projection leaking into modes off moves the "
|
||||
"late-sample scenario", _moved)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1,440 +0,0 @@
|
||||
"""SAFETY-112: waiting for Home Assistant to finish starting. `python3 -B test_safety112.py`
|
||||
|
||||
The Supervisor answers 502 to every add-on read, write and websocket until
|
||||
Home Assistant is RUNNING (DECISIONS/SAFETY-112-waiting-for-home-assistant.md),
|
||||
so the add-on cannot control then. This file grades what it says meanwhile:
|
||||
|
||||
1. The log: one WARNING when Home Assistant stops answering, one INFO line
|
||||
every 5 minutes while it stays down, one INFO line with the wait when it
|
||||
answers again. Only the API channel counts as "waiting": a 502 or a
|
||||
refused connection turns it on, the next good read off, a timeout never.
|
||||
2. The status page, N=1 and N=2: the waiting row is first and red, and is the
|
||||
banner. Home says "Waiting for Home Assistant" in English and Dutch, not
|
||||
"Needs you now", unless a safety stop outranks it. The page script has it.
|
||||
3. Control is unchanged: the same readings give the same writes with the
|
||||
waiting flag on or off, with 502 readings and with live ones.
|
||||
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app import hass as _hass
|
||||
from app import messages
|
||||
from app.main import Controller
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class _Rec(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__(logging.DEBUG)
|
||||
self.records = []
|
||||
|
||||
def emit(self, record):
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
def captured(fn):
|
||||
rec, lg = _Rec(), logging.getLogger("goodwe.hass")
|
||||
old = lg.level
|
||||
lg.addHandler(rec)
|
||||
lg.setLevel(logging.DEBUG)
|
||||
try:
|
||||
fn()
|
||||
finally:
|
||||
lg.removeHandler(rec)
|
||||
lg.setLevel(old)
|
||||
return [(r.levelno, r.getMessage()) for r in rec.records
|
||||
if r.levelno >= logging.INFO]
|
||||
|
||||
|
||||
class Clock:
|
||||
"""`hass.time` stand-in: only `monotonic` is read by Outage."""
|
||||
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
|
||||
def monotonic(self):
|
||||
return self.t
|
||||
|
||||
|
||||
def with_clock(fn):
|
||||
clock, real = Clock(), _hass.time
|
||||
_hass.time = SimpleNamespace(monotonic=clock.monotonic)
|
||||
try:
|
||||
return fn(clock)
|
||||
finally:
|
||||
_hass.time = real
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the log while Home Assistant does not answer")
|
||||
W, I = logging.WARNING, logging.INFO
|
||||
|
||||
|
||||
def outage(clock):
|
||||
o = _hass.Outage()
|
||||
o.down("api", "error 502", "read sensor.x: 502")
|
||||
for t in (1, 60, 299):
|
||||
clock.t = 1000 + t
|
||||
o.down("api", "error 502", "read sensor.x: 502")
|
||||
clock.t = 1300
|
||||
o.down("api", "error 502", "") # 5 minutes: the first reminder
|
||||
clock.t = 1301
|
||||
o.down("websocket", "the meter connection closed", "")
|
||||
clock.t = 1599
|
||||
o.down("api", "error 502", "") # 299 s after the reminder: none
|
||||
clock.t = 1600
|
||||
o.down("api", "error 502", "") # 10 minutes: the second
|
||||
clock.t = 1640
|
||||
o.up("api")
|
||||
o.up("websocket") # 640 s in all
|
||||
clock.t = 2000
|
||||
o.down("api", "no connection", "") # a new outage starts afresh
|
||||
clock.t = 2030
|
||||
o.up("api")
|
||||
|
||||
|
||||
lines = with_clock(lambda c: captured(lambda: outage(c)))
|
||||
check(f"one WARNING per outage, in plain words ({lines})",
|
||||
[m for lv, m in lines if lv == W] == [
|
||||
"Home Assistant is not answering (error 502), perhaps it is still "
|
||||
"starting. Control waits and resumes by itself.",
|
||||
"Home Assistant is not answering (no connection), perhaps it is "
|
||||
"still starting. Control waits and resumes by itself."])
|
||||
check("one 'still waiting' line per 5 minutes down, with the time so far",
|
||||
[m for lv, m in lines if lv == I and m.startswith("Still")] == [
|
||||
"Still waiting for Home Assistant to finish starting, 5 minutes "
|
||||
"so far.",
|
||||
"Still waiting for Home Assistant to finish starting, 10 minutes "
|
||||
"so far."])
|
||||
check("one INFO line when the last channel is back, with the wait, and a new "
|
||||
"outage counts from its own start",
|
||||
[m for lv, m in lines if lv == I and m.startswith("Home")] == [
|
||||
"Home Assistant answers again after 11 minutes.",
|
||||
"Home Assistant answers again after 30 seconds."])
|
||||
|
||||
|
||||
def channels(_clock):
|
||||
o = _hass.Outage()
|
||||
seen = [o.waiting()]
|
||||
o.down("websocket", "the meter connection closed", "")
|
||||
seen.append(o.waiting())
|
||||
o.down("api", "error 502", "")
|
||||
seen.append(o.waiting())
|
||||
o.up("websocket")
|
||||
seen.append(o.waiting())
|
||||
o.up("api")
|
||||
seen.append(o.waiting())
|
||||
return seen
|
||||
|
||||
|
||||
check("waiting() follows the API channel only, not the meter websocket",
|
||||
with_clock(channels) == [False, False, True, True, False])
|
||||
|
||||
|
||||
# The flag as `HomeAssistant.state()` sets it: a 502 or a refused connection
|
||||
# turns it on, the next good read turns it off, and a timeout (a slow Home
|
||||
# Assistant, not a starting one) leaves it alone.
|
||||
class _Answer:
|
||||
def __init__(self, what):
|
||||
self.what = what
|
||||
|
||||
async def __aenter__(self):
|
||||
if isinstance(self.what, BaseException):
|
||||
raise self.what
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_a):
|
||||
return False
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self.what
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
async def json(self):
|
||||
return {"state": "1"}
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, answers):
|
||||
self.answers = list(answers)
|
||||
|
||||
def get(self, *_a, **_k):
|
||||
return _Answer(self.answers.pop(0))
|
||||
|
||||
|
||||
def reads(answers):
|
||||
async def go():
|
||||
real, _hass.OUTAGE = _hass.OUTAGE, _hass.Outage()
|
||||
lg = logging.getLogger("goodwe.hass")
|
||||
lg.disabled = True
|
||||
try:
|
||||
h = _hass.HomeAssistant(_Session(answers), token="t")
|
||||
seen = []
|
||||
for _ in answers:
|
||||
await h.state("sensor.x")
|
||||
seen.append(_hass.OUTAGE.waiting())
|
||||
return seen
|
||||
finally:
|
||||
_hass.OUTAGE = real
|
||||
lg.disabled = False
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
check("a 502 read turns waiting on and the next good read turns it off",
|
||||
reads([502, 200, 503, 404]) == [True, False, True, False])
|
||||
check("a refused connection is waiting too",
|
||||
reads([_hass.aiohttp.ClientConnectionError(), 200]) == [True, False])
|
||||
check("a read timeout is not waiting",
|
||||
reads([TimeoutError(), TimeoutError()]) == [False, False])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("2. the status page, N=1 and N=2")
|
||||
|
||||
|
||||
class _Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
def __init__(self):
|
||||
self.writes = []
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, _e, invert=False):
|
||||
return None # what a 502 read gives
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
|
||||
async def set_number(self, e, v):
|
||||
self.writes.append((e, float(v)))
|
||||
return False # what a 502 write gives
|
||||
|
||||
async def call(self, *_a):
|
||||
return False
|
||||
|
||||
async def states(self):
|
||||
return None
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get(self, k, d=None):
|
||||
return self.data.get(k, d)
|
||||
|
||||
def set_time(self, k, v):
|
||||
self.data[k] = v.isoformat()
|
||||
|
||||
def get_time(self, k):
|
||||
return None
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *_a, **_k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
||||
"maintenance_enabled": False, "stale_input_s": 15}
|
||||
TWO = dict(ONE, setpoint_entity="", soc_entity="", batt_entity="", inverters=[
|
||||
{"name": "inverter-a", "setpoint_entity": "number.a", "soc_entity":
|
||||
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
|
||||
"bms_soc_floor": 10},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.b", "soc_entity":
|
||||
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
|
||||
"bms_soc_floor": 10}])
|
||||
|
||||
|
||||
def make(opts, hass=None):
|
||||
c = Controller(dict(opts), hass or _Hass(), _Store(), _Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for inv in c.inverters:
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
return c
|
||||
|
||||
|
||||
def waiting_on():
|
||||
_hass.OUTAGE.down_on = {"api"}
|
||||
_hass.OUTAGE.since = _hass.OUTAGE._told = _hass.time.monotonic()
|
||||
|
||||
|
||||
def waiting_off():
|
||||
_hass.OUTAGE.down_on = set()
|
||||
|
||||
|
||||
BANNER = "Not ready — Waiting for Home Assistant to finish starting"
|
||||
for label, opts in (("N=1", ONE), ("N=2", TWO)):
|
||||
c = make(opts)
|
||||
waiting_off()
|
||||
plain = c.checks()
|
||||
pay_off = c.status_payload()
|
||||
waiting_on()
|
||||
rows = c.checks()
|
||||
pay = c.status_payload()
|
||||
pay_nl = c.status_payload(lang="nl")
|
||||
waiting_off()
|
||||
check(f"{label}: no waiting row while Home Assistant answers",
|
||||
not [r for r in plain if r.get("id") == "home_assistant"]
|
||||
and pay_off["state"]["kind"] != "waiting")
|
||||
check(f"{label}: while it does not, the waiting row is first and red",
|
||||
rows[0] == {"ok": False, "warn": False, "id": "home_assistant",
|
||||
"banner": messages.banner_ha_waiting(),
|
||||
"text": messages.row_ha_waiting()}
|
||||
and rows[1:] == plain)
|
||||
check(f"{label}: ...and it is the banner ({pay['banner']!r})",
|
||||
pay["banner"] == BANNER and pay["level"] == "bad")
|
||||
check(f"{label}: Home says waiting, not 'Needs you now' "
|
||||
f"({pay['state']['pill']!r}, {pay['state']['sub']!r})",
|
||||
pay["state"]["kind"] == "waiting"
|
||||
and pay["state"]["pill"] == "Waiting for Home Assistant"
|
||||
and pay["state"]["sub"] == "Control resumes by itself when Home "
|
||||
"Assistant answers.")
|
||||
check(f"{label}: ...and in Dutch ({pay_nl['state']['pill']!r})",
|
||||
pay_nl["state"]["pill"] == "Wacht op Home Assistant"
|
||||
and pay_nl["state"]["sub"] == "De regeling gaat vanzelf verder "
|
||||
"zodra Home Assistant antwoordt.")
|
||||
check(f"{label}: Needs attention lists it first, with its own title",
|
||||
pay["attention"][0]["id"] == "home_assistant"
|
||||
and pay["attention"][0]["title"] == "Home Assistant is not answering"
|
||||
and pay_nl["attention"][0]["title"] == "Home Assistant antwoordt "
|
||||
"niet")
|
||||
|
||||
# A safety stop outranks the wait: it still needs a person.
|
||||
c = make(TWO)
|
||||
c.inverters[1].sign_wd.tripped = True
|
||||
waiting_on()
|
||||
pay = c.status_payload()
|
||||
waiting_off()
|
||||
check(f"a safety stop keeps its banner and 'Needs you now' over the wait "
|
||||
f"({pay['banner']!r}, {pay['state']['kind']!r})",
|
||||
pay["banner"] == messages.banner_direction(True)
|
||||
and pay["state"]["kind"] == "alarm")
|
||||
# A stalled control loop does not resume by itself, so it stays the headline.
|
||||
c = make(ONE)
|
||||
c.loop_pass_mono = 0.0
|
||||
waiting_on()
|
||||
rows, pay = c.checks(), c.status_payload()
|
||||
waiting_off()
|
||||
check(f"a stalled control loop stays first and keeps 'Needs you now' "
|
||||
f"({pay['banner']!r})",
|
||||
[r["id"] for r in rows[:2]] == ["controller", "home_assistant"]
|
||||
and pay["state"]["kind"] == "alarm")
|
||||
c = make(dict(ONE, shadow_mode="on"))
|
||||
waiting_on()
|
||||
pay = c.status_payload()
|
||||
waiting_off()
|
||||
check("shadow mode keeps its own state over the wait",
|
||||
pay["state"]["kind"] == "shadow")
|
||||
|
||||
|
||||
# The page script, read as text (the image has no JS engine): an amber dot,
|
||||
# and the batteries tab carries the banner like the other held states.
|
||||
JS = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
|
||||
"static", "app.js"), encoding="utf-8").read()
|
||||
check("the page script gives 'waiting' an amber dot",
|
||||
re.search(r'alarm: " bad", waiting: " warn" \}\[st\.kind\]', JS)
|
||||
is not None)
|
||||
check("...and shows it as the batteries-tab banner",
|
||||
'["shadow", "stopped", "alarm", "waiting"].includes(S.state.kind)'
|
||||
in JS)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("3. control is unchanged by the waiting flag")
|
||||
|
||||
|
||||
def run(opts, flag):
|
||||
async def go():
|
||||
h = _Hass()
|
||||
c = make(opts, h)
|
||||
(waiting_on if flag else waiting_off)()
|
||||
targets = []
|
||||
for _ in range(4):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await c.send_heartbeats(asyncio.get_running_loop().time(), 10.0)
|
||||
targets.append(c.target)
|
||||
waiting_off()
|
||||
return targets, h.writes
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
for label, opts in (("N=1", ONE), ("N=2", TWO)):
|
||||
off, on = run(opts, False), run(opts, True)
|
||||
check(f"{label}: same targets and writes with the flag on or off "
|
||||
f"({on[0]}, {len(on[1])} writes)", off == on)
|
||||
check(f"{label}: ...and every write while Home Assistant does not answer "
|
||||
f"asks 0 W", on[1] and all(v == 0.0 for _e, v in on[1]))
|
||||
|
||||
|
||||
# With 502 readings every write is 0 W whatever the flag says, so the check
|
||||
# above cannot see a flag that forces 0 W. Live readings can: the flag alone
|
||||
# must not change a non-zero write.
|
||||
class _LiveHass(_Hass):
|
||||
async def number(self, e, invert=False):
|
||||
v = {"sensor.grid": 900.0}.get(e, 50.0 if "soc" in e else 0.0)
|
||||
return -v if invert else v
|
||||
|
||||
async def set_number(self, e, v):
|
||||
self.writes.append((e, float(v)))
|
||||
return True
|
||||
|
||||
|
||||
def run_live(opts, flag):
|
||||
async def go():
|
||||
h = _LiveHass()
|
||||
c = make(opts, h)
|
||||
(waiting_on if flag else waiting_off)()
|
||||
for _ in range(4):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await c.send_heartbeats(asyncio.get_running_loop().time(), 10.0)
|
||||
waiting_off()
|
||||
return h.writes
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
for label, opts in (("N=1", ONE), ("N=2", TWO)):
|
||||
off, on = run_live(opts, False), run_live(opts, True)
|
||||
check(f"{label}: with live readings the flag changes no write "
|
||||
f"({on[-2:]})", off == on and any(v != 0.0 for _e, v in on))
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
raise SystemExit(1)
|
||||
print("all passed")
|
||||
@@ -57,28 +57,6 @@ BASE = {
|
||||
"twice7000": ([1001.0, 1502.0, 2699.0], "1cddda8cf35859efb2ad503f1683f744cb812a6325a390c4fa3df19d717af043"),
|
||||
"step6500_gap": ([1201.0, 1801.0], "95e5c90e4a6814526cb4f3bea76286063b449d86a420f888c014531fb6e913a3"),
|
||||
}
|
||||
# OPT-15: with battery modes ON the guard no longer reads "no seconds left" on
|
||||
# a quarter's edge tick (it projects the quarter it is in), so the 1 s 0 W
|
||||
# command at each edge is gone and every run differs from 5a4cdc2's. Printed by
|
||||
# `python3 tools/safety81_sim.py` on the OPT-15 head (BASE above stays as the
|
||||
# reference). Modes OFF stays byte-identical (section 1b).
|
||||
OPT15 = {
|
||||
"const4500": "ed2f4ef9a74172ada2f00707a09ad839743449642f61dc6583ace44bac33caf4",
|
||||
"const4500_m0": "63d4d62643b9d39697b16078603576d180d90090117a497122e77c33f3db2227",
|
||||
"sine4500": "a034af0a896d55552915f1e4816055e26c006d4df0a86d1c9d9c3a625faf747f",
|
||||
"edge4600": "74e03c7182469715c1517765c2f53f1d68d7342aed4a8ad388878eac24964020",
|
||||
"edge4600_m0": "93e4402b6b879a44da5ecc28fb3b51b16c0b0f701396e00a9703803273dbd18e",
|
||||
"burst_end": "8dc4001e06ad8fa562caea02d755227cdfa16396708fa986aef7a5704e22a691",
|
||||
"noise5000_m50": "71e29a970837211835e56636280c29f70d7c2d3f3062cfac2b940ea30fa66424",
|
||||
"noise5000_m0": "a47988b7bbee629a6f8722588f325a4c87d7dcb143bba549b6df452664b30251",
|
||||
"step6500": "a7356fe0f8c00ac4d16dd4f60b054af7336ceb2eedcd76911d30138df4b457b8",
|
||||
"miss5560": "2b0afb600fe1cfd0bfe4b28fbfeeff73fc7e875e3d5f377ae7c2a489e20abf53",
|
||||
"ramp6500": "ae3838aa8f2af9458f73b540eeb76cb5fa25ace0f1ea06df2b4f6d9201907522",
|
||||
"step7500_reserve": "21eb9c07a4582f47f347084d1c75a28a5d767bc068bc3f74f32433d3239e9ccb",
|
||||
"sine4500_fast": "47ed9d02a4f7afbc50905f44519b14364f262ba793aae80f3cd077eb6f600b8b",
|
||||
"twice7000": "b89396022f589bbb90bc592ece121cf2a0bf3a4f04316da36891ac1a2b6ae18e",
|
||||
"step6500_gap": "a7356fe0f8c00ac4d16dd4f60b054af7336ceb2eedcd76911d30138df4b457b8",
|
||||
}
|
||||
OUT = {k: sim.run(**v) for k, v in sim.SCENARIOS.items()}
|
||||
RUN = {k: v[:2] for k, v in OUT.items()}
|
||||
ERR = {k: v[4] for k, v in OUT.items()}
|
||||
@@ -87,23 +65,7 @@ ERR = {k: v[4] for k, v in OUT.items()}
|
||||
print("1. every command byte-identical to release/1.0 5a4cdc2")
|
||||
check("the same scenarios as 5a4cdc2", set(RUN) == set(BASE))
|
||||
for k, (alarms, digest) in RUN.items():
|
||||
check(f"{k}: digest {digest[:12]} (OPT-15 pin; modes off in 1b)",
|
||||
digest == OPT15[k])
|
||||
|
||||
# ============================================================================ #
|
||||
print("1b. battery modes off: every command byte-identical to release/1.0 e3e4ba6")
|
||||
# Printed on e3e4ba6 (this sim copied in, `modes=False`), in the CI image.
|
||||
OFF = {
|
||||
"const4500": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3",
|
||||
"edge4600": "4f8f943f5a4e9b218c2e8bc6a95d20934ec19f1bebfbdc5748ca52e753e5afa0",
|
||||
"step6500": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3",
|
||||
"burst_end": "4ca4ba895afe7dacfe3952743e13d21607e690dfec39029fd5c5745fe03af5d7",
|
||||
"twice7000": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3",
|
||||
"sine4500_fast": "3e41ad417880a4ebb63ae3d6826f413fb779c18dfb677207775b1c980133423f",
|
||||
}
|
||||
for k, want in OFF.items():
|
||||
digest = sim.run(modes=False, **sim.SCENARIOS[k])[1]
|
||||
check(f"{k} (modes off): digest {digest[:12]}", digest == want)
|
||||
check(f"{k}: digest {digest[:12]}", digest == BASE[k][1])
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. no false alarm (5a4cdc2's are listed)")
|
||||
@@ -114,17 +76,16 @@ for k in ("edge4600", "edge4600_m0", "const4500", "const4500_m0", "sine4500",
|
||||
# ============================================================================ #
|
||||
print("3. real overloads still alarm, ALARM_HOLD_S after 5a4cdc2")
|
||||
# ALARM_HOLD_S (10 s) plus up to 2 s of late meter samples; at 1800 s the
|
||||
# quarter-edge tick restarts the hold (OPT-15: with modes on, by not grading
|
||||
# a projection that rolled into the new quarter before its first sample).
|
||||
for k, want in (("step6500", [1211.0, 1811.0]),
|
||||
("miss5560", [1213.0, 1811.0])):
|
||||
# quarter-edge tick restarts the hold.
|
||||
for k, want in (("step6500", [1211.0, 1813.0]),
|
||||
("miss5560", [1213.0, 1813.0])):
|
||||
print(f" {k}: load steps up at 1200 s; alarms at {RUN[k][0]}, "
|
||||
f"5a4cdc2 {BASE[k][0]}")
|
||||
check(f"{k}: after the hold, and again only at the next quarter",
|
||||
RUN[k][0] == want)
|
||||
check("step6500_gap: cycles stopped by a lost battery reading do not "
|
||||
"restart the hold",
|
||||
RUN["step6500_gap"][0] == [1215.0, 1811.0])
|
||||
RUN["step6500_gap"][0] == [1215.0, 1813.0])
|
||||
check("twice7000: notified once in the quarter (5a4cdc2: "
|
||||
f"{BASE['twice7000'][0]})", RUN["twice7000"][0] == [1011.0])
|
||||
check("twice7000: the ERROR line for both overloads",
|
||||
@@ -135,7 +96,7 @@ print(f" ramp6500: alarms at {RUN['ramp6500'][0]}, "
|
||||
f"5a4cdc2 {BASE['ramp6500'][0]}")
|
||||
check("ramp6500: alarms once the quarter will end over the real cap by "
|
||||
"more than ALARM_MISS_W",
|
||||
RUN["ramp6500"][0] == [1317.0, 1811.0])
|
||||
RUN["ramp6500"][0] == [1316.0, 1813.0])
|
||||
check("step7500 under the peak reserve: not this alarm (the guard is not "
|
||||
"allowed to discharge)", RUN["step7500_reserve"][0] == [])
|
||||
|
||||
@@ -189,7 +150,6 @@ stub = SimpleNamespace(
|
||||
reason="PEAK_GUARD: fixture"),
|
||||
site_rating_w=2000.0, shadow=False, events=[],
|
||||
_peak_batt_prev=None, _peak_alarm_block=None, _peak_high_since=None,
|
||||
_peak_rolled=False,
|
||||
_peak_saturated=False, hass=SimpleNamespace(call=_call),
|
||||
log_event=lambda msg, hint=None: None)
|
||||
first = None
|
||||
|
||||
@@ -19,14 +19,12 @@ import hashlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
|
||||
from app.static_tariff import TariffPlan # noqa: E402
|
||||
|
||||
@@ -55,21 +53,6 @@ def _feed(c, t):
|
||||
s79._feed(c, t)
|
||||
|
||||
|
||||
# OPT-15: the P1 sample of each cycle is built this long after the cycle
|
||||
# reads the grid (the HA source's debounce), so the cycle on a quarter
|
||||
# boundary still sees the old block, as on TEST-23.
|
||||
P1_LAG_S = 0.35
|
||||
|
||||
|
||||
def _feed_lag(c, t):
|
||||
c.tariff_plan, c.tariff_at = PLAN, rig.BASE + timedelta(seconds=t)
|
||||
imp, exp = split_signed(c.hass.grid())
|
||||
c.p1.submit(make_sample(
|
||||
"ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=rig.BASE + timedelta(seconds=t - P1_LAG_S),
|
||||
ingest_mono=time.monotonic()))
|
||||
|
||||
|
||||
def load(t):
|
||||
"""Under the limit, over it, a surplus, a big evening load."""
|
||||
k = int(t // 600) % 5
|
||||
@@ -87,13 +70,10 @@ SCENARIOS = {
|
||||
# The option present and false: an install that saved it off.
|
||||
"n1_off": dict(n=1, extra={"battery_modes_enabled": False}),
|
||||
"n2_off": dict(n=2, extra={"battery_modes_enabled": False}),
|
||||
# OPT-15: every quarter boundary with the old block still open.
|
||||
"n1_lag": dict(n=1, lag=True),
|
||||
"n2_lag": dict(n=2, lag=True),
|
||||
}
|
||||
|
||||
|
||||
def run(n, *, extra=None, dur=DUR, lag=False):
|
||||
def run(n, *, extra=None, dur=DUR):
|
||||
opts = dict(s79.P1, **ARB, **(extra or {}))
|
||||
trade = []
|
||||
|
||||
@@ -105,8 +85,7 @@ def run(n, *, extra=None, dur=DUR, lag=False):
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
try:
|
||||
rows, c = rig.run(n, extra=opts, dur=dur, load=load, soc=soc,
|
||||
cut=lambda u, t: False,
|
||||
before=_feed_lag if lag else _feed,
|
||||
cut=lambda u, t: False, before=_feed,
|
||||
on_cycle=on_cycle)
|
||||
finally:
|
||||
rig.Controller = real
|
||||
|
||||
@@ -18,11 +18,7 @@ earlier (364 days), or the captured quarter-hourly energy-charts bodies for
|
||||
2026-09-09/10), the decision code.
|
||||
MODELLED: the batteries (a first-order lag, 3 s, 800 W/s ramp, SoC
|
||||
integrated, 95 % each way, a reading that jitters by a few watts, and
|
||||
optionally a device limit or a refusal to charge). By default the readings
|
||||
are instantaneous and the loop runs every 5 s, which hides what the loop
|
||||
does on readings a second or two old; `lag=Lag(...)` (OPT-15) adds a command
|
||||
dead time, a meter and battery reading age, the P1 sample's debounce and a
|
||||
1 s cycle, as on TEST-23. The all-in price (the EPEX file's own stated
|
||||
optionally a device limit or a refusal to charge), the all-in price (the EPEX file's own stated
|
||||
assumption: (wholesale + 0.02 + 0.10) x 1.06), export paid at the wholesale
|
||||
price, tomorrow's prices known from 13:00 local. The absolute money is
|
||||
therefore an estimate; the comparison between variants is the point.
|
||||
@@ -108,28 +104,10 @@ def qh_points():
|
||||
if p is not None]
|
||||
|
||||
|
||||
class Lag:
|
||||
"""OPT-15: how old what the add-on reads is, and how often it acts.
|
||||
`dead_s` a command takes effect that late; `meter_s` / `batt_s` the age
|
||||
of the grid and battery readings; `p1_s` a P1 sample is built that long
|
||||
after the reading (the HA source's debounce), so a cycle just after a
|
||||
quarter boundary can still see the old block; `tick` the cycle period;
|
||||
`tau_s` / `ramp_w_s` the battery's own lag (TEST-23's plant: `dead_s` 1,
|
||||
no lag or ramp)."""
|
||||
|
||||
def __init__(self, dead_s=1.0, meter_s=1.0, batt_s=1.0, p1_s=0.35,
|
||||
tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0):
|
||||
self.dead_s, self.meter_s, self.batt_s = dead_s, meter_s, batt_s
|
||||
self.p1_s, self.tick, self.sub = p1_s, tick, sub
|
||||
self.tau_s, self.ramp_w_s = tau_s, ramp_w_s
|
||||
|
||||
|
||||
class Plant:
|
||||
def __init__(self, soc, refuse_charge=False):
|
||||
self.cmd, self.p, self.soc = 0.0, 0.0, float(soc)
|
||||
self.refuse_charge = refuse_charge
|
||||
self.queue = [] # (t, command) not yet in effect
|
||||
self.tau, self.ramp = 3.0, 800.0
|
||||
|
||||
def step(self, dt, floor):
|
||||
c = self.cmd
|
||||
@@ -137,8 +115,8 @@ class Plant:
|
||||
c = 0.0
|
||||
if c < 0 and (self.soc >= 100.0 or self.refuse_charge):
|
||||
c = 0.0
|
||||
want = self.p + (c - self.p) * (1 - math.exp(-dt / self.tau))
|
||||
dp = max(-self.ramp * dt, min(self.ramp * dt, want - self.p))
|
||||
want = self.p + (c - self.p) * (1 - math.exp(-dt / 3.0))
|
||||
dp = max(-800.0 * dt, min(800.0 * dt, want - self.p))
|
||||
self.p += dp
|
||||
e = self.p * dt / 3600.0 / 1000.0 # kWh, + = out
|
||||
eff = 1 / 0.95 if e > 0 else 0.95
|
||||
@@ -147,52 +125,17 @@ class Plant:
|
||||
|
||||
|
||||
class Hass:
|
||||
def __init__(self, units, house_at, soc0=None, refuse=(), limits=None,
|
||||
lag=None):
|
||||
def __init__(self, units, house_at, soc0=None, refuse=(), limits=None):
|
||||
self.units = units
|
||||
self.lim = limits or {}
|
||||
self.plants = {u: Plant((soc0 or {}).get(u, 50.0), u in refuse)
|
||||
for u in units}
|
||||
self.house_at = house_at
|
||||
self.t = 0.0
|
||||
self.lag = lag
|
||||
self.hist = [] # (t, grid, {unit: p}), with `lag`
|
||||
for p in self.plants.values():
|
||||
if lag is not None:
|
||||
p.tau, p.ramp = lag.tau_s, lag.ramp_w_s
|
||||
|
||||
def grid(self):
|
||||
return self.house_at(self.t) - sum(p.p for p in self.plants.values())
|
||||
|
||||
def _past(self, age):
|
||||
"""(grid, {unit: p}) as they were `age` seconds ago."""
|
||||
want = self.t - age
|
||||
for t, g, ps in reversed(self.hist):
|
||||
if t <= want + 1e-9:
|
||||
return g, ps
|
||||
return self.hist[0][1:] if self.hist else (self.grid(), {
|
||||
u: p.p for u, p in self.plants.items()})
|
||||
|
||||
def advance(self, dt, floor):
|
||||
"""Step the plant `dt` seconds (with `lag`: in `sub` steps, commands
|
||||
applied after `dead_s`, history kept for the lagged readings)."""
|
||||
lag = self.lag
|
||||
if lag is None:
|
||||
for p in self.plants.values():
|
||||
p.step(dt, floor)
|
||||
return
|
||||
end = self.t + dt
|
||||
while self.t < end - 1e-9:
|
||||
h = min(lag.sub, end - self.t)
|
||||
for p in self.plants.values():
|
||||
while p.queue and p.queue[0][0] <= self.t + 1e-9:
|
||||
p.cmd = p.queue.pop(0)[1]
|
||||
p.step(h, floor)
|
||||
self.t += h
|
||||
self.hist.append((self.t, self.grid(),
|
||||
{u: p.p for u, p in self.plants.items()}))
|
||||
del self.hist[:-200]
|
||||
|
||||
async def limits(self, e):
|
||||
lo, hi = self.lim.get(e[-1], (-5000.0, 5000.0))
|
||||
return (lo, hi, 1.0)
|
||||
@@ -202,8 +145,7 @@ class Hass:
|
||||
|
||||
async def number(self, e, invert=False):
|
||||
if e == "sensor.grid":
|
||||
return self.grid() if self.lag is None else \
|
||||
self._past(self.lag.meter_s)[0]
|
||||
return self.grid()
|
||||
u = e[-1]
|
||||
if e.startswith("sensor.soc"):
|
||||
return round(self.plants[u].soc, 1)
|
||||
@@ -211,17 +153,11 @@ class Hass:
|
||||
# A real reading jitters by a few watts; a dead-flat one is a
|
||||
# stale register to the add-on (SAFETY-37), which is not modelled.
|
||||
j = (ord(u) * 31 + int(self.t)) % 7 - 3
|
||||
p = self.plants[u].p if self.lag is None else \
|
||||
self._past(self.lag.batt_s)[1][u]
|
||||
return float(round(p) + j)
|
||||
return float(round(self.plants[u].p) + j)
|
||||
return None
|
||||
|
||||
async def set_number(self, e, v):
|
||||
p = self.plants[e[-1]]
|
||||
if self.lag is None:
|
||||
p.cmd = float(v)
|
||||
else:
|
||||
p.queue.append((self.t + self.lag.dead_s, float(v)))
|
||||
self.plants[e[-1]].cmd = float(v)
|
||||
return True
|
||||
|
||||
|
||||
@@ -303,7 +239,7 @@ def plan_at(now, pts):
|
||||
|
||||
def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
soc0=None, refuse=(), extra=None, choice=None, patch=None,
|
||||
limits=None, lag=None):
|
||||
limits=None):
|
||||
modes, arb, var_choice = VARIANTS[variant]
|
||||
choice = var_choice if choice is None else choice
|
||||
units = tuple("ab"[:n])
|
||||
@@ -336,8 +272,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
ticks=0, quarters={}, trade_ticks=0)
|
||||
|
||||
async def go():
|
||||
hass = Hass(units, house_at, soc0, refuse, limits, lag)
|
||||
tick = TICK if lag is None else lag.tick
|
||||
hass = Hass(units, house_at, soc0, refuse, limits)
|
||||
import tempfile
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="p3r-"), "tariff.db")
|
||||
c = Controller(dict(options(modes, arb, n), **(extra or {})), hass,
|
||||
@@ -364,8 +299,6 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
now = start + timedelta(seconds=t)
|
||||
hass.t = t
|
||||
clock[0] = now
|
||||
if lag is not None and not hass.hist:
|
||||
hass.advance(0.0, 10.0)
|
||||
if plan_hour != now.replace(minute=0, second=0,
|
||||
microsecond=0):
|
||||
plan_hour = now.replace(minute=0, second=0,
|
||||
@@ -373,25 +306,17 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
c.tariff_plan, c.tariff_at = plan_at(now, pts), now
|
||||
else:
|
||||
c.tariff_at = now
|
||||
if lag is None:
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
elif t >= lag.p1_s:
|
||||
# The sample of the reading one debounce ago.
|
||||
imp, exp = split_signed(hass._past(
|
||||
lag.meter_s + lag.p1_s)[0])
|
||||
c.p1.submit(make_sample(
|
||||
"ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now - timedelta(seconds=lag.p1_s),
|
||||
ingest_mono=time.monotonic()))
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(tick, 10.0)
|
||||
for p in hass.plants.values():
|
||||
p.step(TICK, 10.0)
|
||||
g = hass.grid()
|
||||
pr = price_at(now)
|
||||
dt_h = tick / 3600.0
|
||||
dt_h = TICK / 3600.0
|
||||
if g > 0:
|
||||
stats["imp_kwh"] += g / 1000 * dt_h
|
||||
stats["cost"] += g / 1000 * dt_h * (all_in(pr) if pr
|
||||
@@ -414,7 +339,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
stats["ticks"] += 1
|
||||
if check is not None:
|
||||
check(c, hass, now, pr)
|
||||
t += tick
|
||||
t += TICK
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
return c
|
||||
|
||||
@@ -119,15 +119,15 @@ class _Plant:
|
||||
|
||||
|
||||
def run(load, *, margin=50, dur=2700.0, rating=2000.0, cap=3500.0,
|
||||
late_at=(), soc=60.0, batt_gap=(), modes=True):
|
||||
late_at=(), soc=60.0, batt_gap=()):
|
||||
"""Alarm times (s from the start of the first quarter), a digest of every
|
||||
cycle's target and written command, the Controller, the rows, and the
|
||||
times of the ERROR log line. A sample is also late at each `t % 900` in
|
||||
`late_at`. `modes=False` (OPT-15) runs with battery modes off."""
|
||||
`late_at`."""
|
||||
opts = rig._options(1, {
|
||||
"meter_source": "ha_signed", "p1_net_entity": "sensor.net",
|
||||
"meter_phases": 1, "peak_cap_w": cap, "peak_margin_w": margin,
|
||||
"battery_modes_enabled": modes, "max_w": rating})
|
||||
"battery_modes_enabled": True, "max_w": rating})
|
||||
store = rig._Store()
|
||||
store.data[unit_key("inverter-a", packmode.USE_KEY)] = False
|
||||
store.data[packmode.USE_KEY] = False
|
||||
|
||||
@@ -767,79 +767,6 @@ ok_i4, out_i4 = validate(dict(defaults, **{_mk: 24}))
|
||||
show("the range is unchanged: 24 is refused (`at most 23`)",
|
||||
(not ok_i4) and "at most 23" in str(out_i4), out_i4)
|
||||
|
||||
print("\n== J. OPT-17: several solar forecast entities per day ==")
|
||||
# The same question as §G, for `pv_forecast_entity` /
|
||||
# `pv_forecast_tomorrow_entity` (`str?` since 0.15.0). Each refusal is
|
||||
# varied from the accepted baseline and asserted on the key's name.
|
||||
# DECISIONS/OPT-17-several-forecast-entities.md.
|
||||
FC_STORED = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
|
||||
"pv_forecast_tomorrow_entity": "sensor.energy_production_tomorrow"}
|
||||
_fc_old = {k: v for k, v in defaults.items()
|
||||
if k not in ("pv_forecast_entities", "pv_forecast_tomorrow_entities")}
|
||||
_fc_base = MERGER.merge(copy.deepcopy(defaults),
|
||||
dict(_fc_old, **FC_STORED))
|
||||
ok_j, out_j = validate(_fc_base)
|
||||
show("a 0.17.0 blob (single forecast keys set, no list keys) validates "
|
||||
"unchanged, and both new keys default to []",
|
||||
ok_j and out_j.get("pv_forecast_entity") == FC_STORED["pv_forecast_entity"]
|
||||
and out_j.get("pv_forecast_entities") == []
|
||||
and out_j.get("pv_forecast_tomorrow_entities") == [],
|
||||
"" if ok_j else out_j)
|
||||
for _k in ("pv_forecast_entity", "pv_forecast_tomorrow_entity"):
|
||||
_s1 = dict(schema, **{_k: ["str"]})
|
||||
ok_j1, out_j1 = validate_with(_s1, _fc_base)
|
||||
show(f"`{_k}` declared as a LIST refuses the string an install stores",
|
||||
(not ok_j1) and f"Invalid list for option '{_k}'" in str(out_j1),
|
||||
out_j1)
|
||||
_m1 = MERGER.merge(copy.deepcopy(dict(defaults, **{_k: []})),
|
||||
copy.deepcopy(FC_STORED))
|
||||
ok_j2, out_j2 = validate_with(_s1, _m1)
|
||||
show("...and a `[]` default does not rescue it (list merge is override)",
|
||||
_m1[_k] == FC_STORED[_k] and not ok_j2, out_j2)
|
||||
_sm = dict(schema, **{_k: "match(.*)?"})
|
||||
_ok_s, _ = validate_with(_sm, _fc_base)
|
||||
_ok_l, _ = validate_with(_sm, dict(_fc_base, **{_k: ["sensor.a",
|
||||
"sensor.b"]}))
|
||||
_ok_n, _ = validate_with(_sm, dict(_fc_base, **{_k: 417}))
|
||||
_ui_m = _ui_of(_sm, _k)
|
||||
show(f"...`match(.*)?` on `{_k}` takes a string AND a list, but also the "
|
||||
"integer 417, and renders as a plain text box (no second row)",
|
||||
_ok_s and _ok_l and _ok_n and _ui_m
|
||||
and _ui_m[0].get("multiple") is not True,
|
||||
f"string={_ok_s} list={_ok_l} integer-417={_ok_n} "
|
||||
f"ui={json.dumps(_ui_m)}")
|
||||
_two = ["sensor.fc_east", "sensor.fc_west"]
|
||||
ok_j3, out_j3 = validate(MERGER.merge(copy.deepcopy(defaults), dict(
|
||||
_fc_old, pv_forecast_entities=_two,
|
||||
pv_forecast_tomorrow_entities=_two)))
|
||||
show("⭐ SHIPPED: two-entity lists for today and tomorrow validate",
|
||||
ok_j3 and out_j3.get("pv_forecast_entities") == _two
|
||||
and out_j3.get("pv_forecast_tomorrow_entities") == _two,
|
||||
"" if ok_j3 else out_j3)
|
||||
for _k in ("pv_forecast_entities", "pv_forecast_tomorrow_entities"):
|
||||
ok_j4, out_j4 = validate({k: v for k, v in defaults.items() if k != _k})
|
||||
show(f"...and the `options:` block with ONLY `{_k}` removed is refused "
|
||||
"by name, so its `[]` default is load-bearing",
|
||||
(not ok_j4) and f"Missing option '{_k}' in root" in str(out_j4),
|
||||
out_j4)
|
||||
_ui_j = _ui_of(schema, _k)
|
||||
show(f"...and `{_k}` renders as a repeatable string field",
|
||||
len(_ui_j) == 1 and _ui_j[0].get("multiple") is True
|
||||
and _ui_j[0].get("type") == "string", json.dumps(_ui_j))
|
||||
_flat_j = {k: v for k, v in schema.items()
|
||||
if k not in ("pv_forecast_entities", "pv_forecast_tomorrow_entities")}
|
||||
try:
|
||||
_rj = vol.Schema(vol.All(dict, AppOptions(
|
||||
None, _flat_j, cfg["name"], "local_goodwe_controller")))(
|
||||
MERGER.merge(copy.deepcopy(defaults), dict(
|
||||
FC_STORED, pv_forecast_entities=_two)))
|
||||
show("a DOWNGRADE to 0.17.0's schema drops the list keys and keeps the "
|
||||
"single ones",
|
||||
"pv_forecast_entities" not in _rj
|
||||
and _rj.get("pv_forecast_entity") == FC_STORED["pv_forecast_entity"])
|
||||
except vol.Invalid as e:
|
||||
show("a DOWNGRADE to 0.17.0's schema drops the list keys", False, str(e))
|
||||
|
||||
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
|
||||
# and still succeeds, and a transcript is only evidence if a wrong answer is
|
||||
# distinguishable from a right one by something other than reading it.
|
||||
|
||||
@@ -91,12 +91,6 @@ configuration:
|
||||
pv_forecast_tomorrow_entity:
|
||||
name: "Hardware · Solar forecast, tomorrow (kWh)"
|
||||
description: "Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight."
|
||||
pv_forecast_entities:
|
||||
name: "Hardware · Several solar forecasts, today (kWh)"
|
||||
description: "Optional. One row per forecast sensor, such as one per inverter, used instead of the single one above. They are added up."
|
||||
pv_forecast_tomorrow_entities:
|
||||
name: "Hardware · Several solar forecasts, tomorrow (kWh)"
|
||||
description: "Optional. The same list for tomorrow. If one sensor has no reading, that day's forecast is not used."
|
||||
solar_trust_pct:
|
||||
name: "Hardware · Trust in the forecast (%)"
|
||||
description: "Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast."
|
||||
|
||||
@@ -72,12 +72,6 @@ configuration:
|
||||
pv_forecast_tomorrow_entity:
|
||||
name: "Hardware · Zonneprognose, morgen (kWh)"
|
||||
description: "Optioneel. Dezelfde prognose voor morgen. Ze helpt een goedkoop uur voor middernacht te plannen."
|
||||
pv_forecast_entities:
|
||||
name: "Hardware · Meerdere zonneprognoses, vandaag (kWh)"
|
||||
description: "Optioneel. Eén rij per prognosesensor, bijvoorbeeld per omvormer, in plaats van de losse sensor hierboven. Ze worden opgeteld."
|
||||
pv_forecast_tomorrow_entities:
|
||||
name: "Hardware · Meerdere zonneprognoses, morgen (kWh)"
|
||||
description: "Optioneel. Dezelfde lijst voor morgen. Heeft één sensor geen meting, dan wordt de prognose voor die dag niet gebruikt."
|
||||
solar_trust_pct:
|
||||
name: "Hardware · Vertrouwen in de prognose (%)"
|
||||
description: "Prognoses zijn vaak te zonnig. Bij 70 % plant de add-on met 7 van elke 10 kWh uit de prognose."
|
||||
|
||||
Reference in New Issue
Block a user