diff --git a/goodwe_controller/test_arbitrage.py b/goodwe_controller/test_arbitrage.py index 7dd1f9d..a6d99c7 100644 --- a/goodwe_controller/test_arbitrage.py +++ b/goodwe_controller/test_arbitrage.py @@ -378,7 +378,11 @@ check("BUYING in a negative-price hour is still permitted - §17 forbids " "refusing to be paid to charge", act(NEG_PLAN, NEG.index(min(NEG)), thresh=_shipped, unc=NO_WEATHER).action == CHARGE) -# A zero price is the boundary and it is NOT negative. +# A zero price is the boundary and it is NOT negative. ⚠️ KEEP THIS PROBE. It +# pins the boundary from ABOVE - it is what catches a guard written `<= 0` - and +# the SAFETY-26 block below pins it from BELOW. Neither replaces the other, and +# the whole defect that card exists to close is that only one of the two sides +# was ever stated. ZERO = list(POS) ZERO[DEAR_H] = 0.0 check("a sell hour priced at exactly ZERO is not a negative one, so the guard " @@ -387,6 +391,119 @@ check("a sell hour priced at exactly ZERO is not a negative one, so the guard " act(plan_of(ZERO), DEAR_H, thresh=_shipped, unc=NO_WEATHER).action == DISCHARGE) +# --------------------------------------------------------------------------- # +# SAFETY-26. THE OTHER SIDE OF THE SAME BOUNDARY, AND WHY IT HAD TO BE ADDED. +# +# OPT-04's reviewer mutated `if price < 0` to `if price < -0.001` and EVERY +# SUITE STAYED GREEN, with a -0.0005 EUR/kWh sell hour discharging. The shipped +# code was right the whole time; the CHECKS could not tell correct from +# nearly-correct, because the only probe near the boundary sat at 0.0000. A +# probe above the line catches `<=`. It says nothing at all about a tolerance +# invented below the line, and a tolerance below the line IS §17's regression +# case walking through with the suite passing. +# +# ⚠️ AND -0.0005 IS NOT AN EXOTIC INPUT. Belgian day-ahead prints small negative +# hours regularly; an all-in price a twentieth of a cent below zero is an +# ordinary day, not a fuzz value. That is what makes this a live regression +# rather than a theoretical one. +# +# ⚠️ SO THE PROBE IS A SWEEP, NOT A POINT, and the sweep is the discriminating +# part. One probe at -0.0005 closes `-0.001` and nothing else; a mutation +# reaching for `-0.01` would survive it, which is the identical defect one +# decimal place further out. The sweep spans four orders of magnitude down to +# the last place the canonical six-decimal format can even express, so there is +# no tolerance a guard can invent that some member of it does not straddle. +# `_ROUNDING` asserts the smallest member SURVIVES the format rather than being +# rounded to zero on the way in - a probe that silently became 0.0 would be the +# `0.0000` check again, wearing a different name. +TINY_NEG = (-0.05, -0.005, -0.0005, -0.00005, -0.000001) +_ROUNDING = [round(p, 6) for p in TINY_NEG] +check(f"every probe price survives the canonical 6-decimal rounding as a " + f"STRICTLY negative number {_ROUNDING} - one that rounded to 0.0 would " + f"be the check above again under another name", + all(r < 0 for r in _ROUNDING) and len(set(_ROUNDING)) == len(TINY_NEG)) +check("...and they span four orders of magnitude, so no single invented " + "tolerance can sit below all of them and above none", + abs(min(TINY_NEG)) / abs(max(TINY_NEG)) > 10000) + + +def tiny_neg_plan(price): + """The POS day with its DEAREST hour set to a small negative print.""" + day = list(POS) + day[DEAR_H] = price + return plan_of(day), day + + +# The stimulus has to survive the rungs ABOVE §17 or it grades nothing: the +# hour must still be the dearest, and the spread must still clear the widened +# threshold, at every probe. Asserted first, for all of them, as its own check. +_wellformed = [ + (p, day.index(max(day)) == DEAR_H, + round(max(day) - min(day), 6) > required_spread(_shipped, NO_WEATHER)) + for p, (_pl, day) in ((p, tiny_neg_plan(p)) for p in TINY_NEG)] +check(f"at every probe the negative hour is still the DEAREST one and the " + f"spread still clears the threshold, so §17 is the only rung that can " + f"refuse {_wellformed}", + all(dearest and clears for _p, dearest, clears in _wellformed)) +_swept = [(p, act(tiny_neg_plan(p)[0], DEAR_H, thresh=_shipped, + unc=NO_WEATHER).action) for p in TINY_NEG] +check(f"SAFETY-26: a sell hour that is negative BY ANY MARGIN AT ALL refuses - " + f"down to -0.000001 EUR/kWh, so the guard is `< 0` and not `< -epsilon` " + f"for any epsilon {_swept}", + all(a is None for _p, a in _swept)) +check("...and each refusal is §17's, not some other rung that happens to fire " + "at small numbers", + all("§17" in act(tiny_neg_plan(p)[0], DEAR_H, thresh=_shipped, + unc=NO_WEATHER).reason for p in TINY_NEG)) +# The mirror image, one place above zero. Without this the sweep above would +# pass for a guard that refused EVERYTHING, which grades §17 by nothing. +_tiny_pos = [(-p, act(tiny_neg_plan(-p)[0], DEAR_H, thresh=_shipped, + unc=NO_WEATHER).action) for p in TINY_NEG] +check(f"...while THE SAME MAGNITUDES with the sign flipped all DISCHARGE, so " + f"the sweep is a boundary at exactly zero rather than a refusal of small " + f"numbers - which a guard that refused everything would also have passed " + f"{_tiny_pos}", + all(a == DISCHARGE for _p, a in _tiny_pos)) + +# SAFETY-26 AC4 - THE LOOK AT THE OTHER BOUNDARIES, NAMED RATHER THAN FIXED. +# +# The card asks whether the same one-sided pinning exists elsewhere. Every +# price/quantity boundary in arbitrage.py and static_tariff.py was given the +# tolerance mutation that survived on `price < 0`, in both directions where +# both exist, under `-B` with a recursive purge. Two survived, BOTH in +# static_tariff.py - TARIFF-03's file, and deliberately NOT touched here: +# +# D `spread == 0.0` (the flat-tariff rung) -> `spread <= 0.001` SURVIVES. +# Measured, not reasoned: with it loosened, a series priced 0.20000 / +# 0.20050 - a REAL 0.0005 spread - flips from ALLOWED to REFUSED at +# min_spread_eur_kwh=0.0, which `arbitrage()`'s own docstring documents as +# a supported value ("pass 0.0 to mean any positive spread, explicitly"). +# At a shipped threshold the OUTCOME is unchanged, but the operator's +# reason becomes the false sentence "the tariff is flat - every one of the +# 2 firm hours costs 0.2000 EUR/kWh" about a tariff that is not flat. +# Same shape as §17's: the rung is only ever probed with an EXACTLY flat +# series, so nothing pins the tiny-but-real side. +# +# F `len(firm) < 2` (§8.3's firm-pair rung) -> `< 1` SURVIVES, at +0 BYTES, +# which is the delta TEST-15 names as the risky one. It is not live on its +# own: a single firm hour has a spread of 0.0, so the flat rung refuses it +# anyway - but the reason degrades from §8.3's honest "only 1 of 3 hours +# carry a published price" to "the tariff is flat". THE FINDING THAT +# MATTERS IS THE INTERACTION: D and F MASK EACH OTHER. Neither is +# individually live because the other catches it, which is exactly the +# arrangement in which a later edit to either one opens a hole nothing +# reports. +# +# Everything else in arbitrage.py is pinned on BOTH sides and was verified so: +# `price < 0` (this card), `soc <= floor_pct` and `soc >= ceiling_pct` are each +# red under a +5 and a -5 tolerance; static_tariff's `spread < need` is red +# under +/-0.001 and additionally bisected to 1e-6 in section 1; `cheap_now`'s +# `price < mean` is red under a +0.001 tolerance in test_static_tariff.py. +# +# Not fixed here on purpose. D and F are TARIFF-03's rungs, they need probes in +# test_static_tariff.py rather than in this file, and the card is explicit that +# its fourth criterion is a look and not a refactor. + # --------------------------------------------------------------------------- # print()