Merge pull request 'TEL-14: PV sign declared per entity (pv_invert_entities)' (#124) from TEL-14-pv-sign into release/1.0

This commit was merged in pull request #124.
This commit is contained in:
2026-09-25 01:30:33 +02:00
10 changed files with 458 additions and 48 deletions
+215 -6
View File
@@ -56,7 +56,8 @@ absence every clear evening.
Part 8 ⭐⭐ THE SUM, AND WHAT ONE ABSENT MEMBER DOES TO IT.
Part 9 ⭐ publication at a roster of three, as payload bytes.
Part 10 the operator surfaces at three - and 0.11.0's, unchanged, at one.
Part 11 what this file cannot see, with sizes, and both mutation tables.
Part 11 what this file cannot see, with sizes, and the mutation tables.
Part 12 TEL-14: the sign, declared per entity (runs before part 11).
⚠️ MUTATING A MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
@@ -666,7 +667,9 @@ NEEDLES = ("pv_entity", "pv_w", "pv_why", "optional_keys", "ABSENT_AS_UNKNOWN",
# TEL-12's own names. `pv_entities` and `pv_entity` share a prefix so
# the first would have caught it anyway; spelled out because a scope
# that only works by accident stops working when a name is renamed.
"pv_entities", "pv_parts", "pv_roster", "entity_is_ignored")
"pv_entities", "pv_parts", "pv_roster", "entity_is_ignored",
# TEL-14's.
"pv_invert_entities", "stray_inverts", "NEGATIVE_W")
def touches(src: str) -> bool:
@@ -1020,6 +1023,209 @@ check("...and an install with NO PV roster still adds no row of any kind - the "
and run(cycles=1, pv_entities=[" "])[1].state_calls == [])
# ==============================================================================
print()
print("12. TEL-14: the sign, declared PER ENTITY")
# ==============================================================================
# Some installs' PV entities read POSITIVE for consumption and NEGATIVE for
# production (a kWh meter or an ESPHome clamp in the PV circuit). The fixture is
# a MIXED roster, because that is the case a global flag gets wrong:
# E1 an inverter, positive = producing 1200 W -> 1200
# E2 a meter, NEGATIVE = producing, flipped -2.1 kW -> 2100
# E3 a meter at night, correctly signed, NOT flipped: -30 W standby -> -30
# and the values keep every wrong reduction apart:
# right 3270; no flip -930; flip all 930; abs() of every member 3330;
# flip E3 instead of E2 -870.
# ⚠️ EVERY CHECK BELOW READS THROUGH `_at`/`_row` AND NEVER INDEXES BLIND, so a
# missing value is a NAMED FAIL rather than a traceback that hides the rest.
def _at(seq, i):
"""`seq[i]`, or None when there is no such element."""
try:
return seq[i]
except (IndexError, KeyError, TypeError):
return None
def _row(c, needle):
"""Every status-row dict whose text contains `needle` (a list, maybe [])."""
try:
return [r for r in c.checks() if needle in r.get("text", "")]
except Exception as e: # noqa: BLE001 - named, below
return [{"text": f"checks() raised {e!r}", "ok": None, "warn": None}]
MIX = {E1: [state("1200", "W")], E2: [state("-2.1", "kW")],
E3: [state("-30", "W")]}
MIX_TOTAL = 3270.0
_MIX_WRONG = {"no flip": -930.0, "flip all": 930.0, "abs": 3330.0,
"flip E3 not E2": -870.0}
PV_FLIP = dict(PV_MANY, pv_invert_entities=[E2])
check(f"PRECONDITION: the mixed fixture's right answer {MIX_TOTAL:g} W is apart "
f"from every wrong reduction {_MIX_WRONG}",
MIX_TOTAL not in _MIX_WRONG.values()
and len(set(_MIX_WRONG.values())) == len(_MIX_WRONG))
# --- the option ---------------------------------------------------------------
check("inverted() is the stripped, non-empty entries of `pv_invert_entities`, "
"and a 0.12.0 blob (no key at all) flips nothing",
pv.inverted({"pv_invert_entities": [" sensor.a ", "", None, "sensor.b"]})
== frozenset({"sensor.a", "sensor.b"})
and pv.inverted({}) == frozenset()
and pv.inverted({"pv_invert_entities": None}) == frozenset())
# --- the arithmetic -----------------------------------------------------------
_t_mix = pv.total([(e, _at(MIX[e], 0)) for e in (E1, E2, E3)],
pv.inverted(PV_FLIP))
_t_raw = pv.total([(e, _at(MIX[e], 0)) for e in (E1, E2, E3)])
print(f" mixed, E2 flipped: {_t_mix[0]} no flip: {_t_raw[0]}")
check(f"⭐ A MIXED ROSTER: only the listed entity is flipped, before the sum - "
f"{MIX_TOTAL:g} W, not any of {_MIX_WRONG}",
_t_mix[0] == MIX_TOTAL
and [w for _e, w, _y in _t_mix[2]] == [1200.0, 2100.0, -30.0])
check("⭐ DEFAULT IS 0.12.0: with no flip `total()` answers exactly what it "
"always did - the raw sum, the same `why`, the same parts",
_t_raw == (-930.0, "3 entities",
[(E1, 1200.0, "W"), (E2, -2100.0, "kW"), (E3, -30.0, "W")]))
_t_zero = pv.total([(E2, state("0", "W"))], frozenset({E2}))
check("a flipped 0 W is +0.0, never -0.0 - so the wire says `0.0` and the page "
"says `0 W`, not `-0`",
_t_zero[0] == 0.0 and str(_t_zero[0]) == "0.0"
and f"{_t_zero[0]:.0f}" == "0")
_t_gone = pv.total([(E1, state("1200")), (E2, None)], frozenset({E2}))
check("a flipped member that is ABSENT is still an absence of the total - the "
"flip never turns None into a number",
_t_gone[0] is None and _at(_at(_t_gone[2], 1), 1) is None)
check("a single flipped entity keeps 0.11.0's pass-through `why` (the unit)",
pv.total([(E2, state("-13", "W"))], frozenset({E2}))
== (13.0, "W", [(E2, 13.0, "W")]))
# --- the read path, the wire and the page -------------------------------------
_c_mix, _, _p_mix = run(cycles=1, by_entity=MIX, **PV_FLIP)
_f_mix = _at(_p_mix.frames, -1) or {}
print(f" controller pv_w {_c_mix.pv_w} wire {pv_msg(_f_mix)}")
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")]
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("...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", ""))
# The owner's report, at a roster of one: a meter reading +13 W at night.
_OWN = "sensor.pv_meter_power"
_c_own, _, _ = run(cycles=1, by_entity={_OWN: [state("13", "W")]},
pv_entity=_OWN, pv_invert_entities=[_OWN])
_c_own0, _, _ = run(cycles=1, by_entity={_OWN: [state("13", "W")]},
pv_entity=_OWN)
check("⭐ THE REPORTED CASE: 13 W of night-time standby on a reversed meter is "
"-13 W once flipped, the row says so and marks it - and it raises NO "
"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)"])
# 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
# while the page marks it, or the page calls a flipped entity a stray.
_c_pad, _, _ = run(cycles=1, by_entity=MIX,
pv_entities=[E1, f" {E2}", E3],
pv_invert_entities=[f"{E2} "])
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") == [])
# --- the negative caution -------------------------------------------------------
def _neg(watts_by_entity, flip=()):
opts = dict(PV_MANY, pv_invert_entities=list(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")
_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`",
[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"])
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`"
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",
bool(_n_add) and all(r.get("ok") is False and r.get("warn") is True
for r in _n_add + _n_rm))
check(f"the threshold is {pv.NEGATIVE_W:g} W per ENTITY: -50 W is standby and "
"stays quiet, -51 W cautions",
pv.NEGATIVE_W == -50.0
and _neg({E1: -50, E2: 0, E3: 0}) == []
and len(_neg({E1: -51, E2: 0, E3: 0})) == 1)
check("⭐ ...PER ENTITY, NOT ON THE TOTAL: three correctly signed meters at "
"-20 W standby each (total -60 W) raise nothing, and a reversed -1500 W "
"hidden in a positive total (+1650 W) is still caught",
_neg({E1: -20, E2: -20, E3: -20}) == []
and len(_neg({E1: 2700, E2: -1500, E3: 450})) == 1)
# --- the stray entry ------------------------------------------------------------
_c_typo, _, _ = run(cycles=1, by_entity=FULL,
**dict(PV_MANY, pv_invert_entities=["sensor.pv_wset"]))
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") == [])
# --- default: byte-identical to 0.12.0 ------------------------------------------
_c_d0, _, _p_d0 = run(cycles=1, by_entity=MIX, **PV_MANY)
_c_d1, _, _p_d1 = run(cycles=1, by_entity=MIX,
**dict(PV_MANY, pv_invert_entities=[]))
check("⭐ DEFAULT: `pv_invert_entities: []` and a 0.12.0 blob with no such key "
"give the same rows, the same frame and the raw sum",
[r["text"] for r in _c_d0.checks()] == [r["text"] for r in _c_d1.checks()]
and _at(_p_d0.frames, -1) == _at(_p_d1.frames, -1)
and _c_d0.pv_w == -930.0)
# --- still not a control input --------------------------------------------------
_BIG_FLIP = {E1: [state("1500", "W")], E2: [state("-4.5", "kW")],
E3: [state("2000", "W")]}
_c_bf, _h_bf, _ = run(**_stim, by_entity=_BIG_FLIP,
**dict(PV_MANY, pv_invert_entities=[E2]))
check("⭐ NOT A CONTROL INPUT, WITH A FLIP: 40 cycles against the settled 600 W "
"import write exactly what `unset` writes, and the flip really did move "
"the reading (8000 W, part 6's shift precondition)",
_h_bf.writes == _w_none and _c_bf.target == _c_none.target
and _c_bf.pv_w == BIG_TOTAL)
# ==============================================================================
print()
print("11. what this file CANNOT see, with sizes - and the mutation table")
@@ -1032,10 +1238,9 @@ for line in [
" (mqtt/const.py PAYLOAD_NONE, mqtt/sensor.py's `_message_received`,",
" retrieved 2026-09-24) and quoted in app/mqtt.py. No HA runs in this",
" suite, so what is graded here is the BYTES this add-on publishes.",
"Whether the operator's PV sensor is telling the truth. A sensor wired",
" backwards reads -4 kW at noon; this file asserts that the sign is",
" published unchanged, never that it is right. That is what the history",
" the card creates is FOR - see DECISIONS/TEL-11-pv-production.md §2.",
"Whether the operator's PV sensor is telling the truth. TEL-14 lets the",
" installer declare a reversed one; this file grades the flip and the",
" -50 W caution, never that the declaration is right.",
"The control-path claim OUTSIDE app/. `tools/`, the ESPHome firmware and",
" the ingress page are not scanned by part 6's census.",
"A roster of more than one INVERTER (CFG-04's battery kind). Every",
@@ -1142,6 +1347,10 @@ 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.")
print()
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:
print(f"{len(fails)} FAILED:")