diff --git a/goodwe_controller/DECISIONS/TEL-14-pv-sign.md b/goodwe_controller/DECISIONS/TEL-14-pv-sign.md index d9a9b81..8325099 100644 --- a/goodwe_controller/DECISIONS/TEL-14-pv-sign.md +++ b/goodwe_controller/DECISIONS/TEL-14-pv-sign.md @@ -7,49 +7,24 @@ Owner report on 0.12.0: `sensor.espressif_vermogen` (ESPHome clamp) and ## 1. This reverses TEL-11 §2, and why -TEL-11 §2 refused a `pv_invert` because (1) PV is read by nothing in the control -path, and (2) a reversed sensor "announces itself as −4 kW at noon" and should be -"fixed where it is produced". (1) still holds: the flip is applied inside -`pv.total()`, which only feeds the MQTT `pv` row and the status page; -`test_pv.py` part 12 re-runs part 6's 40-cycle identity with a flip set, and -`tools/tel11_pv_trace.py` on release/1.0 vs this tree gives identical `control` -documents for `unset`, `one` and `multi`; the only telemetry change is the new -caution in the `negative` (−4200 W) cells. Its `--compare` verdict itself now -REFUSES on any honest run, because both trees know `pv_entities` — TEL-12 §3's -discriminator has aged out, as TEL-11's did; not fixed here. (2) did -not survive contact with a real site: these sensors are not *wrong*, they -follow a different convention; at night they read a plausible small positive -number, so nothing announces anything; and "fix it where it is produced" means a -template sensor, which TEL-12 §2 already names as the place an `unavailable` -source goes quietly wrong. +TEL-11 §2 refused a `pv_invert` on two grounds. "PV is read by nothing in the +control path" still holds: the flip lives in `pv.total()`, and `test_pv.py` +part 12 re-runs part 6's 40-cycle identity with a flip set. "A reversed sensor +announces itself" did not survive a real site: these sensors follow a different +convention, read a plausible small positive number at night, and the only other +fix is a template sensor, the place TEL-12 §2 says goes quietly wrong. -## 2. The shape: a parallel list, `pv_invert_entities` — measured +## 2. The shape: a parallel list, `pv_invert_entities` -`tools/supervisor_shape_probe.py` §H, Supervisor 2026.08.0, against a 0.12.0 -blob (`pv_entities` set, no new key): - -``` -== H. TEL-14: the SHAPE of a per-entity PV sign option == - PASS (a) PREFIX: `-sensor.pv_meter` validates under the shipped `- str` - Supervisor does not look inside the string, ... - PASS (b) ⭐ PARALLEL LIST, SHIPPED: the 0.12.0 blob (no `pv_invert_entities` key) validates unchanged and the new key defaults to [] - PASS ...and one entity marked inverted validates - PASS ⭐ ...and the shipped `options:` block with ONLY `pv_invert_entities` removed is REFUSED BY NAME ... - Missing option 'pv_invert_entities' in root in GoodWe RS485 Controller (local_goodwe_controller) - PASS ...and the Configuration tab renders it as a REPEATABLE string field with its own top-level (translated) label - [{"name": "pv_invert_entities", "multiple": true, "required": true, "type": "string"}] - PASS (c) LIST OF DICTS ON `pv_entities`: the STRINGS a 0.12.0 install stored are REFUSED ... - Invalid dict for option 'pv_entities' in GoodWe RS485 Controller (local_goodwe_controller) - PASS (c2) ...as a THIRD key it validates, but renders as a type-schema repeatable section whose per-entry keys reach the screen RAW ... - PASS (d) GLOBAL `pv_invert: bool` validates too - it loses on SEMANTICS (one sign for a mixed roster), not on the schema -== all cases passed == -``` +Measured by `tools/supervisor_shape_probe.py` §H (Supervisor 2026.08.0, against +a 0.12.0 blob); re-run it for the transcript. | shape | schema | why it lost / won | |---|---|---| | (a) `-sensor.x` prefix | accepted | invisible: the sign is a syntax only the description explains, in a field an installer fills with the entity picker's text | | (b) parallel list | accepted, 0.12.0 blob unchanged | **ships**: its own labelled, translated field ("PV entities whose sign is reversed"); an installer lists the odd ones out | | (c) dicts on `pv_entities` | **refuses 0.12.0's stored strings** | bricks the upgrade — TEL-12's trap again | -| (c2) dicts as a third key | accepted | per-entry labels render raw (`entity`, `invert`) — §F; and a third PV surface | +| (c2) dicts as a third key | accepted | per-entry labels render raw (`entity`, `invert`) — probe §F; and a third PV surface | | (d) global bool | accepted | one sign cannot describe inverter + meter in one roster | (b)'s known cost is a typo in the second list: `pv.stray_inverts()` turns it into @@ -67,12 +42,35 @@ three correct meters at −20 W sum to −60 W and must stay quiet, while a reve a wrong threshold costs a spurious or missed caution, never data. The text says ADD (unflipped) or REMOVE (already flipped). -## 4. History +## 4. Mutations + +Fresh `cp -a` each, `__pycache__` purged, CI image, `:ro`, `--network none`; +`test_pv.py` only unless stated. T1-T12 at 7559d21; M2/M5 are review #1628's, +red since B1 (merged into release/1.0 @ 43b2740). + +| # | mutation | bytes | reds | +|---|---|---|---| +| T1 | flip ignored in `total()` | −45 | 9 | +| T2 | flip applied to every member | −7 | 6 | +| T3 | `0.0 - w` → `-w` | −5 | 1 | +| T4 | flip written as `abs()` | −1 | 2 | +| T5 | `_read_pv()` stops passing the flip | −26 | 7 | +| T6 | caution threshold −50 → 0 W | −17 | 3 | +| T7 | `[sign inverted]` marker dropped | −39 | 3 | +| T8 | negative caution made `bad` | +1 | 1 | +| T9 | `stray_inverts()` always empty | −50 | 1 | +| T10 | `inverted()` stops stripping | −8 | 1 | +| T11 | ADD/REMOVE advice swapped | +4 | 2 | +| T12 | `pv_invert_entities: []` deleted from `options:` | −25 | 2 test_translations + 17 probe | +| M2 | `stray_inverts()` roster ignores `pv_entity` | +15 | 2 (REPORTED CASE, PADDING) | +| M5 | `_read_pv()` flip set not stripped | +25 | 1 (PADDING) | + +## 5. History OPT-10's `sensor.goodwe_pv_power` recorded since 2026-09-24 on an install with such sensors carries the old sign; nothing rewrites it. -## 5. CHANGELOG entry for the next PKG cut +## 6. CHANGELOG entry for the next PKG cut Not written into `CHANGELOG.md`: `test_version_gate.py` 11b/11d refuse an `## Unreleased` heading (measured on this branch: 4 reds). For PKG to paste: diff --git a/goodwe_controller/test_pv.py b/goodwe_controller/test_pv.py index 71385bc..59408ec 100644 --- a/goodwe_controller/test_pv.py +++ b/goodwe_controller/test_pv.py @@ -1343,27 +1343,9 @@ print(" time - see DECISIONS/TEL-11-pv-production.md §5 for the transcript." print(" ⚠️ A RED COUNT IS A PROPERTY OF THE SUITE AS MUCH AS OF THE MUTATION:") print(" these name THIS tree. Re-measure rather than inherit.") -# --- TEL-14's battery: fresh copy each, no __pycache__, CI image, :ro, -------- -# --network none. Every red is a NAMED check in part 12; no traceback. print() -print(" TEL-14 mutation bytes reds") -print(" ------------------------------------------------ ----- --------") -for row in [ - ("T1 the flip ignored in total()", "-45 B", "9 here"), - ("T2 the flip applied to EVERY member", "-7 B", "6 here"), - ("T3 `0.0 - w` -> `-w` (a -0.0 leaks)", "-5 B", "1 here"), - ("T4 the flip written as abs()", "-1 B", "2 here"), - ("T5 _read_pv() stops passing the flip", "-26 B", "7 here"), - ("T6 caution threshold -50 W -> 0 W", "-17 B", "3 here"), - ("T7 the [sign inverted] marker dropped", "-39 B", "3 here"), - ("T8 the negative caution made `bad`", "+1 B", "1 here"), - ("T9 stray_inverts() always empty", "-50 B", "1 here"), - ("T10 inverted() stops stripping", "-8 B", "1 here"), - ("T11 ADD/REMOVE advice swapped", "+4 B", "2 here"), - ("T12 `pv_invert_entities: []` deleted from options:", "-25 B", - "2 test_translations + 17 probe"), -]: - print(f" {row[0]:<48} {row[1]:>5} {row[2]}") +print(" TEL-14: 14 mutations, all red, no survivors - bytes and named reds in") +print(" DECISIONS/TEL-14-pv-sign.md §4.") print() if fails: