diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index c24a8ac..8af1c83 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -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 diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 4315368..a57fb5f 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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** | diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index e0a8ce3..0e99f52 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -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 diff --git a/goodwe_controller/test_inverter.py b/goodwe_controller/test_inverter.py index e24dbb8..094f8b9 100644 --- a/goodwe_controller/test_inverter.py +++ b/goodwe_controller/test_inverter.py @@ -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 diff --git a/goodwe_controller/test_maintenance.py b/goodwe_controller/test_maintenance.py index 8f84aea..9d5429d 100644 --- a/goodwe_controller/test_maintenance.py +++ b/goodwe_controller/test_maintenance.py @@ -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", diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index 4adb374..ea80156 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -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 diff --git a/goodwe_controller/test_validate.py b/goodwe_controller/test_validate.py index d3d74e1..8565bbf 100644 --- a/goodwe_controller/test_validate.py +++ b/goodwe_controller/test_validate.py @@ -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: diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index 7ce60b8..d0d83a0 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -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 diff --git a/goodwe_controller/tools/supervisor_shape_probe.py b/goodwe_controller/tools/supervisor_shape_probe.py index 49d7356..713d65b 100644 --- a/goodwe_controller/tools/supervisor_shape_probe.py +++ b/goodwe_controller/tools/supervisor_shape_probe.py @@ -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]))