Merge pull request 'SPIKE-04a: inverter B is 4600 W FROM ITS DATASHEET, and the three documented copies are pinned' (#49) from SPIKE-04a into release/1.0
This commit was merged in pull request #49.
This commit is contained in:
@@ -2,6 +2,67 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
**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
|
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
|
true its coulomb counter. There was exactly one `Maintenance` state machine and
|
||||||
|
|||||||
@@ -78,9 +78,20 @@ inverters:
|
|||||||
setpoint_entity: number.goodwe_b_setpoint_w
|
setpoint_entity: number.goodwe_b_setpoint_w
|
||||||
soc_entity: sensor.goodwe_b_soc
|
soc_entity: sensor.goodwe_b_soc
|
||||||
batt_entity: sensor.goodwe_b_battery_power
|
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 |
|
| 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** |
|
| `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** |
|
||||||
|
|||||||
@@ -60,7 +60,19 @@ options:
|
|||||||
# setpoint_entity: number.goodwe_b_setpoint_w
|
# setpoint_entity: number.goodwe_b_setpoint_w
|
||||||
# soc_entity: sensor.goodwe_b_soc
|
# soc_entity: sensor.goodwe_b_soc
|
||||||
# batt_entity: sensor.goodwe_b_battery_power
|
# 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
|
# ⚠️ 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
|
# that is the whole of DEV-02 restated as a config rule. `soc`, `batt` and the
|
||||||
|
|||||||
@@ -192,9 +192,16 @@ class Hass:
|
|||||||
|
|
||||||
A_ENT = {"setpoint_entity": "number.sp_a", "soc_entity": "sensor.soc_a",
|
A_ENT = {"setpoint_entity": "number.sp_a", "soc_entity": "sensor.soc_a",
|
||||||
"batt_entity": "sensor.batt_a"}
|
"batt_entity": "sensor.batt_a"}
|
||||||
# ⚠️ A HYPOTHESIS, NOT A MEASUREMENT. Inverter B does not exist; 3000 W is
|
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
|
||||||
# chosen only because it DIFFERS from A's 5000 W, which is what lets a check
|
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
|
||||||
# tell B's number from A's. SPIKE-04 measures the real one.
|
# (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
|
B_RATING_HYPOTHESIS = 3000.0
|
||||||
A_RATING = 5000.0
|
A_RATING = 5000.0
|
||||||
|
|
||||||
|
|||||||
@@ -1059,9 +1059,16 @@ class Pub:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ⚠️ 3000 W FOR PACK B IS A HYPOTHESIS AND IS MARKED AS ONE EVERYWHERE IT
|
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
|
||||||
# APPEARS. Inverter B does not physically exist; the number is here only because
|
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
|
||||||
# it DIFFERS from pack A's 5000 W, which is the only property these checks use.
|
# (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
|
B_RATING_HYPOTHESIS = 3000.0
|
||||||
A_RATING = 5000.0
|
A_RATING = 5000.0
|
||||||
FLAT_OPTS = dict(setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
FLAT_OPTS = dict(setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# 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
|
B_RATING_HYPOTHESIS = LIVE_MAX_W - 2000.0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
not re.search(r"^\s*(charge_w|discharge_w):\s*float\s*=\s*-?\d",
|
||||||
_srcs["maintenance.py"], re.M))
|
_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()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
|
|||||||
@@ -798,9 +798,18 @@ print(" circulation the allocator NEVER COMMANDED")
|
|||||||
|
|
||||||
from app.inverter import Inverter # noqa: E402
|
from app.inverter import Inverter # noqa: E402
|
||||||
|
|
||||||
# ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST - it is ~10 days out and SPIKE-04
|
# ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST - it is ~8 days out (2026-09-04) and
|
||||||
# measures it. Its rating here is a HYPOTHESIS, named as one, exactly as
|
# the bench half of SPIKE-04 measures it.
|
||||||
# test_peakguard.py's helper names its own.
|
# ⚠️ 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
|
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)
|
f"max_w={out.get('max_w')!r}" if ok else out)
|
||||||
|
|
||||||
print("\n== C. AC2: a two-inverter blob an operator would POST ==")
|
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=[
|
TWO = dict(LIVE, setpoint_entity="", inverters=[
|
||||||
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
|
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
|
||||||
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
|
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
|
||||||
"max_w": 5000},
|
"max_w": 5000},
|
||||||
{"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w",
|
{"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w",
|
||||||
"soc_entity": "sensor.goodwe_b_soc", "batt_entity": "sensor.goodwe_b_battery_power",
|
"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)))
|
ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
|
||||||
show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out)
|
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
|
# The validated blob, for anyone who wants to POST it. Optional: the probe's
|
||||||
@@ -162,9 +184,12 @@ except OSError:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
print("\n== D. what the schema itself refuses, per entry ==")
|
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 (
|
for title, entry in (
|
||||||
("no name", {"setpoint_entity": "number.b", "max_w": 3000}),
|
("no name", {"setpoint_entity": "number.b", "max_w": 4600}),
|
||||||
("no setpoint_entity", {"name": "inverter-b", "max_w": 3000}),
|
("no setpoint_entity", {"name": "inverter-b", "max_w": 4600}),
|
||||||
("no max_w", {"name": "inverter-b", "setpoint_entity": "number.b"}),
|
("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})):
|
("max_w over the range", {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 9999})):
|
||||||
ok, out = validate(dict(TWO, inverters=[entry]))
|
ok, out = validate(dict(TWO, inverters=[entry]))
|
||||||
|
|||||||
Reference in New Issue
Block a user