diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 12e2736..304cd96 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -149,6 +149,22 @@ that direction would fire on every maintenance charge. two-inverter runs plus 1890 at one inverter as the control), the inverter-B sensitivity sweep the margin is sized on, and the mutation ledger. +⚠️ **AND THE TWO THRESHOLDS IT READS ARE NOW GRADED AT A CONFIG THAT IS NOT THE +SHIPPED ONE.** `saturation_w` and each unit's `bms_soc_floor` are handed to this +check from the running config, and the promise that comes with that - raise +`saturation_w` and this check moves with you, there is no fourth copy of a +default to keep in step - is only true while the call site passes the +**configured** value. A literal equal to the shipped value keeps every fixture +green and removes the promise silently, and two such literals were survivors of +all 23 suites - measured, not argued - because no fixture that reaches this +check had ever run at a `saturation_w` other than the shipped 500 or at a +`bms_soc_floor` other than the shipped example's 10. Part 4b of +`test_delivery.py` now runs the same fault at a raised `saturation_w` and at two +cut-outs that bracket 10 from both sides, and each pair must answer two +different ways - so no constant passes. §9 of the decision record enumerates +every number this check reads and says which were already pinned this way and +which were not. + ⚠️ **A PER-INVERTER POWER READING THAT HAS STOPPED FOLLOWING ITS OWN COMMAND IS NOW `None`, NOT A HELD NUMBER ([[SAFETY-37]]).** `Inverter.batt` (register `0x0518`) goes through a freshness gate: unchanged for >= 35 s *while* the diff --git a/goodwe_controller/DECISIONS/SAFETY-42-unit-delivery.md b/goodwe_controller/DECISIONS/SAFETY-42-unit-delivery.md index 6c5e23b..44c67a5 100644 --- a/goodwe_controller/DECISIONS/SAFETY-42-unit-delivery.md +++ b/goodwe_controller/DECISIONS/SAFETY-42-unit-delivery.md @@ -503,3 +503,90 @@ the way `circulation.circulating_w()` records its own `max(0.0, …)`. — `parked=inv.sign_wd.tripped` — is not mutated on its own. Part 6's reversed-unit fixture exercises the path end to end and asserts the account it produces, which is weaker than a mutation and is stated as such. + +## 9. Round 2 — the thresholds this check reads, and which of them are pinned at a **changed** config + +Everything in §8 mutated the *logic*. This section asks a narrower question that +§8 never asked, and it found two live survivors of the whole 23-file suite: + +> for each number `main.py` hands this reduction, is the check still red when +> that number is replaced by a **literal equal to the value the fixtures use**? + +That is not pedantry about style. Two of these numbers are **options**, and +`delivery.MIN_W_IS_SATURATION_W` and the `delivery_hold_s` block in `config.yaml` +both make a promise about them *in shipped, operator-readable prose*: there is no +fourth copy of a default to keep in step, and no way for the two to drift, +because an operator who raises `saturation_w` has told the loop that a bigger gap +is ordinary tracking. A literal at the call site keeps every fixture green and +removes exactly that promise — and the promise is why the design was chosen over +a fourth option key. + +### The enumeration + +| # | what the call site reads | mutated to | Δ | before round 2 | now | +|---|---|---|---|---|---| +| R5 | `self.tuning.saturation_w` | `250.0` | −19 B | **SURVIVED all 23 suites** | red — part 4b(a) | +| R6 | `bms_floor=inv.bms_soc_floor` | `10.0` | −13 B | **SURVIVED all 23 suites** | red — part 4b(b) | +| R7 | `arm(self.delivery_cfg)` | `arm(DeliveryConfig())` | −1 B | red | red — part 4 already ran at a hold of 1 s, not the shipped 185 s | +| R8 | `inv.is_live(now)[0]` | `inv.is_live(now, 3600.0)[0]` | +8 B | red | red — part 6's link-dead fixture | +| R9 | `bms_floor=inv.bms_soc_floor` | the **conjoined** floor, `max(pack cut-out, site peak_soc_floor)` | +105 B | red | red — and now red **twice**: the pre-existing between-floors check at shipped floors, and part 4b(b)'s shallow arm at a cut-out that is not shipped | + +**R7 and R8 were already pinned across a changed setting, and R5 and R6 were +not.** The difference is entirely in the fixtures, not in the code: +`delivery_hold_s` had to be moved off 185 s for any of this file to run inside a +two-second test, and part 6 had to back-date `rejected_since` past +`LINK_STALE_S` to build a dropped unit at all. Nothing ever forced +`saturation_w` off 500 or a `bms_soc_floor` off 10 — so those two sat at the +shipped value in every fixture, and a literal equal to the shipped value is +invisible to a fixture that only ever uses the shipped value. **That is this +repository's recurring defect stated in one line: a fixture whose values cannot +reach the failure it names.** + +`LINK_STALE_S` (R8) is the one number here with **no option behind it** — it is a +module constant in `inverter.py` and no test reassigns it — so "pinned across a +changed config" is not available for it, and is not claimed. What part 6 pins is +that *widening* the window changes the answer. Spelling `is_live(now, 30.0)` at +the call site would be an equivalent mutant today and would only drift if the +constant moved, which is a source change and not a config change. + +### Why R5 and R6 are not equivalent mutants — the inputs that separate them + +Both are separating inputs found by asking where the shipped answer and the +literal's answer can differ, and both are configs the shipped schema accepts. + +**R5, watts.** Two copies of inverter A, B wedged, `saturation_w: 1800` (inside +its own schema `int(100,2000)`), load 1600 W. B's standing share and its +shortfall then land strictly **between** `250.0` and the configured 1800 W: + +* head → `named = {}` — the operator raised the threshold and this check moved + with them, which is the whole promise; +* R5 → `named = {inverter-b}` — an alarm at a gap the control law has been told + is ordinary tracking. + +At the shipped `saturation_w: 500` **both** name it, which is why every fixture +in part 4 is blind: their gaps are all above both thresholds. + +**R6, percent.** The literal is invisible because `bms_soc_floor: 10` is what +`config.yaml`'s own `inverters:` example states and what every fixture in +`test_delivery.py` used. Two arms, on either side of it, both inside the schema +`int(0,50)` and both `validate.py` rule-8-valid: + +* a pack at **15 %** against its own **25 %** cut-out is genuinely cold. Head + stays silent; R6 grades it against 10 %, finds it "permitted", and **names an + empty pack** — the crying-wolf failure this card exists to remove. +* a pack at **8 %** against its own **5 %** cut-out is genuinely wedged. Head + names it; R6 grades it against 10 %, calls it a cold pack, and **loses the + fault**. + +One arm on each side is what leaves no constant at all: a single deeper arm +kills `10.0` but not `50.0`, a single shallower arm kills `10.0` but not `0.0`. +The two arms are byte-identical in the power domain on every cycle — the +configured cut-out is the only thing that differs, which is §3.1's claim made +again at cut-outs that are not the shipped one. + +### What is still not pinned this way, said rather than left implicit + +`parked=inv.sign_wd.tripped` and `standing[inv.name]`/`inv.batt` are wiring, not +thresholds: there is no "value the fixture happens to use" for a boolean or for a +measurement, so this question does not apply to them, and §8's last note already +records that the `parked` wiring is graded end-to-end rather than by mutation. diff --git a/goodwe_controller/app/delivery.py b/goodwe_controller/app/delivery.py index cc9820a..9ffad04 100644 --- a/goodwe_controller/app/delivery.py +++ b/goodwe_controller/app/delivery.py @@ -345,6 +345,16 @@ DELIVERY_OPTION_DEFAULTS = { # that a bigger gap is ordinary tracking, and this check moves with them by # construction rather than by somebody remembering. # +# ⚠️ AND THAT SENTENCE IS A CHECKED CLAIM, NOT A DESCRIPTION OF AN INTENTION. +# It is only true while the call site passes the CONFIGURED value; a literal +# there that happens to equal 500 today keeps every fixture green and quietly +# removes the whole guarantee - `250.0` in place of `self.tuning.saturation_w` +# was a survivor of all 23 suites, because every fixture's gap was far above +# both numbers. test_delivery.py part 4b is what makes it checkable: it runs +# the same wedged unit at the shipped `saturation_w` and at a raised one inside +# the shipped schema, with a shortfall that falls BETWEEN them, and the two +# arms must answer differently. No constant passes both. +# # ⚠️ IT IS ALSO WHY THIS CANNOT DOUBLE-REPORT THE SATURATION FREEZE'S OWN # EVENT. `compute()` needs `saturation_cycles` (3) CONSECUTIVE cycles over # that same gap before it freezes anything, so every cycle this module counts diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index b6e333f..efec4b4 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -1803,7 +1803,36 @@ class Controller: # cut-out and a wedged unit produce IDENTICAL traces at the unit and at # the meter, so nothing computed from one unit alone can tell them # apart. At a roster of one the reduction is empty for every input. + # + # ⚠️ EVERY NUMBER BELOW IS THE CONFIGURED ONE, NEVER A LITERAL THAT + # HAPPENS TO EQUAL IT TODAY, AND THAT IS A SHIPPED PROMISE RATHER THAN + # A STYLE RULE. `delivery.MIN_W_IS_SATURATION_W` and config.yaml's + # `delivery_hold_s` block both tell an operator, in prose, that there is + # no fourth copy of a threshold to keep in step and no way for the two + # to drift - an operator who raises `saturation_w` has told the loop a + # bigger gap is ordinary tracking, and this check moves with them BY + # CONSTRUCTION. A literal here breaks exactly that promise, silently. + # + # Both of these were once literals that no check in this repository + # could see, because every fixture sat at the shipped value: + # + # self.tuning.saturation_w -> 250.0 (-19 B) - a survivor of all + # 23 suites, because part 4's + # gaps are far above BOTH + # bms_floor=inv.bms_soc_floor -> 10.0 (-13 B) - a survivor of all + # 23 suites, because every + # fixture's cut-out WAS 10 % + # + # test_delivery.py part 4b is what stands between this call and both of + # them: it grades this reduction at a `saturation_w` and at two + # `bms_soc_floor`s that are NOT the shipped ones, and its arms are + # answered opposite ways at two configured values, so no constant can + # pass. Change an argument here to a number and read that part before + # you decide the red is wrong. for inv in self.inverters: + # ⚠️ `self.delivery_cfg`, NOT `DeliveryConfig()`. Same promise: the + # window is `delivery_hold_s`, and part 4b's neighbours already + # grade this one at a hold that is not the shipped 185 s. inv.delivery_wd.arm(self.delivery_cfg) _delinquent = delinquent( [UnitDelivery( @@ -1815,8 +1844,17 @@ class Controller: # "the allocator has already dropped this unit and said so", # and a different window here would be this check answering # a question about a roster the allocator did not build. + # It is the ONE number here with no option behind it, so + # "graded at a changed config" is not available for it; + # what part 6 grades instead is that WIDENING the window + # changes the answer. live=inv.is_live(now)[0], parked=inv.sign_wd.tripped, + # ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not + # the site's `peak_soc_floor` either - that one bounds a + # money-saving discharge only, and grading a pack against it + # explains a wedged unit away in a band an ordinary install + # has ten points of. soc=inv.soc, bms_floor=inv.bms_soc_floor)) for inv in self.inverters], self.tuning.saturation_w) diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index a21559d..f2b9333 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -522,7 +522,11 @@ options: # half of this check is `saturation_w`, the option two blocks down, read # directly: it is already the gap at which the control law itself concludes # the inverter is not following, and a second key would be a fourth place a - # threshold has to be kept in step and a way for the two to disagree. + # threshold has to be kept in step and a way for the two to disagree. So + # RAISING `saturation_w` raises this check with it, and test_delivery.py part + # 4b holds that promise by running the same fault at two different + # `saturation_w` values and requiring two different answers - a check that + # only ever ran at 500 could not tell this wiring from a hard-coded number. # # How long ONE inverter may stand at a discharge share it is not producing - # short by more than `saturation_w` - while at least one sibling is NOT in diff --git a/goodwe_controller/test_delivery.py b/goodwe_controller/test_delivery.py index 496e424..9c91805 100644 --- a/goodwe_controller/test_delivery.py +++ b/goodwe_controller/test_delivery.py @@ -10,6 +10,14 @@ target, per-unit share, per-unit measured power), to the last emitted watt. If this file's two fixtures ever stop differing, the check has stopped being graded on the only thing that separates the two cases. +⚠️ AND PART 4b IS THE OTHER HALF OF THE SAME LESSON, ONE LEVEL UP. Part 4 shows +that no threshold on the power domain can separate the two cases; part 4b shows +that a check which only ever runs at the SHIPPED value of a threshold cannot +separate "this call site reads the operator's config" from "this call site has a +number typed into it". Both are the same defect - a fixture whose values cannot +reach the failure it names - and the second one hid two literals from all 23 +suites in this repository. + ⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run `tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it stops bytecode being WRITTEN, not a stale one being READ. That is TEST-15, and @@ -349,14 +357,24 @@ class _DeliveryHass: return True -def _roster(): +def _roster(floors=(_FLOOR_PCT, _FLOOR_PCT)): + """The two-unit roster. `floors` is (A's own cut-out, B's own cut-out). + + ⚠️ IT IS AN ARGUMENT RATHER THAN `_FLOOR_PCT` TWICE, AND THAT IS HALF OF + PART 4b. Every fixture in this file used to state 10 %, which is also the + number config.yaml's own `inverters:` example states, so `bms_floor=10.0` + written at main.py's call site in place of `inv.bms_soc_floor` answered + identically in every check here and in the other 22 files. A default that + happens to agree with the fixture is not a pin. + """ return [{"name": n, "setpoint_entity": f"number.sp_{t}", "soc_entity": f"sensor.soc_{t}", "batt_entity": f"sensor.batt_{t}", - "max_w": 2000, "bms_soc_floor": _FLOOR_PCT} - for n, t in (("inverter-a", "a"), ("inverter-b", "b"))] + "max_w": 2000, "bms_soc_floor": f} + for (n, t), f in zip((("inverter-a", "a"), ("inverter-b", "b")), + floors)] -def _ctrl(hass, store=None, **over): +def _ctrl(hass, store=None, *, floors=(_FLOOR_PCT, _FLOOR_PCT), **over): """A two-inverter Controller, with the NEIGHBOURING guards given long windows. ⚠️ THE LONG `circulation_hold_s` AND SIGN HOLDS ARE ISOLATION, NOT A CLAIM @@ -367,7 +385,7 @@ def _ctrl(hass, store=None, **over): below would be measuring the wrong thing. """ opts = {"meter_entity": "sensor.grid", "meter_source": "off", - "inverters": _roster(), "gain": 0.6, "slew_w": 1000, + "inverters": _roster(floors), "gain": 0.6, "slew_w": 1000, # ⚠️ 1 s, NOT THE SHIPPED 95. This window is WALL CLOCK - the # detector takes the caller's `now`, and `_drive()` runs fourteen # real cycles 0.2 s apart, so a shipped-length hold would make every @@ -521,6 +539,193 @@ check("a wedged unit BETWEEN its own cut-out and the site's peak_soc_floor is " and float(_cbf.o.get("peak_soc_floor")) == 20.0) +# =========================================================================== +print() +print("=" * 72) +print("4b. THE NUMBERS THIS CHECK READS ARE THE CONFIGURED ONES, AND THAT IS") +print(" GRADED AT A CONFIG THAT IS NOT THE SHIPPED ONE") +print("=" * 72) +# ⚠️ WHY THIS PART EXISTS, AND IT IS NOT A MUTATION SCORE. Two of the values +# main.py hands this check are OPTIONS, and the paragraph above +# `delivery.MIN_W_IS_SATURATION_W` and the `delivery_hold_s` block in +# config.yaml both make a PROMISE about them in shipped, operator-readable +# prose: there is no fourth copy of a default to keep in step, and no way for +# the two to drift, because an operator who raises `saturation_w` has told the +# loop that a bigger gap is ordinary tracking and this check moves with them BY +# CONSTRUCTION. A literal written at the call site breaks exactly that promise, +# silently, and the promise is why the design was chosen over a `delivery_w` +# key. +# +# ⚠️ AND EVERY FIXTURE ABOVE IS BLIND TO IT, WHICH IS THIS REPOSITORY'S +# RECURRING DEFECT (MUTATION.md's third failure mode): a fixture whose values +# cannot reach the failure it names. Part 4's gaps are all far above BOTH the +# shipped `saturation_w` and any smaller literal, and every `bms_soc_floor` in +# this file was 10 - the number config.yaml's own example states. So +# +# self.tuning.saturation_w -> 250.0 (-19 B) +# bms_floor=inv.bms_soc_floor -> 10.0 (-13 B) +# +# were BOTH survivors of the whole 23-file suite, not of this file alone. The +# two blocks below are what kills them, and they kill not just those two +# literals but EVERY literal: each pair of arms is answered one way at one +# configured value and the other way at another, on the same fixture, so no +# single constant can satisfy both rows. + +print() +print(" (a) THE WATTS - `saturation_w`, raised to a value inside its own") +print(" schema. Same load, same wedged unit, two configured thresholds.") + +# 1800 is inside the shipped schema `int(100,2000)` and is not the shipped +# default - both asserted below rather than asserted here in a comment. The +# load is what puts B's shortfall BETWEEN the two: above the shipped threshold, +# below the raised one. The gap itself is printed by the run that produces it +# and is never written down here; what is asserted is the ORDERING. +_SAT_RAISED = 1800 +_SAT_LOAD_W = 1600.0 +_SAT_SHIPPED = int(read_option("saturation_w")) +_sat_lo, _sat_hi = (int(x) for x in re.match( + r"int\((\d+),(\d+)\)", read_option("saturation_w", "schema")).groups()) + + +async def _at_saturation_w(sat): + hass = _DeliveryHass(load_w=_SAT_LOAD_W) + c = _ctrl(hass, saturation_w=sat) + return c, await _drive(c, watch=_sample) + + +_c_ship, _r_ship = asyncio.run(_at_saturation_w(_SAT_SHIPPED)) +_c_up, _r_up = asyncio.run(_at_saturation_w(_SAT_RAISED)) +_b_ship, _b_up = _c_ship.inverters[1], _c_up.inverters[1] +_gap_ship = short_by(_b_ship.standing_w(), _b_ship.batt) +_gap_up = short_by(_b_up.standing_w(), _b_up.batt) + +check(f"the raised threshold is a config an operator can actually enter: " + f"{_SAT_RAISED} W is inside the shipped schema " + f"{read_option('saturation_w', 'schema')}, and it is NOT the shipped " + f"default ({_SAT_SHIPPED} W) - both read out of config.yaml, not typed " + f"here", + _sat_lo <= _SAT_RAISED <= _sat_hi and _SAT_RAISED != _SAT_SHIPPED) +check("...and the controller really is running on it - `Tuning.saturation_w` " + "carries the option through, so this is a changed CONFIG and not a " + "changed test", + _c_up.tuning.saturation_w == float(_SAT_RAISED) + and _c_ship.tuning.saturation_w == float(_SAT_SHIPPED)) + +# ⚠️ THE VACUOUS-PASS GUARD AGAIN, and here it is the whole load. "the raised +# arm named nobody" reads identically against a fixture where B was never +# asked, was not short, or was explained away. So the raised arm is asserted to +# have reached the threshold comparison and to have been turned away by NOTHING +# BUT the number. +check("the raised arm REACHED the comparison: B was asked for a discharge " + "share, is short of it, is live, is not parked, and NOTHING accounts for " + "it - the only thing standing between this unit and an alarm is the " + "configured watts", + float(_b_up.standing_w()) > 0 and _gap_up is not None + and not _b_up.sign_wd.tripped + and account(live=True, parked=False, soc=_b_up.soc, + bms_floor=_b_up.bms_soc_floor) is None) +check("...and its shortfall lands STRICTLY BETWEEN the two configured " + "thresholds, which is the one place the two answers can differ. Part 4's " + "fixtures are all above both, which is exactly why they cannot see this", + _gap_up is not None and float(_SAT_SHIPPED) < _gap_up < float(_SAT_RAISED)) + +check("AT THE SHIPPED THRESHOLD THE UNIT IS NAMED", + _r_ship[-1]["named"] == ("inverter-b",)) +check("AT THE RAISED THRESHOLD, ON THE SAME FIXTURE, IT IS NOT - the operator " + "has told the loop that a gap this size is ordinary tracking, and this " + "check moved with them. THIS PAIR IS THE PIN: a literal at the call site " + "is one number, and no one number answers both of these rows", + _r_up[-1]["named"] == ()) +print(f" shipped saturation_w {_SAT_SHIPPED} W: B short by " + f"{_gap_ship:.0f} W -> named {_r_ship[-1]['named']}") +print(f" raised saturation_w {_SAT_RAISED} W: B short by " + f"{_gap_up:.0f} W -> named {_r_up[-1]['named']}") + +# And the same two answers out of the pure function, on the raised arm's OWN +# two watt figures - so the flip is a property of `delinquent()`'s threshold +# and not of anything the controller did differently between the two runs. +_pair = [U("inverter-a", float(_c_up.inverters[0].standing_w()), + _c_up.inverters[0].batt), + U("inverter-b", float(_b_up.standing_w()), _b_up.batt)] +check("...and `delinquent()` says the same thing about that one pair of watt " + "figures: named at the shipped number, silent at the raised one. The " + "threshold is the only input that moved", + list(delinquent(_pair, _SAT_SHIPPED)) == ["inverter-b"] + and delinquent(_pair, _SAT_RAISED) == {}) + +print() +print(" (b) THE PERCENT - each unit's OWN `bms_soc_floor`, at two cut-outs") +print(" that BRACKET the shipped example from both sides.") + +# ⚠️ WHY TWO ARMS AND NOT ONE. A single arm at a deeper cut-out kills `10.0` +# but not `50.0`; a single arm at a shallower one kills `10.0` but not `0.0`. +# One arm on each side of the shipped example leaves no constant at all: the +# deep arm must stay SILENT (its pack is genuinely below its own cut-out) and +# the shallow arm must SPEAK (its pack is genuinely above its own cut-out), and +# a literal that satisfies the first cannot satisfy the second. +_FLOOR_DEEP, _DEEP_SOC = 25, 15.0 # pack BELOW its own cut-out: cold +_FLOOR_SHALLOW, _SHAL_SOC = 5, 8.0 # pack ABOVE its own cut-out: wedged +_floor_lo, _floor_hi = (int(x) for x in re.match( + r"int\((\d+),(\d+)\)", read_option("bms_soc_floor", "schema")).groups()) + + +async def _at_floor(b_floor, b_soc, peak): + hass = _DeliveryHass(b_soc=b_soc) + # ⚠️ `peak_soc_floor` IS RAISED WITH THE DEEP ARM SO THE FIXTURE STAYS A + # CONFIG THE ADD-ON WOULD START ON: validate.py rule 8 refuses a + # `peak_soc_floor` at or below ANY unit's `bms_soc_floor`. A fixture that + # only a refused config can produce would grade a state no install reaches. + c = _ctrl(hass, floors=(_FLOOR_PCT, b_floor), peak_soc_floor=peak) + return c, await _drive(c, watch=_sample) + + +_c_deep, _r_deep = asyncio.run(_at_floor(_FLOOR_DEEP, _DEEP_SOC, 30)) +_c_shal, _r_shal = asyncio.run(_at_floor(_FLOOR_SHALLOW, _SHAL_SOC, 20)) +_bd, _bs = _c_deep.inverters[1], _c_shal.inverters[1] + +check(f"both cut-outs are configs an operator can enter - inside the shipped " + f"schema {read_option('bms_soc_floor', 'schema')} - and NEITHER is the " + f"{read_option('bms_soc_floor')} % this file and config.yaml's own " + f"example both state, which is the number a literal would have agreed " + f"with everywhere", + _floor_lo <= _FLOOR_SHALLOW < int(read_option("bms_soc_floor")) + < _FLOOR_DEEP <= _floor_hi) +check("...and each arm is a config validate.py rule 8 accepts: its " + "peak_soc_floor is above EVERY unit's own cut-out", + all(float(c.o.get("peak_soc_floor")) > float(i.bms_soc_floor) + for c in (_c_deep, _c_shal) for i in c.inverters)) +check("...and the two arms are IDENTICAL IN THE POWER DOMAIN on every cycle - " + "grid, site target, every share, every measured watt. The configured " + "cut-out is the only thing that differs, which is part 4's claim made " + "again at two cut-outs that are not the shipped one", + _power_domain(_r_deep) == _power_domain(_r_shal)) + +check(f"a pack at {_DEEP_SOC:.0f} % against its OWN {_FLOOR_DEEP} % cut-out is " + f"cold, and is NOT named - even though it is far above the " + f"{read_option('bms_soc_floor')} % a literal would have graded it " + f"against, which would have called an empty pack a fault", + _r_deep[-1]["named"] == () and float(_bd.bms_soc_floor) == _FLOOR_DEEP + and _bd.soc == _DEEP_SOC) +check("...and it REACHED that decision: it was asked for a discharge share, " + "was short of it by more than saturation_w, and was turned away by " + "`account()` alone", + float(_bd.standing_w()) > 0 + and short_by(_bd.standing_w(), _bd.batt) > _c_deep.tuning.saturation_w + and account(live=True, parked=False, soc=_bd.soc, + bms_floor=_bd.bms_soc_floor) is not None) +check(f"a pack at {_SHAL_SOC:.0f} % against its OWN {_FLOOR_SHALLOW} % cut-out " + f"is WEDGED, and IS named - even though it is below the " + f"{read_option('bms_soc_floor')} % a literal would have explained it " + f"away with, which would have lost the fault this card exists to find. " + f"THIS ROW AND THE ONE ABOVE ARE THE PIN: no constant answers both", + _r_shal[-1]["named"] == ("inverter-b",) + and float(_bs.bms_soc_floor) == _FLOOR_SHALLOW and _bs.soc == _SHAL_SOC) +print(f" deep cut-out {_FLOOR_DEEP} %, SoC {_DEEP_SOC:.0f} % -> named " + f"{_r_deep[-1]['named']}") +print(f" shallow cut-out {_FLOOR_SHALLOW} %, SoC {_SHAL_SOC:.0f} % -> " + f"named {_r_shal[-1]['named']}") + + # =========================================================================== print() print("=" * 72)