Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
571b884315 | ||
|
|
696142bccc | ||
|
|
d017fc67bf | ||
|
|
5c151a3458 | ||
|
|
24f955cb26 | ||
|
|
48ef06ac1f | ||
|
|
7b65a3e69a | ||
|
|
50ccea3dd7 | ||
|
|
2503de124e | ||
|
|
55a6d5dcdb | ||
|
|
fd14569a71 | ||
|
|
59415ea83f | ||
|
|
f37268f3e4 | ||
|
|
397dd15dba | ||
|
|
6028e21298 | ||
|
|
fe132515fe | ||
|
|
00e9a7b4e1 | ||
|
|
82f032e8cf |
+1
-1
@@ -1,2 +1,2 @@
|
||||
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
|
||||
2d026dfa7c425f462c6969b2f7cdfddc17b17a81
|
||||
7081df4276ad3cf788355b97a3b3f40ea28d72bd
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# STYLE.md - how user-facing text is written (UX-08)
|
||||
|
||||
This applies to everything a home owner or installer reads: notifications, phone pushes, the add-on page, refusals, add-on log lines at WARNING and above, and the names and descriptions of options. It is enforced by `goodwe_controller/test_messages.py`. Kinds that have not been rewritten yet are on its allowlist, and that allowlist only shrinks.
|
||||
|
||||
## 1. Shape
|
||||
- **Title**: at most 5 everyday words. It says what is wrong ("Meter reading may be stuck"). No "GoodWe:" prefix, no full stop.
|
||||
- **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost.
|
||||
- **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "<title>" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body.
|
||||
- **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md.
|
||||
- **Log line** (WARNING/ERROR): one line, at most 25 words. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated.
|
||||
- **Sentences**: one idea each, active voice, present tense, with nothing nested in brackets and no semicolons (a semicolon is a second sentence in disguise). These translate well into Dutch and French.
|
||||
|
||||
## 2. Numbers
|
||||
Use a number only if it helps the reader act, for example the peak limit, or what was asked and what was sent in a fault report. Write it the way `app/messages.py` does: `5 000 W`, `20 %`, `10 minutes`, `14:05`.
|
||||
|
||||
## 3. Severity
|
||||
Severity is shown by the title and the colour, never by CAPITALS or by words like "BUG SIGNAL".
|
||||
|
||||
| Level | Colour | When |
|
||||
|---|---|---|
|
||||
| alarm | red | Control stopped, money or energy lost now, a fault, or a person must act now. Up to 3 sentences. |
|
||||
| warning | orange | Control is still running, but something needs a look. 1 sentence. |
|
||||
| info | grey | No action needed. 1 sentence. |
|
||||
| resolved | green | An earlier alarm or warning has cleared. 1 sentence. |
|
||||
|
||||
A rewrite never changes an alarm's severity, `notification_id` or cadence.
|
||||
|
||||
## 4. Words - the same word for the same thing everywhere
|
||||
| Say | Not |
|
||||
|---|---|
|
||||
| battery | pack, BMS pack |
|
||||
| inverter, or its name | unit, device, actuator |
|
||||
| controller | loop, planner |
|
||||
| meter | P1 feed, telegram |
|
||||
| peak limit / this month's peak | ceiling, capaciteitstarief, billed quarter |
|
||||
| 15-minute period | quarter |
|
||||
| battery level | SoC, state of charge |
|
||||
| cut-out level | BMS floor, `bms_soc_floor` |
|
||||
| peak reserve | `peak_soc_floor`, EMS floor |
|
||||
| inverter rating | `max_w`, rail |
|
||||
| control is stopped / every inverter is set to 0 W | failsafe, trip, latch, parked |
|
||||
| software fault | BUG SIGNAL |
|
||||
| control board | master, ESP32 |
|
||||
| shadow mode | a mode that makes every decision but commands 0 W. The name is kept. |
|
||||
|
||||
**Banned in titles and bodies** (the test fails on them): SAFETY-/TARIFF-/TEL-/CFG-/FW-/HW-nn and other card ids, integrator, anti-windup, windup, clamp, clamped, saturated, BUG SIGNAL, latch, latched, arbiter, allocator, register, `0x…`, layer 1/2/3, heartbeat, watchdog, telegram, ACK, CRC, OTA, Modbus, planner, `§n`, file names, IP addresses, option keys in snake_case, **HA entity ids**, and runs of 2 or more CAPITALISED words.
|
||||
|
||||
HA entity ids are allowed only in refusals and validator messages, where the installer has to find that entity. They are never allowed in alarms or status rows.
|
||||
@@ -0,0 +1,28 @@
|
||||
# SAFETY-40: the integrator bound is not a bug
|
||||
|
||||
**Decision.** `i_clamped` (the integrator reaching `integrator_max_w`, i.e.
|
||||
anti-windup) keeps alarming through the same `ClampWatchdog`: it is counted,
|
||||
notified on the same cadence, and escalates on a sustained run
|
||||
(SAFETY-41). It gets its own text, a `WARNING` and an amber status row. Only
|
||||
`clamped` (a command over `max_w`) and write-path clamps keep the BUG SIGNAL,
|
||||
the `ERROR` and the red row.
|
||||
|
||||
**Why it still notifies.** On a default install `clamped` is unreachable
|
||||
(`control.py`, at the integrator bound), so the integrator bound is the only
|
||||
loop-side signal of a runaway such as the 14 768 W one. Silencing it would
|
||||
remove that. The card says the same: "the fix is to say the true thing, not to
|
||||
say less."
|
||||
|
||||
**Why a bug has its own budget.** A genuine over-rating must not wait up to
|
||||
`REASSERT_S` behind anti-windup notices. `ClampWatchdog` tracks a separate
|
||||
`last_bug_alarm`, and a bug's text is never overwritten by later anti-windup
|
||||
text within an episode. Across episodes the WARNING uses its own
|
||||
notification id (`goodwe_integrator_bound`), so it cannot replace a BUG SIGNAL
|
||||
notice still on screen (review of #130). With only bug engagements, the cadence is the same as
|
||||
before (`test_clamp.py` part 9).
|
||||
|
||||
**Numbers.** They come from the `Decision` of the clamping tick (`i_bound_w`,
|
||||
`requested_w`, `clamped_w`, `target_w`), not from `i_w` or `self.target`.
|
||||
|
||||
**Open for the owner.** Whether scattered one-cycle anti-windup episodes should
|
||||
notify at all, or only log and count. They notify now, as before.
|
||||
@@ -357,3 +357,5 @@ the bump is PKG's call. The material an entry needs is this file.
|
||||
|
||||
The option surface moves **76 keys → 77**, one new optional key, `pv_entities`,
|
||||
empty on every install that does not set it.
|
||||
|
||||
**Superseded in part (UX-08, 2026-09-27):** the PV status rows no longer read 0.11.0's sentences at one entity. The owner ruled out entity ids on status rows, so 2/5 now holds for the control document only. See `UX-08-short-notifications.md`, phase 2b item 3.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# UX-08 phase 2a: short notifications, and what moved out of them
|
||||
|
||||
The owner decided on 2026-09-27: a title of at most about 5 everyday words and a one-sentence body. A red safety alarm may use up to 3 sentences, so no action-relevant information is lost. Titles are neutral, with no "GoodWe:" prefix. HA entity ids appear only in refusals. The rule is `STYLE.md` at the repository root, the texts are in `app/messages.py`, and `test_messages.py` enforces both. The detail each notification used to carry is in DOCS.md under "Notifications", and the body links to it.
|
||||
|
||||
**Unchanged:** every `notification_id`, every trigger and cadence (edges, SAFETY-34 re-announce, per-unit ids), every log line and every status row. The status banner and rows are phase 2b, refusals and option texts 2c, logs 2d; `ux08_allowlist.txt` lists them until then.
|
||||
|
||||
## Where a choice was made
|
||||
1. **Severity of "not delivering" (SAFETY-42).** The notification is an *alarm*, so it may use 3 sentences. Its log line is ERROR and the alarm is latched. The page banner stays amber, as SAFETY-42 decided, because control keeps running; the two answer different questions (is control running / must a person act) and HA shows no colour on a notification. The three checks stay in their original order. Clearing it takes stop then start, and the body says so.
|
||||
2. **Figures left the bodies** of the frozen-meter card (the rail and the command), the circulating-power card (the watts and the time window) and the peak cards (the projection). None of them is needed to act, and all of them are still in the reason text that the log line and the status row carry. `test_freeze.py` and `test_watchdog.py` now pin that. The frozen-meter card can therefore no longer quote a wrong rail (SAFETY-20), because it quotes none.
|
||||
3. **The over-rating alarm keeps its numbers** (asked, sent, and which limit cut it), because they are what a fault report needs. `ClampWatchdog.facts` carries them under the same keep-the-bug rule as `reason`, so the words and the numbers always describe the same tick, with asked before sent (SAFETY-40, `test_clamp.py` part 6).
|
||||
4. **SAFETY-25 stays in the peak-reserve alarm** as its last sentence ("the reserve only limits peak shaving … down to its cut-out level"). The alarm now gives each branch its own action: at the reserve, lower it; battery level unread, check the sensor; floor unknown, set the cut-out level. That last branch is `soc_floor is None`, which happens when an inverter's cut-out level is missing, not the peak reserve (that option always has a value). Before, every branch said "lower peak_soc_floor". The title, "Battery stopped limiting the peak", is true on all three branches.
|
||||
5. **SAFETY-32 at a roster of one.** The body no longer ends in a separate "WHICH PACK" paragraph. The pack's name is part of the first sentence ("The battery of inverter-b …"), and at a roster of one that is the only difference.
|
||||
6. **Dropped as background, now in DOCS:** "charging stays blocked" (peak reserve), "why no other guard fired" (circulating power, not delivering), and the saturation-freeze distinction (over-rating). "The meter cannot see this" stays in the circulating-power card, as the owner asked.
|
||||
7. **The Details link** opens the add-on's documentation page (`/hassio/addon/<slug>/documentation`, with the slug taken from the container hostname) and names the section. A `#heading` anchor was not used because nobody has checked that Home Assistant's documentation tab renders heading ids; if it does, adding one is a one-line change. Outside an add-on container it names the section without a link.
|
||||
|
||||
# Phase 2b: status rows and page banners
|
||||
|
||||
Every row of the Commissioning list and every banner is now in `app/messages.py` (`row_*`, `banner_*`), and `test_messages.py` checks a rendering of each. The row and banner kinds are gone from `ux08_allowlist.txt` (200 → 168 entries). DOCS.md "Status page" explains the rows. **Unchanged:** every row's colour (ok / warn / bad), which rows show when, and the banner cascade order.
|
||||
|
||||
## Where a choice was made
|
||||
1. **A red row may use 3 sentences (30 words).** STYLE.md gave rows 15 words; the owner allows red safety states 3 sentences. A red row needs room for what happened, the state (0 W) and how it clears. STYLE.md now says so, and the test applies it by colour.
|
||||
2. **Figures left the rows, not the product.** The reason texts (freeze, sign, circulating, delivery, peak guard) carry reason codes and limits; they stay on the ERROR/WARNING log line and the event list, as before. Rows keep the numbers a reader acts on: the stall age and the 0 W deadline, the meter age and limit, asked and sent for an over-rating, the peak limit, and "opposite for 12 of 60 seconds" while a check counts. So 2a item 2 ("also on the status row") no longer holds, and DOCS.md says "in the add-on log".
|
||||
3. **No entity id on any row**, even the green ones that used to print `sensor.x = 54`. The sensor rows name the setting (*Battery power for inverter-b*). Solar sources are numbered in the order of the settings list (*source 2 of 3*, *entry 1 of the reversed-sign list*). This retires TEL-12's rule that a one-entity site reads 0.11.0's sentence byte for byte. A `tools/tel11_pv_trace.py --compare` against a pre-UX-08 tree therefore shows the PV rows as changed telemetry, and the control documents stay equal. The per-source watts and units left the multi-source row. The total, the count and which source is missing remain.
|
||||
4. **UX-07 item 1.** `ClampWatchdog` now also counts `over_rating` and `bound` separately. After an episode, each count shows under its own alarm's title ("Power request too high: 2 times since start, not now", "Battery is not keeping up: …"). A site with both gets two amber rows where it had one.
|
||||
5. **The "Not ready" banner** is the first red row's own `banner`, or else its first sentence. The peak-reserve and over-rating rows set `banner` to their notification title. The three trips keep their own banner branches, reworded (e.g. "Inverters worked against each other. Every inverter is set to 0 W.").
|
||||
6. **How a latch clears stays on the row**, shortened: "Only starting control clears this", "A restart does not clear this", "only stopping and starting control clears this" (not delivering). SAFETY-25's sentence stays on the peak-reserve row.
|
||||
7. **Rows got an `id`** (`meter_frozen`, `direction`, `pv`, …), so tests find a row by what it is, not by its words. The page ignores it.
|
||||
8. **Left for later:** texts that rows embed from other modules and that were not flagged as rows (the month-peak line, `peakguard.explain()`, the liveness and stale notes, `shadow.banner()`). The month-peak row only lost its semicolons.
|
||||
+179
-19
@@ -477,7 +477,7 @@ History recorded before you set it keeps the old sign. See
|
||||
The add-on reads your entity's own `unit_of_measurement` and converts: `mW`,
|
||||
`W`, `kW`, `MW`, `GW`, `TW` — Home Assistant's own power units. A sensor with
|
||||
**no** unit, or with a unit that is not one of those, is **refused**, and the
|
||||
status page says which entity and which unit. That is deliberate: there is no
|
||||
status page says so (*the sensor has no unit*). That is deliberate: there is no
|
||||
magnitude test that can separate 30 W of dusk from 30 kW of noon, so a guess
|
||||
here would be a silent factor of 1000 in the one channel that exists to be
|
||||
believed a week later.
|
||||
@@ -534,7 +534,7 @@ about:
|
||||
|---|---|---|
|
||||
| all three reporting 1200 / 2100 / 450 W | `3750.0` | `PV 3 750 W` |
|
||||
| all three reporting, one of them **0 W** | `1650.0` | `PV 1 650 W` |
|
||||
| one of the three `unavailable` | `unknown` | `PV —`, plus a caution naming which entity, why, and what the others reported |
|
||||
| one of the three `unavailable` | `unknown` | `PV —`, plus a caution naming which source is missing |
|
||||
| all three gone | `unknown` | as above |
|
||||
|
||||
**It does not publish the sum of the ones that answered.** Two inverters out of
|
||||
@@ -546,8 +546,8 @@ it say something false. So the total goes to `unknown` and the recorder stores a
|
||||
gap, exactly as it does for a single dead sensor.
|
||||
|
||||
The diagnostic is not thrown away — it is put where a person will see it. The
|
||||
status page names the entity that is missing, the reason, **and** what the
|
||||
others reported. That page is read live by a human who wants to fix something;
|
||||
status page names the missing source by its place in the `pv_entities` list
|
||||
(*source 2 of 3*). That page is read live by a human who wants to fix something;
|
||||
the recorded number is read cold by an analysis that would believe it.
|
||||
|
||||
Note the **third row against the second**: a member honestly reporting `0 W` is
|
||||
@@ -569,7 +569,7 @@ It is **instrumentation**. It is not an input to the control law, the
|
||||
allocator, the arbiter, the peak guard, the maintenance cycle or any watchdog;
|
||||
it is not part of `inputs_ok`, so a PV integration that breaks cannot stop the
|
||||
battery being regulated; and its status row is a **caution**, never a
|
||||
`NOT READY`. Wiring production into the control law would be a behaviour change
|
||||
*Not ready*. Wiring production into the control law would be a behaviour change
|
||||
on a system driving a real battery — and it would destroy the measurement,
|
||||
because the loop would then be reacting to the thing being measured.
|
||||
|
||||
@@ -791,8 +791,8 @@ repeats. Thresholding that at 30 s would rebuild the false-trip limit cycle at
|
||||
the exact operating point the controller aims for. Freeze detection is a
|
||||
separate problem and needs the low-power case solved first.
|
||||
|
||||
You read it yourself instead: the add-on's status page shows it on the P1 line,
|
||||
as `… 4 rejected, measurement unchanged for 312 s`. On a house drawing real
|
||||
You read it yourself instead: the add-on's status page shows it on the Meter
|
||||
row, as `… 4 readings refused, unchanged for 5 minutes`. On a house drawing real
|
||||
power that figure stays in the seconds; minutes of it while the load is clearly
|
||||
not near zero is the meter to go and look at.
|
||||
|
||||
@@ -1050,9 +1050,9 @@ If the setpoint this add-on is emitting and the grid figure it is regulating on
|
||||
point **opposite ways** — charging the battery while the house imports, or
|
||||
discharging it while the house exports — with both over `sign_disagree_w`, for
|
||||
`sign_disagree_s` **continuously**, the add-on commands **0 W** and holds it
|
||||
there. The banner reads `FAILSAFE`, the commissioning list says which two
|
||||
numbers disagreed and for how long, and the `Controller status` entity reports
|
||||
`failsafe`.
|
||||
there. The banner reads *Command and meter point opposite ways*, the add-on
|
||||
log says which two numbers disagreed and for how long, and the `Controller
|
||||
status` entity reports `failsafe`.
|
||||
|
||||
#### ⚠️ At two or more inverters this is TWO checks, not one (SAFETY-30)
|
||||
|
||||
@@ -1194,7 +1194,8 @@ start, because a `persistent_notification` is in memory and that is precisely ho
|
||||
the 2026-09-02 outage went unseen. It is cleared by pressing **Start**, like the
|
||||
other latches.
|
||||
|
||||
The banner reads **`NOT DELIVERING — <unit> …`** as a *warning*, not a failure,
|
||||
The banner reads **`<unit> is not delivering. Control keeps running.`** as a
|
||||
*warning*, not a failure,
|
||||
and it **displaces** `Running — holding grid at zero`. That last sentence is a
|
||||
claim the site cannot make while a unit is absent, and a page that goes on
|
||||
reading healthy while the fault runs is the whole shape this check exists to
|
||||
@@ -1428,7 +1429,7 @@ What exists today, and what does not:
|
||||
| SoC guard on the loop | ❌ — the BMS is the only energy backstop |
|
||||
|
||||
On the legacy `meter_source: off` path there are no P1 telegrams, so there is no
|
||||
quarter-hour average to guard, and the status page says **NOT COVERED**. There
|
||||
quarter-hour average to guard, and the status page says *Peak guard: off*. There
|
||||
the mitigation is still operational: set `max_w` no higher than the loop actually
|
||||
needs (the tuning was measured at 2000 W and a 1.78 kW load step is tracked fine
|
||||
there), fit the e-stop, and watch for the alarm.
|
||||
@@ -1630,7 +1631,9 @@ share no reason code, no notification id, no status row and no counter.
|
||||
running - this is not the sign-disagreement failsafe, which commands 0 W. You
|
||||
get a persistent notification (`goodwe_output_clamp`, replaced rather than
|
||||
stacked), an `ERROR` in the log with the reason code `OUTPUT_CLAMP`, a row in
|
||||
the event list, and a red status banner while it is engaged. Once it clears, a
|
||||
the event list, and a red status banner while it is engaged. The message quotes
|
||||
the command the loop computed and the value it was clamped to, both from the
|
||||
cycle that clamped. Once it clears, a
|
||||
warning row keeps the engagement count so the evidence does not vanish with the
|
||||
condition. Consecutive engagements share one event row carrying the exact count,
|
||||
because the event list keeps only 40 rows and a row per cycle would flush every
|
||||
@@ -1639,9 +1642,11 @@ other guard's entries out of it.
|
||||
**There is nothing to configure**, deliberately. The bound is `max_w`, which you
|
||||
already set; giving the guard a limit of its own would be a second number that
|
||||
can disagree with the first, which is exactly the failure the startup validation
|
||||
exists to prevent. The alarm's cadence - first engagement, then re-asserted every
|
||||
30 s while it persists, cleared after 3 clean cycles - is fixed for the same
|
||||
reason `RAIL_FRAC` is: it describes the notification bus, not the plant.
|
||||
exists to prevent. The alarm's cadence - normally at most one notification per
|
||||
150 s (an episode that has not yet notified does so once it has engaged 3
|
||||
cycles in a row, and an over-rating has its own 150 s budget), cleared after 3
|
||||
clean cycles - is fixed for the same reason `RAIL_FRAC` is: it describes the
|
||||
notification bus, not the plant.
|
||||
|
||||
WARNING: **on a default install the engagement you will see is the integrator's,
|
||||
not the output's.** The control law bounds the integrator to `max_w` and *then*
|
||||
@@ -1650,6 +1655,17 @@ rating unless `integrator_max_w` has been deliberately raised above `max_w`.
|
||||
Both are watched. A guard that watched only the emitted value would be silent on
|
||||
every standard configuration.
|
||||
|
||||
**The integrator bound is not a bug, and it is reported as what it is.** It
|
||||
reads *"the loop's integrator reached its +5000 W anti-windup bound; the loop's
|
||||
command this cycle was +2000 W"*, titled *"the control loop is at its integrator
|
||||
bound"*, logged as a `WARNING`, with an amber (not red) status row and its own
|
||||
notification (`goodwe_integrator_bound`), so it never replaces a BUG SIGNAL
|
||||
notice still on screen. Brief
|
||||
engagements after a large load or solar step are normal. If it stays engaged,
|
||||
check that the battery is following its commands. Only a command over the rating
|
||||
carries the BUG SIGNAL text, and it has its own notification budget.
|
||||
Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md).
|
||||
|
||||
### The frozen-meter alarm
|
||||
|
||||
A meter that has died is easy — it stops answering, the sample age climbs past
|
||||
@@ -1697,13 +1713,13 @@ the add-on UI.
|
||||
|
||||
⚠️ **Only `meter_source: homewizard_local` is covered.** It is the only transport
|
||||
that reads the meter's own document; on `ha_dsmr`, `mqtt_p1`, `ha_signed` and
|
||||
`off` there is no signal to threshold and the status page says **NOT COVERED**
|
||||
rather than pretending the guard is armed.
|
||||
`off` there is no signal to threshold and the status page says *Stuck-meter
|
||||
check: off* rather than pretending the guard is armed.
|
||||
|
||||
The same row appears for a second reason: a setpoint device that reports a range
|
||||
from which no command magnitude is available in both directions leaves no rail
|
||||
to measure at all, and a 0 W rail would be satisfied by *every* command. That is
|
||||
reported as **NOT COVERED** too, and the detector stays silent rather than
|
||||
reported as *off* too, and the detector stays silent rather than
|
||||
degenerating into the bare "unchanged for N seconds" rule this design exists to
|
||||
avoid.
|
||||
|
||||
@@ -2787,6 +2803,126 @@ it.
|
||||
| `estop_fitted` | Whether the RS485 e-stop is installed. Drives the warning banner |
|
||||
| `log_level` | `trace`/`debug`/`info`/`warning`/`error` |
|
||||
|
||||
## Notifications
|
||||
|
||||
Each Home Assistant notification this add-on raises has a short title, one
|
||||
sentence (a red alarm may use three), and a **Details** link to the add-on
|
||||
documentation that names the section below. This section holds what the
|
||||
notification leaves out. The wording rule is `STYLE.md` in the repository; the
|
||||
texts live in `app/messages.py`. Every notification keeps a fixed
|
||||
`notification_id`, so a repeat replaces the card instead of adding one.
|
||||
|
||||
| Title | Severity | id |
|
||||
|---|---|---|
|
||||
| Battery is not keeping up | warning | `goodwe_integrator_bound` |
|
||||
| Power request too high | alarm | `goodwe_output_clamp` |
|
||||
| Load too high for battery | alarm | `goodwe_peak_saturated` |
|
||||
| Battery stopped limiting the peak | alarm | `goodwe_peak_soc_floor` |
|
||||
| Inverters working against each other | alarm | `goodwe_circulating_power` |
|
||||
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` |
|
||||
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` |
|
||||
|
||||
### Battery is not keeping up
|
||||
|
||||
The grid-following loop's integrator reached its anti-windup bound
|
||||
(`integrator_max_w`): the controller has been asking for more than the battery
|
||||
delivers for a while. This is SAFETY-40, and it is **not** a fault. No command
|
||||
went over the rating and control keeps running. Short spells after a big load
|
||||
or solar step are normal. If it keeps coming back, check that the battery
|
||||
follows its commands. The bound and the command of that tick are in the add-on
|
||||
log (WARNING, reason code `OUTPUT_CLAMP`). See *The winding-loop alarm* for the
|
||||
full story.
|
||||
|
||||
### Power request too high
|
||||
|
||||
The controller computed a command above a limit, and the command was cut to that
|
||||
limit before it was written. The notification names what was asked, what was
|
||||
sent, and which limit cut it: the configured inverter rating (`max_w`), the
|
||||
inverter's own limit (the setpoint device's range), or the inverter rating on
|
||||
the loop side. Asked always comes first. This is a **software fault** (the old
|
||||
wording was "BUG SIGNAL"). It is not the inverter failing to follow a legal
|
||||
command, which is the saturation freeze and does not alarm. Control keeps
|
||||
running. Report it with the add-on log, whose ERROR line carries the reason code
|
||||
`OUTPUT_CLAMP` and the full figures. See *The winding-loop alarm*.
|
||||
|
||||
### Load too high for battery
|
||||
|
||||
The capacity-peak guard (SAFETY-07) is asking for everything the inverter can
|
||||
discharge, and the running 15-minute average is still projected over the peak
|
||||
limit. That 15-minute period will set or raise the month's billed peak
|
||||
(capaciteitstarief). The controller is not at fault: the house load is larger
|
||||
than the inverter. Switching off load in the next few minutes is the only thing
|
||||
that still changes the bill. The projection and the watts are in the add-on
|
||||
log. See *The capacity-peak guard*.
|
||||
|
||||
### Battery stopped limiting the peak
|
||||
|
||||
Two words in this notification are settings. The **peak reserve** is
|
||||
*Peak guard will not discharge below (%)* (`peak_soc_floor`). The
|
||||
**cut-out level** is *Battery cut-out level (%)* (`bms_soc_floor`), where the
|
||||
battery's own system stops discharging.
|
||||
|
||||
The peak guard wanted to discharge to hold the 15-minute peak, but it may not go
|
||||
below the peak reserve (`peak_soc_floor`, SAFETY-24). It also stops when the
|
||||
battery level cannot be read, or when an inverter's cut-out level is not set,
|
||||
because the reserve is graded against it. So this 15-minute
|
||||
period will go over the peak limit and set or raise the month's billed peak.
|
||||
Charging from the grid stays blocked meanwhile. On a site with several inverters
|
||||
the notification names the battery at its limit (SAFETY-32): each battery is
|
||||
graded against its own cut-out level (`bms_soc_floor`), and raising the other
|
||||
batteries' settings will not change this one.
|
||||
|
||||
⚠️ **The reserve binds the peak guard only (SAFETY-25).** It is not a
|
||||
whole-battery reserve. Ordinary self-consumption has no such floor and goes on
|
||||
discharging below it, down to the battery's own cut-out level. See *What
|
||||
`peak_soc_floor` does not protect*.
|
||||
|
||||
### Inverters working against each other
|
||||
|
||||
Two inverters were pushing power into each other: one discharging while another
|
||||
charged (SAFETY-31). **The meter cannot see this.** The two flows cancel at the
|
||||
P1 meter, so the site reads idle while both batteries cycle through two
|
||||
conversion stages. Every other guard reads that net figure, so none of them
|
||||
fires. The add-on has **stopped control and commands 0 W on every inverter**.
|
||||
If the grid reading does not move after that, one inverter is no longer
|
||||
listening to the add-on; this has happened on this hardware, once for
|
||||
6 h 25 min. Switch the inverters off at the isolator. The alarm is latched: a
|
||||
Home Assistant or add-on restart does not clear it, and the card is raised
|
||||
again every 5 min until you start control on the add-on page. The watts and the time
|
||||
window are in the add-on log; the *Circulating* line on the status page shows the
|
||||
live watts. See *The circulating-power alarm*.
|
||||
|
||||
### An inverter is not delivering
|
||||
|
||||
One inverter has stood well short of its share of the site target for longer
|
||||
than the hold time (SAFETY-42), while the others have not. No other check sees
|
||||
this. A unit at 0 W is absent rather than pushing the wrong way, so the per-unit
|
||||
sign check and the circulation check stay quiet. **Nothing is commanded
|
||||
differently.** The guard only reports: commanding an inverter that has stopped
|
||||
listening achieves nothing, and the only fallback the add-on has is
|
||||
all-or-nothing. The other inverters keep regulating, and the site is short by up
|
||||
to the missing unit's rating. Check, in this order:
|
||||
|
||||
1. Is the inverter answering on RS485 at all?
|
||||
2. Is its battery colder or emptier than its configured cut-out level
|
||||
(`bms_soc_floor`) says? If so, that setting does not describe this battery.
|
||||
3. Is anything else writing to it?
|
||||
|
||||
The alarm is latched and survives restarts. Control is still running, so to
|
||||
clear it, press *Start / stop control* twice (stop, then start) once you know
|
||||
why. See *A unit that stops DELIVERING is a third fault again*.
|
||||
|
||||
### Meter reading may be stuck
|
||||
|
||||
The meter keeps answering, but its value has not changed for longer than the
|
||||
hold time while the command stands at the power limit (SAFETY-18). The loop is
|
||||
regulating against a number that has stopped moving. The age is still fresh, so
|
||||
no other guard stops it. This is an alarm only: control keeps running and keeps
|
||||
following the reading. Held for a whole 15-minute period, it can set the month's
|
||||
billed peak. Check the meter; if it is stuck, stop control on the add-on page.
|
||||
The limit and command figures are in the add-on log. See
|
||||
*The frozen-meter alarm*.
|
||||
|
||||
## The Web UI
|
||||
|
||||
The ingress panel shows live values, why the controller is commanding what it
|
||||
@@ -2800,6 +2936,30 @@ inverter.** The configuration page reads and writes the add-on's own settings
|
||||
and can restart the add-on; the set of commands that reach the hardware is
|
||||
exactly the three buttons it always had.
|
||||
|
||||
## Status page
|
||||
|
||||
The banner at the top says in one line what matters most now. Below it, the
|
||||
**Commissioning** list has one short row per check: ✓ green is fine, ! amber
|
||||
needs a look, ✗ red needs action. The rows name settings in words, never a Home
|
||||
Assistant entity id. The full figures of any alarm are in the add-on log, on the
|
||||
ERROR or WARNING line of that alarm. The texts are in `app/messages.py`.
|
||||
|
||||
| Row starts with | What it tells you |
|
||||
|---|---|
|
||||
| Controller | When the controller last ran. Red when it has stalled: the control board then sets the inverter to 0 W. |
|
||||
| Meter | The grid reading, its age, readings refused, and how long its value has not changed. |
|
||||
| Stuck-meter check | Whether the frozen-meter alarm can run. It needs the HomeWizard local source (`homewizard_local`), an inverter rating, and a power range the inverter can reach. *The inverter's own limit* means the setpoint device's range sets the threshold, below `max_w`. |
|
||||
| Meter reading may be stuck | The frozen-meter alarm is on. See *Meter reading may be stuck* under Notifications. |
|
||||
| *month*: peak … | This month's peak, and how close it is to the soft limit. |
|
||||
| Peak guard, Load too high, Battery at its peak reserve | What the capacity-peak guard is doing. *Peak guard: off* means `meter_source` is `off`, so there is no 15-minute average to guard. |
|
||||
| Battery level, Battery power, Grid power | The reading of that sensor. *No usable reading* means the sensor is missing **or** its reading is held back as too old (a separate stale-reading row then says so). Check it in Developer Tools before replacing it. |
|
||||
| Inverter control | The setpoint entity, and the power range the inverter accepts. |
|
||||
| Power request too high, Battery is not keeping up | The two loop alarms, each with its own count since start. After an episode clears, the count stays, ending in *fine now*. See *The winding-loop alarm*. |
|
||||
| Direction check, Site direction check | The sign checks (SAFETY-02/30). *Opposite for 12 of 60 seconds* counts toward a stop. When it trips, the inverter, or every inverter for the site check, is set to 0 W until you start control. |
|
||||
| Circulating power check, Inverters worked against each other | SAFETY-31, only with several inverters. |
|
||||
| Delivery check, *name* is not delivering | SAFETY-42, only with several inverters. |
|
||||
| Solar production | The PV reading. Sources are numbered in the order of `pv_entities`. The *reversed-sign list* is `pv_invert_entities`. A strongly negative reading by day means that source's sign is reversed. |
|
||||
|
||||
## Status entities
|
||||
|
||||
If an MQTT broker is available the add-on publishes setpoint, grid power,
|
||||
|
||||
@@ -73,6 +73,7 @@ An alarm wired only to this module's `ClampResult.engaged` would therefore be
|
||||
silent on the exact configuration the whole ticket is about. The winding is
|
||||
visible one layer up, at the integrator bound, and that is why `Decision` now
|
||||
carries `i_clamped` / `clamped` BOOLEANS and the caller ORs them in here.
|
||||
They alarm in different words: see `loop_clamp_note()` (SAFETY-40).
|
||||
|
||||
AND WHY BOOLEANS RATHER THAN `Decision.reason`. `reason` is a single string that
|
||||
later stages overwrite: a cycle that clamps the integrator and is then slew
|
||||
@@ -200,9 +201,8 @@ REASON_CODE = "OUTPUT_CLAMP"
|
||||
# ceiling rests on one number a test author chose; if that number is ever
|
||||
# revisited, this constant moves with it.
|
||||
#
|
||||
# ⚠️ THIS IS A STOP-GAP AND DOES NOT MAKE THE ALARM CORRECT. The message still
|
||||
# calls the integrator bound a BUG SIGNAL and still pairs a request from one
|
||||
# moment with a `self.target` read at another - SAFETY-40. Nothing here is
|
||||
# ⚠️ THIS WAS A STOP-GAP; the wording and the numbers were SAFETY-40's, fixed
|
||||
# in `loop_clamp_note()` below. Nothing here is
|
||||
# suppressed: every engagement is still counted, the action-log row is written on
|
||||
# every engaged cycle regardless, and the rising edge of every episode alarms
|
||||
# unconditionally, so no alarm is DROPPED - only delayed.
|
||||
@@ -234,6 +234,31 @@ CLEAR_CYCLES = 3
|
||||
SUSTAINED_CYCLES = 3
|
||||
|
||||
|
||||
BUG_SIGNAL = ("This is a BUG SIGNAL, not a limit being used: something "
|
||||
"upstream computed a setpoint the actuator cannot deliver.")
|
||||
|
||||
|
||||
def loop_clamp_note(d) -> str:
|
||||
"""The loop-side alarm text for one `control.Decision`, or "".
|
||||
|
||||
SAFETY-40: the integrator bound is anti-windup doing its job and says so;
|
||||
only a command over the rating is a BUG SIGNAL. Every number is the
|
||||
decision's own, i.e. from the tick that clamped.
|
||||
"""
|
||||
if d.clamped:
|
||||
return (f"{REASON_CODE}: the loop computed {d.requested_w:+.0f} W, over "
|
||||
f"the {abs(d.clamped_w):.0f} W rating, and it was clamped to "
|
||||
f"{d.clamped_w:+.0f} W; the loop's command this cycle was "
|
||||
f"{d.target_w:+.0f} W. {BUG_SIGNAL}")
|
||||
if d.i_clamped:
|
||||
return (f"{REASON_CODE}: the loop's integrator reached its "
|
||||
f"{d.i_bound_w:+.0f} W anti-windup bound; the loop's command "
|
||||
f"this cycle was {d.target_w:+.0f} W. Brief "
|
||||
f"engagements after a large load or solar step are normal. If it "
|
||||
f"persists, check that the battery is following its commands.")
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClampResult:
|
||||
"""What the guard did, with the RAW value preserved.
|
||||
@@ -333,32 +358,72 @@ class ClampWatchdog:
|
||||
def __init__(self):
|
||||
self.active = False
|
||||
self.engagements = 0 # every engagement, never deduplicated
|
||||
# UX-08: the same count split by alarm type, so the status page names
|
||||
# each with its own alarm's title after the episode clears.
|
||||
self.over_rating = 0 # bug=True: "Power request too high"
|
||||
self.bound = 0 # bug=False: "Battery is not keeping up"
|
||||
self.episodes = 0 # rising edges
|
||||
self.since: datetime | None = None
|
||||
self.last_alarm: datetime | None = None
|
||||
self.reason = ""
|
||||
# UX-08: the numbers behind `reason`, for the notification - set under
|
||||
# the same rule as `reason`, so both always describe the same tick.
|
||||
self.facts = ()
|
||||
self.clean_cycles = 0
|
||||
# SAFETY-41. Consecutive engaged cycles, and whether THIS episode has
|
||||
# spoken yet - the two the escalation clause needs.
|
||||
self.run_len = 0
|
||||
self.spoken = False
|
||||
# SAFETY-40. Did this episode include a genuine over-rating (`bug`), and
|
||||
# has THAT spoken? A bug has its own budget, so anti-windup notices can
|
||||
# never delay it; with bug=True throughout, cadence is as before.
|
||||
self.bug = False
|
||||
self.bug_spoken = False
|
||||
self.last_bug_alarm: datetime | None = None
|
||||
|
||||
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
|
||||
def _due(self, last, now) -> bool:
|
||||
return last is None or (now - last).total_seconds() >= REASSERT_S
|
||||
|
||||
def _speak(self, now, bug) -> bool:
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
if bug:
|
||||
self.last_bug_alarm = now
|
||||
self.bug_spoken = True
|
||||
return True
|
||||
|
||||
def note(self, engaged: bool, reason: str, now: datetime,
|
||||
bug: bool = True, facts=()) -> bool:
|
||||
"""Advance one cycle. True when the caller should raise the alarm.
|
||||
|
||||
`now` is the caller's clock, the same discipline as
|
||||
`SignWatchdog.evaluate()` and `FreezeWatchdog.evaluate()`.
|
||||
`SignWatchdog.evaluate()` and `FreezeWatchdog.evaluate()`. `bug` False
|
||||
is the integrator bound (SAFETY-40): counted and notified like the
|
||||
rest, but it never replaces a bug's text within an episode.
|
||||
"""
|
||||
if engaged:
|
||||
self.engagements += 1
|
||||
if bug:
|
||||
self.over_rating += 1
|
||||
else:
|
||||
self.bound += 1
|
||||
self.clean_cycles = 0
|
||||
self.reason = reason
|
||||
self.run_len += 1
|
||||
if not self.active:
|
||||
rising = not self.active
|
||||
if rising:
|
||||
self.active = True
|
||||
self.since = now
|
||||
self.episodes += 1
|
||||
self.spoken = False
|
||||
self.bug = False
|
||||
self.bug_spoken = False
|
||||
if bug or not self.bug:
|
||||
self.reason = reason
|
||||
self.facts = tuple(facts)
|
||||
self.bug = self.bug or bug
|
||||
if bug and not self.bug_spoken and self._due(self.last_bug_alarm, now):
|
||||
return self._speak(now, True)
|
||||
if rising:
|
||||
# ⚠️ SAFETY-41. THE RISING EDGE IS RATE-LIMITED TOO, and it did
|
||||
# not used to be. It fired unconditionally, so a condition that
|
||||
# engages and clears repeatedly raised one notification PER
|
||||
@@ -384,11 +449,8 @@ class ClampWatchdog:
|
||||
# still written on every engaged cycle, and a genuine runaway -
|
||||
# which by definition keeps engaging - is picked up by the
|
||||
# re-assert path within REASSERT_S. Delay, not loss.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
if self._due(self.last_alarm, now):
|
||||
return self._speak(now, self.bug)
|
||||
return False
|
||||
# ⚠️ THE ESCALATION. An episode that has not yet spoken because the
|
||||
# budget was spent by an EARLIER, unrelated episode speaks anyway
|
||||
@@ -396,15 +458,10 @@ class ClampWatchdog:
|
||||
# engages every cycle and reaches it; the scattered one-cycle
|
||||
# railing that produced the live flood never does.
|
||||
if not self.spoken and self.run_len >= SUSTAINED_CYCLES:
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return self._speak(now, self.bug)
|
||||
# Still engaged: re-assert on the slow cadence, not every cycle.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
if self._due(self.last_alarm, now):
|
||||
return self._speak(now, self.bug)
|
||||
return False
|
||||
|
||||
self.run_len = 0
|
||||
@@ -420,5 +477,7 @@ class ClampWatchdog:
|
||||
# above. The episode ends; the notification budget does not reset
|
||||
# with it.
|
||||
self.reason = ""
|
||||
self.facts = ()
|
||||
self.bug = False
|
||||
self.clean_cycles = 0
|
||||
return False
|
||||
|
||||
@@ -340,6 +340,12 @@ class Decision:
|
||||
# the comment at the bound below, and clamp.py.
|
||||
i_clamped: bool = False
|
||||
clamped: bool = False
|
||||
# SAFETY-40. The alarm's numbers, taken at the clamp so they are one tick's:
|
||||
# the bound the integrator reached, and the command before/after the
|
||||
# rating clamp. `target_w` is this tick's emitted command.
|
||||
i_bound_w: float = 0.0
|
||||
requested_w: float = 0.0
|
||||
clamped_w: float = 0.0
|
||||
|
||||
|
||||
def slew_limit_w(tuning: Tuning) -> float:
|
||||
@@ -563,6 +569,7 @@ def compute(
|
||||
# winding, "clamped" is a command that came out over the rating anyway.
|
||||
reason = "i-clamped"
|
||||
i_w = bounded
|
||||
i_bound_w = bounded if i_clamped else 0.0
|
||||
want = i_w
|
||||
|
||||
# ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live
|
||||
@@ -574,6 +581,7 @@ def compute(
|
||||
# An early draft applied a floor after the clamp and let demand escape it.
|
||||
target = max(-tuning.max_w, min(tuning.max_w, want))
|
||||
clamped = target != want
|
||||
clamped_w = target
|
||||
if clamped:
|
||||
reason = "clamped"
|
||||
|
||||
@@ -740,7 +748,8 @@ def compute(
|
||||
target = round(target / step) * step
|
||||
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||
i_clamped, clamped)
|
||||
i_clamped, clamped, float(i_bound_w), float(want),
|
||||
float(clamped_w))
|
||||
|
||||
|
||||
def maintenance_charge_floor(
|
||||
|
||||
+293
-452
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,676 @@
|
||||
"""Every Home Assistant notification this add-on raises, in plain words (UX-08).
|
||||
|
||||
The rule is `STYLE.md` at the repository root; `test_messages.py` enforces it.
|
||||
Titles, bodies and severities live here and nowhere else, so `main.py` only
|
||||
decides WHEN to raise a notice and this file decides WHAT it says. The detail
|
||||
that used to fill the bodies is in DOCS.md, under "Notifications", one section
|
||||
per title; the body ends with a link to it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Severity (STYLE.md section 3). An ALARM may use up to three sentences so
|
||||
# that no action a person must take is lost; everything else gets one.
|
||||
ALARM, WARNING, INFO, RESOLVED = "alarm", "warning", "info", "resolved"
|
||||
|
||||
DOCS_SECTION = "Notifications"
|
||||
|
||||
|
||||
def watts(value) -> str:
|
||||
"""`7412` -> `7 412 W`: a magnitude, grouped for reading, no sign."""
|
||||
return f"{abs(float(value)):,.0f} W".replace(",", " ")
|
||||
|
||||
|
||||
def duration(seconds) -> str:
|
||||
s = float(seconds)
|
||||
if s < 120:
|
||||
return f"{s:.0f} seconds"
|
||||
return f"{s / 60:.0f} minutes"
|
||||
|
||||
|
||||
def names(items) -> str:
|
||||
items = list(items)
|
||||
if len(items) <= 1:
|
||||
return "".join(items)
|
||||
return ", ".join(items[:-1]) + " and " + items[-1]
|
||||
|
||||
|
||||
def addon_slug() -> str | None:
|
||||
"""This add-on's Supervisor slug, from the container hostname.
|
||||
|
||||
The Supervisor names an add-on's host after its slug with `_` spelled `-`
|
||||
(`local_goodwe_controller` -> `local-goodwe-controller`). Anything else,
|
||||
e.g. a test run, gets no link rather than a wrong one.
|
||||
"""
|
||||
host = os.environ.get("HOSTNAME", "")
|
||||
if re.fullmatch(r"[a-z0-9]+-goodwe-controller", host):
|
||||
return host.replace("-", "_")
|
||||
return None
|
||||
|
||||
|
||||
def details(title: str) -> str:
|
||||
where = f"section “{title}” under {DOCS_SECTION}"
|
||||
slug = addon_slug()
|
||||
if slug is None:
|
||||
return f"Details: add-on documentation, {where}."
|
||||
return (f"Details: [add-on documentation](/hassio/addon/{slug}/documentation),"
|
||||
f" {where}.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Notice:
|
||||
id: str
|
||||
severity: str
|
||||
title: str
|
||||
body: str
|
||||
section: str = "" # the DOCS.md heading, when it is not the title itself
|
||||
|
||||
@property
|
||||
def docs(self) -> str:
|
||||
return self.section or self.title
|
||||
|
||||
def data(self) -> dict:
|
||||
"""The `persistent_notification.create` payload."""
|
||||
return {"notification_id": self.id, "title": self.title,
|
||||
"message": f"{self.body}\n\n{details(self.docs)}"}
|
||||
|
||||
|
||||
# --- the catalogue ----------------------------------------------------------
|
||||
# Each title below is also a DOCS.md heading (test_messages.py checks it).
|
||||
|
||||
T_LOOP_BOUND = "Battery is not keeping up"
|
||||
T_OUTPUT_CLAMP = "Power request too high"
|
||||
T_PEAK_SATURATED = "Load too high for battery"
|
||||
T_PEAK_RESERVE = "Battery stopped limiting the peak"
|
||||
T_CIRCULATING = "Inverters working against each other"
|
||||
T_NOT_DELIVERING = "{unit} is not delivering"
|
||||
S_NOT_DELIVERING = "An inverter is not delivering"
|
||||
T_METER_FROZEN = "Meter reading may be stuck"
|
||||
|
||||
ID_LOOP_BOUND = "goodwe_integrator_bound"
|
||||
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
|
||||
ID_PEAK_SATURATED = "goodwe_peak_saturated"
|
||||
ID_PEAK_RESERVE = "goodwe_peak_soc_floor"
|
||||
ID_CIRCULATING = "goodwe_circulating_power"
|
||||
ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
|
||||
ID_METER_FROZEN = "goodwe_meter_frozen"
|
||||
|
||||
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
|
||||
LIMIT_WORDS = {
|
||||
"max_w": "the configured inverter rating",
|
||||
"device": "the inverter's own limit",
|
||||
"max_w+device": "the configured inverter rating and the inverter's own limit",
|
||||
"rating": "the inverter rating",
|
||||
}
|
||||
|
||||
|
||||
def loop_bound() -> Notice:
|
||||
"""SAFETY-40: the integrator bound. Normal in short bursts; not a fault."""
|
||||
return Notice(ID_LOOP_BOUND, WARNING, T_LOOP_BOUND,
|
||||
"The battery cannot give what the controller asks, which is "
|
||||
"normal for a moment but needs a check if it keeps happening.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Clamp:
|
||||
"""One command that was cut: what was asked, what was sent, and by what."""
|
||||
unit: str | None
|
||||
asked_w: float
|
||||
sent_w: float
|
||||
limit: str # a key of LIMIT_WORDS
|
||||
|
||||
|
||||
def output_clamp(clamps) -> Notice:
|
||||
"""SAFETY-03: a computed command above the limit. A software fault.
|
||||
|
||||
Asked comes before sent, always - reading them the other way round turns
|
||||
the alarm backwards (test_clamp.py part 6).
|
||||
"""
|
||||
if not clamps:
|
||||
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
|
||||
"The controller asked for more power than the inverter "
|
||||
"rating and was cut back. Control is still running. This "
|
||||
"is a software fault, so please report it to your "
|
||||
"installer.")
|
||||
c = clamps[0]
|
||||
who = f"For {c.unit}, the" if c.unit else "The"
|
||||
others = [x.unit for x in clamps[1:] if x.unit]
|
||||
running = (f"{names(others)} {'was' if len(others) == 1 else 'were'} cut "
|
||||
f"too, and control is still running." if others
|
||||
else "Control is still running.")
|
||||
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
|
||||
f"{who} controller asked for {watts(c.asked_w)}, above "
|
||||
f"{LIMIT_WORDS.get(c.limit, LIMIT_WORDS['rating'])}, and sent "
|
||||
f"{watts(c.sent_w)} instead. {running} "
|
||||
f"This is a software fault, so please report it to your "
|
||||
f"installer.")
|
||||
|
||||
|
||||
def peak_saturated(ceiling_w) -> Notice:
|
||||
"""SAFETY-07: the battery gives all it can and the peak still goes up."""
|
||||
return Notice(ID_PEAK_SATURATED, ALARM, T_PEAK_SATURATED,
|
||||
f"The battery is giving all it can, but this 15-minute period "
|
||||
f"will still go over your {watts(ceiling_w)} peak limit and "
|
||||
f"raise this month's peak. Switch off large loads in the next "
|
||||
f"few minutes. This is not a fault: the load is bigger than "
|
||||
f"the inverter.")
|
||||
|
||||
|
||||
def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
packs=()) -> Notice:
|
||||
"""SAFETY-24/25/32: the peak guard may not discharge any further.
|
||||
|
||||
The last sentence is SAFETY-25 and must stay: the reserve binds the peak
|
||||
guard only, and normal home use goes on discharging below it.
|
||||
`packs` names the batteries at their limit, only on a site with several.
|
||||
`floor_unknown`: the floor the guard grades against is None, which at a
|
||||
roster means some inverter's cut-out level is missing (the peak reserve
|
||||
itself always has a value) - so the action names the cut-out level.
|
||||
"""
|
||||
if soc_unread:
|
||||
why = "The battery level cannot be read"
|
||||
act = "Switch off large loads, and check the battery level sensor."
|
||||
elif floor_unknown:
|
||||
why = "The cut-out level of a battery is not known"
|
||||
act = ("Switch off large loads, and set each battery's cut-out level "
|
||||
"in the settings.")
|
||||
else:
|
||||
why = (f"The battery of {names(packs)} is at its peak reserve" if packs
|
||||
else "The battery is at its peak reserve")
|
||||
act = ("Switch off large loads, or lower the peak reserve if the "
|
||||
"battery allows it.")
|
||||
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
|
||||
f"{why}, so this 15-minute period will go over your "
|
||||
f"{watts(ceiling_w)} peak limit and raise this month's peak. "
|
||||
f"{act} The reserve only applies to keeping the peak down: normal home use "
|
||||
f"can still discharge the battery below it, down to its "
|
||||
f"cut-out level.")
|
||||
|
||||
|
||||
def circulating(discharging=(), charging=()) -> Notice:
|
||||
"""SAFETY-31: the inverters push power round each other. Control stopped."""
|
||||
if discharging and charging:
|
||||
what = (f"{names(discharging)} was discharging while "
|
||||
f"{names(charging)} was charging, and your meter cannot see "
|
||||
f"this.")
|
||||
else:
|
||||
what = ("One inverter was charging from another, and your meter "
|
||||
"cannot see this.")
|
||||
return Notice(ID_CIRCULATING, ALARM, T_CIRCULATING,
|
||||
f"{what} Every inverter is set to 0 W: if the grid power does "
|
||||
f"not change now, switch the inverters off at the isolator. "
|
||||
f"Control stays stopped, even after a restart, until you "
|
||||
f"start it on the add-on page once you know why.")
|
||||
|
||||
|
||||
def not_delivering(unit: str, slug: str) -> Notice:
|
||||
"""SAFETY-42: one inverter gives less than its share. Reports, never acts."""
|
||||
return Notice(
|
||||
ID_NOT_DELIVERING.format(slug=slug), ALARM,
|
||||
T_NOT_DELIVERING.format(unit=unit),
|
||||
f"{unit} gives less power than it is asked for, but nothing was "
|
||||
f"changed and control keeps running. Check that {unit} is "
|
||||
f"on and connected, that its battery is not empty or cold, and that "
|
||||
f"nothing else controls it. A restart does not clear this: once you "
|
||||
f"know why, stop and start control on the add-on page.",
|
||||
section=S_NOT_DELIVERING)
|
||||
|
||||
|
||||
def meter_frozen(unchanged_s) -> Notice:
|
||||
"""SAFETY-18: the meter answers but its value does not move."""
|
||||
return Notice(ID_METER_FROZEN, ALARM, T_METER_FROZEN,
|
||||
f"The meter has shown the same value for "
|
||||
f"{duration(unchanged_s)} while the controller asks the "
|
||||
f"battery for full power. Control is still running and still "
|
||||
f"follows this reading. Check the meter, and if it is stuck, "
|
||||
f"stop control on the add-on page.")
|
||||
|
||||
|
||||
# --- the status page (UX-08 phase 2b) ----------------------------------------
|
||||
# The rows of the Commissioning list and the banner at the top of the add-on
|
||||
# page. `main.py` decides WHEN a row shows and its colour (ok / warn / bad);
|
||||
# these decide WHAT it says. A row is one sentence of at most 15 words; a red
|
||||
# row may use up to 3 sentences and 30 words. A banner is at most 12 words.
|
||||
# Rows never name a Home Assistant entity: they name the setting in words.
|
||||
# DOCS.md, "Status page", explains every row.
|
||||
|
||||
NOT_READY = "Not ready"
|
||||
DOCS_STATUS = "Status page"
|
||||
|
||||
|
||||
def signed_watts(value) -> str:
|
||||
"""`-1200` -> `-1 200 W`: like `watts()`, but the sign is kept."""
|
||||
return f"{float(value):,.0f} W".replace(",", " ")
|
||||
|
||||
|
||||
def seconds(value) -> str:
|
||||
"""Always in seconds, for a live count set against its limit."""
|
||||
n = f"{float(value):.0f}"
|
||||
return f"{n} second" if n == "1" else f"{n} seconds"
|
||||
|
||||
|
||||
def times(n) -> str:
|
||||
return "1 time" if int(n) == 1 else f"{int(n)} times"
|
||||
|
||||
|
||||
def for_unit(unit) -> str:
|
||||
"""` for inverter-b` on a site with several inverters, else nothing."""
|
||||
return f" for {unit}" if unit else ""
|
||||
|
||||
|
||||
def headline(text: str) -> str:
|
||||
"""The first sentence of a row, for the banner when the row has no own."""
|
||||
first = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text.strip(), maxsplit=1)[0]
|
||||
return first.rstrip(".")
|
||||
|
||||
|
||||
# Banners (the headline at the top of the page).
|
||||
def banner_not_ready(row: dict) -> str:
|
||||
return f"{NOT_READY} — {row.get('banner') or headline(row['text'])}"
|
||||
|
||||
|
||||
def banner_circulating() -> str:
|
||||
return "Inverters worked against each other. Every inverter is set to 0 W."
|
||||
|
||||
|
||||
def banner_site_direction() -> str:
|
||||
return "Total command and meter point opposite ways. All inverters at 0 W."
|
||||
|
||||
|
||||
def banner_direction(several: bool) -> str:
|
||||
if several:
|
||||
return "An inverter's own power opposes its command. It is at 0 W."
|
||||
return "Command and meter point opposite ways. Inverter set to 0 W."
|
||||
|
||||
|
||||
def banner_not_delivering(units) -> str:
|
||||
units = list(units)
|
||||
verb = "are" if len(units) > 1 else "is"
|
||||
return f"{names(units)} {verb} not delivering. Control keeps running."
|
||||
|
||||
|
||||
# Rows: the controller and the meter.
|
||||
def row_controller_starting() -> str:
|
||||
return "Controller: starting"
|
||||
|
||||
|
||||
def row_controller_ran(age_s) -> str:
|
||||
return f"Controller: last ran {seconds(age_s)} ago"
|
||||
|
||||
|
||||
def row_controller_stalled(age_s, veto_s) -> str:
|
||||
return (f"Controller has not run for {seconds(age_s)}. The control board "
|
||||
f"sets the inverter to 0 W after {seconds(veto_s)}.")
|
||||
|
||||
|
||||
def row_meter_stale(age_s, limit_s) -> str:
|
||||
return (f"Meter: no reading for {seconds(age_s)}, limit "
|
||||
f"{seconds(limit_s)}. The add-on log shows why.")
|
||||
|
||||
|
||||
def row_meter(net_w, age_s, rejected=0, unchanged_s=None) -> str:
|
||||
return (f"Meter: {signed_watts(net_w)}, {seconds(age_s)} old"
|
||||
+ (f", {rejected} readings refused" if rejected else "")
|
||||
+ ("" if unchanged_s is None
|
||||
else f", unchanged for {duration(unchanged_s)}"))
|
||||
|
||||
|
||||
def row_meter_frozen() -> str:
|
||||
return (f"{T_METER_FROZEN}. Control still follows it. Check the meter, "
|
||||
f"and stop control if it is stuck.")
|
||||
|
||||
|
||||
FREEZE_OFF = {
|
||||
"source": "it only works with the HomeWizard local meter source",
|
||||
"rating": "the inverter rating is not set",
|
||||
"range": "the inverter reports no usable power range",
|
||||
}
|
||||
|
||||
|
||||
def row_freeze_off(why: str) -> str:
|
||||
"""`why` is a key of FREEZE_OFF: why the stuck-meter check cannot run."""
|
||||
return f"Stuck-meter check: off, {FREEZE_OFF[why]}"
|
||||
|
||||
|
||||
def row_freeze_on(hold_s, rail_w, own_limit: bool) -> str:
|
||||
return (f"Stuck-meter check: alerts after {duration(hold_s)} unchanged "
|
||||
f"above {watts(rail_w)}"
|
||||
+ (", the inverter's own limit" if own_limit else ""))
|
||||
|
||||
|
||||
# Rows: the peak guard.
|
||||
def row_peak_saturated(ceiling_w) -> str:
|
||||
return (f"{T_PEAK_SATURATED}. Switch off large loads to stay under the "
|
||||
f"{watts(ceiling_w)} peak limit.")
|
||||
|
||||
|
||||
def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
packs=()) -> str:
|
||||
"""SAFETY-24/25/32. The second sentence is SAFETY-25 and must stay."""
|
||||
if soc_unread:
|
||||
why = "Battery level cannot be read"
|
||||
elif floor_unknown:
|
||||
why = "A battery cut-out level is not set"
|
||||
else:
|
||||
why = (f"{names(packs)} battery at its peak reserve" if packs
|
||||
else "Battery at its peak reserve")
|
||||
return (f"{why}, so the {watts(ceiling_w)} peak limit will be passed. Home "
|
||||
f"use still discharges below the reserve, down to the cut-out level.")
|
||||
|
||||
|
||||
def row_peak_guard_off() -> str:
|
||||
"""No meter source: the 15-minute average cannot be measured."""
|
||||
return ("Peak guard: off, it needs a meter source instead of the grid "
|
||||
"power sensor")
|
||||
|
||||
|
||||
def banner_peak_reserve() -> str:
|
||||
return T_PEAK_RESERVE
|
||||
|
||||
|
||||
# Rows: the sensors and the inverter control.
|
||||
LABEL_SOC = "Battery level"
|
||||
LABEL_BATTERY = "Battery power"
|
||||
LABEL_GRID = "Grid power"
|
||||
|
||||
|
||||
def row_sensor(label, value, unit=None) -> str:
|
||||
shown = f"{float(value):g} %" if label.startswith(LABEL_SOC) \
|
||||
else signed_watts(value)
|
||||
return f"{label}{for_unit(unit)}: {shown}"
|
||||
|
||||
|
||||
def row_sensor_unset(label, unit=None) -> str:
|
||||
return f"{label}{for_unit(unit)}: no sensor set in the settings"
|
||||
|
||||
|
||||
def row_sensor_unusable(label, unit=None) -> str:
|
||||
"""SAFETY-56: it may be missing OR held back as stale; say both."""
|
||||
return (f"{label}{for_unit(unit)}: no usable reading. The sensor may be "
|
||||
f"missing, or its reading is held back as too old. Check it in "
|
||||
f"Developer Tools before replacing it.")
|
||||
|
||||
|
||||
def row_control_unset(unit=None) -> str:
|
||||
return f"Inverter control{for_unit(unit)}: not set in the settings"
|
||||
|
||||
|
||||
def row_control_missing(unit=None) -> str:
|
||||
return f"Inverter control{for_unit(unit)}: not found in Home Assistant"
|
||||
|
||||
|
||||
def row_control(lo_w, hi_w, unit=None) -> str:
|
||||
def w(v):
|
||||
return "?" if v is None else signed_watts(v)
|
||||
return f"Inverter control{for_unit(unit)}: ready, from {w(lo_w)} to {w(hi_w)}"
|
||||
|
||||
|
||||
# Rows: the two loop alarms. The names are the notification titles, so the
|
||||
# row and the card always call the same thing by the same name (UX-07 item 1).
|
||||
def row_power_too_high(clamps, count) -> str:
|
||||
c = clamps[0] if clamps else None
|
||||
what = (f"asked {watts(c.asked_w)}{for_unit(c.unit)}, sent "
|
||||
f"{watts(c.sent_w)}. Cut back" if c else "cut back")
|
||||
return (f"{T_OUTPUT_CLAMP}: {what} {times(count)} since start. This is a "
|
||||
f"software fault, so please report it.")
|
||||
|
||||
|
||||
def banner_power_too_high() -> str:
|
||||
return T_OUTPUT_CLAMP
|
||||
|
||||
|
||||
def row_not_keeping_up(count) -> str:
|
||||
return f"{T_LOOP_BOUND}: {times(count)} since start"
|
||||
|
||||
|
||||
def row_before(title, count) -> str:
|
||||
"""After an episode clears: how often it happened, so the evidence stays."""
|
||||
return f"{title}: {times(count)} since start, fine now"
|
||||
|
||||
|
||||
def row_write_refused(unit=None) -> str:
|
||||
return (f"{unit or 'The inverter'} refused the last command. Check the "
|
||||
f"add-on log.")
|
||||
|
||||
|
||||
# Rows: the direction checks (SAFETY-02/30).
|
||||
def row_direction_trip(unit=None) -> str:
|
||||
"""Per inverter. `unit` only at several inverters, graded on its own power."""
|
||||
what = (f"{unit}: command and its own power point opposite ways, so it is"
|
||||
if unit else "Command and meter point opposite ways, so the "
|
||||
"inverter is")
|
||||
return (f"{what} set to 0 W. Find out why, then start control. A restart "
|
||||
f"does not clear this.")
|
||||
|
||||
|
||||
def row_direction_check(held_s, hold_s, unit=None) -> str:
|
||||
who = (f"Direction check for {unit}, against its own power" if unit
|
||||
else "Direction check")
|
||||
if held_s:
|
||||
return f"{who}: opposite for {held_s:.0f} of {seconds(hold_s)}"
|
||||
return f"{who}: on"
|
||||
|
||||
|
||||
def row_site_direction_trip() -> str:
|
||||
return ("Total command and meter point opposite ways, so every inverter is "
|
||||
"set to 0 W. The cause is the total, not one inverter. Only starting "
|
||||
"control clears this.")
|
||||
|
||||
|
||||
def row_site_direction_check(held_s, hold_s) -> str:
|
||||
if held_s:
|
||||
return (f"Site direction check: opposite for {held_s:.0f} of "
|
||||
f"{seconds(hold_s)}")
|
||||
return "Site direction check: on, against the meter"
|
||||
|
||||
|
||||
# Rows: circulating power (SAFETY-31) and delivery (SAFETY-42).
|
||||
def row_circulating_trip() -> str:
|
||||
return ("Inverters worked against each other, so every inverter is set to "
|
||||
"0 W. The meter cannot see this. Only starting control clears this.")
|
||||
|
||||
|
||||
def row_circulating_check(now_w, held_s, hold_s) -> str:
|
||||
return ("Circulating power check: on"
|
||||
+ (f", now {watts(now_w)}" if now_w else "")
|
||||
+ (f", for {held_s:.0f} of {seconds(hold_s)}" if held_s else ""))
|
||||
|
||||
|
||||
def row_one_inverter(check: str) -> str:
|
||||
return f"{check}: not needed with one inverter"
|
||||
|
||||
|
||||
def row_not_delivering(unit) -> str:
|
||||
return (f"{unit} is not delivering, and only stopping and starting control "
|
||||
f"clears this")
|
||||
|
||||
|
||||
def row_delivery_check(unit, short_s, hold_s) -> str:
|
||||
if short_s:
|
||||
return (f"Delivery check for {unit}: short for {short_s:.0f} of "
|
||||
f"{seconds(hold_s)}")
|
||||
return f"Delivery check for {unit}: on"
|
||||
|
||||
|
||||
def row_estop_fitted() -> str:
|
||||
return "Emergency stop: fitted"
|
||||
|
||||
|
||||
def row_no_peak_forecast() -> str:
|
||||
return ("No peak forecast set, so maintenance charging is not kept under "
|
||||
"the peak limit")
|
||||
|
||||
|
||||
def row_peak_guard_waiting() -> str:
|
||||
return "Peak guard: no decision yet, control has not run"
|
||||
|
||||
|
||||
def row_no_estop() -> str:
|
||||
return ("No emergency stop fitted: if this computer fails, the battery "
|
||||
"keeps its last command")
|
||||
|
||||
|
||||
# Rows: solar production (TEL-11/12/14). Sources are numbered in the order of
|
||||
# the solar sensor list in the settings.
|
||||
def source(n, of) -> str:
|
||||
return "the sensor" if of == 1 else f"source {n}"
|
||||
|
||||
|
||||
def pv_problem(why: str) -> str:
|
||||
"""`pv.watts()`'s reason, in plain words."""
|
||||
w = str(why)
|
||||
if w.startswith("no state"):
|
||||
return "the sensor is missing"
|
||||
if w.startswith("entity is"):
|
||||
return "the sensor is unavailable"
|
||||
if w.endswith("is not numeric"):
|
||||
return "the reading is not a number"
|
||||
if w.startswith("no unit"):
|
||||
return "the sensor has no unit"
|
||||
if w.startswith("unit "):
|
||||
return "the sensor's unit is not a power unit"
|
||||
return "the reading is not usable"
|
||||
|
||||
|
||||
def row_pv(total_w, count, reversed_=False) -> str:
|
||||
return (f"Solar production: {signed_watts(total_w)}"
|
||||
+ (f" from {count} sources" if count > 1 else "")
|
||||
+ (", sign reversed" if reversed_ else ""))
|
||||
|
||||
|
||||
def row_pv_none(why) -> str:
|
||||
return f"Solar production: no reading, {pv_problem(why)}"
|
||||
|
||||
|
||||
def row_pv_partial(missing, count) -> str:
|
||||
"""`missing` are the 1-based positions of the sources that did not report."""
|
||||
which = names([str(n) for n in missing])
|
||||
return (f"Solar production: no total, source{'s' if len(missing) > 1 else ''} "
|
||||
f"{which} of {count} {'have' if len(missing) > 1 else 'has'} no "
|
||||
f"reading")
|
||||
|
||||
|
||||
def row_pv_single_ignored() -> str:
|
||||
return ("Solar production: clear the single sensor setting, the list "
|
||||
"replaces it")
|
||||
|
||||
|
||||
def row_pv_negative(n, count, value_w, flipped: bool) -> str:
|
||||
fix = ("remove it from" if flipped else "add it to")
|
||||
who = source(n, count)
|
||||
return (f"Solar production: {who} reads {signed_watts(value_w)}, "
|
||||
f"so {fix} the reversed-sign list")
|
||||
|
||||
|
||||
def row_pv_stray(n) -> str:
|
||||
return (f"Solar production: entry {n} of the reversed-sign list is not a "
|
||||
f"solar sensor in use")
|
||||
|
||||
|
||||
RED, AMBER, GREEN = "bad", "warn", "ok"
|
||||
|
||||
|
||||
def status_samples() -> list:
|
||||
"""`(function name, colour, text)` for every row and banner, for the test.
|
||||
|
||||
The colour is the one main.py gives the row; a red row may use three
|
||||
sentences. Banners are listed with the colour None.
|
||||
"""
|
||||
clamp = [Clamp(None, 7412, 5000, "rating")]
|
||||
return [
|
||||
("banner_not_ready", None, banner_not_ready(
|
||||
{"text": row_meter_frozen()})),
|
||||
("banner_not_ready", None, banner_not_ready(
|
||||
{"text": "x", "banner": banner_peak_reserve()})),
|
||||
("banner_circulating", None, banner_circulating()),
|
||||
("banner_site_direction", None, banner_site_direction()),
|
||||
("banner_direction", None, banner_direction(False)),
|
||||
("banner_direction", None, banner_direction(True)),
|
||||
("banner_not_delivering", None, banner_not_delivering(["inverter-b"])),
|
||||
("banner_peak_reserve", None, banner_peak_reserve()),
|
||||
("banner_power_too_high", None, banner_power_too_high()),
|
||||
("row_controller_starting", GREEN, row_controller_starting()),
|
||||
("row_controller_ran", GREEN, row_controller_ran(2)),
|
||||
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
|
||||
("row_meter_stale", RED, row_meter_stale(40, 10)),
|
||||
("row_meter", GREEN, row_meter(-350, 2, 3, 340)),
|
||||
("row_meter_frozen", RED, row_meter_frozen()),
|
||||
*(("row_freeze_off", AMBER, row_freeze_off(k)) for k in FREEZE_OFF),
|
||||
("row_freeze_on", GREEN, row_freeze_on(600, 4465, True)),
|
||||
("row_peak_saturated", RED, row_peak_saturated(5000)),
|
||||
("row_peak_guard_off", AMBER, row_peak_guard_off()),
|
||||
("row_peak_reserve", RED, row_peak_reserve(
|
||||
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"])),
|
||||
("row_peak_reserve", RED, row_peak_reserve(
|
||||
5000, soc_unread=True, floor_unknown=False)),
|
||||
("row_peak_reserve", RED, row_peak_reserve(
|
||||
5000, soc_unread=False, floor_unknown=True)),
|
||||
("row_sensor", GREEN, row_sensor(LABEL_SOC, 54, "inverter-b")),
|
||||
("row_sensor", GREEN, row_sensor(LABEL_BATTERY, -1200.0, "inverter-b")),
|
||||
("row_sensor_unset", RED, row_sensor_unset(LABEL_GRID)),
|
||||
("row_sensor_unusable", RED, row_sensor_unusable(LABEL_BATTERY,
|
||||
"inverter-b")),
|
||||
("row_control_unset", RED, row_control_unset("inverter-b")),
|
||||
("row_control_missing", RED, row_control_missing("inverter-b")),
|
||||
("row_control", GREEN, row_control(-5000, 5000, "inverter-b")),
|
||||
("row_power_too_high", RED, row_power_too_high(clamp, 3)),
|
||||
("row_power_too_high", RED, row_power_too_high(
|
||||
[Clamp("inverter-b", 6000, 4600, "max_w+device")], 12)),
|
||||
("row_power_too_high", RED, row_power_too_high((), 1)),
|
||||
("row_not_keeping_up", AMBER, row_not_keeping_up(48)),
|
||||
("row_before", AMBER, row_before(T_OUTPUT_CLAMP, 2)),
|
||||
("row_before", AMBER, row_before(T_LOOP_BOUND, 1)),
|
||||
("row_write_refused", RED, row_write_refused("inverter-b")),
|
||||
("row_direction_trip", RED, row_direction_trip()),
|
||||
("row_direction_trip", RED, row_direction_trip("inverter-b")),
|
||||
("row_direction_check", GREEN, row_direction_check(0, 60)),
|
||||
("row_direction_check", GREEN, row_direction_check(12, 60,
|
||||
"inverter-b")),
|
||||
("row_site_direction_trip", RED, row_site_direction_trip()),
|
||||
("row_site_direction_check", GREEN, row_site_direction_check(0, 60)),
|
||||
("row_site_direction_check", GREEN, row_site_direction_check(5, 60)),
|
||||
("row_circulating_trip", RED, row_circulating_trip()),
|
||||
("row_circulating_check", GREEN, row_circulating_check(1200, 20, 60)),
|
||||
("row_one_inverter", GREEN, row_one_inverter("Delivery check")),
|
||||
("row_not_delivering", AMBER, row_not_delivering("inverter-b")),
|
||||
("row_delivery_check", GREEN, row_delivery_check("inverter-b", 30, 120)),
|
||||
("row_delivery_check", GREEN, row_delivery_check("inverter-b", 0, 120)),
|
||||
("row_no_estop", AMBER, row_no_estop()),
|
||||
("row_estop_fitted", GREEN, row_estop_fitted()),
|
||||
("row_no_peak_forecast", AMBER, row_no_peak_forecast()),
|
||||
("row_peak_guard_waiting", AMBER, row_peak_guard_waiting()),
|
||||
("row_pv", GREEN, row_pv(3200, 2)),
|
||||
("row_pv", GREEN, row_pv(1800, 1, True)),
|
||||
("row_pv_none", AMBER, row_pv_none("no unit_of_measurement on the "
|
||||
"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_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)),
|
||||
("row_pv_stray", AMBER, row_pv_stray(2)),
|
||||
]
|
||||
|
||||
|
||||
def samples() -> list:
|
||||
"""One rendering of every notice, with realistic values, for the test."""
|
||||
return [
|
||||
loop_bound(),
|
||||
output_clamp([Clamp(None, 7412, 5000, "rating")]),
|
||||
output_clamp(()),
|
||||
output_clamp([Clamp("inverter-a", -3000, -2500, "device"),
|
||||
Clamp("inverter-b", 6000, 4600, "max_w+device")]),
|
||||
peak_saturated(5000),
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=False),
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=False,
|
||||
packs=["inverter-b"]),
|
||||
peak_reserve(5000, soc_unread=True, floor_unknown=False),
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=True),
|
||||
circulating(["inverter-a"], ["inverter-b"]),
|
||||
circulating(),
|
||||
not_delivering("inverter-b", "inverter_b"),
|
||||
meter_frozen(600),
|
||||
meter_frozen(90),
|
||||
]
|
||||
+257
-26
@@ -38,6 +38,9 @@ acceptance criteria; part 6 is TEST-11's and part 7 is SAFETY-28's:
|
||||
the check that replaced a config-shape proxy: `ACTUATOR_WRITE_KEYS` graded
|
||||
how many option KEYS exist, which stays 1 while a per-inverter config adds
|
||||
a second real writer.
|
||||
9. SAFETY-40. The integrator bound (normal anti-windup) is reported in its
|
||||
own words and never as a BUG SIGNAL; the over-rating keeps it; and every
|
||||
number in either message is the clamp tick's own Decision.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHY PART 4 ALSO ASSERTS ON `Decision`'s BOOLEANS AND NOT ON `reason`
|
||||
@@ -80,6 +83,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from named_absence import given, nth, present
|
||||
|
||||
from app import messages
|
||||
from app.clamp import (
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog,
|
||||
clamp_to_rating,
|
||||
@@ -99,6 +103,9 @@ T0 = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
|
||||
# which is also the device rating, so this is the bound the alarm is written at.
|
||||
LIVE_MAX_W = 5000.0
|
||||
CLAMP_ID = "goodwe_output_clamp"
|
||||
# SAFETY-40: the integrator-bound WARNING has its own id, so it can never
|
||||
# replace a BUG SIGNAL notice still on screen.
|
||||
BOUND_ID = "goodwe_integrator_bound"
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
@@ -458,7 +465,7 @@ def wired(grid, cycles=40, batt=0.0, track=True, **over):
|
||||
def clamp_notes(hass):
|
||||
return [d for dom, svc, d in hass.calls
|
||||
if (dom, svc) == ("persistent_notification", "create")
|
||||
and d.get("notification_id") == CLAMP_ID]
|
||||
and d.get("notification_id") in (CLAMP_ID, BOUND_ID)]
|
||||
|
||||
|
||||
# THE 10.3 RUNAWAY, replayed. "With the inverter switched off entirely, an
|
||||
@@ -497,12 +504,16 @@ print(f" the loop clamped on {ctl_r.clamp_wd.engagements} of {60} cycles, "
|
||||
f"notification(s)")
|
||||
check("AC 3: and it ALARMED - silently clipping this is the defect",
|
||||
ctl_r.clamp_wd.engagements > 0 and len(notes_r) >= 1)
|
||||
check("...with its own notification_id, not shared with any other guard",
|
||||
all(d["notification_id"] == CLAMP_ID for d in notes_r))
|
||||
check("...with its own notification_id, not shared with any other guard - "
|
||||
"on a default install the integrator bound's (SAFETY-40)",
|
||||
all(d["notification_id"] == BOUND_ID for d in notes_r))
|
||||
check("...one episode, re-asserted rather than one per cycle",
|
||||
ctl_r.clamp_wd.episodes == 1 and len(notes_r) < ctl_r.clamp_wd.engagements)
|
||||
check("...and it names itself a bug signal, not a limit in use",
|
||||
bool(notes_r) and "out of range" in notes_r[0]["message"])
|
||||
# SAFETY-40: on a default install this runaway is the INTEGRATOR bound, so it
|
||||
# says so, and does not call anti-windup a bug (part 8 grades the wording).
|
||||
check("...and it is the integrator-bound notice, which calls nothing a fault",
|
||||
bool(notes_r) and notes_r[0]["title"] == messages.T_LOOP_BOUND
|
||||
and "fault" not in notes_r[0]["message"])
|
||||
rows_r = [e for e in ctl_r.events if REASON_CODE in e]
|
||||
check(f"the action log records every engagement, coalesced into one row per "
|
||||
f"episode ({len(rows_r)} row(s), {ctl_r.clamp_wd.engagements} "
|
||||
@@ -528,12 +539,40 @@ check("...and the count in the row is the counter's, so nothing is lost",
|
||||
f"{ctl_l.clamp_wd.engagements} engagement(s)" in nth(rows_l, -1))
|
||||
# The RED row exists only while engaged, and the episode above is short, so it
|
||||
# is captured mid-run rather than asserted after the hysteresis has cleared it.
|
||||
check("the status page goes red WHILE it is engaged",
|
||||
any(not c["ok"] and not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"]
|
||||
check("the status page flags it WHILE it is engaged - amber, since it is the "
|
||||
"integrator bound (SAFETY-40; red is part 9's over-rating case)",
|
||||
any(not c["ok"] and c["warn"] and c.get("id") == "not_keeping_up"
|
||||
and c["text"].startswith(messages.T_LOOP_BOUND)
|
||||
for c in ctl_r.red_snapshot))
|
||||
check("...and once clear, a warn row still says how many times it engaged",
|
||||
any(c["warn"] and f"{ctl_r.clamp_wd.engagements} time(s)" in c["text"]
|
||||
for c in ctl_r.checks()))
|
||||
check("...and once clear, a warn row still says how many times it engaged, "
|
||||
"under the integrator bound's own title",
|
||||
any(c["warn"] and c.get("id") == "not_keeping_up"
|
||||
and c["text"].startswith(messages.T_LOOP_BOUND)
|
||||
and messages.times(ctl_r.clamp_wd.bound) in c["text"]
|
||||
and "fine now" in c["text"] for c in ctl_r.checks()))
|
||||
# UX-07 item 1: the after-episode row used to say "output clamp ... engaged N
|
||||
# time(s)" for integrator-bound cycles. Each count now sits under its own name.
|
||||
check("UX-07 item 1: bound-only engagements are counted as the bound, and no "
|
||||
"row calls them 'Power request too high'",
|
||||
ctl_r.clamp_wd.bound == ctl_r.clamp_wd.engagements > 0
|
||||
and ctl_r.clamp_wd.over_rating == 0
|
||||
and not any(c.get("id") == "power_too_high"
|
||||
or messages.T_OUTPUT_CLAMP in c["text"]
|
||||
for c in ctl_r.checks()))
|
||||
# ...and an earlier over-rating count stays on the page while a LATER bound
|
||||
# episode runs: it is the software-fault evidence, and that episode (bug False)
|
||||
# cannot have added to it, so "fine now" is true of it.
|
||||
_w = ctl_r.clamp_wd
|
||||
_saved = (_w.active, _w.bug, _w.over_rating)
|
||||
_w.active, _w.bug, _w.over_rating = True, False, 2
|
||||
_rows = ctl_r.checks()
|
||||
_w.active, _w.bug, _w.over_rating = _saved
|
||||
check("UX-07 item 1: during a bound episode an earlier over-rating count is "
|
||||
"still shown, amber, under its own title",
|
||||
any(c.get("id") == "not_keeping_up" for c in _rows)
|
||||
and any(c.get("id") == "power_too_high" and c["warn"] and not c["ok"]
|
||||
and c["text"] == messages.row_before(messages.T_OUTPUT_CLAMP, 2)
|
||||
for c in _rows))
|
||||
|
||||
# ⚠️ NOT A TRIP. 11.2's action is "clamp and alarm"; the loop keeps running and
|
||||
# keeps writing the clamped value. If a later cut changes that it must come here.
|
||||
@@ -580,7 +619,8 @@ check("AC 2: the clamp counted ZERO engagements", ctl_s.clamp_wd.engagements ==
|
||||
check("AC 2: no OUTPUT_CLAMP row in the action log",
|
||||
not [e for e in ctl_s.events if REASON_CODE in e])
|
||||
check("AC 2: and no clamp row on the status page",
|
||||
not [c for c in ctl_s.checks() if "output clamp" in c["text"].lower()])
|
||||
not [c for c in ctl_s.checks()
|
||||
if c.get("id") in ("power_too_high", "not_keeping_up")])
|
||||
|
||||
print(" ...and the two alarms do not share a notification_id")
|
||||
ids_clamp = {d["notification_id"] for d in clamp_notes(hass_r)}
|
||||
@@ -806,11 +846,11 @@ class _SpyWatchdog(ClampWatchdog):
|
||||
self.seen = [] # clamp_last.requested_w at each consultation
|
||||
self.seen_write = [] # was the WRITE-path half engaged, per cycle
|
||||
|
||||
def note(self, engaged, reason, now):
|
||||
def note(self, engaged, reason, now, **kw):
|
||||
cl = self.owner.clamp_last
|
||||
self.seen.append(None if cl is None else cl.requested_w)
|
||||
self.seen_write.append(bool(cl and cl.engaged))
|
||||
return super().note(engaged, reason, now)
|
||||
return super().note(engaged, reason, now, **kw)
|
||||
|
||||
|
||||
def probed(cls=Controller, grid=9000.0, cycles=5, **over):
|
||||
@@ -986,12 +1026,14 @@ check("PRECONDITION: the write really did clamp, on the DEVICE bound",
|
||||
and hass_w.writes[-1] == 2500.0)
|
||||
check("...and the loop-side half is SILENT here, so self.clamp_last is the "
|
||||
"only evidence that this happened at all",
|
||||
ctl_w.loop_clamped == (False, ""))
|
||||
ctl_w.loop_clamped == (False, "", False))
|
||||
check("AC 3: the guard alarmed on write-path evidence alone",
|
||||
ctl_w.clamp_wd.engagements == 1 and len(notes_w) == 1)
|
||||
check("...and the operator is told WHICH bound bit, so a device-rating clamp "
|
||||
"is not reported as a max_w one",
|
||||
bool(notes_w) and "device bound" in notes_w[0]["message"])
|
||||
bool(notes_w)
|
||||
and messages.LIMIT_WORDS["device"] in notes_w[0]["message"]
|
||||
and messages.LIMIT_WORDS["max_w"] not in notes_w[0]["message"])
|
||||
check("...and the action log carries the row, written by write_setpoint()",
|
||||
any(REASON_CODE in e and "device bound" in e for e in ctl_w.events))
|
||||
|
||||
@@ -1083,19 +1125,24 @@ check("⚠️ THE ORDERED PAIR IN THE §15 ACTION-LOG ROW (main.py:1366). The "
|
||||
"reading backwards on the one line an operator is woken by",
|
||||
any(f"the computed setpoint {_asked_x:+.0f} W was clamped to "
|
||||
f"{_sent_x:+.0f} W" in e for e in _rows_x))
|
||||
check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION REASON (main.py:2452), "
|
||||
"which is a SECOND surface off the same sentence and has to be pinned "
|
||||
"separately - SAFETY-38 round 3's whole lesson was that pinning one call "
|
||||
"site leaves the other shipping",
|
||||
bool(notes_x)
|
||||
and f"the computed setpoint {_asked_x:+.0f} W was clamped to "
|
||||
f"{_sent_x:+.0f} W" in notes_x[0]["message"])
|
||||
# UX-08: the notification is a SECOND surface off the same numbers, in its own
|
||||
# words (messages.output_clamp): asked first, sent second.
|
||||
def _asked_then_sent(msg, asked, sent):
|
||||
return (f"asked for {messages.watts(asked)}," in msg
|
||||
and f"and sent {messages.watts(sent)} instead" in msg
|
||||
and msg.index(messages.watts(asked)) < msg.index("and sent"))
|
||||
|
||||
|
||||
check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION, which is a SECOND surface "
|
||||
"off the same numbers and has to be pinned separately - SAFETY-38 round "
|
||||
"3's whole lesson was that pinning one call site leaves the other "
|
||||
"shipping",
|
||||
bool(notes_x) and _asked_then_sent(notes_x[0]["message"], _asked_x, _sent_x))
|
||||
check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
|
||||
"checks above discriminate rather than decorate",
|
||||
not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to "
|
||||
f"{_asked_x:+.0f} W" in e for e in _rows_x)
|
||||
and f"the computed setpoint {_sent_x:+.0f} W was clamped to "
|
||||
f"{_asked_x:+.0f} W" not in nth(notes_x, 0)["message"]
|
||||
and not _asked_then_sent(nth(notes_x, 0)["message"], _sent_x, _asked_x)
|
||||
and present(nth(notes_x, 0)))
|
||||
# The DEVICE bound, off part 6c's controller: same sentence, other stage. The
|
||||
# unit of coverage is the call site, and `bound` is the only thing that differs
|
||||
@@ -1107,8 +1154,7 @@ check("...and the same ordered pair holds on the DEVICE-bound row, where the "
|
||||
f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}"
|
||||
and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to "
|
||||
f"{_sent_w:+.0f} W" in e for e in ctl_w.events)
|
||||
and f"the computed setpoint {_asked_w:+.0f} W was clamped to "
|
||||
f"{_sent_w:+.0f} W" in nth(notes_w, 0)["message"])
|
||||
and _asked_then_sent(nth(notes_w, 0)["message"], _asked_w, _sent_w))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -1858,6 +1904,191 @@ check("...EDGE-logged, not level-logged: 25 cycles did not flush the 40-row "
|
||||
len(_shadow_lines) < 25 and len(_shad_c.events) <= 40)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("9. SAFETY-40: the integrator bound is not a BUG SIGNAL, and the numbers "
|
||||
"are the clamp tick's own")
|
||||
# Three defects, each graded on the wired Controller.cycle() with compute()
|
||||
# spied, so every number is compared with the Decision of the SAME tick:
|
||||
# 1. `i_clamped` (normal anti-windup) used the over-rating BUG SIGNAL text;
|
||||
# 2. "the loop asked for" printed `Decision.i_w`, the integrator, as a request;
|
||||
# 3. "was clamped to" printed `self.target`, read after slew/freeze rewrote it.
|
||||
# The fixtures are chosen so the numbers the old text used DIFFER from the true
|
||||
# ones (MUTATION.md habit 4); the PRECONDITION checks pin that.
|
||||
import logging
|
||||
import app.main as _main_mod
|
||||
|
||||
_real_compute = _main_mod.compute
|
||||
|
||||
|
||||
class _LogCap(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.records = []
|
||||
|
||||
def emit(self, record):
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
def s40_run(grid, cycles, track, **over):
|
||||
"""Per tick: (Decision, self.target, clamp_wd.reason, engaged) + hass + log."""
|
||||
ticks, decisions = [], []
|
||||
|
||||
def spy(*a, **k):
|
||||
d = _real_compute(*a, **k)
|
||||
decisions.append(d)
|
||||
return d
|
||||
|
||||
cap = _LogCap()
|
||||
lg = logging.getLogger("goodwe")
|
||||
lg.addHandler(cap)
|
||||
_main_mod.compute = spy
|
||||
|
||||
async def go():
|
||||
hass = _Hass(grid, track=track)
|
||||
c = Controller(dict(
|
||||
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
|
||||
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
|
||||
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15},
|
||||
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
red = []
|
||||
for _ in range(cycles):
|
||||
n = len(decisions)
|
||||
before = c.clamp_wd.engagements
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
d = decisions[-1] if len(decisions) > n else None
|
||||
ticks.append((d, c.target, c.clamp_wd.reason,
|
||||
c.clamp_wd.engagements > before))
|
||||
if c.clamp_wd.active and not red:
|
||||
red = c.checks()
|
||||
return hass, c, red
|
||||
|
||||
try:
|
||||
hass, c, red = asyncio.run(go())
|
||||
finally:
|
||||
_main_mod.compute = _real_compute
|
||||
lg.removeHandler(cap)
|
||||
return ticks, hass, c, red, cap.records
|
||||
|
||||
|
||||
def _f(d, name):
|
||||
"""A Decision field, NaN when absent - so an old tree goes red, not crashes."""
|
||||
return getattr(d, name, float("nan"))
|
||||
|
||||
|
||||
# --- 9a. default install: only the INTEGRATOR bound can engage ---------------
|
||||
ticks_i, hass_i, ctl_i, red_i, log_i = s40_run(6000.0, 20, track=False)
|
||||
wind = [(d, t, r) for d, t, r, e in ticks_i
|
||||
if e and d is not None and d.i_clamped and not d.clamped]
|
||||
for d, t, r in wind:
|
||||
print(f" tick: bound {_f(d, 'i_bound_w'):+.0f} i_w {d.i_w:+.0f} command "
|
||||
f"{d.target_w:+.0f} self.target {t:+.0f}\n -> {r}")
|
||||
check("PRECONDITION: the integrator bound engaged, and on a tick where the "
|
||||
"carried integrator AND the emitted command both differ from the bound",
|
||||
any(d.i_w != _f(d, 'i_bound_w') and d.target_w != _f(d, 'i_bound_w')
|
||||
for d, t, r in wind))
|
||||
check("defect 1: the integrator bound is NOT reported as a BUG SIGNAL",
|
||||
wind and all("BUG SIGNAL" not in r for d, t, r in wind))
|
||||
check("defect 1: ...it names the anti-windup bound, from the same tick",
|
||||
wind and all(f"its {_f(d, 'i_bound_w'):+.0f} W anti-windup bound" in r
|
||||
for d, t, r in wind))
|
||||
check("defect 2: the integrator accumulator is not printed as a request",
|
||||
wind and all(f"{d.i_w:+.0f} W" not in r and "asked for" not in r
|
||||
for d, t, r in wind))
|
||||
check("the command quoted is that tick's Decision, not a later read",
|
||||
wind and all(f"command this cycle was {d.target_w:+.0f} W" in r
|
||||
for d, t, r in wind))
|
||||
check("at the bound nothing is described as 'above' or 'over' the rating",
|
||||
wind and all(" above " not in r and " over " not in r
|
||||
for d, t, r in wind))
|
||||
notes_i = clamp_notes(hass_i)
|
||||
check("it still NOTIFIES (not suppressed), under a title that claims no bug",
|
||||
notes_i and all(n["title"] == messages.T_LOOP_BOUND
|
||||
and "fault" not in n["message"] for n in notes_i))
|
||||
check("...under its OWN notification_id, so it cannot replace a BUG SIGNAL "
|
||||
"notice still on screen",
|
||||
notes_i and all(n["notification_id"] == BOUND_ID for n in notes_i))
|
||||
check("...logged as a WARNING, not an ERROR",
|
||||
any(x.levelno == logging.WARNING and "anti-windup" in x.getMessage()
|
||||
for x in log_i)
|
||||
and not any(x.levelno >= logging.ERROR and "OUTPUT_CLAMP" in x.getMessage()
|
||||
for x in log_i))
|
||||
check("...and the status row is amber, not red",
|
||||
any(c["warn"] and c.get("id") == "not_keeping_up" for c in red_i)
|
||||
and not any(not c["warn"] and c.get("id") == "power_too_high"
|
||||
for c in red_i))
|
||||
|
||||
# --- 9b. the genuine over-rating: integrator_max_w above max_w ----------------
|
||||
ticks_o, hass_o, ctl_o, red_o, log_o = s40_run(9000.0, 12, track=True,
|
||||
integrator_max_w=9000)
|
||||
over = [(d, t, r) for d, t, r, e in ticks_o
|
||||
if e and d is not None and d.clamped]
|
||||
for d, t, r in over[:2]:
|
||||
print(f" tick: requested {_f(d, 'requested_w'):+.0f} clamped {_f(d, 'clamped_w'):+.0f}"
|
||||
f" i_w {d.i_w:+.0f} self.target {t:+.0f}\n -> {r}")
|
||||
check("PRECONDITION: the output clamp engaged on a tick where the slew limit "
|
||||
"rewrote the command AND the integrator differs from the request",
|
||||
any(t != _f(d, 'clamped_w') and d.i_w != _f(d, 'requested_w') for d, t, r in over))
|
||||
check("the over-rating KEEPS the BUG SIGNAL",
|
||||
over and all("BUG SIGNAL" in r for d, t, r in over))
|
||||
check("defect 2: it quotes the requested COMMAND of that tick, not i_w",
|
||||
over and all(f"computed {_f(d, 'requested_w'):+.0f} W" in r for d, t, r in over))
|
||||
check("defect 3: 'clamped to' is the clamp's own result, not self.target",
|
||||
over and all(f"clamped to {_f(d, 'clamped_w'):+.0f} W" in r for d, t, r in over))
|
||||
check("...and the 14 768 W rationale is still in its ERROR",
|
||||
any(x.levelno == logging.ERROR and "14 768 W" in x.getMessage()
|
||||
for x in log_o))
|
||||
check("...under the over-rating title, quoting a tick's own asked and sent "
|
||||
"numbers in that order, with a red status row",
|
||||
clamp_notes(hass_o)
|
||||
and all(n["title"] == messages.T_OUTPUT_CLAMP
|
||||
and n["notification_id"] == CLAMP_ID
|
||||
and any(_asked_then_sent(n["message"], _f(d, 'requested_w'),
|
||||
_f(d, 'clamped_w')) for d, t, r in over)
|
||||
for n in clamp_notes(hass_o))
|
||||
and any(not c["warn"] and c.get("id") == "power_too_high"
|
||||
and c["text"].startswith(messages.T_OUTPUT_CLAMP)
|
||||
for c in red_o))
|
||||
# The red row quotes the same tick's numbers as the card, asked before sent.
|
||||
_row_o = [c["text"] for c in red_o if c.get("id") == "power_too_high"]
|
||||
check("...and the red row quotes asked before sent, from one of those ticks",
|
||||
_row_o and any(
|
||||
f"asked {messages.watts(_f(d, 'requested_w'))}" in _row_o[0]
|
||||
and f"sent {messages.watts(_f(d, 'clamped_w'))}" in _row_o[0]
|
||||
and _row_o[0].index("asked") < _row_o[0].index("sent")
|
||||
for d, t, r in over))
|
||||
|
||||
# --- 9c. the text, directly, and the two budgets -------------------------------
|
||||
from app.clamp import loop_clamp_note # noqa: E402
|
||||
|
||||
d_at = compute(prev_w=1000.0, grid_w=9000.0, actual_w=1000.0,
|
||||
tuning=Tuning(max_w=5000.0, slew_w=1000.0, gain=0.6), i_w=4500.0)
|
||||
check(f"exactly at the rating ({d_at.i_bound_w:+.0f} W) is 'reached', never "
|
||||
f"'above' - {loop_clamp_note(d_at)!r}",
|
||||
d_at.i_clamped and not d_at.clamped and abs(d_at.i_bound_w) == 5000.0
|
||||
and "above" not in loop_clamp_note(d_at))
|
||||
check("a healthy decision produces no text",
|
||||
loop_clamp_note(compute(0.0, 100.0, 0.0, Tuning(max_w=5000.0))) == "")
|
||||
|
||||
# Anti-windup notices must never spend the budget a BUG SIGNAL needs.
|
||||
wd = ClampWatchdog()
|
||||
fired_w = wd.note(True, "wind", T0, bug=False)
|
||||
fired_b = wd.note(True, "bug", T0 + timedelta(seconds=5), bug=True)
|
||||
wd.note(True, "wind again", T0 + timedelta(seconds=10), bug=False)
|
||||
check("an anti-windup notice does not delay a bug in the same episode, and "
|
||||
"does not overwrite its text",
|
||||
fired_w and fired_b and wd.bug and wd.reason == "bug")
|
||||
wd2 = ClampWatchdog()
|
||||
seq = [wd2.note(True, "b", T0 + timedelta(seconds=5 * i)) for i in range(40)]
|
||||
check("bug-only cadence is unchanged: rising edge, then every REASSERT_S",
|
||||
seq[0] and sum(seq) == 1 + int(5 * 39 // REASSERT_S))
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -337,15 +337,19 @@ def rows_for(pass_age):
|
||||
|
||||
r_new, r_29, r_31 = rows_for(None), rows_for(29), rows_for(31)
|
||||
print(f" {r_new[0]} | {r_29[0]} | {r_31[0]}")
|
||||
# UX-08: the wording is app/messages.py's; these pin the meaning - which row,
|
||||
# its colour, and the two numbers (age, veto) an operator acts on.
|
||||
check("before the first pass the row is ok and says 'starting'",
|
||||
r_new[0] == {"ok": True, "warn": False, "text": "control loop: starting"})
|
||||
r_new[0]["id"] == "controller" and r_new[0]["ok"] and not r_new[0]["warn"]
|
||||
and "starting" in r_new[0]["text"])
|
||||
check("29 s after a pass it is ok",
|
||||
r_29[0] == {"ok": True, "warn": False,
|
||||
"text": "control loop: last pass 29 s ago"})
|
||||
r_29[0]["id"] == "controller" and r_29[0]["ok"] and not r_29[0]["warn"]
|
||||
and "29 seconds ago" in r_29[0]["text"])
|
||||
check("31 s after a pass it is RED and first, naming the age and the veto",
|
||||
r_31[0] == {"ok": False, "warn": False,
|
||||
"text": "control loop: NO PASS FOR 31 s (layer 1 zeroes the "
|
||||
"inverter after 30 s without a heartbeat)"})
|
||||
r_31[0]["id"] == "controller" and not r_31[0]["ok"]
|
||||
and not r_31[0]["warn"]
|
||||
and "not run for 31 seconds" in r_31[0]["text"]
|
||||
and "0 W after 30 seconds" in r_31[0]["text"])
|
||||
|
||||
|
||||
async def one_pass():
|
||||
|
||||
@@ -33,6 +33,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from named_absence import given, present
|
||||
|
||||
from app import messages
|
||||
from app.control import Tuning
|
||||
from app.delivery import (
|
||||
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
|
||||
@@ -929,14 +930,18 @@ check("...but the banner does NOT go on saying 'Running - holding grid at "
|
||||
"zero'. That sentence is a claim the site cannot make while a unit is "
|
||||
"absent, and 'the page reads healthy while the fault runs' is the shape "
|
||||
"this whole card exists to remove. It names the unit and stays a WARNING",
|
||||
_pay["level"] == "warn" and "NOT DELIVERING" in _pay["banner"]
|
||||
_pay["level"] == "warn" and "is not delivering" in _pay["banner"]
|
||||
and "inverter-b" in _pay["banner"]
|
||||
and "holding grid at zero" not in _pay["banner"])
|
||||
print(f" banner: {_pay['level']!r} / {_pay['banner']!r}")
|
||||
_row = [r for r in _cw.checks() if "NOT DELIVERING" in r["text"]]
|
||||
_row = [r for r in _cw.checks()
|
||||
if r.get("id") == "delivery" and "not delivering" in r["text"]]
|
||||
check("the status page carries it as a CAUTION, not a failure: the site is "
|
||||
"still running and short, which a red cross would misdescribe",
|
||||
len(_row) == 1 and _row[0]["ok"] is False and _row[0]["warn"] is True)
|
||||
len(_row) == 1 and _row[0]["ok"] is False and _row[0]["warn"] is True
|
||||
and "inverter-b" in _row[0]["text"])
|
||||
check("...and the row says how it clears: a stop and start of control",
|
||||
bool(_row) and "stopping and starting control" in _row[0]["text"])
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
@@ -1172,10 +1177,11 @@ check("...and no store key was written, so a one-inverter install's state file "
|
||||
"is byte-identical to what it was before this card",
|
||||
unit_key(_only.name, "delivery_tripped") not in _st1.data
|
||||
and unit_key(_only.name, "delivery_reason") not in _st1.data)
|
||||
_n1row = [r for r in _c1.checks() if "delivery watchdog" in r["text"]]
|
||||
_n1row = [r for r in _c1.checks() if r.get("id") == "delivery"]
|
||||
check("...and the status page says n/a rather than 'armed', which would be a "
|
||||
"claim to be watching something that cannot happen",
|
||||
len(_n1row) == 1 and _n1row[0]["ok"] and "n/a at one inverter" in _n1row[0]["text"])
|
||||
len(_n1row) == 1 and _n1row[0]["ok"]
|
||||
and "not needed with one inverter" in _n1row[0]["text"])
|
||||
check("...no persistent notification of this kind was raised either",
|
||||
not [c for c in _h1.calls
|
||||
if "not_delivering" in str(c[2].get("notification_id", ""))])
|
||||
@@ -1195,8 +1201,8 @@ async def _restart():
|
||||
c1 = _ctrl(h1, store=shared)
|
||||
await _drive(c1)
|
||||
# A NEW process, a NEW HomeAssistant object (so the in-memory notification
|
||||
# is gone with it), the SAME durable file. That is what a Home Assistant
|
||||
# restart is from this add-on's point of view: the add-on goes with it.
|
||||
# is gone with it), the SAME durable file: an ADD-ON restart. A Core-only
|
||||
# restart keeps the process; graded in part 8b (SAFETY-34).
|
||||
h2 = _DeliveryHass(b_soc=50.0)
|
||||
c2 = _ctrl(h2, store=shared)
|
||||
await c2.check_not_delivering()
|
||||
@@ -1232,7 +1238,7 @@ check("the notification NAMES THE UNIT - in its id, its title and its body - "
|
||||
check("...and it says, in the operator's own sentence, that nothing has been "
|
||||
"commanded differently. An alarm that let somebody believe the add-on "
|
||||
"had handled it would be worse than no alarm",
|
||||
"NOTHING HAS BEEN COMMANDED DIFFERENTLY" in _note[2]["message"])
|
||||
"nothing was changed and control keeps running" in _note[2]["message"])
|
||||
print(f" id={_note[2]['notification_id']!r} title={_note[2]['title']!r}")
|
||||
_before_calls = len(_rh2.calls)
|
||||
await_again = asyncio.run(_r2.check_not_delivering())
|
||||
@@ -1250,6 +1256,40 @@ check("the operator's START press is the only way out, and it clears the "
|
||||
and _rs.data.get(unit_key("inverter-b", "delivery_reason")) == ""
|
||||
and _r2._delivery_announced == set())
|
||||
|
||||
print()
|
||||
print("8b. SAFETY-34: a Core-only restart - same add-on process, HA's memory wiped")
|
||||
import app.main as _main_mod # noqa: E402
|
||||
|
||||
|
||||
async def _core_restart():
|
||||
shared = _MemStore()
|
||||
c = _ctrl(_DeliveryHass(b_soc=50.0), store=shared)
|
||||
await _drive(c)
|
||||
return c
|
||||
|
||||
|
||||
_c = asyncio.run(_core_restart())
|
||||
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
|
||||
_main_mod._mono = lambda: _clock[0]
|
||||
try:
|
||||
_c.hass = _DeliveryHass(b_soc=50.0)
|
||||
asyncio.run(_c.check_not_delivering())
|
||||
_early = [x for x in _c.hass.calls
|
||||
if "not_delivering" in str(x[2].get("notification_id", ""))]
|
||||
_clock[0] += _main_mod.REANNOUNCE_S
|
||||
asyncio.run(_c.check_not_delivering())
|
||||
_late = [x for x in _c.hass.calls
|
||||
if "not_delivering" in str(x[2].get("notification_id", ""))]
|
||||
finally:
|
||||
_main_mod._mono = _real_mono
|
||||
check("SAFETY-34: the latch tripped in this process",
|
||||
_c.inverters[1].delivery_wd.tripped)
|
||||
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise",
|
||||
len(_early) == 0)
|
||||
check("SAFETY-34: after REANNOUNCE_S the SAME process re-asserts the card, "
|
||||
"because a Core-only restart wiped it and did not restart the add-on",
|
||||
len(_late) == 1 and "inverter_b" in _late[0][2]["notification_id"])
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -85,6 +85,7 @@ from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app import messages
|
||||
from app.control import Tuning, compute
|
||||
from app.freeze import (
|
||||
RAIL_FRAC, FreezeWatchdog, FreezeWatchdogConfig, looks_frozen,
|
||||
@@ -773,7 +774,7 @@ def _is_freeze_note(d):
|
||||
calls itself frozen, which would be a confusable alarm on its own account.
|
||||
"""
|
||||
return (d.get("notification_id") == FREEZE_NOTE_ID
|
||||
or "frozen" in str(d.get("title", "")).lower())
|
||||
or d.get("title") == messages.T_METER_FROZEN)
|
||||
|
||||
|
||||
def notes_of(hass):
|
||||
@@ -788,7 +789,7 @@ def notes_by_text_of(hass):
|
||||
both would be true by construction.
|
||||
"""
|
||||
return [d for d in all_notes_of(hass)
|
||||
if "frozen" in str(d.get("title", "")).lower()]
|
||||
if d.get("title") == messages.T_METER_FROZEN]
|
||||
|
||||
|
||||
def wired(unchanged_s, grid, cycles=60, dev=(-5000.0, 5000.0), **over):
|
||||
@@ -831,11 +832,12 @@ all_notes = all_notes_of(hass_f) # the whole surface
|
||||
# install has. Flipped here rather than plumbing a whole P1 transport in - the
|
||||
# row's own content is what is under test.
|
||||
ctl_f.p1_enabled = True
|
||||
frozen_rows = [c for c in ctl_f.checks() if "METER MAY BE FROZEN" in c["text"]]
|
||||
frozen_rows = [c for c in ctl_f.checks() if c.get("id") == "meter_frozen"]
|
||||
print(f" frozen at +4000 W: command ran to {ctl_f.target:.0f} W, "
|
||||
f"{len(notes)} notification(s), {len(frozen_rows)} red status row(s)")
|
||||
# SURFACE-WIDE, deliberately, and the second guard has now landed. SAFETY-03
|
||||
# ships `goodwe_output_clamp`, and this scenario - a runaway loop winding into
|
||||
# ships `goodwe_output_clamp` (and, for the integrator bound on a default
|
||||
# install, `goodwe_integrator_bound` - SAFETY-40), and this scenario - a runaway loop winding into
|
||||
# the rail - raises it legitimately: the loop asks for more than the rating and
|
||||
# is clamped. So the answer to "is this scenario still meant to raise exactly
|
||||
# one alarm" is NO, it raises exactly two, and BOTH ARE NAMED here rather than
|
||||
@@ -846,7 +848,7 @@ _ids = sorted(d.get("notification_id") for d in all_notes)
|
||||
print(f" the whole notification surface this run: {_ids}")
|
||||
check("the whole notification surface carried exactly two alarms this run - "
|
||||
"SAFETY-18's and SAFETY-03's, both named",
|
||||
_ids == ["goodwe_meter_frozen", "goodwe_output_clamp"])
|
||||
_ids == ["goodwe_integrator_bound", "goodwe_meter_frozen"])
|
||||
# THIS GUARD'S, keyed on the id.
|
||||
check("...and the freeze detector is the one that raised it", len(notes) == 1)
|
||||
# ⚠️ Selected by TEXT, not by id - see notes_by_text_of(). One of the two
|
||||
@@ -857,9 +859,12 @@ check("...with a fixed notification_id so repeats replace, not stack",
|
||||
[d.get("notification_id") for d in notes_by_text_of(hass_f)]
|
||||
== [FREEZE_NOTE_ID])
|
||||
check("...and it says the controller is still running",
|
||||
bool(notes) and "STILL RUNNING" in notes[0]["message"])
|
||||
bool(notes) and "Control is still running" in notes[0]["message"])
|
||||
check("the status page carries a failed row saying so",
|
||||
len(frozen_rows) == 1 and not frozen_rows[0]["ok"] and not frozen_rows[0]["warn"])
|
||||
check("...and the row says control still follows the reading and what to do",
|
||||
bool(frozen_rows) and "still follows it" in frozen_rows[0]["text"]
|
||||
and "Check the meter" in frozen_rows[0]["text"])
|
||||
check("the event log names it", any("METER FROZEN" in e for e in ctl_f.events))
|
||||
# ⚠️ NOTIFY, NOT TRIP. If a later cut turns this into a trip it must come here
|
||||
# and change this expectation deliberately, not discover it in the field.
|
||||
@@ -1104,8 +1109,7 @@ def freeze_rows(unchanged_s, grid, **over):
|
||||
# p1_enabled is what a homewizard_local install has; the row's own content
|
||||
# is what is under test, not the transport that feeds it.
|
||||
ctl.p1_enabled = True
|
||||
return ctl, [r for r in ctl.checks()
|
||||
if r["text"].startswith("freeze detector")]
|
||||
return ctl, [r for r in ctl.checks() if r.get("id") == "freeze_check"]
|
||||
|
||||
|
||||
_ctl_u, _rows_u = freeze_rows(None, 4000.0, meter_source="ha_dsmr")
|
||||
@@ -1114,30 +1118,31 @@ print(f" uncovered: {_rows_u[0]['text'] if _rows_u else '(no row at all)'}")
|
||||
print(f" covered: {_rows_c[0]['text'] if _rows_c else '(no row at all)'}")
|
||||
check("an uncovered transport gets exactly one freeze-detector row",
|
||||
len(_rows_u) == 1)
|
||||
check("...and it says NOT COVERED",
|
||||
bool(_rows_u) and "NOT COVERED" in _rows_u[0]["text"])
|
||||
# UX-08: the row says the check is off and which source it needs, in words
|
||||
# (STYLE.md: no option values). DOCS.md "Status page" maps them to the keys.
|
||||
check("...and it says the check is off",
|
||||
bool(_rows_u) and ": off" in _rows_u[0]["text"])
|
||||
check("...and it does NOT claim the guard is armed - that would be the status "
|
||||
"page lying on behalf of a guard that cannot run",
|
||||
bool(_rows_u) and "armed" not in _rows_u[0]["text"])
|
||||
check("...and it names the meter_source it is not covering, so the operator "
|
||||
"knows which install this is",
|
||||
bool(_rows_u) and "ha_dsmr" in _rows_u[0]["text"])
|
||||
check("...and it names homewizard_local as the transport that would be covered",
|
||||
bool(_rows_u) and "homewizard_local" in _rows_u[0]["text"])
|
||||
bool(_rows_u) and "alerts after" not in _rows_u[0]["text"])
|
||||
check("...and it names HomeWizard local as the source that would be covered",
|
||||
bool(_rows_u) and "HomeWizard local" in _rows_u[0]["text"])
|
||||
check("...flagged as a warning rather than a silent ok",
|
||||
bool(_rows_u) and not _rows_u[0]["ok"] and _rows_u[0]["warn"])
|
||||
|
||||
check("a COVERED transport gets exactly one freeze-detector row",
|
||||
len(_rows_c) == 1)
|
||||
check("...and that one DOES say armed - without this, 'the uncovered row does "
|
||||
"not say armed' is satisfied by deleting the armed branch",
|
||||
bool(_rows_c) and "armed" in _rows_c[0]["text"]
|
||||
and "NOT COVERED" not in _rows_c[0]["text"])
|
||||
check("...and that one DOES say it is on - without this, 'the uncovered row "
|
||||
"does not say armed' is satisfied by deleting the armed branch",
|
||||
bool(_rows_c) and "alerts after" in _rows_c[0]["text"]
|
||||
and ": off" not in _rows_c[0]["text"])
|
||||
check("...and it quotes the hold and the rail it is armed at, derived from the "
|
||||
"config the controller was actually built with",
|
||||
bool(_rows_c)
|
||||
and f"{_ctl_c.freeze_wd.cfg.hold_s:.0f} s unchanged" in _rows_c[0]["text"]
|
||||
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" in _rows_c[0]["text"])
|
||||
and f"{messages.duration(_ctl_c.freeze_wd.cfg.hold_s)} unchanged"
|
||||
in _rows_c[0]["text"]
|
||||
and f"above {messages.watts(rail_w(LIVE_INSTALL_MAX_W))}"
|
||||
in _rows_c[0]["text"])
|
||||
check("...as an ok row, since a covered armed guard is not a warning",
|
||||
bool(_rows_c) and _rows_c[0]["ok"] and not _rows_c[0]["warn"])
|
||||
|
||||
@@ -1179,17 +1184,23 @@ check("...and at or above the device rail, which is why it must",
|
||||
check("THE ALARM FIRES through the real cycle() - the rating reaches the "
|
||||
"predicate from the controller, not just from a unit call",
|
||||
len(_n20) == 1 and _c20.freeze_wd.active)
|
||||
check("...and the notification the operator gets quotes the capped rail, "
|
||||
"not the max_w one",
|
||||
# UX-08: the notification no longer quotes a rail at all (the reader's action
|
||||
# is "check the meter"), so it cannot quote the wrong one; the capped rail is
|
||||
# still what the reason - the log line and the status row - carries.
|
||||
check("...and the notification the operator gets names no max_w rail, and the "
|
||||
"reason behind it quotes the capped rail",
|
||||
bool(_n20)
|
||||
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W"
|
||||
not in str(_n20[0].get("message", ""))
|
||||
and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
|
||||
in str(_n20[0].get("message", "")))
|
||||
in _c20.freeze_wd.reason)
|
||||
# p1_enabled is what a homewizard_local install has - set by hand here for the
|
||||
# same reason part 4 sets it, see freeze_rows().
|
||||
_c20.p1_enabled = True
|
||||
check("...and the red status row is up too, so the page and the notification "
|
||||
"agree",
|
||||
any("METER MAY BE FROZEN" in r["text"] for r in _c20.checks()))
|
||||
any(r.get("id") == "meter_frozen" and not r["ok"] and not r["warn"]
|
||||
for r in _c20.checks()))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1215,23 +1226,25 @@ check("a device smaller than max_w still gets exactly one freeze-detector row",
|
||||
check("...and the page does NOT print the max_w rail the command can never "
|
||||
"reach - THE DEFECT, in one assertion",
|
||||
bool(_rows_s)
|
||||
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" not in _rows_s[0]["text"])
|
||||
and messages.watts(rail_w(LIVE_INSTALL_MAX_W)) not in _rows_s[0]["text"])
|
||||
check("...it prints the rail the predicate actually uses",
|
||||
bool(_rows_s)
|
||||
and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
|
||||
in _rows_s[0]["text"])
|
||||
check("...and it names the device range as the reason, since the operator "
|
||||
"configured max_w and is owed the reason the guard is armed elsewhere",
|
||||
bool(_rows_s) and "4700" in _rows_s[0]["text"]
|
||||
and "max_w" in _rows_s[0]["text"])
|
||||
and messages.watts(rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0,
|
||||
dev_max=4700.0)) in _rows_s[0]["text"])
|
||||
check("...and it names the inverter's own limit as the reason, since the "
|
||||
"operator configured max_w and is owed the reason the guard is armed "
|
||||
"elsewhere",
|
||||
bool(_rows_s) and "the inverter's own limit" in _rows_s[0]["text"])
|
||||
check("...and it does not say so when max_w itself sets the rail",
|
||||
bool(_rows_c) and "own limit" not in _rows_c[0]["text"])
|
||||
|
||||
# ⚠️ THE NUMBER IS TAKEN OFF THE ROW AND FED BACK INTO THE SHIPPED PREDICATE.
|
||||
# Asserting the page against `rail_w(...)` alone proves the two agree, which a
|
||||
# revert of BOTH to bare max_w also satisfies. What a revert cannot satisfy is
|
||||
# this: the rail the page advertises has to be a magnitude the wire can carry,
|
||||
# and the wire stops at the device rating.
|
||||
_m = re.search(r"command is over (\d+(?:\.\d+)?) W", _rows_s[0]["text"]) if _rows_s else None
|
||||
_page_rail = float(_m.group(1)) if _m else None
|
||||
_m = re.search(r"above (\d[\d ]*) W", _rows_s[0]["text"]) if _rows_s else None
|
||||
_page_rail = float(_m.group(1).replace(" ", "")) if _m else None
|
||||
print(f" the page advertises a rail of {_page_rail} W against a device that "
|
||||
f"stops at {_ctl_s.dev_max:.0f} W")
|
||||
check("...the row states a rail in watts at all, so the parse below means "
|
||||
@@ -1249,11 +1262,13 @@ check("...as an ok row - a guard armed at a lower rail is still armed, not a "
|
||||
"warning", bool(_rows_s) and _rows_s[0]["ok"] and not _rows_s[0]["warn"])
|
||||
|
||||
check("a device with no commandable range gets exactly one row", len(_rows_z) == 1)
|
||||
check("...and it says NOT COVERED - the rail is not merely low there, it is "
|
||||
"0 W, which would be TRUE of every command including a parked one",
|
||||
bool(_rows_z) and "NOT COVERED" in _rows_z[0]["text"])
|
||||
check("...and it NEVER says armed",
|
||||
bool(_rows_z) and "armed" not in _rows_z[0]["text"])
|
||||
check("...and it says the check is off for the power range - the rail is not "
|
||||
"merely low there, it is 0 W, which would be TRUE of every command "
|
||||
"including a parked one",
|
||||
bool(_rows_z) and ": off" in _rows_z[0]["text"]
|
||||
and "power range" in _rows_z[0]["text"])
|
||||
check("...and it NEVER says armed",
|
||||
bool(_rows_z) and "alerts after" not in _rows_z[0]["text"])
|
||||
check("...flagged as a warning rather than a silent ok, like the uncovered "
|
||||
"transport row it shares",
|
||||
bool(_rows_z) and not _rows_z[0]["ok"] and _rows_z[0]["warn"])
|
||||
|
||||
@@ -69,6 +69,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app import freshness as F
|
||||
from app import messages
|
||||
from named_absence import first, given, nth, present
|
||||
from app.circulation import circulating_w
|
||||
from app.freshness import BOUNDS, BATT_OBLIGED_S, BATT_UNCHANGED_S, MOVE_W, Freshness
|
||||
@@ -1823,7 +1824,8 @@ def _s56_row(c):
|
||||
a red with no name attached, which reads like broken infrastructure and gets
|
||||
retried rather than read. Measured: 0 named reds before, 2 after.
|
||||
"""
|
||||
hits = [r for r in c.checks() if r["text"].startswith("battery power")]
|
||||
hits = [r for r in c.checks() if r.get("id") == "sensor"
|
||||
and r["text"].startswith(messages.LABEL_BATTERY)]
|
||||
if len(hits) == 1:
|
||||
return hits[0]
|
||||
return {"ok": None, "warn": None,
|
||||
@@ -1933,14 +1935,17 @@ check("⚠️ AND THE VERDICT IS UNCHANGED BY THIS CARD - both rows are still "
|
||||
_row56_ref["ok"] is False and _row56_ref["warn"] is False
|
||||
and _row56_abs["ok"] is False and _row56_abs["warn"] is False
|
||||
and _row56_ref["text"] == _row56_abs["text"])
|
||||
check("...and what it says instead is the observation plus BOTH causes, with "
|
||||
f"{_BATT_ENT} in its own slot - `label` and `entity` are two "
|
||||
f"interpolations in one sentence and swapping them costs zero bytes, so "
|
||||
f"the literal around each is pinned (habit 14)",
|
||||
f"battery power: no usable value this cycle from {_BATT_ENT} - and this "
|
||||
f"row cannot tell which of two things that is" in _row56_ref["text"]
|
||||
and "the entity is missing or not numeric" in _row56_ref["text"]
|
||||
and "a number that this controller is withholding" in _row56_ref["text"])
|
||||
# UX-08: the row names the setting by its label, never the entity id
|
||||
# (STYLE.md), so there is no second interpolation left to transpose.
|
||||
check("...and what it says instead is the observation plus BOTH causes, under "
|
||||
"its own label, and the check to make before replacing the sensor - with "
|
||||
f"no entity id ({_BATT_ENT}) on the page row",
|
||||
_row56_ref["text"].startswith(f"{messages.LABEL_BATTERY}: no usable "
|
||||
f"reading")
|
||||
and "may be missing" in _row56_ref["text"]
|
||||
and "held back as too old" in _row56_ref["text"]
|
||||
and "Developer Tools before replacing it" in _row56_ref["text"]
|
||||
and _BATT_ENT not in _row56_ref["text"])
|
||||
check("⚠️ AND THE DISCRIMINATOR IT POINTS AT EXISTS AND IS SELECTIVE, which is "
|
||||
"why the row does not need to ask `batt_fresh.stale` itself: `checks()` "
|
||||
"carries a STALE_READING row in REFUSED and NONE in ABSENT, so 'is that "
|
||||
@@ -1957,7 +1962,7 @@ check("...and the row's wording is NOT shared with the log line's, which is a "
|
||||
"other caller does not need",
|
||||
_S56_EXPLANATIONS[0][1] not in _row56_ref["text"]
|
||||
and _S56_EXPLANATIONS[1][1] not in _row56_ref["text"]
|
||||
and "no usable value this cycle" not in _r56_ref)
|
||||
and "no usable reading" not in _r56_ref)
|
||||
|
||||
# --- 11e. what part 11 does NOT grade, with its shape (habit 13) -------------
|
||||
# * It does not show the live episode was a BMS refusal beyond doubt. 11a
|
||||
|
||||
@@ -811,9 +811,11 @@ check("...and the status page shows BOTH per-inverter rows - one latched, one "
|
||||
"'the sign watchdog is armed' printed over a latched sibling is the page "
|
||||
"lying",
|
||||
len([r for r in _c3r.checks()
|
||||
if r["text"].startswith("sign watchdog [")]) == 1
|
||||
if r.get("id") == "direction" and r["ok"]
|
||||
and "inverter-a" in r["text"]]) == 1
|
||||
and len([r for r in _c3r.checks()
|
||||
if "SIGN-DISAGREEMENT FAILSAFE [" in r["text"]]) == 1)
|
||||
if r.get("id") == "direction" and not r["ok"]
|
||||
and not r["warn"] and "inverter-b" in r["text"]]) == 1)
|
||||
check("⚠️ AND SAFETY-30's SITE ROW IS THERE BESIDE THEM, EXACTLY ONCE. At two "
|
||||
"inverters there are three sign questions on this page and they are three "
|
||||
"rows: is A doing what it was told, is B, and is the SITE'S TOTAL "
|
||||
@@ -823,15 +825,15 @@ check("⚠️ AND SAFETY-30's SITE ROW IS THERE BESIDE THEM, EXACTLY ONCE. At tw
|
||||
"per-unit rows would be advertising two guards where the install has "
|
||||
"three",
|
||||
len([r for r in _c3r.checks()
|
||||
if r["text"].startswith("site sign watchdog:")]) == 1)
|
||||
if r.get("id") == "site_direction"]) == 1)
|
||||
check("...and the per-inverter rows now say WHAT the unit was compared "
|
||||
"against, because at a roster of two it is no longer the meter. A row "
|
||||
"still reading 'the command opposed the meter' would send an operator to "
|
||||
"look at P1 for a fault that is visible only on one inverter's own power "
|
||||
"sensor",
|
||||
all("its own measured power" in r["text"] for r in _c3r.checks()
|
||||
if r["text"].startswith(("sign watchdog [",
|
||||
"SIGN-DISAGREEMENT FAILSAFE ["))))
|
||||
len([r for r in _c3r.checks() if r.get("id") == "direction"]) == 2
|
||||
and all("its own power" in r["text"] for r in _c3r.checks()
|
||||
if r.get("id") == "direction"))
|
||||
|
||||
# The operator's clear, which is one button and has to reach every latch.
|
||||
run(_c3r.handle_action("auto_toggle")) # -> stopped
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
"""UX-08: user-facing text follows STYLE.md. RUN: python3 -B test_messages.py
|
||||
|
||||
1. Every notification in `app/messages.py` obeys the rule. The test renders
|
||||
each one with realistic values and checks the title length, the number of
|
||||
sentences and words for its severity, the banned terms, the entity ids and
|
||||
the Details line.
|
||||
2. No notification is worded anywhere else. An AST scan of `app/` fails any
|
||||
`persistent_notification` call whose payload is not built by
|
||||
`app/messages.py`.
|
||||
3. The status page. Every row and banner in `app/messages.py` obeys the rule
|
||||
for its colour: a row one sentence of at most 15 words, a red row up to 3
|
||||
sentences and 30 words, a banner at most 12 words.
|
||||
THE RATCHET for the kinds not rewritten yet. Every status-row and banner
|
||||
literal left in `app/`, every validator refusal, every WARNING+ log line and every option
|
||||
name and description in `translations/en.yaml` must either obey its limit
|
||||
or appear in `ux08_allowlist.txt`. A new string that breaks the rule and is
|
||||
not listed goes red. So does a listed entry that no longer matches a
|
||||
failing string, so the list can only shrink: remove the line when you
|
||||
rewrite the string.
|
||||
4. DOCS.md has a section for every notification, under "## Notifications".
|
||||
5. Mutations: each rule is shown to catch a spoiled input (habit 1 of
|
||||
MUTATION.md).
|
||||
|
||||
Run `python3 -B test_messages.py --violations` to print what part 3 sees.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
|
||||
from app import messages # noqa: E402
|
||||
import translations_lint as TL # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# --- the rule (STYLE.md sections 1 and 4) --------------------------------------
|
||||
BANNED = re.compile(
|
||||
r"\b(?:(?:SAFETY|TARIFF|TEL|CFG|FCST|OPT|UX|HW|FW|WITNESS|PKG|ARCH)-\d+|"
|
||||
r"integrator|anti-?windup|windup|clamp(?:ed|s)?|i_clamped|saturat\w*|"
|
||||
r"BUG SIGNAL|latch(?:ed)?|arbiter|allocator|registers?|0x[0-9A-Fa-f]+|"
|
||||
r"layer [123]|heartbeat|watchdog|telegrams?|ACKs?|CRC|OTA|Modbus|planner|"
|
||||
r"capaciteitstarief|journalctl|ssh|grep|"
|
||||
r"\d+\.\d+\.\d+\.\d+|[\w-]+\.(?:py|md|yaml|json|db))\b|§\s?\d", re.I)
|
||||
ENTITY = re.compile(r"\b(?:sensor|number|binary_sensor|input_\w+|switch|select)"
|
||||
r"\.[a-z0-9_*]+")
|
||||
SNAKE = re.compile(r"\b[a-z0-9]+(?:_[a-z0-9]+)+\b")
|
||||
SHOUT = re.compile(r"\b[A-Z]{2,}[A-Z0-9]*(?:[\s-]+[A-Z]{2,}[A-Z0-9]*)+\b")
|
||||
PLACEHOLDER = re.compile(r"\{[^{}]*\}|%[-+ #0-9.]*[sdrfgx%]")
|
||||
|
||||
|
||||
def words(text):
|
||||
return [w for w in re.findall(r"\S+", PLACEHOLDER.sub("X", text))
|
||||
if re.search(r"[A-Za-z0-9]", w)]
|
||||
|
||||
|
||||
def sentences(text):
|
||||
"""Sentence count: a stop followed by a space and a capital, or the end."""
|
||||
t = PLACEHOLDER.sub("X", text.strip())
|
||||
t = re.sub(r"\b(e\.g|i\.e|etc)\.", "eg", t)
|
||||
return len([s for s in re.split(r"[.!?](?:\s+|$)", t) if s.strip()])
|
||||
|
||||
|
||||
def title_problems(title):
|
||||
out = []
|
||||
if len(words(title)) > 5:
|
||||
out.append(f"title over 5 words ({len(words(title))})")
|
||||
if title.lower().startswith("goodwe"):
|
||||
out.append("title carries a GoodWe prefix")
|
||||
if title.rstrip().endswith("."):
|
||||
out.append("title ends in a full stop")
|
||||
return out + text_problems(title, keys=False, entities=False)
|
||||
|
||||
|
||||
def text_problems(text, *, keys, entities):
|
||||
t = PLACEHOLDER.sub("X", text)
|
||||
out = []
|
||||
hit = sorted({m.group(0) for m in BANNED.finditer(t)})
|
||||
if hit:
|
||||
out.append("banned: " + ", ".join(hit[:4]))
|
||||
if not entities and ENTITY.search(t):
|
||||
out.append("entity id: " + ENTITY.search(t).group(0))
|
||||
if not keys and SNAKE.search(ENTITY.sub("", t)):
|
||||
out.append("option key: " + SNAKE.search(ENTITY.sub("", t)).group(0))
|
||||
if SHOUT.search(t):
|
||||
out.append("capitals: " + SHOUT.search(t).group(0))
|
||||
return out
|
||||
|
||||
|
||||
def body_problems(body, severity):
|
||||
out = []
|
||||
n_s, n_w = sentences(body), len(words(body))
|
||||
if severity == messages.ALARM:
|
||||
if n_s > 3:
|
||||
out.append(f"alarm body over 3 sentences ({n_s})")
|
||||
if n_w > 70:
|
||||
out.append(f"alarm body over 70 words ({n_w})")
|
||||
long = [s for s in re.split(r"(?<=[.!?])\s+", body) if len(words(s)) > 30]
|
||||
if long:
|
||||
out.append(f"sentence over 30 words ({len(words(long[0]))})")
|
||||
else:
|
||||
if n_s > 1:
|
||||
out.append(f"{severity} body over 1 sentence ({n_s})")
|
||||
if n_w > 25:
|
||||
out.append(f"{severity} body over 25 words ({n_w})")
|
||||
if ";" in body:
|
||||
out.append("semicolon: split it into sentences or drop a clause")
|
||||
return out + text_problems(body, keys=False, entities=False)
|
||||
|
||||
|
||||
def notice_problems(n):
|
||||
out = []
|
||||
if n.severity not in (messages.ALARM, messages.WARNING, messages.INFO,
|
||||
messages.RESOLVED):
|
||||
out.append(f"unknown severity {n.severity!r}")
|
||||
out += title_problems(n.title) + body_problems(n.body, n.severity)
|
||||
if not ends_in_details(n.data()["message"], n.docs):
|
||||
out.append("message does not end in its Details line")
|
||||
return out
|
||||
|
||||
|
||||
def ends_in_details(message, docs):
|
||||
return message.endswith("\n\n" + messages.details(docs))
|
||||
|
||||
|
||||
# ============================================================================== #
|
||||
print("1. every notification obeys the rule")
|
||||
SAMPLES = messages.samples()
|
||||
for n in SAMPLES:
|
||||
p = notice_problems(n)
|
||||
check(f"{n.severity:8} {n.title!r}: {len(words(n.body))} words, "
|
||||
f"{sentences(n.body)} sentence(s){' - ' + '; '.join(p) if p else ''}",
|
||||
not p)
|
||||
ids = {v for k, v in vars(messages).items() if k.startswith("ID_")}
|
||||
seen = {re.sub(r"inverter_b$", "{slug}", n.id) for n in SAMPLES}
|
||||
check(f"the samples cover every notification id in the catalogue "
|
||||
f"(missing: {sorted(ids - seen)})", ids <= seen)
|
||||
check("the payload carries exactly the three keys Home Assistant accepts",
|
||||
all(set(n.data()) == {"notification_id", "title", "message"}
|
||||
for n in SAMPLES))
|
||||
os.environ["HOSTNAME"] = "abcd1234-goodwe-controller"
|
||||
check("inside the add-on the Details line links to its documentation page",
|
||||
"(/hassio/addon/abcd1234_goodwe_controller/documentation)"
|
||||
in SAMPLES[0].data()["message"])
|
||||
os.environ["HOSTNAME"] = "some-laptop"
|
||||
check("...and anywhere else it names the section without a guessed link",
|
||||
"](" not in SAMPLES[0].data()["message"]
|
||||
and "under Notifications" in SAMPLES[0].data()["message"])
|
||||
del os.environ["HOSTNAME"]
|
||||
check("the output-clamp alarm quotes asked before sent",
|
||||
messages.output_clamp([messages.Clamp(None, 7412, 5000, "rating")]).body
|
||||
.index("7 412 W") < messages.output_clamp(
|
||||
[messages.Clamp(None, 7412, 5000, "rating")]).body.index("5 000 W"))
|
||||
|
||||
|
||||
# ============================================================================== #
|
||||
print("\n2. no notification is worded outside app/messages.py")
|
||||
APP = HERE / "app"
|
||||
|
||||
|
||||
def render(node):
|
||||
"""A string expression as text, placeholders as {expr}; None otherwise."""
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return node.value
|
||||
if isinstance(node, ast.JoinedStr):
|
||||
return "".join(v.value if isinstance(v, ast.Constant)
|
||||
else "{" + ast.unparse(v.value) + "}" for v in node.values)
|
||||
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
||||
a, b = render(node.left), render(node.right)
|
||||
if a is None and b is None:
|
||||
return None
|
||||
return (a if a is not None else "{…}") + (b if b is not None else "{…}")
|
||||
if isinstance(node, ast.IfExp):
|
||||
a, b = render(node.body), render(node.orelse)
|
||||
if a is None and b is None:
|
||||
return None
|
||||
return (a or "") + " / " + (b or "")
|
||||
return None
|
||||
|
||||
|
||||
def notification_calls(tree):
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Call) and node.args
|
||||
and ast.unparse(node.func).endswith("call")
|
||||
and render(node.args[0]) == "persistent_notification"):
|
||||
yield node
|
||||
|
||||
|
||||
bad_calls, n_calls = [], 0
|
||||
for f in sorted(APP.glob("*.py")):
|
||||
for call in notification_calls(ast.parse(f.read_text(encoding="utf-8"))):
|
||||
n_calls += 1
|
||||
payload = call.args[2] if len(call.args) > 2 else None
|
||||
ok = (isinstance(payload, ast.Call)
|
||||
and isinstance(payload.func, ast.Attribute)
|
||||
and payload.func.attr == "data"
|
||||
and "messages." in ast.unparse(payload.func.value))
|
||||
if not ok:
|
||||
bad_calls.append(f"{f.name}:{call.lineno}")
|
||||
check(f"the scan found the add-on's notification calls ({n_calls})", n_calls >= 7)
|
||||
check(f"every one is built by app/messages.py (not: {bad_calls})", not bad_calls)
|
||||
|
||||
|
||||
# ============================================================================== #
|
||||
print("\n3. the ratchet: other user-facing text obeys its limit or is listed")
|
||||
|
||||
LIMITS = { # kind: (words, keys ok, entity ids ok)
|
||||
"row": (15, False, False),
|
||||
"banner": (12, False, False),
|
||||
"refusal": (30, True, True),
|
||||
"log": (25, True, True),
|
||||
"option-name": (6, False, False),
|
||||
"option-desc": (30, True, True),
|
||||
}
|
||||
LOG_CALL = re.compile(r"(?:^|\.)(?:_LOG|log|logger|LOG)\.(warning|error|critical|exception)$")
|
||||
|
||||
|
||||
def strings_in(node):
|
||||
"""Top-level string expressions inside `node` (not their parts)."""
|
||||
t = render(node)
|
||||
if t is not None:
|
||||
yield node, t
|
||||
return
|
||||
for child in ast.iter_child_nodes(node):
|
||||
yield from strings_in(child)
|
||||
|
||||
|
||||
def collect():
|
||||
"""(kind, where, text) for every user-facing literal of a ratcheted kind."""
|
||||
out = []
|
||||
for f in sorted(APP.glob("*.py")):
|
||||
if f.name == "messages.py":
|
||||
continue
|
||||
tree = ast.parse(f.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Dict):
|
||||
for k, v in zip(node.keys, node.values):
|
||||
key = render(k) if k is not None else None
|
||||
if key in ("text", "banner"):
|
||||
for n, t in strings_in(v):
|
||||
out.append(("row" if key == "text" else "banner",
|
||||
f"{f.name}:{n.lineno}", t))
|
||||
elif isinstance(node, ast.Assign) and f.name == "main.py":
|
||||
tgt = ast.unparse(node.targets[0]).replace(" ", "").strip("()")
|
||||
if tgt in ("level,banner", "banner"):
|
||||
for n, t in strings_in(node.value):
|
||||
if len(words(t)) > 1:
|
||||
out.append(("banner", f"{f.name}:{n.lineno}", t))
|
||||
elif isinstance(node, ast.Call):
|
||||
name = ast.unparse(node.func)
|
||||
if LOG_CALL.search(name) or name.endswith("._moan"):
|
||||
arg = node.args[1 if name.endswith("._moan") else 0] \
|
||||
if node.args else None
|
||||
if arg is not None:
|
||||
for n, t in strings_in(arg):
|
||||
out.append(("log", f"{f.name}:{n.lineno}", t))
|
||||
elif re.search(r"problems\.(append|extend)$", name):
|
||||
for a in node.args:
|
||||
for n, t in strings_in(a):
|
||||
if len(words(t)) > 1:
|
||||
out.append(("refusal", f"{f.name}:{n.lineno}", t))
|
||||
tr = TL.parse_translation_file(HERE / "translations" / "en.yaml")
|
||||
|
||||
def walk(conf, path):
|
||||
for key, v in (conf or {}).items():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
if isinstance(v.get("name"), str):
|
||||
out.append(("option-name", f"en.yaml:{path}{key}",
|
||||
v["name"].split("·")[-1].strip()))
|
||||
if isinstance(v.get("description"), str):
|
||||
out.append(("option-desc", f"en.yaml:{path}{key}",
|
||||
v["description"]))
|
||||
if isinstance(v.get("fields"), dict):
|
||||
walk(v["fields"], f"{path}{key}.")
|
||||
walk(tr.get("configuration"), "")
|
||||
return out
|
||||
|
||||
|
||||
def problems_of(kind, text):
|
||||
lim, keys, ents = LIMITS[kind]
|
||||
p = []
|
||||
if len(words(text)) > lim:
|
||||
p.append(f"over {lim} words ({len(words(text))})")
|
||||
return p + text_problems(text, keys=keys, entities=ents)
|
||||
|
||||
|
||||
def key_of(kind, text):
|
||||
return f"{kind} {hashlib.sha1(text.encode('utf-8')).hexdigest()[:12]}"
|
||||
|
||||
|
||||
ALLOW_FILE = HERE / "ux08_allowlist.txt"
|
||||
# The allowlist may never grow. The test demands the list be exactly this long,
|
||||
# so deleting lines means lowering this number in the same change, and adding
|
||||
# one means raising it where a reviewer sees it.
|
||||
ALLOWLIST_MAX = 168
|
||||
|
||||
|
||||
def read_allowlist(path):
|
||||
keys = set()
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.split("#", 1)[0].strip()
|
||||
if line:
|
||||
keys.add(" ".join(line.split()[:2]))
|
||||
return keys
|
||||
|
||||
|
||||
found = collect()
|
||||
violating = {}
|
||||
for kind, where, text in found:
|
||||
p = problems_of(kind, text)
|
||||
if p:
|
||||
violating.setdefault(key_of(kind, text), (kind, where, text, p))
|
||||
if "--violations" in sys.argv:
|
||||
for k, (kind, where, text, p) in sorted(violating.items(),
|
||||
key=lambda kv: kv[1][:2]):
|
||||
print(f"{k} # {where} {'; '.join(p)} :: {text[:70]!r}")
|
||||
sys.exit(0)
|
||||
|
||||
STATUS = messages.status_samples()
|
||||
|
||||
|
||||
def row_problems(colour, text):
|
||||
"""STYLE.md section 1 for a status row (by colour) or a banner (None)."""
|
||||
n_s, n_w = sentences(text), len(words(text))
|
||||
out = []
|
||||
if colour is None:
|
||||
if n_w > 12:
|
||||
out.append(f"banner over 12 words ({n_w})")
|
||||
elif colour == messages.RED:
|
||||
if n_s > 3:
|
||||
out.append(f"red row over 3 sentences ({n_s})")
|
||||
if n_w > 30:
|
||||
out.append(f"red row over 30 words ({n_w})")
|
||||
else:
|
||||
if n_s > 1:
|
||||
out.append(f"{colour} row over 1 sentence ({n_s})")
|
||||
if n_w > 15:
|
||||
out.append(f"{colour} row over 15 words ({n_w})")
|
||||
if ";" in text:
|
||||
out.append("semicolon: split it into sentences or drop a clause")
|
||||
return out + text_problems(text, keys=False, entities=False)
|
||||
|
||||
|
||||
for fn, colour, text in STATUS:
|
||||
p = row_problems(colour, text)
|
||||
check(f"{colour or 'banner':6} {text!r}{' - ' + '; '.join(p) if p else ''}",
|
||||
not p)
|
||||
_catalogue = {k for k, v in vars(messages).items()
|
||||
if callable(v) and k.startswith(("row_", "banner_"))}
|
||||
_missing = sorted(_catalogue - {fn for fn, _c, _t in STATUS})
|
||||
check(f"the samples cover every row and banner in the catalogue "
|
||||
f"(missing: {_missing})", not _missing)
|
||||
# A red row with no banner of its own heads the page as "Not ready - <its first
|
||||
# sentence>". The three trips have their own branch in status_payload(), and
|
||||
# main.py gives the other two a "banner" key (both are sampled above).
|
||||
_own_banner = {"row_direction_trip", "row_site_direction_trip",
|
||||
"row_circulating_trip", "row_peak_reserve", "row_power_too_high"}
|
||||
_long = [t for fn, c, t in STATUS if c == messages.RED and fn not in _own_banner
|
||||
and len(words(messages.banner_not_ready({"text": t}))) > 12]
|
||||
check(f"every other red row makes a 'Not ready' banner of at most 12 words "
|
||||
f"(too long: {_long})", not _long)
|
||||
|
||||
# A latch row must say how it clears: that is the one action it exists for.
|
||||
_clears = {
|
||||
"row_direction_trip": "A restart does not clear this",
|
||||
"row_site_direction_trip": "Only starting control clears this",
|
||||
"row_circulating_trip": "Only starting control clears this",
|
||||
"row_not_delivering": "only stopping and starting control clears this",
|
||||
}
|
||||
_unclear = [fn for fn, _c, t in STATUS if fn in _clears and _clears[fn] not in t]
|
||||
check(f"every latch row says how it clears (missing: {_unclear})",
|
||||
not _unclear and {fn for fn, _c, _t in STATUS} >= set(_clears))
|
||||
|
||||
allow = read_allowlist(ALLOW_FILE)
|
||||
by_kind = {}
|
||||
for kind, *_ in found:
|
||||
by_kind[kind] = by_kind.get(kind, 0) + 1
|
||||
for _fn, colour, _t in STATUS:
|
||||
kind = "row" if colour else "banner"
|
||||
by_kind[kind] = by_kind.get(kind, 0) + 1
|
||||
check(f"the scan found every ratcheted kind ({by_kind})",
|
||||
all(by_kind.get(k, 0) >= 5 for k in LIMITS))
|
||||
new = sorted(k for k in violating if k not in allow)
|
||||
for k in new:
|
||||
kind, where, text, p = violating[k]
|
||||
print(f" NEW {where}: {'; '.join(p)} :: {text[:90]!r}\n"
|
||||
f" (rewrite it to STYLE.md; listing it needs a reason)")
|
||||
check(f"no new string breaks the rule ({len(new)} new)", not new)
|
||||
stale = sorted(k for k in allow if k not in violating)
|
||||
for k in stale:
|
||||
print(f" STALE {k} - that string now obeys the rule or is gone: "
|
||||
f"delete its line from ux08_allowlist.txt")
|
||||
check(f"no allowlist entry is stale, so the list only shrinks "
|
||||
f"({len(stale)} stale)", not stale)
|
||||
check(f"the allowlist is exactly its cap ({len(allow)} entries, cap "
|
||||
f"{ALLOWLIST_MAX}): lower the cap when you delete lines",
|
||||
len(allow) == ALLOWLIST_MAX)
|
||||
print(f"CI: UX-08 allowlist {len(allow)} entries "
|
||||
+ ", ".join(f"{k} {sum(1 for x in allow if x.startswith(k + ' '))}"
|
||||
for k in LIMITS))
|
||||
|
||||
|
||||
# ============================================================================== #
|
||||
print("\n4. DOCS.md holds the detail of every notification")
|
||||
DOCS = (HERE / "DOCS.md").read_text(encoding="utf-8")
|
||||
part = DOCS.split("\n## Notifications\n", 1)
|
||||
check("DOCS.md has a '## Notifications' section", len(part) == 2)
|
||||
section = part[-1].split("\n## ", 1)[0] if len(part) == 2 else ""
|
||||
heads = set(re.findall(r"^### (.+)$", section, re.M))
|
||||
missing = sorted({n.docs for n in SAMPLES} - heads)
|
||||
check(f"...with a heading for every Details line (missing: {missing})", not missing)
|
||||
check("DOCS.md explains the status page rows under "
|
||||
f"'## {messages.DOCS_STATUS}'", f"\n## {messages.DOCS_STATUS}\n" in DOCS)
|
||||
|
||||
|
||||
# ============================================================================== #
|
||||
print("\n5. mutations: each rule catches a spoiled input")
|
||||
N = messages.Notice
|
||||
check("a six-word title is refused",
|
||||
bool(title_problems("The meter reading may be stuck")))
|
||||
check("a GoodWe prefix is refused", bool(title_problems("GoodWe: meter stuck")))
|
||||
check("a second sentence in a warning is refused",
|
||||
bool(body_problems("The battery lags. Check it.", messages.WARNING)))
|
||||
check("a semicolon run-on is refused, in an alarm too",
|
||||
bool(body_problems("It stopped; check it.", messages.ALARM)))
|
||||
check("a fourth sentence in an alarm is refused",
|
||||
bool(body_problems("One. Two. Three. Four.", messages.ALARM)))
|
||||
check("an internal name is refused", bool(body_problems(
|
||||
"The integrator reached its bound.", messages.WARNING)))
|
||||
check("a card id is refused", bool(body_problems(
|
||||
"See SAFETY-25.", messages.WARNING)))
|
||||
check("shouting is refused", bool(body_problems(
|
||||
"Control is STILL RUNNING.", messages.WARNING)))
|
||||
check("an entity id in an alarm is refused", bool(body_problems(
|
||||
"Check sensor.battery_power now.", messages.ALARM)))
|
||||
check("...but allowed in a refusal", not problems_of(
|
||||
"refusal", "Check sensor.battery_power in Developer Tools."))
|
||||
check("an option key in an alarm is refused", bool(body_problems(
|
||||
"Lower peak_soc_floor.", messages.ALARM)))
|
||||
check("a message that lost its Details line is refused",
|
||||
ends_in_details(N("x", messages.WARNING, "Meter stuck", "It is.")
|
||||
.data()["message"], "Meter stuck")
|
||||
and not ends_in_details("It is.", "Meter stuck"))
|
||||
_fake = " x = {'text': 'THE METER IS FROZEN AND NOTHING WORKS AT ALL'}\n"
|
||||
_n = [t for node in ast.walk(ast.parse(_fake.strip()))
|
||||
if isinstance(node, ast.Dict) for _, t in strings_in(node.values[0])]
|
||||
check("a second sentence in an amber row is refused",
|
||||
bool(row_problems(messages.AMBER, "Meter stuck. Check it.")))
|
||||
check("a fourth sentence in a red row is refused",
|
||||
bool(row_problems(messages.RED, "One. Two. Three. Four.")))
|
||||
check("a 16-word green row is refused",
|
||||
bool(row_problems(messages.GREEN, " ".join(["word"] * 16))))
|
||||
check("a 13-word banner is refused",
|
||||
bool(row_problems(None, " ".join(["word"] * 13))))
|
||||
check("an entity id in a status row is refused",
|
||||
bool(row_problems(messages.GREEN, "Battery level: sensor.soc = 54")))
|
||||
check("a semicolon in a status row is refused",
|
||||
bool(row_problems(messages.RED, "It stopped; check it.")))
|
||||
check("a new shouting status row is seen by the scan and is not listed",
|
||||
_n and problems_of("row", _n[0])
|
||||
and key_of("row", _n[0]) not in allow)
|
||||
_bad = ast.parse("await self.hass.call('persistent_notification', 'create', "
|
||||
"{'title': 'x', 'message': 'y'})", mode="exec")
|
||||
check("a hand-written notification payload is seen by part 2",
|
||||
any(not (isinstance(c.args[2], ast.Call)) for c in notification_calls(_bad)))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -1432,7 +1432,7 @@ def p1_rows(ctl):
|
||||
a traceback instead of reddening the check that names the rule.
|
||||
"""
|
||||
try:
|
||||
return [c["text"] for c in ctl.checks() if c["text"].startswith("P1 meter")]
|
||||
return [c["text"] for c in ctl.checks() if c.get("id") == "meter"]
|
||||
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
|
||||
print(f" checks() raised {type(err).__name__}: {err}")
|
||||
return err
|
||||
|
||||
@@ -114,6 +114,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from math import inf, isinf
|
||||
from pathlib import Path
|
||||
|
||||
from app import messages
|
||||
from app import peakguard
|
||||
from named_absence import first, given, nth, present
|
||||
|
||||
@@ -1037,7 +1038,7 @@ check(f"a Controller built with peak_cap_w ABSENT takes the shipped "
|
||||
c.peak.cap_w == CAP_W)
|
||||
check("...and before the first cycle the guard has NO decision, which the "
|
||||
"status page must not spell as 'idle'", c.peak_guard is None)
|
||||
pre = [r for r in c.checks() if "peak guard" in r["text"]]
|
||||
pre = [r for r in c.checks() if r.get("id") == "peak_guard"]
|
||||
print(" status row before the first cycle: "
|
||||
+ (pre[0]["text"] if pre else "(none)"))
|
||||
check("the status page says so in its own row",
|
||||
@@ -1053,17 +1054,20 @@ c_off, hass_off = built(TMP / "legacy.db",
|
||||
{"meter_source": "off", "meter_entity": "sensor.grid"})
|
||||
c_off.auto, c_off.soc, c_off.batt, c_off.grid = True, 55.0, 0.0, -6000.0
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c_off.cycle())
|
||||
off_rows = [r for r in c_off.checks() if "peak guard" in r["text"]]
|
||||
off_rows = [r for r in c_off.checks() if r.get("id") == "peak_guard"]
|
||||
print(f" meter_source off: guard={c_off.peak_guard}, wrote "
|
||||
f"{nth(hass_off.writes, -1):+.0f} W")
|
||||
print(" status row: " + (off_rows[0]["text"] if off_rows else "(none)"))
|
||||
check("with no P1 the guard does not run at all - it files nothing, and the "
|
||||
"legacy path can still charge", c_off.peak_guard is None
|
||||
and hass_off.writes and hass_off.writes[-1] < 0)
|
||||
check("...and the status page says NOT COVERED rather than 'idle' or a green "
|
||||
"row, which is the same answer SAFETY-18 already gives on a transport "
|
||||
"that cannot see it", len(off_rows) == 1
|
||||
and "NOT COVERED" in off_rows[0]["text"] and off_rows[0]["warn"])
|
||||
check("...and the status page says the guard is off, and why, rather than "
|
||||
"'idle' or a green row - the same answer SAFETY-18 already gives on a "
|
||||
"transport that cannot see it", len(off_rows) == 1
|
||||
and ": off" in off_rows[0]["text"]
|
||||
and "meter source" in off_rows[0]["text"]
|
||||
and "idle" not in off_rows[0]["text"]
|
||||
and not off_rows[0]["ok"] and off_rows[0]["warn"])
|
||||
|
||||
# ⚠️ THE FIXTURE MUST LAND IN THE CURRENT QUARTER, AND THAT IS NOT PEDANTRY.
|
||||
# `cycle()` reads the real clock, and `project()` measures the open block against
|
||||
@@ -1213,7 +1217,7 @@ check("the real cycle files the peak claim and the real arbiter binds the real "
|
||||
and not c.peak_guard.saturated and "peak" in (c.reason or ""))
|
||||
check("...and the number written to the inverter is the guard's floor",
|
||||
hass.writes and near(hass.writes[-1], c.peak_guard.floor_w, 1.5))
|
||||
rows = [r for r in c.checks() if "peak guard" in r["text"].lower()]
|
||||
rows = [r for r in c.checks() if r.get("id") == "peak_guard"]
|
||||
print(" status row: " + (rows[0]["text"] if rows else "(none)"))
|
||||
check("the status page carries exactly one peak-guard row, flagged",
|
||||
len(rows) == 1 and rows[0]["warn"])
|
||||
@@ -1921,12 +1925,12 @@ asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c24.cycle())
|
||||
check("...once per episode, not once per cycle - a quarter of an hour of "
|
||||
"identical cards is how an operator learns to dismiss the one that "
|
||||
"mattered", hass24.notifications.count("goodwe_peak_soc_floor") == 1)
|
||||
rows24 = [r for r in c24.checks() if "peak guard" in r["text"].lower()]
|
||||
rows24 = [r for r in c24.checks() if r.get("id") == "peak_guard"]
|
||||
print(" status row: " + (rows24[0]["text"] if rows24 else "(none)"))
|
||||
check("the status page carries exactly one peak-guard row and it is NOT ok - "
|
||||
"the money is going to be spent and a human is the only actuator left",
|
||||
len(rows24) == 1 and not rows24[0]["ok"]
|
||||
and "FLOOR-LIMITED" in rows24[0]["text"])
|
||||
len(rows24) == 1 and not rows24[0]["ok"] and not rows24[0]["warn"]
|
||||
and "peak limit will be passed" in rows24[0]["text"])
|
||||
check("...and it reaches the action log too", any("FLOOR-LIMITED" in e
|
||||
for e in c24.events))
|
||||
# A missing SoC sensor, through the same real cycle. `inputs_ok` stops the loop
|
||||
@@ -2478,17 +2482,23 @@ check("...and DOCS.md sends the reader to it rather than leaving the open "
|
||||
# comment is a scope sentence the operator never sees.
|
||||
c25, hass25, NET25, _ = wired_over_limit(
|
||||
TMP / "soc25.db", LIVE_MAX_W * 0.3, soc=SOC_FLOOR - 1.0)
|
||||
rows25 = [r for r in c25.checks() if "peak guard" in r["text"].lower()]
|
||||
rows25 = [r for r in c25.checks() if r.get("id") == "peak_guard"]
|
||||
|
||||
|
||||
def _row_scope(text):
|
||||
"""SAFETY-25 in messages.row_peak_reserve()'s words."""
|
||||
return ("Home use still discharges below the reserve" in text
|
||||
and "down to the cut-out level" in text)
|
||||
|
||||
|
||||
print(" status row: " + (rows25[0]["text"] if rows25 else "(none)"))
|
||||
check("the STATUS PAGE row for a floor-limited guard says the floor bounds the "
|
||||
"guard only and that self-consumption may keep discharging below it. "
|
||||
"Without that sentence the page implies a pack-wide reserve at the exact "
|
||||
"moment the replay above says the loop is still emptying the pack",
|
||||
len(rows25) == 1
|
||||
and "PEAK GUARD FLOOR-LIMITED" in rows25[0]["text"]
|
||||
and "BOUNDS THE PEAK GUARD ONLY" in rows25[0]["text"].upper()
|
||||
and "not a pack-wide reserve" in rows25[0]["text"]
|
||||
and "BMS cut-out" in rows25[0]["text"])
|
||||
and "at its peak reserve" in rows25[0]["text"]
|
||||
and _row_scope(rows25[0]["text"]))
|
||||
# ⚠️ THE ROW KEEPS THE WHOLE SENTENCE; THE BANNER DOES NOT. status_payload()
|
||||
# renders the first not-ok/not-warn row as the page headline, so before this the
|
||||
# loudest element on the page was the same 550-character paragraph the row three
|
||||
@@ -2500,35 +2510,36 @@ print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}")
|
||||
# The guard's own line, which the banner has always carried and which is not
|
||||
# this card's to shorten: `explain()` is SAFETY-24's sentence about a quarter
|
||||
# that is going over the ceiling.
|
||||
_expl25 = nth(rows25, 0)["text"].split(". NOTE:")[0]
|
||||
check("the NOT READY banner is the guard's own line plus a SHORT scope tag - "
|
||||
"under 90 characters of it - and NOT the row's whole paragraph repeated "
|
||||
"at the top of the page. A headline that is a paragraph is a headline "
|
||||
"nobody reads, and this one is followed three lines later by the same "
|
||||
"text verbatim",
|
||||
present(_expl25)
|
||||
and _pay25["level"] == "bad"
|
||||
and _pay25["banner"].startswith("NOT READY")
|
||||
and _expl25 in _pay25["banner"]
|
||||
and "BOUNDS THE PEAK GUARD ONLY" in _pay25["banner"].upper()
|
||||
and "not a pack-wide reserve" not in _pay25["banner"]
|
||||
and len(_pay25["banner"]) - len(_expl25) < 90)
|
||||
check("...and shortening the banner did NOT shorten the row: the full AC 3 "
|
||||
# UX-08: the banner is the notification's own title, and the row below keeps
|
||||
# the SAFETY-25 sentence.
|
||||
check("the Not ready banner is a short headline - the notification's title - "
|
||||
"and NOT the row repeated at the top of the page. A headline that is a "
|
||||
"paragraph is a headline nobody reads",
|
||||
_pay25["level"] == "bad"
|
||||
and _pay25["banner"] == (f"{messages.NOT_READY} — "
|
||||
f"{messages.T_PEAK_RESERVE}")
|
||||
and not _row_scope(_pay25["banner"]))
|
||||
check("...and shortening the banner did NOT shorten the row: the SAFETY-25 "
|
||||
"sentence is still what the checks list carries, which is the surface the "
|
||||
"operator reads to find out what the floor does not bound",
|
||||
len(rows25) == 1
|
||||
and len(rows25[0]["text"]) > 300
|
||||
and "not a pack-wide reserve" in rows25[0]["text"]
|
||||
and "BMS cut-out" in rows25[0]["text"])
|
||||
"operator reads to find out what the reserve does not bound",
|
||||
len(rows25) == 1 and _row_scope(rows25[0]["text"]))
|
||||
|
||||
_body25 = hass25.last_message("goodwe_peak_soc_floor")
|
||||
|
||||
|
||||
def _says_scope(body):
|
||||
"""SAFETY-25 in messages.peak_reserve()'s words: the reserve binds the peak
|
||||
guard only, and home use goes on discharging below it to the cut-out."""
|
||||
return ("The reserve only applies to keeping the peak down" in body
|
||||
and "normal home use can still discharge the battery below it" in body
|
||||
and "down to its cut-out level" in body)
|
||||
|
||||
|
||||
print(f" notification body tail: ...{_body25[-180:]}")
|
||||
check("...and so does the persistent notification, which outlives the page and "
|
||||
"is the thing an operator reads on a phone at 19:00. It names what has "
|
||||
"stopped - the guard buying a lower bill - and what has not",
|
||||
"peak_soc_floor bounds THE PEAK GUARD ONLY" in _body25
|
||||
and "not a" in _body25 and "pack-wide reserve" in _body25
|
||||
and "BMS cut-out" in _body25)
|
||||
_says_scope(_body25))
|
||||
|
||||
|
||||
# --- 10f. THE CLAIM-SOURCE SET, PINNED --------------------------------------
|
||||
@@ -4332,8 +4343,8 @@ check("the notification an operator reads at 19:00 NAMES THE PACK that stopped "
|
||||
"the site, and names only that one. 'the battery is at its floor' on a "
|
||||
"site with two batteries is an instruction to go and look at both, and "
|
||||
"the wrong one is the one that is fine",
|
||||
"SAFETY-32" in _body32 and "inverter-b" in _body32
|
||||
and "inverter-a" not in _body32.split("WHICH PACK")[-1])
|
||||
"The battery of inverter-b is at its peak reserve" in _body32
|
||||
and "inverter-a" not in _body32)
|
||||
# The mirror again, on the SoC axis: swap which pack is empty and the name
|
||||
# swaps. A hardwired `self.inverters[1].name` passes the line above.
|
||||
_body32b = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
@@ -4341,8 +4352,8 @@ _body32b = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
check("...and with the SAME two readings swapped between the packs it names "
|
||||
"inverter-a instead - so the sentence follows the measurement rather "
|
||||
"than a position in the roster",
|
||||
"inverter-a" in _body32b
|
||||
and "inverter-b" not in _body32b.split("WHICH PACK")[-1])
|
||||
"The battery of inverter-a is at its peak reserve" in _body32b
|
||||
and "inverter-b" not in _body32b)
|
||||
|
||||
|
||||
# ⚠️ AC 5, THE OTHER HALF: THE SAME METHOD ON A ROSTER OF ONE MUST PRINT WHAT IT
|
||||
@@ -4366,13 +4377,14 @@ async def _notify32_one():
|
||||
|
||||
_body32one = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
_notify32_one())
|
||||
check("⚠️ AC 5. At a roster of ONE the same notification carries NO per-pack "
|
||||
"sentence at all - byte for byte the message SAFETY-25 shipped. There is "
|
||||
check("⚠️ AC 5. At a roster of ONE the same notification names NO pack - "
|
||||
"byte for byte the two-pack message minus the name (UX-08). There is "
|
||||
"one battery, naming it is noise, and this alarm reaches every existing "
|
||||
"install",
|
||||
"SAFETY-32" not in _body32one and "WHICH PACK" not in _body32one
|
||||
and _body32one == _body32.split("\n\nSAFETY-32")[0]
|
||||
and "peak_soc_floor bounds THE PEAK GUARD ONLY" in _body32one)
|
||||
"inverter-" not in _body32one
|
||||
and _body32one == _body32.replace("The battery of inverter-b is",
|
||||
"The battery is")
|
||||
and _says_scope(_body32one))
|
||||
|
||||
|
||||
# --- 12i. THE SIBLING ARGUMENT: `pack_floor_soc`, THE OTHER HALF OF THE PAIR --
|
||||
@@ -5604,8 +5616,7 @@ MUT.append(("rule 8's refusal naming a hardwired inverter",
|
||||
# 21. the notification naming a fixed roster position rather than the pack that
|
||||
# is actually at its floor.
|
||||
MUT.append(("the per-pack notification naming a fixed roster position",
|
||||
"inverter-a" in _body32b
|
||||
and "inverter-b" not in _body32b.split("WHICH PACK")[-1],
|
||||
"inverter-a" in _body32b and "inverter-b" not in _body32b,
|
||||
"part 12h: the same two readings swapped between the packs"))
|
||||
|
||||
# ---- the two PR #48 SURVIVORS, and why they survived -------------------------
|
||||
@@ -5955,7 +5966,7 @@ check("...and the row still names WHERE the ceiling came from, which is the "
|
||||
# main.py:1951 is what the release edge writes.
|
||||
_c38, _h38 = built(TMP / "s38pin.db")
|
||||
_c38.peak_guard = _S38_IDLE
|
||||
_rows38 = [r for r in _c38.checks() if "peak guard" in r["text"]]
|
||||
_rows38 = [r for r in _c38.checks() if r.get("id") == "peak_guard"]
|
||||
print(" status row: " + (_rows38[0]["text"] if _rows38 else "(none)"))
|
||||
check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), read "
|
||||
"off the shipped `checks()` rather than off `explain()` a second time. "
|
||||
|
||||
@@ -69,7 +69,7 @@ import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app import mqtt, pv
|
||||
from app import messages, mqtt, pv
|
||||
from named_absence import field, nth, present
|
||||
from app.main import Controller
|
||||
|
||||
@@ -499,6 +499,11 @@ def rows_of(c):
|
||||
return [r["text"] for r in c.checks()]
|
||||
|
||||
|
||||
def pv_rows(c):
|
||||
"""The solar rows of the status page (UX-08: selected by id, not wording)."""
|
||||
return [r for r in c.checks() if r.get("id") == "pv"]
|
||||
|
||||
|
||||
def page_rows(c):
|
||||
return c.status_payload()["rows"]
|
||||
|
||||
@@ -510,38 +515,39 @@ check("⚠️ AN UNSET OPTION ADDS NO STATUS ROW AND NO PAGE ROW - not a caution
|
||||
"is unset, because an unset forecast leaves a money guard without an "
|
||||
"input; this is instrumentation and a permanent caution on every install "
|
||||
"that never asked for it is the warning spam the card forbids",
|
||||
not [t for t in _off_rows if "PV" in t]
|
||||
not pv_rows(c_off)
|
||||
and not [r for r in page_rows(c_off) if r[0] == "PV"])
|
||||
check("...and the whole row list is otherwise the same length, so nothing else "
|
||||
"moved either", len(_on_rows) == len(_off_rows) + 1)
|
||||
_pv_row = [t for t in _on_rows if t.startswith("PV production")]
|
||||
_pv_row = [r["text"] for r in pv_rows(c_on)]
|
||||
print(f" {_pv_row}")
|
||||
# habit 14: the ordered pair - value, entity and unit each in its OWN slot in
|
||||
# the literal sentence, so a transposition is visible rather than merely
|
||||
# present.
|
||||
check("a good read says the watts, the entity it came from and the unit it was "
|
||||
"reported in, each in its own slot",
|
||||
_pv_row == ["PV production: 3210 W from sensor.pv_power "
|
||||
"(reported in W)"]
|
||||
# UX-08: the row says the watts, and no entity id (STYLE.md). The unit it was
|
||||
# read in is DEBUG detail; a refused unit still gets its own caution below.
|
||||
check("a good read says the watts, as a green row, and names no entity id",
|
||||
_pv_row == [f"Solar production: {messages.signed_watts(3210)}"]
|
||||
and "sensor." not in _pv_row[0]
|
||||
and pv_rows(c_on)[0]["ok"] is True
|
||||
and ("PV", "3 210 W") in page_rows(c_on))
|
||||
|
||||
c_bad, _, _ = run(cycles=1, states=[state("1234", "%")], **PV_ON)
|
||||
_bad_row = [r for r in c_bad.checks() if r["text"].startswith("PV production")]
|
||||
_bad_row = pv_rows(c_bad)
|
||||
print(f" {[r['text'] for r in _bad_row]}")
|
||||
check("a refused read says WHICH refusal, and names the entity - 'no reading' "
|
||||
"on its own sends an operator to look at a working sensor",
|
||||
check("a refused read says WHICH refusal - 'no reading' on its own sends an "
|
||||
"operator to look at a working sensor",
|
||||
len(_bad_row) == 1
|
||||
and _bad_row[0]["text"] == ("PV production: no reading from "
|
||||
"sensor.pv_power - unit '%' is not one of "
|
||||
"mW, W, kW, MW, GW, TW - it cannot be read "
|
||||
"in watts"))
|
||||
and _bad_row[0]["text"] == ("Solar production: no reading, the sensor's "
|
||||
"unit is not a power unit"))
|
||||
check("...and every refusal pv.watts() can give has its own plain words",
|
||||
len({messages.pv_problem(pv.watts(st)[1]) for st in (
|
||||
None, state("unavailable", "W"), state("abc", "W"),
|
||||
state("12", ""), state("12", "%"))}) == 5)
|
||||
check("⭐ ...and it is a CAUTION, never a `bad` row: `status_payload()` builds "
|
||||
"NOT READY out of rows that are neither ok nor warn, and a telemetry "
|
||||
"channel that could put a battery site into NOT READY would be "
|
||||
"observability with a foot in the control path",
|
||||
len(_bad_row) == 1
|
||||
and _bad_row[0] == {"ok": False, "warn": True, "text": _bad_row[0]["text"]}
|
||||
and not c_bad.status_payload()["banner"].startswith("NOT READY")
|
||||
and _bad_row[0]["ok"] is False and _bad_row[0]["warn"] is True
|
||||
and not c_bad.status_payload()["banner"].startswith(messages.NOT_READY)
|
||||
and c_bad.status_payload()["level"] != "bad"
|
||||
and c_bad.status_payload()["banner"]
|
||||
== c_off.status_payload()["banner"])
|
||||
@@ -923,41 +929,32 @@ print()
|
||||
print("10. the operator surfaces at a roster of three - and 0.11.0 at one")
|
||||
# ==============================================================================
|
||||
_c_many, _, _ = run(cycles=1, by_entity=FULL, **PV_MANY)
|
||||
_many_row = [t for t in rows_of(_c_many) if t.startswith("PV production")]
|
||||
_many_row = [r["text"] for r in pv_rows(_c_many)]
|
||||
print(f" {_many_row}")
|
||||
# habit 14 at a roster: the total in its own slot and EVERY contributor in its
|
||||
# own slot with its own watts and its own unit, so a sum that disagrees with the
|
||||
# addends printed beside it is visible in the sentence.
|
||||
check("a healthy roster says the SITE total, how many entities it came from, "
|
||||
"and then every entity with its own watts and its own unit - so a total "
|
||||
"that disagrees with the numbers printed beside it is visible rather than "
|
||||
"merely present",
|
||||
_many_row == ["PV production: 3750 W from 3 entities "
|
||||
f"({E1} 1200 W in W, {E2} 2100 W in kW, "
|
||||
f"{E3} 450 W in W)"]
|
||||
# UX-08: the row carries the site total and the number of sources; the
|
||||
# per-source figures are DOCS/DEBUG detail (DECISIONS/UX-08-short-notifications).
|
||||
check("a healthy roster says the SITE total and how many sources it came from",
|
||||
_many_row == [f"Solar production: {messages.signed_watts(3750)} from 3 "
|
||||
f"sources"]
|
||||
and ("PV", "3 750 W") in page_rows(_c_many))
|
||||
|
||||
_c_gap, _, _ = run(cycles=1, by_entity=HOLES[E2][0], **PV_MANY)
|
||||
_gap_row = [r for r in _c_gap.checks() if r["text"].startswith("PV production")]
|
||||
_gap_row = pv_rows(_c_gap)
|
||||
print(f" {[r['text'] for r in _gap_row]}")
|
||||
check("⭐ A HOLED ROSTER SAYS THERE IS NO SITE TOTAL, IN THOSE WORDS, names the "
|
||||
"entity that is missing and why - AND names what the others reported. The "
|
||||
"partial information is not thrown away, it is put where a HUMAN reading "
|
||||
check("⭐ A HOLED ROSTER SAYS THERE IS NO SITE TOTAL, IN THOSE WORDS, and names "
|
||||
"the missing source by its place in the list - put where a HUMAN reading "
|
||||
"the page live will see it, and kept out of the number a recorder hands "
|
||||
"back cold a week later",
|
||||
len(_gap_row) == 1
|
||||
and _gap_row[0]["text"] == (
|
||||
"PV production: NO SITE TOTAL - only 2 of 3 entities are reporting "
|
||||
"and a partial sum would understate production: "
|
||||
f"{E2} - no state (entity missing, or the read failed). "
|
||||
f"Reporting: {E1} 1200 W, {E3} 450 W"))
|
||||
and _gap_row[0]["text"] == ("Solar production: no total, source 2 of 3 "
|
||||
"has no reading"))
|
||||
check("⭐ ...and it is a CAUTION, never a `bad` row, at a roster exactly as at "
|
||||
"one entity: `status_payload()` builds NOT READY out of rows that are "
|
||||
"neither ok nor warn, and a telemetry channel that could put a battery "
|
||||
"site into NOT READY would be observability with a foot in the control "
|
||||
"path",
|
||||
len(_gap_row) == 1
|
||||
and _gap_row[0] == {"ok": False, "warn": True, "text": _gap_row[0]["text"]}
|
||||
and _gap_row[0]["ok"] is False and _gap_row[0]["warn"] is True
|
||||
and _c_gap.status_payload()["level"] != "bad"
|
||||
and _c_gap.status_payload()["banner"]
|
||||
== c_off.status_payload()["banner"])
|
||||
@@ -974,27 +971,24 @@ _c_one_new, _, _ = run(cycles=3, by_entity={E1: [state("3210", "W")]},
|
||||
pv_entities=[E1])
|
||||
print(f" one entity via pv_entities : "
|
||||
f"{[t for t in rows_of(_c_one_new) if 'PV' in t]}")
|
||||
check("⭐ ONE ENTITY IS 0.11.0's PATH, however it was declared: the status "
|
||||
"sentence, the page row and the published value are the same shape "
|
||||
"through `pv_entity` and through a one-member `pv_entities`, and the "
|
||||
"sentence is 0.11.0's own - `(reported in W)`, not `from 1 entities`",
|
||||
[t.replace(E1, "sensor.pv_power")
|
||||
for t in rows_of(_c_one_new) if "PV" in t]
|
||||
== [t for t in rows_of(c_on) if "PV" in t]
|
||||
== ["PV production: 3210 W from sensor.pv_power (reported in W)"]
|
||||
check("⭐ ONE ENTITY IS ONE PATH, however it was declared: the status "
|
||||
"sentence, the page row and the published value are the same through "
|
||||
"`pv_entity` and through a one-member `pv_entities` - no `from 1 sources`",
|
||||
[r["text"] for r in pv_rows(_c_one_new)]
|
||||
== [r["text"] for r in pv_rows(c_on)]
|
||||
== [f"Solar production: {messages.signed_watts(3210)}"]
|
||||
and page_rows(_c_one_new)[[r[0] for r in page_rows(_c_one_new)]
|
||||
.index("PV")] == ("PV", "3 210 W"))
|
||||
check("...and a refused read at a roster of one is 0.11.0's refusal sentence "
|
||||
"too, not the roster one - `no reading from <entity> - <reason>`",
|
||||
[r["text"] for r in run(cycles=1, pv_entities=[E1],
|
||||
by_entity={E1: [state("1234", "%")]})[0].checks()
|
||||
if r["text"].startswith("PV production")]
|
||||
== [f"PV production: no reading from {E1} - unit '%' is not one of "
|
||||
"mW, W, kW, MW, GW, TW - it cannot be read in watts"])
|
||||
check("...and a refused read at a roster of one is the one-sensor refusal "
|
||||
"too, not the roster one - `no reading, <reason>`",
|
||||
[r["text"] for r in pv_rows(run(cycles=1, pv_entities=[E1],
|
||||
by_entity={E1: [state("1234", "%")]})[0])]
|
||||
== [r["text"] for r in _bad_row])
|
||||
|
||||
# ⚠️ AND THE REPLACEMENT IS STATED RATHER THAN SILENT.
|
||||
_c_both, _, _ = run(cycles=1, by_entity=FULL, **dict(PV_ON, **PV_MANY))
|
||||
_ign = [r for r in _c_both.checks() if "IGNORED" in r["text"]]
|
||||
_IGN = messages.row_pv_single_ignored()
|
||||
_ign = [r for r in _c_both.checks() if r["text"] == _IGN]
|
||||
print(f" {[r['text'] for r in _ign]}")
|
||||
check("⭐ BOTH KEYS SET: the page SAYS `pv_entity` is ignored and how many "
|
||||
"entities are summed instead. `entities()` REPLACES rather than unions - "
|
||||
@@ -1002,20 +996,17 @@ check("⭐ BOTH KEYS SET: the page SAYS `pv_entity` is ignored and how many "
|
||||
"key still names - but a replacement nobody is told about is the 'one of "
|
||||
"which silently wins' this config refuses elsewhere",
|
||||
len(_ign) == 1 and _ign[0]["ok"] is False and _ign[0]["warn"] is True
|
||||
and _ign[0]["text"] == ("PV production: `pv_entities` is set, so "
|
||||
"`pv_entity` is IGNORED - the 3 entities in the "
|
||||
"list are what is summed. Clear `pv_entity` to "
|
||||
"remove this caution")
|
||||
and "clear the single sensor setting" in _ign[0]["text"]
|
||||
and _c_both.pv_w == 3750.0)
|
||||
check("...and that caution appears ONLY when both are set - not on the 0.11.0 "
|
||||
"configuration, not on a list-only one, and not on an install with "
|
||||
"neither",
|
||||
not [r for r in c_on.checks() if "IGNORED" in r["text"]]
|
||||
and not [r for r in _c_many.checks() if "IGNORED" in r["text"]]
|
||||
and not [r for r in c_off.checks() if "IGNORED" in r["text"]])
|
||||
not [r for r in c_on.checks() if r["text"] == _IGN]
|
||||
and not [r for r in _c_many.checks() if r["text"] == _IGN]
|
||||
and not [r for r in c_off.checks() if r["text"] == _IGN])
|
||||
check("...and an install with NO PV roster still adds no row of any kind - the "
|
||||
"whole of TEL-12 is invisible at the shipped default",
|
||||
not [t for t in rows_of(c_off) if "PV" in t]
|
||||
not pv_rows(c_off)
|
||||
and not [r for r in page_rows(c_off) if r[0] == "PV"]
|
||||
and len(rows_of(_c_many)) == len(rows_of(c_off)) + 1
|
||||
and run(cycles=1)[1].state_calls == []
|
||||
@@ -1109,19 +1100,18 @@ check("⭐ END TO END: the controller reads the option, and the MQTT `pv` row "
|
||||
"carries the corrected site total",
|
||||
_c_mix.pv_w == MIX_TOTAL
|
||||
and pv_msg(_f_mix) == ("goodwe_ctl/pv", "3270.0", False))
|
||||
_mix_row = [r["text"] for r in _row(_c_mix, "W from 3 entities")]
|
||||
_mix_row = [r["text"] for r in _row(_c_mix, "from 3 sources")]
|
||||
print(f" {_mix_row}")
|
||||
check("the status row prints each entity's watts AFTER the sign and marks "
|
||||
"ONLY the flipped one",
|
||||
_mix_row == [f"PV production: 3270 W from 3 entities ({E1} 1200 W in W, "
|
||||
f"{E2} 2100 W in kW [sign inverted], {E3} -30 W in W)"])
|
||||
check("the status row prints the total AFTER the sign",
|
||||
_mix_row == [f"Solar production: {messages.signed_watts(MIX_TOTAL)} "
|
||||
f"from 3 sources"])
|
||||
check("...and the page's PV row is the corrected total",
|
||||
("PV", "3 270 W") in page_rows(_c_mix))
|
||||
_c_hole_f, _, _ = run(cycles=1, by_entity=dict(MIX, **{E1: [None]}), **PV_FLIP)
|
||||
check("a holed roster's 'Reporting:' list marks the flipped entity too",
|
||||
[r["text"] for r in _row(_c_hole_f, "Reporting:")]
|
||||
and f"{E2} 2100 W [sign inverted]" in
|
||||
(_at(_row(_c_hole_f, "Reporting:"), 0) or {}).get("text", ""))
|
||||
check("a holed roster with a flipped member names the missing source, not "
|
||||
"the flipped one",
|
||||
[r["text"] for r in _row(_c_hole_f, "no total")]
|
||||
== ["Solar production: no total, source 1 of 3 has no reading"])
|
||||
|
||||
# The owner's report, at a roster of one: a meter reading +13 W at night.
|
||||
_OWN = "sensor.pv_meter_power"
|
||||
@@ -1134,13 +1124,11 @@ check("⭐ THE REPORTED CASE: 13 W of night-time standby on a reversed meter is
|
||||
"negative caution, because -13 W is standby and not a reversed sign - "
|
||||
"and NO stray caution, because a lone `pv_entity` is in the roster",
|
||||
_c_own.pv_w == -13.0
|
||||
and [r["text"] for r in _row(_c_own, "PV production: -13 W")]
|
||||
== [f"PV production: -13 W from {_OWN} (reported in W) [sign inverted]"]
|
||||
and _row(_c_own, "probably reversed") == []
|
||||
and _row(_c_own, "flips nothing") == [])
|
||||
check("...and WITHOUT the option it is 0.12.0's row, byte for byte",
|
||||
[r["text"] for r in _row(_c_own0, "PV production:")]
|
||||
== [f"PV production: 13 W from {_OWN} (reported in W)"])
|
||||
and [r["text"] for r in pv_rows(_c_own)]
|
||||
== ["Solar production: -13 W, sign reversed"]
|
||||
and _row(_c_own, "reversed-sign list") == [])
|
||||
check("...and WITHOUT the option it is the plain row",
|
||||
[r["text"] for r in pv_rows(_c_own0)] == ["Solar production: 13 W"])
|
||||
|
||||
# Whitespace: the roster entry and the invert entry differ only by padding. Both
|
||||
# sides must normalise the same way, or the read path leaves the sign unflipped
|
||||
@@ -1152,9 +1140,7 @@ check("⭐ PADDING: ` sensor.x` in the roster and `sensor.x ` in "
|
||||
"`pv_invert_entities` are the same entity - flipped on the read path, "
|
||||
"marked on the page, and no stray caution",
|
||||
_c_pad.pv_w == MIX_TOTAL
|
||||
and f"{E2} 2100 W in kW [sign inverted]"
|
||||
in (_at(_row(_c_pad, "W from 3 entities"), 0) or {}).get("text", "")
|
||||
and _row(_c_pad, "flips nothing") == [])
|
||||
and _row(_c_pad, "reversed-sign list") == [])
|
||||
|
||||
# --- the negative caution -------------------------------------------------------
|
||||
def _neg(watts_by_entity, flip=()):
|
||||
@@ -1162,20 +1148,20 @@ def _neg(watts_by_entity, flip=()):
|
||||
c, _, _ = run(cycles=1, by_entity={e: [state(str(w), "W")]
|
||||
for e, w in watts_by_entity.items()},
|
||||
**opts)
|
||||
return _row(c, "probably reversed")
|
||||
return _row(c, "reads ")
|
||||
|
||||
|
||||
_n_add = _neg({E1: 1200, E2: -1500, E3: 450})
|
||||
_n_rm = _neg({E1: 1200, E2: 1500, E3: 450}, flip=[E2])
|
||||
print(f" {[r['text'] for r in _n_add + _n_rm]}")
|
||||
check("⭐ AN UNFLIPPED ENTITY FAR BELOW ZERO gets a caution that names it, its "
|
||||
"watts, and says to ADD it to `pv_invert_entities`",
|
||||
check("⭐ AN UNFLIPPED ENTITY FAR BELOW ZERO gets a caution that names it by "
|
||||
"its place in the list, its watts, and says to ADD it to the "
|
||||
"reversed-sign list (`pv_invert_entities`)",
|
||||
[r["text"] for r in _n_add]
|
||||
== [f"PV production: {E2} reads -1500 W - far below zero, so its sign "
|
||||
"is probably reversed. If it is producing now, ADD it to "
|
||||
"`pv_invert_entities` in the Configuration tab"])
|
||||
== ["Solar production: source 2 reads -1 500 W, so add it to the "
|
||||
"reversed-sign list"])
|
||||
check("...and a FLIPPED one far below zero is told to REMOVE it instead",
|
||||
len(_n_rm) == 1 and "REMOVE it from `pv_invert_entities`"
|
||||
len(_n_rm) == 1 and "remove it from the reversed-sign list"
|
||||
in (_at(_n_rm, 0) or {}).get("text", ""))
|
||||
check("⭐ ...and it is a CAUTION, never a `bad` row - a telemetry sign cannot "
|
||||
"put a battery site into NOT READY",
|
||||
@@ -1198,11 +1184,10 @@ _c_typo, _, _ = run(cycles=1, by_entity=FULL,
|
||||
check("a `pv_invert_entities` entry naming no PV entity in use is a named "
|
||||
"caution (a typo would otherwise leave the sign silently unflipped)",
|
||||
[(r["text"], r.get("ok"), r.get("warn"))
|
||||
for r in _row(_c_typo, "flips nothing")]
|
||||
== [("PV production: `pv_invert_entities` names sensor.pv_wset, which "
|
||||
"is not a PV entity in use, so it flips nothing - check the "
|
||||
"spelling against `pv_entities`", False, True)]
|
||||
and _row(_c_mix, "flips nothing") == [])
|
||||
for r in _row(_c_typo, "not a solar sensor in use")]
|
||||
== [("Solar production: entry 1 of the reversed-sign list is not a "
|
||||
"solar sensor in use", False, True)]
|
||||
and _row(_c_mix, "not a solar sensor in use") == [])
|
||||
|
||||
# --- default: byte-identical to 0.12.0 ------------------------------------------
|
||||
_c_d0, _, _p_d0 = run(cycles=1, by_entity=MIX, **PV_MANY)
|
||||
|
||||
@@ -1008,7 +1008,7 @@ check("the row's coverage figure is the LIVE month's tally - graded against a "
|
||||
"row count taken straight from quarter_blocks, so this pins the gap "
|
||||
"rather than restating the property that produces it",
|
||||
bool(peak_rows)
|
||||
and f"; {_live_rows} quarter(s) banked" in peak_rows[0]["text"])
|
||||
and f", {_live_rows} quarter(s) banked" in peak_rows[0]["text"])
|
||||
check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point "
|
||||
f"on a {given(SHIPPED_CAP_W):.0f} W limit",
|
||||
bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"])
|
||||
|
||||
@@ -22,6 +22,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from named_absence import nth, present
|
||||
|
||||
from app import messages # noqa: E402
|
||||
from app.watchdog import (
|
||||
SignWatchdog, SignWatchdogConfig, loop_is_following,
|
||||
)
|
||||
@@ -368,10 +369,12 @@ check("0 W actually reached the device", 0.0 in hass.writes)
|
||||
check("and it got there through the failsafe claim, not by accident",
|
||||
"failsafe" in ctrl.reason)
|
||||
print(f" status line: {ctrl.reason!r}")
|
||||
check("the status page says so in words",
|
||||
any("SIGN-DISAGREEMENT FAILSAFE" in c["text"] for c in ctrl.checks()))
|
||||
check("the status page says so in words: a red row, at 0 W, cleared by start",
|
||||
any(c.get("id") == "direction" and not c["ok"] and not c["warn"]
|
||||
and "set to 0 W" in c["text"] and "start control" in c["text"]
|
||||
for c in ctrl.checks()))
|
||||
check("the banner leads with it",
|
||||
"FAILSAFE" in ctrl.status_payload()["banner"])
|
||||
ctrl.status_payload()["banner"] == messages.banner_direction(False))
|
||||
check("it stays at 0 W on later cycles too",
|
||||
hass.writes[-1] == 0.0 and hass.writes.count(0.0) >= 1)
|
||||
|
||||
@@ -478,7 +481,7 @@ check("...commands 0 W from its very first cycle", post.target == 0.0)
|
||||
check("...and never commanded anything else after the restart",
|
||||
hass2.writes != [] and set(hass2.writes) == {0.0})
|
||||
check("the banner still leads with the failsafe",
|
||||
"FAILSAFE" in post.status_payload()["banner"])
|
||||
post.status_payload()["banner"] == messages.banner_direction(False))
|
||||
# ⚠️ TWO PRESSES, and the first one must NOT clear it. `auto` is True while
|
||||
# tripped, so the first press stops control; only the transition back to True
|
||||
# clears. That is the documented behaviour and it is worth an assertion, because
|
||||
@@ -951,12 +954,12 @@ check("...and it got there through the `circulation` claim, by name, rather "
|
||||
"circulation" in _c.reason)
|
||||
print(f" status line: {_c.reason!r}")
|
||||
check("the status page says so in words, and says the meter cannot see it",
|
||||
any("CIRCULATING POWER" in ck["text"] and "meter cannot see" in ck["text"]
|
||||
for ck in _c.checks()))
|
||||
any(ck.get("id") == "circulating" and not ck["ok"] and not ck["warn"]
|
||||
and "meter cannot see" in ck["text"] for ck in _c.checks()))
|
||||
check("the banner leads with it - above the sign failsafe, because a sign "
|
||||
"disagreement is visible on every dashboard in the house and this is "
|
||||
"visible nowhere at all",
|
||||
"CIRCULATING POWER" in _c.status_payload()["banner"])
|
||||
_c.status_payload()["banner"] == messages.banner_circulating())
|
||||
_pub = {}
|
||||
_c.mqtt = type("P", (), {"enabled": True,
|
||||
"publish": lambda _s, v: _pub.update(v),
|
||||
@@ -1027,7 +1030,8 @@ check("...no `circulation` claim is ever filed, so the arbitration is "
|
||||
check("...and the status page says n/a rather than claiming to watch something "
|
||||
"that cannot happen - an 'armed' row for an identically-zero quantity is "
|
||||
"a guard advertising work it is not doing",
|
||||
any("circulating power: n/a at one inverter" in ck["text"]
|
||||
any(ck.get("id") == "circulating" and ck["ok"]
|
||||
and "not needed with one inverter" in ck["text"]
|
||||
for ck in _c1.checks()))
|
||||
_pub1 = {}
|
||||
_c1.mqtt = type("P", (), {"enabled": True,
|
||||
@@ -1055,8 +1059,8 @@ async def _circ_restart():
|
||||
await a.read_inputs()
|
||||
await a.cycle()
|
||||
await asyncio.sleep(0.25)
|
||||
# THE RESTART. Same store, brand new Controller and Hass - which is exactly
|
||||
# what a Home Assistant restart does to this add-on. The house has stopped
|
||||
# THE RESTART. Same store, brand new Controller and Hass - an ADD-ON
|
||||
# restart. A Core-only restart keeps the process; graded below (SAFETY-34). The house has stopped
|
||||
# circulating in the meantime, so nothing in the new process's own
|
||||
# measurements could re-derive the trip.
|
||||
h2 = _CircHass(a_w=0.0, b_w=0.0)
|
||||
@@ -1105,17 +1109,49 @@ check("...under a fixed notification_id of its own, so a second episode "
|
||||
for c in _notes1 + _notes2)
|
||||
and len({c[2]["notification_id"] for c in _notes1 + _notes2}) == 1)
|
||||
_msg = nth(_notes2, 0)[2]["message"]
|
||||
|
||||
|
||||
def _says_three(m):
|
||||
"""UX-08: the three things only this alarm can say, in its plain words
|
||||
(messages.circulating): the meter cannot see it, control is stopped at
|
||||
0 W, and if the grid does not move a person at the isolator is next."""
|
||||
return ("meter cannot see this" in m and "Control stays stopped" in m
|
||||
and "Every inverter is set to 0 W" in m and "isolator" in m
|
||||
and "if the grid power does not change" in m)
|
||||
|
||||
|
||||
check("...and the message tells the operator the three things only this alarm "
|
||||
"can: that the meter cannot see it, that control has been stopped, and "
|
||||
"that if the grid reading does not move afterwards one inverter is no "
|
||||
"longer listening and a human at the isolator is the last actuator",
|
||||
"METER CANNOT SEE" in _msg and "CONTROL HAS BEEN STOPPED" in _msg
|
||||
and "isolator" in _msg)
|
||||
_says_three(_msg))
|
||||
check("...and it does not raise the same card on every cycle - a per-PROCESS "
|
||||
"flag, deliberately not persisted, is what makes 'has a human been shown "
|
||||
"this' a different question from 'is this site tripped'",
|
||||
len(_notes2) == 1)
|
||||
|
||||
# SAFETY-34: a Core-ONLY restart. The add-on process lives on (measured on
|
||||
# TEST-23), HA's memory is wiped, so the SAME process must re-assert the card.
|
||||
import app.main as _main_mod # noqa: E402
|
||||
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
|
||||
_main_mod._mono = lambda: _clock[0]
|
||||
try:
|
||||
_h3 = _CircHass(a_w=0.0, b_w=0.0)
|
||||
_b.hass = _h3
|
||||
asyncio.run(_b.check_circulating())
|
||||
_early = [c for c in _h3.calls if c[0] == "persistent_notification"]
|
||||
_clock[0] += _main_mod.REANNOUNCE_S
|
||||
asyncio.run(_b.check_circulating())
|
||||
_late = [c for c in _h3.calls if c[0] == "persistent_notification"]
|
||||
finally:
|
||||
_main_mod._mono = _real_mono
|
||||
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise - no "
|
||||
"storm on the event bus", len(_early) == 0)
|
||||
check("SAFETY-34: a Core-only restart wipes the card and the add-on process "
|
||||
"survives it, so after REANNOUNCE_S the SAME process re-asserts it",
|
||||
len(_late) == 1
|
||||
and _late[0][2]["notification_id"] == "goodwe_circulating_power")
|
||||
|
||||
|
||||
print()
|
||||
print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND")
|
||||
@@ -1240,8 +1276,8 @@ check("PRECONDITION: both runs actually tripped and actually raised the card, "
|
||||
and len(_notes_g) == 1 and len(_notes_s) == 1)
|
||||
|
||||
# --- AC 4: THE ATTRIBUTION ITSELF -------------------------------------------
|
||||
_WHO_G = "Right now garage is discharging while shed charges."
|
||||
_WHO_S = "Right now shed is discharging while garage charges."
|
||||
_WHO_G = "garage was discharging while shed was charging"
|
||||
_WHO_S = "shed was discharging while garage was charging"
|
||||
check("⚠️ THE ALARM NAMES THE TWO PACKS AND SAYS WHICH WAY EACH ONE IS PULLING. "
|
||||
"`circulation.py` calls this the first thing somebody woken at 3 a.m. "
|
||||
"needs and impossible to reconstruct from the watts alone, and until this "
|
||||
@@ -1262,12 +1298,14 @@ check("...and the RESTING pack is not named. `cellar` is on the roster, is read
|
||||
"beside a 6 kW loop is not reported as one of its two participants",
|
||||
"cellar" not in _msg_g and "cellar" not in _msg_s
|
||||
and _cg.inverters[2].batt == _REST_W)
|
||||
check("...and the sentence sits with the figure, not instead of it: the same "
|
||||
"card still carries the watts, the window and the three things only this "
|
||||
"alarm can say",
|
||||
all("CIRCULATING_POWER" in m and "METER CANNOT SEE" in m
|
||||
and "CONTROL HAS BEEN STOPPED" in m and "isolator" in m
|
||||
for m in (_msg_g, _msg_s)))
|
||||
# UX-08: the watts and the window moved out of the card into the reason the
|
||||
# log line and the status row carry; the card keeps the three things.
|
||||
check("...and the sentence sits with the three things only this alarm can say, "
|
||||
"while the watts and the window stay in the reason behind it",
|
||||
all(_says_three(m) for m in (_msg_g, _msg_s))
|
||||
and all("CIRCULATING_POWER" in c.circ_wd.reason
|
||||
and "W measured going round" in c.circ_wd.reason
|
||||
for c in (_cg, _cs)))
|
||||
print()
|
||||
print(" the operator's press is the only way out, and it clears BOTH halves")
|
||||
|
||||
@@ -1876,9 +1914,11 @@ check("...the latch is FLAT - `site_sign_tripped`, beside SAFETY-31's pair - "
|
||||
check("the status page carries a SITE row of its own, and the banner says the "
|
||||
"site's TOTAL command opposed the meter rather than naming an inverter - "
|
||||
"naming one would send an operator to swap the wrong box",
|
||||
any("SITE SIGN-DISAGREEMENT FAILSAFE" in ck["text"]
|
||||
any(ck.get("id") == "site_direction" and not ck["ok"]
|
||||
and not ck["warn"] and "every inverter" in ck["text"]
|
||||
for ck in _c31.checks())
|
||||
and "total command" in _c31.status_payload()["banner"])
|
||||
and "Total command" in _c31.status_payload()["banner"]
|
||||
and "inverter-" not in _c31.status_payload()["banner"])
|
||||
_pub30 = {}
|
||||
_c31.mqtt = type("P", (), {"enabled": True,
|
||||
"publish": lambda _s, v: _pub30.update(v),
|
||||
@@ -2107,16 +2147,16 @@ check("⚠️ ...AND THE SITE DETECTOR IS NEVER ARMED. Not 'did not trip', and n
|
||||
_n1_site_calls == []
|
||||
and not _c1s.site_sign_wd.tripped
|
||||
and _c1s.site_sign_wd.disagree_since is None)
|
||||
check("...the banner is the sentence that shipped",
|
||||
_c1s.status_payload()["banner"]
|
||||
== "FAILSAFE — the command opposed the meter. Inverter held at 0 W.")
|
||||
check("...the banner is the one-inverter sentence, naming the meter",
|
||||
_c1s.status_payload()["banner"] == messages.banner_direction(False)
|
||||
and "meter" in messages.banner_direction(False))
|
||||
check("...the status page carries ONE sign row, with no unit tag and no site "
|
||||
"row - an extra row for an identically-duplicated question is a page "
|
||||
"advertising two guards where the install has one",
|
||||
len([ck for ck in _c1s.checks()
|
||||
if "sign watchdog" in ck["text"] or "SIGN-DISAGREEMENT" in ck["text"]
|
||||
]) == 1
|
||||
and not any("site sign" in ck["text"].lower() for ck in _c1s.checks()))
|
||||
len([ck for ck in _c1s.checks() if ck.get("id") == "direction"]) == 1
|
||||
and not any(ck.get("id") == "site_direction" for ck in _c1s.checks())
|
||||
and not any("inverter-" in ck["text"] for ck in _c1s.checks()
|
||||
if ck.get("id") == "direction"))
|
||||
check("...and no `sign_site` claim can be filed at a roster of one, so the "
|
||||
"arbitration is byte-identical to what shipped",
|
||||
"sign_site" not in _c1s.reason)
|
||||
@@ -2288,12 +2328,12 @@ check("⚠️ AND THE BANNER LEADS WITH THE FAILSAFE WHICHEVER UNIT IS LATCHED,
|
||||
"the page then leads with whatever the next branch says",
|
||||
_bn_b["level"] == _bn_a["level"] == "bad"
|
||||
and _bn_b["banner"] == _bn_a["banner"]
|
||||
and _bn_b["banner"].startswith("FAILSAFE")
|
||||
and "its own measured power" in _bn_b["banner"])
|
||||
and _bn_b["banner"] == messages.banner_direction(True)
|
||||
and "own power" in _bn_b["banner"])
|
||||
check("...and the counterfactual again: the same site with nothing latched "
|
||||
"carries neither that level nor that sentence, so the banner really is "
|
||||
"being produced by the reduction under test",
|
||||
not _bn_n["banner"].startswith("FAILSAFE")
|
||||
_bn_n["banner"] != messages.banner_direction(True)
|
||||
and _bn_n["banner"] != _bn_b["banner"])
|
||||
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ configuration:
|
||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Point it at your solar inverter's own power sensor (any brand Home Assistant reads; it does not have to be on the RS485 bus) and the add-on records production beside the grid and battery figures, so a day of unstored surplus can be told apart from a day with no sun. POSITIVE MUST MEAN PRODUCING - if yours reads negative while producing, list it under 'sign is reversed' below. The unit is read from the entity itself - W, kW and the rest of Home Assistant's power units - so a sensor with no unit is refused rather than guessed at, and the status page says so. Leave it empty and nothing is read, nothing is published and nothing is shown."
|
||||
pv_entities:
|
||||
name: "Hardware · PV production entities, one per inverter (W)"
|
||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Use this instead of the single entity above when your roof has SEVERAL solar inverters: add one row per inverter, pointing at each one's own power sensor, and the add-on records the SITE total. Each entity's own unit is read, so a kW inverter beside a W one adds up correctly. ⚠️ SETTING THIS IGNORES THE SINGLE ENTITY ABOVE - clear that one when you fill this in, or you will see a caution on the status page saying so. ⚠️ AND IF ANY ONE OF THESE STOPS REPORTING THERE IS NO SITE TOTAL: the sensor goes to 'unknown' and the history records a gap, rather than publishing the sum of the ones that did answer. Two inverters out of three add up to a smaller number that looks exactly like a real one, and a week later that reads as 'there was less sun than there was' - which is the one conclusion this recording exists to test. The status page names which entity is missing, why, and what the others reported. Leave it empty and nothing is read, nothing is published and nothing is shown."
|
||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Use this instead of the single entity above when your roof has SEVERAL solar inverters: add one row per inverter, pointing at each one's own power sensor, and the add-on records the SITE total. Each entity's own unit is read, so a kW inverter beside a W one adds up correctly. ⚠️ SETTING THIS IGNORES THE SINGLE ENTITY ABOVE - clear that one when you fill this in, or you will see a caution on the status page saying so. ⚠️ AND IF ANY ONE OF THESE STOPS REPORTING THERE IS NO SITE TOTAL: the sensor goes to 'unknown' and the history records a gap, rather than publishing the sum of the ones that did answer. Two inverters out of three add up to a smaller number that looks exactly like a real one, and a week later that reads as 'there was less sun than there was' - which is the one conclusion this recording exists to test. The status page says which one is missing, by its place in this list. Leave it empty and nothing is read, nothing is published and nothing is shown."
|
||||
pv_invert_entities:
|
||||
name: "Hardware · PV entities whose sign is reversed"
|
||||
description: "Optional, MEASUREMENT ONLY. Some sensors - typically a kWh meter or a power clamp in the PV circuit - read NEGATIVE while producing and a few watts POSITIVE at night. Add each such entity here, spelled exactly as it is above, and its sign is flipped before it is added up; leave the others out. The status page marks every flipped entity. History recorded before you set this keeps the old sign."
|
||||
|
||||
@@ -69,7 +69,7 @@ configuration:
|
||||
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Verwijs naar de vermogenssensor van je omvormer (elk merk dat Home Assistant kan lezen; hij hoeft niet op de RS485-bus te zitten) en de add-on registreert de productie naast de meter- en batterijwaarden, zodat een dag met niet-opgeslagen overschot te onderscheiden is van een dag zonder zon. POSITIEF MOET PRODUCEREN BETEKENEN - leest de uwe negatief tijdens productie, zet hem dan ook bij 'teken omgekeerd' hieronder. De eenheid wordt van de entiteit zelf gelezen - W, kW en de overige vermogenseenheden van Home Assistant - dus een sensor zonder eenheid wordt geweigerd in plaats van geraden, en de statuspagina zegt waarom. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
|
||||
pv_entities:
|
||||
name: "Hardware · Zonneproductie-entiteiten, één per omvormer (W)"
|
||||
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Gebruik dit in plaats van de losse entiteit hierboven wanneer uw dak MEERDERE zonne-omvormers heeft: voeg één rij per omvormer toe, elk verwijzend naar haar eigen vermogenssensor, en de add-on registreert het totaal van de installatie. De eenheid van elke entiteit wordt afzonderlijk gelezen, dus een omvormer in kW naast een in W telt correct op. ⚠️ ALS U DIT INVULT WORDT DE LOSSE ENTITEIT HIERBOVEN GENEGEERD - maak die leeg, anders verschijnt er een waarschuwing op de statuspagina die dat meldt. ⚠️ EN ALS ÉÉN VAN DEZE ENTITEITEN NIETS MEER DOORGEEFT, IS ER GEEN TOTAAL: de sensor gaat naar 'unknown' en de geschiedenis bewaart een gat, in plaats van de som van de entiteiten die wél antwoordden te publiceren. Twee omvormers van de drie tellen op tot een kleiner getal dat precies op een echt getal lijkt, en een week later leest dat als 'er was minder zon dan er was' - net de conclusie die deze registratie moet kunnen testen. De statuspagina noemt welke entiteit ontbreekt, waarom, en wat de andere doorgaven. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
|
||||
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Gebruik dit in plaats van de losse entiteit hierboven wanneer uw dak MEERDERE zonne-omvormers heeft: voeg één rij per omvormer toe, elk verwijzend naar haar eigen vermogenssensor, en de add-on registreert het totaal van de installatie. De eenheid van elke entiteit wordt afzonderlijk gelezen, dus een omvormer in kW naast een in W telt correct op. ⚠️ ALS U DIT INVULT WORDT DE LOSSE ENTITEIT HIERBOVEN GENEGEERD - maak die leeg, anders verschijnt er een waarschuwing op de statuspagina die dat meldt. ⚠️ EN ALS ÉÉN VAN DEZE ENTITEITEN NIETS MEER DOORGEEFT, IS ER GEEN TOTAAL: de sensor gaat naar 'unknown' en de geschiedenis bewaart een gat, in plaats van de som van de entiteiten die wél antwoordden te publiceren. Twee omvormers van de drie tellen op tot een kleiner getal dat precies op een echt getal lijkt, en een week later leest dat als 'er was minder zon dan er was' - net de conclusie die deze registratie moet kunnen testen. De statuspagina zegt welke ontbreekt, met zijn plaats in deze lijst. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
|
||||
pv_invert_entities:
|
||||
name: "Hardware · Zonneproductie-entiteiten met omgekeerd teken"
|
||||
description: "Optioneel, UITSLUITEND EEN METING. Sommige sensoren - meestal een kWh-meter of een stroomtang in het PV-circuit - lezen NEGATIEF tijdens productie en 's nachts enkele watt POSITIEF. Zet elke zulke entiteit hier, exact gespeld zoals hierboven, en haar teken wordt omgedraaid vóór het optellen; laat de andere weg. De statuspagina markeert elke omgedraaide entiteit. Geschiedenis van vóór deze instelling behoudt het oude teken."
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# UX-08 ratchet (test_messages.py part 3). Each line is a user-facing string that
|
||||
# does not follow STYLE.md yet: "<kind> <sha1-12 of its text> # where :: start".
|
||||
# These are waiting for phase 2c (refusal, option-name, option-desc) and 2d
|
||||
# (log); phase 2b emptied the row and banner kinds. Delete the line when you
|
||||
# rewrite the string, because a stale line fails the test. Never add a line for a new string: write the new
|
||||
# string to STYLE.md instead.
|
||||
|
||||
log a5e99679fd80 # main.py banned: SAFETY-02, latch :: 'SAFETY-02: the per-inverter store migration did not verify (%s). The l'
|
||||
log 128b18b32d86 # main.py over 25 words (41); banned: LATCHED, SAFETY-44, latched :: 'SAFETY-44: this store carries a LATCHED sign-disagreement failsafe und'
|
||||
log f60446541ae5 # main.py over 25 words (83); banned: LATCH, LATCHED, SAFETY-02, latch; capitals: LATCH GUARD :: 'SAFETY-02 LATCH GUARD: this store carries a LATCHED sign-disagreement '
|
||||
log 04d06bf223c8 # main.py over 25 words (73); banned: REGISTER, SAFETY-37, SAFETY-57, register; capitals: WHICH IS NOT BY ITSELF :: 'SAFETY-37: %s battery-power reading did not move under a command that '
|
||||
log aec01642bd07 # main.py banned: SAFETY-57 :: 'SAFETY-57: %s'
|
||||
log aebbca17acb3 # main.py over 25 words (52); banned: allocator :: 'write_setpoint() was asked to put %+.0f W on every one of %d inverters'
|
||||
log b74c3018461e # main.py over 25 words (30); banned: SAFETY-28, allocator :: 'SAFETY-28 REFUSED a write of %+.0f W to %s: %s. Commanding 0 W instead'
|
||||
log cefffafa8b27 # main.py over 25 words (31) :: 'the write of %+.0f W to %s (%s) did not complete in %.0f s and was aba'
|
||||
log 1001c6250b85 # main.py over 25 words (29); banned: SAFETY-02 :: 'SAFETY-02 TRIPPED on %s - %s. Commanding 0 W on that inverter and hold'
|
||||
log 8252ef5eefad # main.py over 25 words (48); banned: SAFETY-30 :: 'SAFETY-30 TRIPPED at the SITE - %s. That is the SUM of every standing '
|
||||
log 8a65cf945e00 # main.py over 25 words (32); banned: SAFETY-31 :: 'SAFETY-31 TRIPPED - %s. Commanding 0 W on every inverter and holding i'
|
||||
log 5d2beed2f1a2 # main.py over 25 words (65); banned: HW-01, SAFETY-42; capitals: NOTHING IS COMMANDED DIFFERENTLY :: 'SAFETY-42 TRIPPED on %s - %s. NOTHING IS COMMANDED DIFFERENTLY: this g'
|
||||
log d50b775b9cd4 # main.py banned: ARBITER; capitals: ARBITER SCOPE :: 'ARBITER SCOPE: %s'
|
||||
log 749c2c6fe81d # main.py over 25 words (26); banned: SAFETY-10, heartbeat; capitals: SHADOW MODE IS ON :: 'SAFETY-10 SHADOW MODE IS ON. The full decision path runs and NOTHING i'
|
||||
log 4d271dfc6f1c # main.py banned: SAFETY-10 :: 'SAFETY-10 shadow mode is OFF. This controller is commanding the batter'
|
||||
log 730387e0da2b # main.py banned: SAFETY-03 :: 'SAFETY-03 - %s Engagements so far: %d.'
|
||||
log 3bf899e99297 # main.py over 25 words (56); banned: SAFETY-03, clamp, clamped, integrator; capitals: STILL RUNNING :: 'SAFETY-03 - %s Silently clipping this is how a winding integrator reac'
|
||||
log 78cb6bd09633 # main.py over 25 words (48); banned: SAFETY-07; capitals: STILL RUNNING :: 'SAFETY-07 - %s. The guard is asking for everything the inverter has an'
|
||||
log cc735997adc7 # main.py over 25 words (44); banned: SAFETY-24; capitals: STILL RUNNING :: 'SAFETY-24 - %s. The controller is STILL RUNNING and charging is still '
|
||||
log ebcb428a70d0 # main.py over 25 words (70); banned: SAFETY-18, arbiter, watchdog; capitals: CHECK THE METER :: 'SAFETY-18 - %s. The grid-following loop is regulating against a number'
|
||||
log 38018b887495 # main.py over 25 words (43) :: 'tariff refresh degraded (%s); KEEPING the plan resolved at %s, %.0f s '
|
||||
log 0eab1ec96fe8 # main.py capitals: CONTROL LOOP DIED :: 'CONTROL LOOP DIED (%s) - commanding 0 W and exiting with status %d'
|
||||
log db529f2c419d # main.py over 25 words (40); banned: SAFETY-02 :: 'SAFETY-02 was TRIPPED on %s before this restart and stays tripped: %s.'
|
||||
log c5aadd5c8bf9 # main.py over 25 words (45); banned: SAFETY-42; capitals: NOTHING IS BEING COMMANDED DIFFERENTLY :: 'SAFETY-42 was TRIPPED on %s before this restart and stays tripped: %s.'
|
||||
log d72bbdb7e595 # main.py over 25 words (39); banned: SAFETY-30 :: 'SAFETY-30 (site) was TRIPPED before this restart and stays tripped: %s'
|
||||
log 8b880e65a7c3 # main.py over 25 words (38); banned: SAFETY-31 :: 'SAFETY-31 was TRIPPED before this restart and stays tripped: %s. Every'
|
||||
log 23f58b7f305c # p1.py:1264 over 25 words (30) :: 'meter_poll_s %.1f leaves no room under meter_max_age_s %.1f: one misse'
|
||||
log 88a7251e842b # p1.py:470 banned: telegram :: 'P1 telegram rejected: %s'
|
||||
log 0dd83d6dfaf4 # price.py over 25 words (30) :: 'the feed published no price for %d of %d instants (index %s); those in'
|
||||
log bcfd7062e28e # price.py over 25 words (79); banned: §8 :: 'price_feed_url is set, so %s is requesting %s exactly as given and is '
|
||||
log b57bc6de4a6b # price.py over 25 words (47); banned: validate.py :: 'price_taxes_profile cannot be used (%s: %s), so this fetch has NO tax '
|
||||
log d5595aaaacc4 # price_history.py banned: §8 :: 'price history at %s is %s - starting from empty. §8.3 carry-forward ha'
|
||||
log dbed0b8e35ea # static_tariff.py banned: validate.py :: 'static_profile is unusable (%s); no cheap window. This should have bee'
|
||||
log 8fbe9f5407ea # static_tariff.py over 25 words (28); banned: §1, §6 :: '§11.4: %s, and no static_profile is configured to fall back to. The pl'
|
||||
log 4ce29e043d89 # static_tariff.py banned: §1 :: '§11.4: %s - falling back to the static profile, plan marked PROVISIONA'
|
||||
log a3c7c9719ed2 # store.py capitals: LOST STATE :: 'per-inverter store migration LOST STATE and has not been marked done: '
|
||||
log 5b537a77052b # validate.py over 25 words (41); banned: arbiter, saturation :: 'integrator_max_w %.0f W is above max_w %.0f W. Not refused - the arbit'
|
||||
log 12ae40f9d289 # validate.py over 25 words (73); banned: arbiter, clamp, clamps :: 'maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not refused - '
|
||||
log c6dc799502cf # validate.py over 25 words (32) :: '%s carries negative levies %s EUR/kWh. That is accepted, but a tax com'
|
||||
log 1688a7478ceb # validate.py over 25 words (36) :: 'price_formula is filled in but price_provider is %r, so the coefficien'
|
||||
log f37f9fce4433 # validate.py over 25 words (96) :: '%s has no "card" block, so its coefficients are UNGRADED: nothing reco'
|
||||
log 396fbdd009c2 # validate.py over 25 words (52); banned: §1 :: "price_provider is %r but static_profile is empty. §11.4's degradation "
|
||||
log 37bba6ed37e3 # validate.py capitals: REFUSING TO START :: 'REFUSING TO START: the add-on configuration is not usable.'
|
||||
option-desc b927826d01f0 # en.yaml over 30 words (112); capitals: LEAVING IT OFF DOES NOT TURN THE PRICE FEED OFF :: 'Charge in the cheapest published hour and discharge in the dearest, bu'
|
||||
option-desc b98c42e34df7 # en.yaml over 30 words (34) :: 'The reserve price trading may not spend. Deliberately not the same key'
|
||||
option-desc 84e054da385b # en.yaml over 30 words (39) :: 'Off by default. Leave it off until the site has been commissioned: wit'
|
||||
option-desc 0f55d3b37afa # en.yaml over 30 words (38); banned: 0x0518, register, watchdog :: "The sensor that reports this inverter's own battery power, positive wh"
|
||||
option-desc 307afdd74f84 # en.yaml over 30 words (98); banned: 0x0560, Register; capitals: GW5000S-BP :: "MEASURED, not chosen: the percent at which the pack's OWN battery mana"
|
||||
option-desc 4da37ff6250d # en.yaml over 30 words (66); banned: SAFETY-31; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen contr'
|
||||
option-desc db464169ed66 # en.yaml over 30 words (144); banned: SAFETY-31; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY; with one inverter the quantity is identica'
|
||||
option-desc df9735452fa1 # en.yaml over 30 words (88); banned: OPT-08; capitals: THIS ONE IS NOT DERIVED FROM :: 'What one full round trip costs per kWh: wear on the pack plus conversi'
|
||||
option-desc 8147a82a437e # en.yaml over 30 words (51) :: 'A deadline on the gap between cycles, reset by every cycle. On a healt'
|
||||
option-desc cb66f230ef63 # en.yaml over 30 words (45); banned: register :: 'Errors smaller than this are ignored. ⚠️ The deadband is a one-way rat'
|
||||
option-desc 4167af90cfc2 # en.yaml over 30 words (288); banned: SAFETY-42, delivery_window.py, saturation; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire a'
|
||||
option-desc 3c0898cc1149 # en.yaml over 30 words (63) :: 'What you are paid for feeding a kWh back into the grid, all in. One nu'
|
||||
option-desc 05560996cb2e # en.yaml capitals: MEASURED ON REAL HARDWARE :: 'How much of the remaining error the loop corrects each cycle. MEASURED'
|
||||
option-desc f225d7a8f3a5 # en.yaml over 30 words (32); banned: watchdog :: 'How often the setpoint is rewritten even when it has not changed. It m'
|
||||
option-desc ef70d16a93bd # en.yaml over 30 words (98); banned: clamp, saturation :: 'Caps how much stale error can be waiting to unwind when the sign flips'
|
||||
option-desc bde7357af38d # en.yaml over 30 words (107); capitals: EMPTY DOES NOT MEAN :: 'Leave this empty unless the site really has more than one inverter. EM'
|
||||
option-desc 217c5ae9599c # en.yaml over 30 words (66); banned: 0x0560, register :: "The percent at which THIS pack's own battery management system cuts ou"
|
||||
option-desc 746a0f9ae506 # en.yaml over 30 words (143) :: "THIS pack's usable capacity in kilowatt-hours, from its datasheet or i"
|
||||
option-desc cb6ad204f59d # en.yaml over 30 words (52) :: 'The supervised ceiling on what may be written to THIS inverter; the si'
|
||||
option-desc 46142bcc1ab1 # en.yaml over 30 words (36); banned: heartbeat, latch :: 'A short identifier for this inverter, for example inverter-a. It gives'
|
||||
option-desc d787e9f2454d # en.yaml over 30 words (63); banned: arbiter; capitals: MUST STAY AT OR BELOW :: '⚠️ MUST STAY AT OR BELOW the inverter rating, and the add-on refuses t'
|
||||
option-desc a90fced63631 # en.yaml over 30 words (52); banned: CLAMPED :: 'How fast the cycle drains the pack; the surplus is exported. Above the'
|
||||
option-desc ab58c8fae4fb # en.yaml over 30 words (57); capitals: THIS IS NOT THE PEAK GUARD :: 'The depth the monthly recalibration deliberately goes to, once a month'
|
||||
option-desc 73f51bdd3d6e # en.yaml over 30 words (83); capitals: THIS IS YOUR NUMBER :: 'THIS IS YOUR NUMBER, not a derived one: the hard ceiling on what may e'
|
||||
option-desc dd9023e6c812 # en.yaml over 30 words (45); capitals: POSITIVE MUST MEAN DRAWING FROM THE GRID :: "The net grid power sensor, used when the meter source is 'off'. POSITI"
|
||||
option-desc aa5b929f3c22 # en.yaml over 30 words (114); banned: SAFETY-18, latch; capitals: NOT CHOSEN :: 'MEASURED, NOT CHOSEN (SAFETY-18). How long the meter may keep answerin'
|
||||
option-desc 1b3038c5c9e5 # en.yaml over 30 words (46) :: 'Beyond this the grid figure counts as missing. On its own it does not '
|
||||
option-desc bbf26329f056 # en.yaml over 30 words (48); banned: telegram, telegrams :: 'Must match your connection. A telegram whose phase count disagrees is '
|
||||
option-desc 92da72e54e77 # en.yaml over 30 words (52) :: "For meter source homewizard_local only. 5 s is the meter's own update "
|
||||
option-desc 24f329136309 # en.yaml over 30 words (84) :: "'off' keeps the single Home Assistant entity below, which is what an e"
|
||||
option-desc 47e5fa0dba52 # en.yaml banned: register :: 'The UNSIGNED injection register, the counterpart of the import registe'
|
||||
option-desc 9b8f5ea6c656 # en.yaml banned: 192.168.2.250 :: "For meter source homewizard_local only: the meter's own address on you"
|
||||
option-desc d5d3e30ac4d2 # en.yaml over 30 words (37); banned: register :: 'The UNSIGNED consumption register. Do not point this at a signed templ'
|
||||
option-desc 0b05d89400ba # en.yaml over 30 words (39) :: 'For meter source ha_signed only. ONE signed sensor: positive is drawn '
|
||||
option-desc e74a5db257e1 # en.yaml over 30 words (46) :: 'For meter source ha_signed only. Optional, in L1..L3 order, each signe'
|
||||
option-desc 3f07a88a61fd # en.yaml over 30 words (34); capitals: THIS IS YOUR NUMBER :: 'THIS IS YOUR NUMBER: the quarter-hour average demand the peak guard tr'
|
||||
option-desc 6ec195081ef9 # en.yaml over 30 words (112); banned: SAFETY-24; capitals: IT BOUNDS THE PEAK GUARD ONLY :: 'DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usa'
|
||||
option-desc 228fdc168458 # en.yaml over 30 words (270); capitals: INJECTION IS EXEMPT FROM VAT :: 'Optional, and only if your supplier pays you a price that moves with t'
|
||||
option-desc 98987808a7f0 # en.yaml over 30 words (98); capitals: WHAT YOU TYPE IS SENT EXACTLY AS WRITTEN :: "Leave empty to use the chosen source's own feed. Only a host that sour"
|
||||
option-desc 26f17751b2f7 # en.yaml over 30 words (234); capitals: IN THE UNITS THE CARD PRINTS :: "For price source custom_formula only: your own contract's two coeffici"
|
||||
option-desc 011841a82d7d # en.yaml over 30 words (34) :: "Your supplier's margin. The add-on adds it ONLY when the chosen price "
|
||||
option-desc d34ef16ba99a # en.yaml over 30 words (63) :: 'The older way of getting a price, kept because retiring it would take '
|
||||
option-desc c70929091e71 # en.yaml over 30 words (252); capitals: SWITCHING THIS ON IS WHAT STARTS THE PRICE FETCH :: "Where day-ahead or real-time prices come from. 'off' is what ships, an"
|
||||
option-desc a62e17ccf445 # en.yaml over 30 words (230); banned: TARIFF-18; capitals: THIS NUMBER IS NOT :: 'Everything on your bill that is not the energy itself - distribution, '
|
||||
option-desc 56d56f58c43e # en.yaml over 30 words (206); capitals: THE NUMBERS HERE ARE THE LEVIES ALONE :: 'Leave this empty unless your network tariff really does charge a diffe'
|
||||
option-desc 0b7ea32cf8d0 # en.yaml over 30 words (193); capitals: MEASUREMENT ONLY :: 'Optional, and MEASUREMENT ONLY - it changes no control behaviour whats'
|
||||
option-desc 4172d9920d65 # en.yaml over 30 words (128); capitals: MEASUREMENT ONLY :: 'Optional, and MEASUREMENT ONLY - it changes no control behaviour whats'
|
||||
option-desc 09ceba2c6afb # en.yaml over 30 words (70); banned: clamp; capitals: MEASUREMENT ONLY :: 'Optional, MEASUREMENT ONLY. Some sensors - typically a kWh meter or a '
|
||||
option-desc 42a7eaac6ff1 # en.yaml banned: saturated :: 'How many consecutive saturated cycles before the loop stops winding up'
|
||||
option-desc 12b0dffec9dd # en.yaml over 30 words (42) :: 'The gap between what was commanded and what the inverter delivers that'
|
||||
option-desc 91fd58c08a59 # en.yaml over 30 words (54) :: 'The ESPHome number entity this add-on writes the power setpoint to, no'
|
||||
option-desc 01e1af709cd5 # en.yaml over 30 words (201); banned: arbiter, clamps, heartbeat, latch :: 'Runs the whole decision path — strategies, arbiter, clamps and guards '
|
||||
option-desc 96b077d4e2e9 # en.yaml over 30 words (72); banned: SAFETY-02; capitals: NOT CHOSEN :: 'MEASURED, NOT CHOSEN (SAFETY-02). How long the setpoint and the meter '
|
||||
option-desc addf5c495ae8 # en.yaml over 30 words (90); banned: SAFETY-02, watchdog; capitals: NOT CHOSEN :: 'MEASURED, NOT CHOSEN (SAFETY-02). Both the setpoint being emitted and '
|
||||
option-desc 0a66ac772b27 # en.yaml over 30 words (117); banned: SAFETY-30; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (S'
|
||||
option-desc c0deabb69ab6 # en.yaml over 30 words (60); banned: SAFETY-30; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. The same 300 W as the single-inverter thre'
|
||||
option-desc 7d7272743277 # en.yaml over 30 words (123); banned: SAFETY-39; capitals: MEANS FOLLOW THE INVERTER RATING :: 'How far the setpoint may move in one control cycle. ⚠️ 0 MEANS FOLLOW '
|
||||
option-desc 436efca21a7d # en.yaml over 30 words (31) :: "The sensor that reports the pack's state of charge in percent. Use the"
|
||||
option-desc 9ac8a3b21a1e # en.yaml over 30 words (43) :: 'How long the inputs may be missing before the add-on commands 0 W. Wit'
|
||||
option-desc 86cbfd6a5693 # en.yaml over 30 words (130); capitals: RECOMMENDED EVEN ON :: 'Your fixed or day/night contract, written as a single line of JSON. RE'
|
||||
option-desc 996dffbf563d # en.yaml over 30 words (74); banned: register :: 'What the meter should sit at when nothing is happening; negative means'
|
||||
option-desc faf346256008 # en.yaml over 30 words (52); banned: SAFETY-30; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (S'
|
||||
option-desc 5323134f3131 # en.yaml over 30 words (95); banned: SAFETY-30, saturation; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY; with one inverter this setting does nothin'
|
||||
option-name ca3a62bb8260 # en.yaml over 6 words (7) :: 'Circulating-power hold (s) — derived, do not change'
|
||||
option-name edee163fdc2b # en.yaml over 6 words (7) :: 'Circulating-power threshold (W) — measured, do not change'
|
||||
option-name 99b3beeab621 # en.yaml over 6 words (7) :: 'Cost of one full charge/discharge cycle (EUR/kWh)'
|
||||
option-name cccd8a42f51a # en.yaml over 6 words (8) :: 'Not-delivering report after (s) — derived, do not change'
|
||||
option-name 989891e2ce23 # en.yaml over 6 words (10); banned: Integrator :: 'Integrator bound (W, 0 means the same as the rating)'
|
||||
option-name 42c6b595320c # en.yaml over 6 words (9) :: 'Inverter list (leave empty if you have one inverter)'
|
||||
option-name 230e6dc9d872 # en.yaml over 6 words (7) :: 'Hour of day a due cycle starts'
|
||||
option-name 18e1de907c54 # en.yaml over 6 words (8) :: 'Frozen-meter alarm after (s) — measured, do not change'
|
||||
option-name 9071c4cfa460 # en.yaml over 6 words (7) :: 'A reading counts as stale after (s)'
|
||||
option-name e3d06ed93e2e # en.yaml banned: register :: 'Export register entity (W)'
|
||||
option-name a0f59ea5de36 # en.yaml banned: register :: 'Import register entity (W)'
|
||||
option-name ed757924f40f # en.yaml over 6 words (7) :: 'Per-phase signed net entities (L1, L2, L3)'
|
||||
option-name 52d03808da01 # en.yaml over 6 words (8) :: 'Your own INJECTION formula (one line of JSON)'
|
||||
option-name bf22f2121af4 # en.yaml over 6 words (8) :: 'Your own tariff formula (one line of JSON)'
|
||||
option-name 5593992fcf5b # en.yaml over 6 words (7) :: 'How far ahead to fetch prices (hours)'
|
||||
option-name d9fee5514a4b # en.yaml over 6 words (11) :: 'Levies and VAT by time of day (one line of JSON)'
|
||||
option-name 7d8d691905e0 # en.yaml over 6 words (7) :: 'PV production entities, one per inverter (W)'
|
||||
option-name eac7ee084aaa # en.yaml banned: Saturated :: 'Saturated cycles before freezing (count)'
|
||||
option-name 5c6bbc5cab40 # en.yaml banned: Saturation :: 'Saturation threshold (W)'
|
||||
option-name 99a1ec154d36 # en.yaml over 6 words (7) :: 'Shadow mode — run every decision, command nothing'
|
||||
option-name 82fcd0faa687 # en.yaml over 6 words (8); banned: watchdog :: 'Sign watchdog hold (s) — measured, do not change'
|
||||
option-name 8fc34172dc01 # en.yaml over 6 words (8); banned: watchdog :: 'Sign watchdog threshold (W) — measured, do not change'
|
||||
option-name 8516934704f5 # en.yaml over 6 words (8) :: 'Site-wide sign hold (s) — derived, do not change'
|
||||
option-name 2b8278ab2b16 # en.yaml over 6 words (8) :: 'Site-wide sign threshold (W) — measured, do not change'
|
||||
option-name bd52e795ea97 # en.yaml over 6 words (8) :: 'Command 0 W after inputs missing for (s)'
|
||||
option-name 9e33febf4481 # en.yaml over 6 words (8) :: 'Fixed or day/night tariff (one line of JSON)'
|
||||
option-name 40e1e3ba3841 # en.yaml over 6 words (8) :: 'Per-inverter sign hold (s) — derived, do not change'
|
||||
option-name 4ba7087578dc # en.yaml over 6 words (8) :: 'Per-inverter sign threshold (W) — derived, do not change'
|
||||
refusal 87620efa0704 # p1.py:1176 banned: telegram :: 'p1_phase_net_entities has {len(phase_net)} entities but meter_phases i'
|
||||
refusal 5d1b8147792b # p1.py:1186 banned: 192.168.2.250 :: "meter_source {SOURCE_HOMEWIZARD} needs p1_host (the meter's own addres"
|
||||
refusal 701db346696d # validate.py over 30 words (85); banned: SAFETY-30, watchdog :: 'unit_sign_disagree_w is {unit_sign_w} W but max_w{unit_where} is {weak'
|
||||
refusal 710b4d89a9d7 # validate.py over 30 words (88); banned: ARBITER, arbiter; capitals: THE CEILING WINS :: 'maintenance_charge_w is {charge_w} W but max_w{maint_where} is {maint_'
|
||||
refusal 16d40434116b # validate.py over 30 words (193); banned: NOTES.md, §5; capitals: THE PLANT WINS :: 'slew_w is {slew_w} W against a site rail of {rail} W, so the loop cros'
|
||||
refusal 613375fb6b5f # validate.py over 30 words (107); banned: SAFETY-07; capitals: THE BMS WINS :: "peak_soc_floor is {soc_floor} % but the pack's own cut-out (bms_soc_fl"
|
||||
refusal ba21c5ba8c0d # validate.py over 30 words (224); banned: TARIFF-07, §1; capitals: IT HAS NEVER FETCHED :: 'price_provider is {pid}, which WAS a shipped plugin and has been WITHD'
|
||||
refusal de8df32186b6 # validate.py over 30 words (46); banned: §1 :: 'price_provider is {pid}, which is not a shipped plugin. The ids that e'
|
||||
refusal 0ee7c15eafef # validate.py over 30 words (103); banned: arbitrage.py, main.py, static_tariff.py; capitals: THE HOME ASSISTANT ENTITIES WIN :: "price_provider is {pid} AND {' and '.join(ha_price)} {'is' if len(ha_p"
|
||||
refusal c4149a762c39 # validate.py over 30 words (62); banned: main.py; capitals: THE HOME ASSISTANT ENTITIES WIN :: "static_profile is set AND {' and '.join(ha_price)} {'is' if len(ha_pri"
|
||||
refusal 6af34466bbc2 # validate.py over 30 words (83); capitals: THE TABLE WINS :: '{TAXES_PROFILE_OPTION} is set AND price_taxes_eur_kwh is {scalar} rath'
|
||||
refusal 8f69bdbbf3fc # validate.py over 30 words (55) :: '{TAXES_PROFILE_OPTION} carries {steep} EUR/kWh. Belgian levies and dis'
|
||||
refusal 96a090d2ad97 # validate.py over 30 words (72); banned: DOCS.md, §1 :: "price_provider is 'custom_formula' but price_formula is empty, so the "
|
||||
refusal db9d0d53c4f1 # validate.py over 30 words (45); banned: §1 :: "cycle_cost_threshold_eur is {opts.get('cycle_cost_threshold_eur')} and"
|
||||
refusal 58ccb3bbd560 # validate.py over 30 words (63); banned: §1; capitals: NO PRICES AT ALL :: "arbitrage_enabled is on but price_provider is 'off' and static_profile"
|
||||
refusal 780592c897fb # validate.py over 30 words (85); banned: arbitrage.py, telegram, §9 :: "arbitrage_enabled is on but meter_source is 'off', so there is no P1 t"
|
||||
refusal 4f80c091ca95 # validate.py over 30 words (69) :: 'arbitrage_soc_floor is {floor} % and arbitrage_soc_ceiling is {ceiling'
|
||||
refusal 0c089bb2f200 # validate.py over 30 words (57); banned: §1 :: "price_provider is 'manual_csv' but price_csv_path is empty, so the off"
|
||||
refusal 152dfec79224 # validate.py over 30 words (48); banned: SAFETY-02, heartbeat, latch :: '{where} has no name. Every inverter needs one: the Store namespaces th'
|
||||
refusal eb14af760f5c # validate.py over 30 words (51); banned: SAFETY-02, heartbeat, latch :: '{where} and {seen[name]} are both called {name}. The Store keys every '
|
||||
refusal 36f53c989b4a # validate.py over 30 words (55) :: '{who} has no setpoint_entity, so this add-on cannot command it. It wou'
|
||||
refusal c98c6ab66e9f # validate.py over 30 words (56) :: "{who} has no usable max_w (got {entry.get('max_w')}). It is the superv"
|
||||
refusal 5ca16a4d0a1e # validate.py over 30 words (71) :: '{who} has capacity_kwh {raw}, which is not a usable number of kilowatt'
|
||||
refusal 63a864d2e0a3 # validate.py over 30 words (73) :: "capacity_kwh is stated on {', '.join(stated)} and left empty on {', '."
|
||||
refusal 8d82500ecb1d # validate.py over 30 words (64) :: 'setpoint_entity is set to {flat_sp} AND an inverters list of {len(entr'
|
||||
refusal 3d778e0ab5e1 # validate.py over 30 words (36); capitals: THE DEVICE WINS :: '{name}: max_w is {cap} W but {entity} accepts at most {float(dev_max)}'
|
||||
refusal ecd0883f5649 # validate.py over 30 words (36); capitals: THE DEVICE WINS :: '{name}: max_w is {cap} W, so this controller may command {-cap} W, but'
|
||||
refusal eff7efac51f8 # validate.py over 30 words (56); banned: heartbeat :: 'setpoint_entity is not set, so this add-on has no actuator: every writ'
|
||||
refusal b126f1a88b77 # validate.py over 30 words (76); banned: §1 :: '{len(who)} configured writers can reach the one actuator {actuator}: {'
|
||||
refusal 07a88756c864 # validate.py over 30 words (48); banned: arbiter :: 'max_w is {max_w} W and must be positive - it is a magnitude, not a sig'
|
||||
refusal c4a1d0371e31 # validate.py over 30 words (42); capitals: THE DEVICE WINS :: "max_w is {max_w} W but {opts.get('setpoint_entity') or 'the setpoint d"
|
||||
refusal a15624d1aa10 # validate.py over 30 words (33); capitals: THE DEVICE WINS :: 'max_w is {max_w} W, so this controller may command {-max_w} W, but {op'
|
||||
refusal 26d0602c32d7 # validate.py over 30 words (79); capitals: THE FIRMWARE WINS :: 'meter_max_age_s is {max_age_s} s but the ESP32 firmware vetoes any P1 '
|
||||
refusal 2ade09aaa579 # validate.py over 30 words (73); banned: SAFETY-02, arbiter, watchdog :: 'sign_disagree_w is {sign_w} W but max_w{where} is {weakest} W, so the '
|
||||
Reference in New Issue
Block a user