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:
@@ -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:")
|
||||
|
||||
Reference in New Issue
Block a user