Compare commits

...
2 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 a9993aeda2 SPIKE-04a: inverter B is 4600 W FROM ITS DATASHEET, and the copies are pinned
checks / unit (pull_request) Successful in 4m51s
The desk half of SPIKE-04. No control behaviour changes; this is documentation
and the check that stops it drifting again.

Three places stated inverter B's rating - config.yaml's commented roster
example, DOCS.md's, and tools/supervisor_shape_probe.py's "a blob an operator
would POST" - and all three said 3000 W, a number nobody had measured or read
off anything. The owner supplied B's model and serial on 2026-09-04: a GoodWe
GW5048D-ES, serial `55048ESU22AW0992`, max charging and discharging power
4600 W. Serial characters 6-8 are `ESU`, platform 105 / AA55, so SPIKE-01 does
not gate this site. All three copies now read 4600.

⚠️ 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT, and the one ambiguity
is recorded rather than guessed. The datasheet prints it under BATTERY INPUT
DATA, so it is probably DC, while max_w is graded against 0x0518 - the
inverter's own AC power. If it is DC the AC ceiling is nearer 4324 W. Neither
reading is written in as fact; SAFETY-20's min(max_w, dev_max) already bounds
it correctly under either, once dev_max is read off the unit, so `max_w: 4600`
is what an operator should configure today. goodwe/NOTES.md §1.1b carries the
full record with the date and the method on every figure.

A NEW CHECK, BECAUSE A DOCUMENTATION FIX WITHOUT ONE DRIFTS AGAIN.
test_validate.py now pins all three documented rosters: findable, in agreement,
and equal to 4600 - with an explicit len(...) == 3 guard so that a regex which
stopped matching cannot leave "they all agree" green on the two sites still
read. It also grades the PROVENANCE beside the number (the datasheet, the
0x0518 it is graded against, and the 4324 W the DC reading would make it)
WITHIN 2500 characters of the roster rather than file-wide.

⚠️ The first version of that provenance check searched whole files and was
ALREADY VACUOUS on two of them: 0x0518 appeared once in config.yaml and once in
DOCS.md before this card, for unrelated reasons, so `"0x0518" in src` was green
no matter what happened next to B's rating. That is a check whose data cannot
reach the failure it names, in the file that exists to complain about them. It
is fixed, and the window is what fixes it - deleting the caveat beside the
number now goes red even where the same token survives elsewhere in the file.
The windowed version immediately found that the DOCS.md caveat did not name
0x0518 at all; DOCS.md now says it.

Four mutations, bytecode purged and counted at 0 before and after each, all
RED (two of them 0-byte deltas that change a printed answer):
  M1  probe's operator blob 4600 -> 3000        0 B   RED (value + agreement)
  M2  DOCS.md's DC/AC caveat deleted         -157 B   RED (provenance)
  M3  config.yaml alone 4600 -> 4324            0 B   RED (agreement)
  M4  probe entity renamed, regex finds nothing       RED (the vacuity guard)
Restored, green again. The probe itself was re-run against Supervisor
2026.08.0's own AppOptions validator with the 4600: all cases passed, exit 0.

⚠️ THE TEST FIXTURES KEEP 3000 W ON PURPOSE AND NOW SAY WHY AT THE LINE.
test_inverter.py, test_maintenance.py, test_peakguard.py and test_watchdog.py
build a second inverter at 3000 W against A's 5000 W. The only property those
checks consume is that B's number is unmistakably not A's, and the real pair is
a 400 W gap - narrower than slew_w, inside the range where an off-by-one-unit
bug and ordinary control arithmetic produce indistinguishable numbers.
Collapsing them onto the real values would buy realism those checks do not use
and cost them their separating power, which is exactly what test_peakguard.py's
own §10g PRECONDITION exists to prevent. Their comments no longer call 3000 W a
hypothesis about B's rating; they call it illustrative, and not a placeholder
either - the placeholder era ended on 2026-09-04.

Also recorded in the changelog for the cards that own them: 94 % battery-to-AC
=> ~88 % round trip => ~360 W burned continuously to circulate 3 kW, which is
the number SAFETY-31 argues its threshold from without having one; < 13 W night
self-consumption per unit, which nothing accounts for; and the A/B asymmetry in
opposite directions (~33 % more energy, ~8 % less power), which means MAINT-05's
12 h drain timeout was derived on A and should be re-checked against B.

⚠️ Honest survivors:
- goodwe/NOTES.md §1.1b, where all of this is actually recorded, is graded by
  nothing here. Rewriting its 16 occurrences of 4600 to 3000 and turning every
  FROM DATASHEET label into MEASURED ON THE UNIT leaves all 19 files GREEN.
  This CI cannot fetch admin/goodwe - private, and GITHUB_TOKEN is minted for
  this repository only, as .gitea/workflows/checks.yml already states - so the
  cross-repo check is not buildable here.
- No check can see whether 4600 is the RIGHT number. Everything above pins a
  datasheet reading against itself. Only the bench half of SPIKE-04 can say
  whether the unit agrees, and the DC-vs-AC ambiguity is the live example.
- The constant is still named B_RATING_HYPOTHESIS in four test files, which
  reads as a hypothesis about B's rating when it is now an illustrative
  fixture. It is left alone deliberately: renaming ~20 sites across
  test_peakguard.py and test_inverter.py would collide hard with the open
  SAFETY-32 pull request, which rewrites 1255 lines of the first. The comment
  directly above each definition says what the value is and is not.

Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and
verified 0 before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 22:19:31 +02:00
admin 3934ad7ea5 Merge pull request 'MAINT-05: one recalibration per pack, and only one pack moves power at a time' (#47) from MAINT-05 into release/1.0 2026-09-04 21:39:25 +02:00
9 changed files with 247 additions and 14 deletions
+61
View File
@@ -2,6 +2,67 @@
## Unreleased
**Inverter B's rating is 4600 W, FROM ITS DATASHEET, and every documented copy
of it now says so (SPIKE-04a).** Three places stated B's rating — `config.yaml`'s
commented roster example, `DOCS.md`'s, and `tools/supervisor_shape_probe.py`'s
"a blob an operator would POST" — and all three said **3000 W**, a number that
was never measured, never read off a datasheet, and simply wrong. The owner
supplied B's model and serial on 2026-09-04: a **GoodWe GW5048D-ES**, serial
`55048ESU22AW0992`, max charging and discharging power **4600 W**. No control
behaviour changes here; this is documentation and its pin.
* **Serial characters 6-8 read `ESU`** → platform 105, AA55, the supported set.
Not `ESN`/`ESC` (ES G2, platform 745). SPIKE-01 does not gate this site.
⚠️ A passed platform gate is **not** a proven control surface: `specs.md`
already calls other platform-105 serials *"same protocol family; untested
silicon"*, and this repo's `0x056E`/`0x056F` map was reverse-engineered from a
passive tap on **one BPS unit**. That check is the bench half of SPIKE-04.
* **⚠️ 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT, and one ambiguity is
recorded rather than guessed.** The datasheet prints it under *Battery Input
Data*, so it is probably **DC**, while `max_w` is graded against `0x0518` —
the inverter's own **AC** power. If it is DC the AC ceiling is nearer
4600 × 0.94 ≈ **4324 W**, and the charge direction moves the other way
(≈ 4894 W drawn AC-side). **Neither reading is written in as fact.**
SAFETY-20's `min(max_w, dev_max)` already bounds it correctly under either,
once `dev_max` is read off the unit — so `max_w: 4600` is what to configure
today. `goodwe/NOTES.md` §1.1b carries the full record, with the date and the
method on every figure.
* **A NEW CHECK, BECAUSE A DOCUMENTATION FIX WITHOUT ONE DRIFTS AGAIN.**
`test_validate.py` now pins all three documented rosters: they must be
findable, they must agree, and they must say **4600**. It also grades the
**provenance beside the number** — the datasheet it came from, the `0x0518`
it is graded against, and the 4324 W the DC reading would make it — **within
2500 characters of the roster, not file-wide.** The first version searched
whole files and was already vacuous on two of them, because `0x0518` appeared
in `config.yaml` and `DOCS.md` for unrelated reasons before this card: a
check whose data could not reach the failure it named, in the file that
exists to complain about those. Four mutations were run against the fixed
version — the probe back to 3000 (0-byte delta), the `DOCS.md` caveat
deleted, `config.yaml` alone "corrected" to 4324 (0-byte delta), and the
probe's entity renamed so the regex finds nothing — and all four are red.
* **THE TEST FIXTURES KEEP 3000 W ON PURPOSE, AND NOW SAY WHY AT THE LINE.**
`test_inverter.py`, `test_maintenance.py`, `test_peakguard.py` and
`test_watchdog.py` build a second inverter at 3000 W against A's 5000 W. The
only property those checks consume is that B's number is **unmistakably** not
A's, and the real pair is a **400 W** gap — narrower than `slew_w`, inside the
range where an off-by-one-unit bug and ordinary control arithmetic produce
indistinguishable numbers. Collapsing them onto the real values would buy
realism those checks do not use and cost them their separating power, which
is the defect `test_peakguard.py`'s own §10g precondition exists to prevent.
They are **illustrative, not B's rating, and no longer a placeholder for it**.
* **Two consequences with numbers, recorded in `goodwe/NOTES.md` §1.1b for the
cards that own them.** At 94 % battery-to-AC a round trip is ≈ 88 %, so A
charging 3 kW while B discharges 3 kW burns ≈ **360 W continuously** with the
meter at zero — SAFETY-31 argues its threshold from *"a few watts"* and has no
figure. And **< 13 W night self-consumption per unit** (≈ 26 W for two,
≈ 0.6 kWh/day), which nothing in this add-on accounts for anywhere.
* **The site is asymmetric in opposite directions:** A = 5000 W / 15.36 kWh
nominal, B = 4600 W / 20.48 kWh — ~33 % more energy, ~8 % less power. No
stability implication (ARCH-01's ḡ = Σαᵢgᵢ is a convex combination), but a
full recalibration of B moves 33 % more energy and **MAINT-05's 12 h `drain`
phase timeout was derived on A**; it should be re-checked against B rather
than assumed to carry.
**Every pack is recalibrated on its own schedule, and only one moves power at a
time (MAINT-05).** §12's monthly cycle drains a pack to empty and refills it to
true its coulomb counter. There was exactly one `Maintenance` state machine and
+12 -1
View File
@@ -78,9 +78,20 @@ inverters:
setpoint_entity: number.goodwe_b_setpoint_w
soc_entity: sensor.goodwe_b_soc
batt_entity: sensor.goodwe_b_battery_power
max_w: 3000
max_w: 4600
```
⚠️ **The 4600 W is inverter B's *datasheet* figure, not a measured one.** B is a
GoodWe GW5048D-ES (serial `55048ESU22AW0992`, identified 2026-09-04) whose
datasheet gives max charging and discharging power as 4600 W; with nothing
connected to its PV inputs the battery path is the ceiling, not the 5000 VA AC
nameplate. The datasheet prints that number under *Battery Input Data*, so it
may be a DC figure, while `max_w` is graded against register `0x0518` — the
inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
`max_w: 4600` is still the right thing to configure, because the freeze rail is
`min(max_w, dev_max)` and the device range read off the unit is what binds.
`goodwe/NOTES.md` §1.1b records both readings and picks neither.
| per-entry key | required | meaning |
|---|---|---|
| `name` | yes | Short identifier. It namespaces this unit's SAFETY-02 latch, heartbeat and write bookkeeping in the add-on's state file, so **two units may not share one** |
+13 -1
View File
@@ -60,7 +60,19 @@ options:
# setpoint_entity: number.goodwe_b_setpoint_w
# soc_entity: sensor.goodwe_b_soc
# batt_entity: sensor.goodwe_b_battery_power
# max_w: 3000
# max_w: 4600
#
# ⚠️ THE 4600 IS INVERTER B'S DATASHEET FIGURE, NOT A MEASUREMENT AND NOT A
# PLACEHOLDER. SPIKE-04a (2026-09-04) identified B as a GoodWe GW5048D-ES,
# serial `55048ESU22AW0992`, whose datasheet gives Max. Charging / Discharging
# Power as 4600 W; nothing will be connected to its MPPTs, so the battery path
# is the ceiling and the 5000 VA AC nameplate is not. The 3000 W this example
# used to carry was a guess made before the unit was known. ⚠️ It is STILL not
# measured: the datasheet prints 4600 W under BATTERY INPUT DATA, so it may be
# DC, and `max_w` is graded against 0x0518 - the inverter's own AC power. If it
# is DC the AC ceiling is nearer 4324 W. SAFETY-20's `min(max_w, dev_max)` is
# what settles that once the device range is read; see goodwe/NOTES.md §1.1b,
# which records both readings and picks neither.
#
# ⚠️ NOTHING IS INHERITED FROM THE FLAT KEYS BY AN ENTRY THAT OMITS IT, and
# that is the whole of DEV-02 restated as a config rule. `soc`, `batt` and the
+10 -3
View File
@@ -192,9 +192,16 @@ class Hass:
A_ENT = {"setpoint_entity": "number.sp_a", "soc_entity": "sensor.soc_a",
"batt_entity": "sensor.batt_a"}
# ⚠️ A HYPOTHESIS, NOT A MEASUREMENT. Inverter B does not exist; 3000 W is
# chosen only because it DIFFERS from A's 5000 W, which is what lets a check
# tell B's number from A's. SPIKE-04 measures the real one.
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
# kept because the only property these checks consume is that B's number is
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
# onto the real values would cost these checks their separating power to buy
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
# placeholder for it either - the placeholder era ended on 2026-09-04.
B_RATING_HYPOTHESIS = 3000.0
A_RATING = 5000.0
+10 -3
View File
@@ -1059,9 +1059,16 @@ class Pub:
pass
# ⚠️ 3000 W FOR PACK B IS A HYPOTHESIS AND IS MARKED AS ONE EVERYWHERE IT
# APPEARS. Inverter B does not physically exist; the number is here only because
# it DIFFERS from pack A's 5000 W, which is the only property these checks use.
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
# kept because the only property these checks consume is that B's number is
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
# onto the real values would cost these checks their separating power to buy
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
# placeholder for it either - the placeholder era ended on 2026-09-04.
B_RATING_HYPOTHESIS = 3000.0
A_RATING = 5000.0
FLAT_OPTS = dict(setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
+10
View File
@@ -2659,6 +2659,16 @@ check("...and the arbiter agrees the routing is legal, on the list main.py "
# DATA could not reach the failure it named (ARB-03's call-site scan saw 3 of 8
# sites; ALLOC-01's replay generator saw 0 escapes where a widened one saw
# 39,991), so the number is different HERE, once, for every fixture in the file.
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
# kept because the only property these checks consume is that B's number is
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
# onto the real values would cost these checks their separating power to buy
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
# placeholder for it either - the placeholder era ended on 2026-09-04.
B_RATING_HYPOTHESIS = LIVE_MAX_W - 2000.0
+91
View File
@@ -828,6 +828,97 @@ check("...and MaintConfig's dataclass defaults carry no numeric literal either "
not re.search(r"^\s*(charge_w|discharge_w):\s*float\s*=\s*-?\d",
_srcs["maintenance.py"], re.M))
# --------------------------------------------------------------------------- #
print("SPIKE-04a: inverter B's documented rating, and the provenance beside it")
# ⚠️ WHY A DOCUMENTATION CARD LEFT A CHECK BEHIND. Inverter B's rating was
# written down in THREE places - config.yaml's commented roster example,
# DOCS.md's, and tools/supervisor_shape_probe.py's "a blob an operator would
# POST" - as 3000 W, a figure nobody had measured, read off a datasheet, or
# sourced at all. It was wrong: SPIKE-04a (2026-09-04) identified inverter B
# from the owner's model number and serial as a GoodWe GW5048D-ES rated 4600 W
# on the battery path. Three transcriptions of one number drifting away from
# the truth together is this repo's recurring defect in its plainest form, and
# correcting them once WITHOUT PINNING THEM buys nothing: the next editor
# touches one of the three and the other two stay wrong, silently.
#
# ⚠️ AND THE PROVENANCE IS GRADED, NOT ONLY THE VALUE, WHICH IS THE HALF THAT
# MATTERS. 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT. The datasheet
# prints it under BATTERY INPUT DATA, so it is probably DC, while `max_w` is
# graded against 0x0518 - the inverter's own AC power - which would put the AC
# ceiling nearer 4324 W. An editor who tidies that caveat away leaves a paper
# number reading as an observation of hardware that does not exist yet, and
# THAT is the failure this card exists to prevent. So the caveat is what these
# checks reach for; a check that graded 4600 alone would watch the value and
# miss the lie.
#
# ⚠️ WHAT THIS DELIBERATELY DOES NOT GRADE: the test fixtures. Several files
# build a second inverter at 3000 W on purpose, because the property those
# checks consume is that B's rating is UNMISTAKABLY not A's, and the real pair
# (5000/4600) is a 400 W gap - narrower than `slew_w`. Those are illustrative,
# they say so at the line, and they are not statements of B's rating. The three
# sites below are the ones that ARE such statements.
_HERE = os.path.dirname(os.path.abspath(__file__))
_docs_txt = open(os.path.join(_HERE, "DOCS.md"), encoding="utf-8").read()
_probe_txt = open(os.path.join(_HERE, "tools", "supervisor_shape_probe.py"),
encoding="utf-8").read()
# One regex per site, each anchored on something only THAT site has, so a match
# cannot silently wander into a neighbouring blob. The probe's anchor is the
# operator blob's real entity name: its section D refusal entries use
# `number.b` and one of them carries a deliberate 9999, so an anchor loose
# enough to reach them would grade the wrong dict and still look green.
_b_rating = {
"config.yaml": re.search(
r"#\s+- name: inverter-b\b(?:[^\n]*\n)*?\s*#\s+max_w:\s*(\d+)", _txt),
"DOCS.md": re.search(
r"^ - name: inverter-b\b(?:[^\n]*\n)*?^ max_w:\s*(\d+)",
_docs_txt, re.M),
"supervisor_shape_probe.py": re.search(
r'"name": "inverter-b",\s*"setpoint_entity": "number\.goodwe_b_setpoint_w"'
r'(?:[^}]*?)"max_w":\s*(\d+)', _probe_txt),
}
check(f"all three documented rosters were FOUND "
f"{ {k: bool(v) for k, v in _b_rating.items()} } - a regex that matches "
"nothing is a green check that read nothing",
all(_b_rating.values()))
# ⚠️ THE `len(...) == 3` IS NOT DECORATION. Without it a regex that stopped
# matching would shrink this dict, and "all the values agree" would go GREEN on
# the two sites that were still read - a check whose data no longer reaches the
# failure it names, which is the exact fault this whole section is about.
_b_vals = {k: int(m.group(1)) for k, m in _b_rating.items() if m}
check(f"...and all three state the SAME rating for inverter B {_b_vals}",
len(_b_vals) == 3 and len(set(_b_vals.values())) == 1)
check(f"...and it is 4600 W - the GW5048D-ES datasheet figure, not the 3000 W "
f"hypothesis that predated the unit being identified {_b_vals}",
len(_b_vals) == 3 and set(_b_vals.values()) == {4600})
# The provenance triple. Each site must still say WHERE 4600 came from, that it
# is graded against an AC register, and what the DC-vs-AC ambiguity does to it -
# the last carried by the 4324 W figure, which is the one marker that cannot
# survive a careless "it's 4600 now, done" rewrite.
#
# ⚠️ SEARCHED IN A WINDOW AROUND THE ROSTER, NOT OVER THE WHOLE FILE, AND THE
# FIRST VERSION OF THIS CHECK DID THE LATTER. `0x0518` already appeared once in
# config.yaml and once in DOCS.md before this card, for unrelated reasons - so a
# whole-file `"0x0518" in src` was GREEN on those two files no matter what
# happened next to inverter B's rating. That is a check whose data cannot reach
# the failure it names, in the same file that exists to complain about them.
# The window is the text either side of the roster match, so deleting the caveat
# beside the number goes red even where the same word survives elsewhere.
_sites = {"config.yaml": _txt, "DOCS.md": _docs_txt,
"supervisor_shape_probe.py": _probe_txt}
_MARKERS = ("datasheet", "0x0518", "4324")
_prov = {}
for _name, _src in _sites.items():
_m = _b_rating[_name]
_win = _src[max(0, _m.start() - 2500):_m.end() + 2500].lower() if _m else ""
_prov[_name] = {k: k in _win for k in _MARKERS}
for _name, _got in sorted(_prov.items()):
check(f"{_name} still carries B's provenance WITHIN 2500 chars of the "
f"roster - the datasheet it came from, the 0x0518 it is graded "
f"against, and the 4324 W the DC reading would make it {_got}",
all(_got.values()))
# --------------------------------------------------------------------------- #
print()
if fails:
+12 -3
View File
@@ -798,9 +798,18 @@ print(" circulation the allocator NEVER COMMANDED")
from app.inverter import Inverter # noqa: E402
# ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST - it is ~10 days out and SPIKE-04
# measures it. Its rating here is a HYPOTHESIS, named as one, exactly as
# test_peakguard.py's helper names its own.
# ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST - it is ~8 days out (2026-09-04) and
# the bench half of SPIKE-04 measures it.
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
# kept because the only property these checks consume is that B's number is
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
# onto the real values would cost these checks their separating power to buy
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
# placeholder for it either - the placeholder era ended on 2026-09-04.
B_RATING_HYPOTHESIS = 3000.0
@@ -145,13 +145,35 @@ show("the same blob MERGED with these defaults - what App.options actually is",
f"max_w={out.get('max_w')!r}" if ok else out)
print("\n== C. AC2: a two-inverter blob an operator would POST ==")
# ⚠️ B's 4600 W IS FROM ITS DATASHEET, NOT A PLACEHOLDER AND NOT A MEASUREMENT.
# SPIKE-04a (2026-09-04) identified inverter B from the owner's model number and
# serial - a GoodWe GW5048D-ES, serial `55048ESU22AW0992` - and the datasheet's
# Max. Charging / Discharging Power is 4600 W. The 3000 W that stood here was a
# hypothesis from before the unit was known and was simply wrong; the 5000 VA AC
# nameplate is wrong for this purpose too, because nothing will be connected to
# B's MPPTs so the battery path is the ceiling. See goodwe/NOTES.md §1.1b.
#
# ⚠️ AND IT IS STILL NOT MEASURED. The datasheet prints 4600 W under BATTERY
# INPUT DATA, so it is probably DC, while `max_w` is graded against 0x0518 - the
# inverter's own AC power. If it is DC the AC ceiling is nearer 4600 x 0.94 =
# 4324 W. NOTES.md §1.1b records both readings without picking one, and
# SAFETY-20's `min(max_w, dev_max)` is what bounds it correctly once dev_max is
# read off the unit. So this blob is what an operator would POST TODAY, not a
# claim about what inverter B will turn out to do.
#
# ⚠️ THE TWO RATINGS ARE STILL DIFFERENT (5000 / 4600) AND MUST STAY DIFFERENT.
# This file's own §B is the record of what an instrument that cannot distinguish
# its two cases is worth. Note that 5000/4600 is a much NARROWER separation than
# the 5000/3000 it replaces - fine here, where nothing computes on the numbers,
# and NOT fine in the test fixtures, which is why those keep a wide illustrative
# pair instead of the real one.
TWO = dict(LIVE, setpoint_entity="", inverters=[
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
"max_w": 5000},
{"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w",
"soc_entity": "sensor.goodwe_b_soc", "batt_entity": "sensor.goodwe_b_battery_power",
"max_w": 3000}])
"max_w": 4600}])
ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out)
# The validated blob, for anyone who wants to POST it. Optional: the probe's
@@ -162,9 +184,12 @@ except OSError:
pass
print("\n== D. what the schema itself refuses, per entry ==")
# The rating here is filler - each entry is refused for a MISSING key, so no
# verdict below depends on the number. It carries B's datasheet 4600 W anyway,
# so that nothing in this file reads as 3000 W being inverter B's rating.
for title, entry in (
("no name", {"setpoint_entity": "number.b", "max_w": 3000}),
("no setpoint_entity", {"name": "inverter-b", "max_w": 3000}),
("no name", {"setpoint_entity": "number.b", "max_w": 4600}),
("no setpoint_entity", {"name": "inverter-b", "max_w": 4600}),
("no max_w", {"name": "inverter-b", "setpoint_entity": "number.b"}),
("max_w over the range", {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 9999})):
ok, out = validate(dict(TWO, inverters=[entry]))