MAINT-21: tests read and patch the Controller through tools/main_files.py

Prepares the split of app/main.py's Controller into files by job, with no app
change. tools/main_files.py gives a test the text and the AST of main.py plus
the files split from it, and `live`, which sets a global in every file whose
functions read it (and raises when none does). Tests that read main.py's
source, and tests and replay tools that patch a main global (datetime,
compute, resolve, _mono, WRITE_BUDGET_S, ...), go through it, so they pass on
this layout and on the split one. test_split_patches.py fails when a test or
tool patches an app.main attribute that main.py no longer reads, or that
another file also reads. tools/maint21_same_ast.py proves every function,
method and module-level assignment of main.py at the base is found unchanged
in its new file.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-09 00:27:07 +01:00
co-authored by Claude
parent 56fc41fffa
commit 0d49027daa
50 changed files with 707 additions and 179 deletions
+6 -7
View File
@@ -109,6 +109,8 @@ from app.validate import validate_startup_config
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
import main_files # noqa: E402
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
# point these fixtures build, so no period has ended and the whole series counts.
@@ -779,9 +781,7 @@ check("...and cycle() calls it rather than inlining the conjunction, so a "
"mutation back to a bare `engaged_now` is red rather than silent",
"arbitrage.guard_state(self.peak_guard)" in
ast.get_source_segment(
open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read(),
_fn(open(os.path.join(HERE, "app", "main.py"),
encoding="utf-8").read(), "cycle")))
main_files.source(), _fn(main_files.source(), "cycle")))
# --------------------------------------------------------------------------- #
@@ -907,8 +907,8 @@ check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export "
# above calls decide() itself - and a required keyword-only parameter that no
# caller fills is a TypeError only on the one path a suite does not run. Same
# pin, same reason, as `weights=` in test_packshare.py part 10.
_S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read()
_S45_MAIN_TREE = ast.parse(_S45_MAIN)
_S45_MAIN = main_files.source()
_S45_MAIN_TREE = main_files.tree(_S45_MAIN)
_s45_calls = [n for n in ast.walk(_S45_MAIN_TREE)
if isinstance(n, ast.Call)
and ast.unparse(n.func).endswith("arbitrage.decide")]
@@ -2215,8 +2215,7 @@ print()
print("8. the background refresh - the tick must never await a fetch")
_MAIN_SRC = derived(lambda: open(os.path.join(HERE, "app", "main.py"),
encoding="utf-8").read())
_MAIN_SRC = derived(main_files.source)
_cycle = derived(lambda: _fn(_MAIN_SRC, "cycle"))
_refresh = derived(lambda: _fn(_MAIN_SRC, "run_tariff_refresh"))
_read_inputs = derived(lambda: _fn(_MAIN_SRC, "read_inputs"))
+6 -5
View File
@@ -1969,9 +1969,11 @@ print("9. SAFETY-40: the integrator bound is not a BUG SIGNAL, and the numbers "
# The fixtures are chosen so the numbers the old text used DIFFER from the true
# ones (MUTATION.md habit 4); the PRECONDITION checks pin that.
import logging
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
import app.main as _main_mod
_real_compute = _main_mod.compute
_real_compute = main_files.live.compute
class _LogCap(logging.Handler):
@@ -1995,7 +1997,7 @@ def s40_run(grid, cycles, track, **over):
cap = _LogCap()
lg = logging.getLogger("goodwe")
lg.addHandler(cap)
_main_mod.compute = spy
main_files.live.compute = spy
async def go():
hass = _Hass(grid, track=track)
@@ -2024,7 +2026,7 @@ def s40_run(grid, cycles, track, **over):
try:
hass, c, red = asyncio.run(go())
finally:
_main_mod.compute = _real_compute
main_files.live.compute = _real_compute
lg.removeHandler(cap)
return ticks, hass, c, red, cap.records
@@ -2104,8 +2106,7 @@ check("...and its ERROR says it is a software fault and that control keeps "
any(x.levelno == logging.ERROR and "software fault" in x.getMessage()
and "control runs on" in x.getMessage() for x in log_o)
and "reached 14 768 W on this project" in
open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
"main.py"), encoding="utf-8").read())
main_files.source())
check("...under the over-rating title, quoting a tick's own asked and sent "
"numbers in that order, with a red status row",
clamp_notes(hass_o)
+7 -1
View File
@@ -149,9 +149,15 @@ print("2. the mirrors, DISCOVERED from the tree rather than listed here")
# `app/` for module-level assignments whose name ends in OPTION_DEFAULTS and
# grades whatever it finds, so the seventh is covered on the commit that
# introduces it. That is the property the six hand-written pins cannot have.
MODULES = sorted(p.name for p in APP.glob("*.py"))
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
# MAINT-21: app/main.py stands for itself and the files split from it.
MODULES = sorted(p.name for p in APP.glob("*.py")
if p.name == "main.py" or p.name not in main_files.files())
TREES = {name: ast.parse(io.open(APP / name, encoding="utf-8").read(), name)
for name in MODULES}
TREES["main.py"] = main_files.tree()
MIRROR = {} # schema key -> (module, table name, value)
TABLES = []
+6 -3
View File
@@ -1272,6 +1272,9 @@ check("the operator's START press is the only way out, and it clears the "
print()
print("8b. SAFETY-34: a Core-only restart - same add-on process, HA's memory wiped")
import os # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
import app.main as _main_mod # noqa: E402
@@ -1283,8 +1286,8 @@ async def _core_restart():
_c = asyncio.run(_core_restart())
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
_main_mod._mono = lambda: _clock[0]
_real_mono, _clock = main_files.live._mono, [main_files.live._mono()]
main_files.live._mono = lambda: _clock[0]
try:
_c.hass = _DeliveryHass(b_soc=50.0)
asyncio.run(_c.check_not_delivering())
@@ -1295,7 +1298,7 @@ try:
_late = [x for x in _c.hass.calls
if "not_delivering" in str(x[2].get("notification_id", ""))]
finally:
_main_mod._mono = _real_mono
main_files.live._mono = _real_mono
check("SAFETY-34: the latch tripped in this process",
_c.inverters[1].delivery_wd.tripped)
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise",
+18 -13
View File
@@ -75,6 +75,9 @@ from pathlib import Path
from named_absence import first, given, nth, present
import os # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
from app import main as app_main
from app.inverter import LINK_STALE_S, UNIT_A, Inverter, build_roster
from app.main import WRITE_BUDGET_S, Controller
@@ -88,8 +91,10 @@ from app.arbiter import P_LOOP, P_SAFETY, Claim, resolve
fails = []
HERE = Path(__file__).resolve().parent
MAIN_SRC = io.open(HERE / "app" / "main.py", encoding="utf-8").read()
MAIN_AST = ast.parse(MAIN_SRC)
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
MAIN_SRC = main_files.source()
MAIN_AST = main_files.tree(MAIN_SRC)
CTRL = [n for n in MAIN_AST.body
if isinstance(n, ast.ClassDef) and n.name == "Controller"][0]
@@ -1097,8 +1102,8 @@ check("...and it is below hass.py's own 10 s HTTP timeout, so the budget is "
# is healthy. The budget is shortened for the run - the shipped value is graded
# above - because a 4 s wait per case is 8 s of suite time to measure something
# that is already a ratio.
_REAL_BUDGET = app_main.WRITE_BUDGET_S
app_main.WRITE_BUDGET_S = 0.30
_REAL_BUDGET = main_files.live.WRITE_BUDGET_S
main_files.live.WRITE_BUDGET_S = 0.30
async def _hung_heartbeat():
@@ -1124,18 +1129,18 @@ async def _hung_heartbeat():
_c6, _h6, _elapsed, _t0 = run(_hung_heartbeat())
_b_at = [t - _t0 for e, _v, t in _h6.write_at if e == "number.sp_b"]
print(f" A hangs forever, B is healthy: the pair took {_elapsed:.2f} s "
f"(budget {app_main.WRITE_BUDGET_S:.2f} s); B's write reached the wire "
f"(budget {main_files.live.WRITE_BUDGET_S:.2f} s); B's write reached the wire "
f"{(_b_at[0] if _b_at else float('nan')):.3f} s in; wire saw "
f"{_h6.writes}")
check("⚠️ AC 6. B'S WRITE REACHED THE WIRE WHILE A WAS STILL HANGING. That is "
"the whole criterion: a hung write to A cannot consume B's layer-1 "
"budget, because B's write was never behind A's in a queue",
_h6.writes == [("number.sp_b", 700.0)]
and bool(_b_at) and _b_at[0] < app_main.WRITE_BUDGET_S / 2.0)
and bool(_b_at) and _b_at[0] < main_files.live.WRITE_BUDGET_S / 2.0)
check("...and the pair returned in about ONE budget rather than in two, which "
"is what says the writes were concurrent and not merely bounded. Two "
"bounded writes in series is still a sibling waiting for a corpse",
_elapsed < app_main.WRITE_BUDGET_S * 1.9)
_elapsed < main_files.live.WRITE_BUDGET_S * 1.9)
check("...and the hung inverter is recorded as having FAILED its write, so "
"`last_write` is not advanced and the next tick re-issues it by itself",
_c6.inverters[0].last_write is None
@@ -1176,7 +1181,7 @@ check("⚠️ AND THE DEFECT IS REPRODUCED BESIDE THE FIX. Written in series, B'
"every inverter behind the hung one, and with the unbounded write this "
"card replaces it was the HTTP stack's timeout - which is what put a "
"healthy sibling within reach of layer 1's veto",
_ser_b_at >= app_main.WRITE_BUDGET_S)
_ser_b_at >= main_files.live.WRITE_BUDGET_S)
# AND THE DEADLINE ITSELF IS PER UNIT - graded through the shipped method, for
# the reason the experiment above is: a `due` list computed in this file is a
@@ -1219,7 +1224,7 @@ check("...and the heartbeat re-emits each unit's OWN share rather than the "
# heartbeat is not the only place a hung inverter can hold the loop: every cycle
# writes every unit's share, and in series that is the same defect at the cycle
# rate rather than at the heartbeat rate.
app_main.WRITE_BUDGET_S = 0.30
main_files.live.WRITE_BUDGET_S = 0.30
async def _hung_cycle():
@@ -1248,17 +1253,17 @@ check("⚠️ AND A REAL CYCLE DELIVERS B'S SHARE WHILE A HANGS. This is the sam
"`cycle()` go out together, so a hung inverter costs the loop one budget "
"and costs its siblings nothing",
_h6c.writes and _h6c.writes[0][0] == "number.sp_b"
and bool(_cyc_b) and _cyc_b[0] < app_main.WRITE_BUDGET_S / 2.0
and _cyc_elapsed < app_main.WRITE_BUDGET_S * 1.9)
and bool(_cyc_b) and _cyc_b[0] < main_files.live.WRITE_BUDGET_S / 2.0
and _cyc_elapsed < main_files.live.WRITE_BUDGET_S * 1.9)
check("...and the share B was told is the allocator's, not the site target - "
"the hung sibling does not turn the cycle into a broadcast",
_h6c.writes[0][1] == float(_c6c.allocation.shares["inverter-b"])
and _h6c.writes[0][1] != _c6c.target)
app_main.WRITE_BUDGET_S = _REAL_BUDGET
main_files.live.WRITE_BUDGET_S = _REAL_BUDGET
check("...and the shipped budget is restored after the experiments, so nothing "
"below runs against a 0.3 s one",
app_main.WRITE_BUDGET_S == WRITE_BUDGET_S)
main_files.live.WRITE_BUDGET_S == WRITE_BUDGET_S)
# ⚠️ AND THE OPERATOR'S STOP HAS TO MOVE THE SAME TWO THINGS. This is a hazard
+2 -1
View File
@@ -22,6 +22,7 @@ from pathlib import Path
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools"))
import main_files # noqa: E402
import app.main as M # noqa: E402
from app import maintenance as MT # noqa: E402
from app.maintenance import ( # noqa: E402
@@ -367,7 +368,7 @@ def amain_zone(zone_body, env_tz):
M.load_options = lambda: {"setpoint_entity": "number.sp"}
M.HomeAssistant = lambda session, **kw: _Hass()
M.validate_startup_config = lambda *a, **k: []
main_files.live.validate_startup_config = lambda *a, **k: []
M.mode_announcement = lambda opts, store: ((), ())
M.Controller = _controller
M.aiohttp.ClientSession = lambda *a, **k: _Session(zone_body)
+5 -3
View File
@@ -520,9 +520,11 @@ check("a fixed tariff set up counts as a gate: outside its cheap hours a pack "
"that is not charging is closed, inside them it is open",
c._price_gated is True
and c._charge_gate_open(c.inverters[0]) is c.cheap)
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
import app.main as main_mod # noqa: E402
_real_gated, _calls = main_mod.static_gated, []
main_mod.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now)
_real_gated, _calls = main_files.live.static_gated, []
main_files.live.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now)
try:
c._gated_key = None
read(c)
@@ -530,7 +532,7 @@ try:
read(c)
c._static_gated(c.o, datetime.now(UTC) + timedelta(hours=1))
finally:
main_mod.static_gated = _real_gated
main_files.live.static_gated = _real_gated
check("...worked out once an hour, not on every tick (3 reads in one hour = 1 "
f"build; a new hour = 1 more): {len(_calls)} builds",
len(_calls) == 2 and c._price_gated is True)
+30 -9
View File
@@ -241,6 +241,27 @@ check("the output-clamp alarm quotes asked before sent",
# ============================================================================== #
print("\n2. no notification is worded outside app/messages.py")
APP = HERE / "app"
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
class _AppFile:
"""An app/*.py file. `main.py` stands for itself and the files split from
it (MAINT-21), so every scan below still sees the Controller whole."""
def __init__(self, path):
self.name, self.stem, self._path = path.name, path.stem, path
def read_text(self, encoding="utf-8"):
if self.name == "main.py":
return main_files.source()
return self._path.read_text(encoding=encoding)
def _app_py():
split = set(main_files.files()) - {"main.py"}
return [_AppFile(f) for f in sorted(APP.glob("*.py"))
if f.name not in split]
# UX-08 2d: a refusal is counted as it is SHOWN. A placeholder that names a
@@ -318,7 +339,7 @@ def notification_calls(tree):
bad_calls, n_calls = [], 0
for f in sorted(APP.glob("*.py")):
for f in _app_py():
for call in notification_calls(ast.parse(f.read_text(encoding="utf-8"))):
n_calls += 1
payload = call.args[2] if len(call.args) > 2 else None
@@ -368,13 +389,13 @@ def _module_constants(tree):
_MODCONST = {f.stem: _module_constants(ast.parse(f.read_text(encoding="utf-8")))
for f in sorted(APP.glob("*.py"))}
for f in _app_py()}
def collect():
"""(kind, where, text) for every user-facing literal of a ratcheted kind."""
out = []
for f in sorted(APP.glob("*.py")):
for f in _app_py():
if f.name == "messages.py":
continue
tree = ast.parse(f.read_text(encoding="utf-8"))
@@ -543,7 +564,7 @@ for key, text in messages.hint_samples():
p = row_problems(messages.GREEN, text)
check(f"hint {text!r}{' - ' + '; '.join(p) if p else ''}", not p)
_calls, _bare = 0, []
for f in sorted(APP.glob("*.py")):
for f in _app_py():
for node in ast.walk(ast.parse(f.read_text(encoding="utf-8"))):
if (isinstance(node, ast.Call)
and ast.unparse(node.func).endswith("log_event")):
@@ -1012,7 +1033,7 @@ def _static(spec):
the named file (placeholders count as one word, as in part 3)."""
fname, kind, name = spec
out = []
for f in ([APP / fname] if fname != "*" else sorted(APP.glob("*.py"))):
for f in ([_AppFile(APP / fname)] if fname != "*" else _app_py()):
tree = ast.parse(f.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if kind == "call" and isinstance(node, ast.Call) and node.args \
@@ -1201,7 +1222,7 @@ def scan_log_lines(files, text=None, data=None, long_ok=None, long_seen=None):
_FILES = [(f.name, ast.parse(f.read_text(encoding="utf-8")))
for f in sorted(APP.glob("*.py"))]
for f in _app_py()]
_long_seen = set()
_unknown, _stale, _bad = scan_log_lines(_FILES, long_seen=_long_seen)
for k, sample, p in _bad:
@@ -1415,7 +1436,7 @@ def _captured(logger_name, fn):
def _level_of(fname, words_in_line):
"""The level of the log call in `fname` whose literal holds the words."""
tree = ast.parse((APP / fname).read_text(encoding="utf-8"))
tree = ast.parse(_AppFile(APP / fname).read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.Call) and node.args \
and re.search(r"_LOG\.\w+$", ast.unparse(node.func)):
@@ -1561,7 +1582,7 @@ check(f"a socket Home Assistant closed is a plain ConnectionResetError, which "
# words. The keys are found in the code: each `Claim.set(source, ..., reason)`
# through main.why_key(), plus main.py's own refusal to split ("unsplit").
_SOURCES = {}
for _f in sorted(APP.glob("*.py")):
for _f in _app_py():
_tree = ast.parse(_f.read_text(encoding="utf-8"))
_consts = _module_constants(_tree)
for _n in ast.walk(_tree):
@@ -1781,7 +1802,7 @@ check(f"...and in the action log, which an installer reads at the default "
and any(e.endswith(messages.NO_REASON_SAVED) for e in _rs_ev))
# UX-11 item 4: the action log's clamp count and the shadow-off entry.
_main_src = (APP / "main.py").read_text(encoding="utf-8")
_main_src = _AppFile(APP / "main.py").read_text(encoding="utf-8")
check("the action log counts 'engagements' with a real plural and the "
"shadow-off entry is plain, not 'engagement(s)' or 'SHADOW MODE OFF -'",
"engagement(s)" not in _main_src and "SHADOW MODE OFF" not in _main_src
+7 -6
View File
@@ -32,6 +32,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
import opt11_identity as rig # noqa: E402
from named_absence import nth, present # noqa: E402
import main_files # noqa: E402
import app.main as app_main # noqa: E402
from app import homeview, messages, packmode # noqa: E402
from app.control import MODE_BOUND # noqa: E402
@@ -62,24 +63,24 @@ def store_with(**modes):
def spy_claims():
"""Record every claim list main.py's own `resolve` is handed."""
seen, real = [], app_main.resolve
seen, real = [], main_files.live.resolve
def _spy(claims, **kw):
seen.append(list(claims))
return real(claims, **kw)
app_main.resolve = _spy
return seen, lambda: setattr(app_main, "resolve", real)
main_files.live.resolve = _spy
return seen, lambda: setattr(main_files.live, "resolve", real)
def spy_bounds():
"""Record the `bound` main.py hands `compute()` on every cycle."""
seen, real = [], app_main.compute
seen, real = [], main_files.live.compute
def _spy(*a, **kw):
seen.append(kw.get("bound", "absent"))
return real(*a, **kw)
app_main.compute = _spy
return seen, lambda: setattr(app_main, "compute", real)
main_files.live.compute = _spy
return seen, lambda: setattr(main_files.live, "compute", real)
def flat(t):
+5 -4
View File
@@ -37,6 +37,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
from named_absence import nth, present # noqa: E402
import main_files # noqa: E402
import opt11p3_replay as rp # noqa: E402
from app import arbitrage, dayplan, homeview, modetrade, packmode # noqa
from app import priceruns, solarfc, validate # noqa: E402
@@ -777,7 +778,7 @@ check("battery modes off: the forecast sensor is never read", READS[0] == 0)
print("7. the Batteries screen and the Simple view")
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
hours=0.6)
rp.m.datetime = datetime
main_files.live.datetime = datetime
sb = homeview.solar_block(c, c.solar_fc.at)
check("the forecast line names the forecast, what is counted and when",
sb["line"].startswith("Solar still to come today:")
@@ -812,14 +813,14 @@ check("a lost forecast says so: no forecast, Leftover solar solar only",
c._charge_latch = latch
# the latch ends at its end time; this replay ran in September, so ask at a
# moment inside it
_real_dt = rp.m.datetime
rp.m.datetime = type("_DT", (datetime,), {"now": classmethod(
_real_dt = main_files.live.datetime
main_files.live.datetime = type("_DT", (datetime,), {"now": classmethod(
lambda cls, tz=None: latch[0] - timedelta(hours=1))})
check("OPT-34: with a charge under way (B charged in this run), the level "
"stays: plan_targets() gives it (the line: test_opt34.py)",
latch is not None and c.plan_targets() is not None
and B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B])
rp.m.datetime = _real_dt
main_files.live.datetime = _real_dt
check("the Why line wins over 'saves its power' only while holding",
homeview.battery_why(modes_on=True, mode="leftover_solar", off=False,
reading=True, phase="idle", shadow=False,
+4 -3
View File
@@ -46,6 +46,7 @@ logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import homeview, modetrade, solarfc # noqa: E402
from app.main import Controller # noqa: E402
@@ -404,14 +405,14 @@ def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
async def go():
c, hass = build(store)
real_dt, real_hv = m.datetime, homeview.datetime
real_dt, real_hv = main_files.live.datetime, homeview.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
main_files.live.datetime = homeview.datetime = _DT
task = None
try:
for t_pre in pre:
@@ -477,7 +478,7 @@ def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime = real_dt, real_hv
main_files.live.datetime, homeview.datetime = real_dt, real_hv
return c
return asyncio.run(go()), rows, pre_rows
+7 -6
View File
@@ -61,6 +61,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import homeview, solarfc # noqa: E402
from app.main import Controller # noqa: E402
@@ -348,14 +349,14 @@ def run_night(spec):
async def go():
cur = store
real_dt, real_hv = m.datetime, homeview.datetime
real_dt, real_hv = main_files.live.datetime, homeview.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
main_files.live.datetime = homeview.datetime = _DT
try:
# TEST-37: built under the scenario's clock, not the real one:
# Controller.__init__ checks a stored latch's end against now().
@@ -420,7 +421,7 @@ def run_night(spec):
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
out["latch"] = getattr(c, "_charge_latch", None) is not None
finally:
m.datetime, homeview.datetime = real_dt, real_hv
main_files.live.datetime, homeview.datetime = real_dt, real_hv
return out
return asyncio.run(go())
@@ -722,7 +723,7 @@ from types import SimpleNamespace # noqa: E402
from app import modetrade, priceruns # noqa: E402
CLOCK = [at(4, 1, 0)]
_dt0 = m.datetime
_dt0 = main_files.live.datetime
class _FixedDT(_dt0):
@@ -731,7 +732,7 @@ class _FixedDT(_dt0):
return CLOCK[0] if tz is None else CLOCK[0].astimezone(tz)
m.datetime = homeview.datetime = _FixedDT
main_files.live.datetime = homeview.datetime = _FixedDT
_tmp = tempfile.mkdtemp(prefix="o34-")
lc = Controller(dict(opts()), rp.Hass(("a", "b"), lambda t: 300.0,
{"a": 40.0, "b": 40.0}),
@@ -858,7 +859,7 @@ lc._charge_latch = (at(4, 6, 10), {A: 80.0}, at(4, 1, 0))
lt, _s = latch_step(None, resting, None, now=at(4, 6, 5), runs=r3)
check("F4: a run that starts AT the bell (06:00-09:00) does carry it on, "
"to its end + one slot", lt is not None and lt[0] == at(4, 9, 15))
m.datetime = homeview.datetime = _dt0
main_files.live.datetime = homeview.datetime = _dt0
print()
if fails:
+4 -3
View File
@@ -49,6 +49,7 @@ logging.disable(logging.WARNING)
from named_absence import nth, present # noqa: E402
import ux54_scenarios as S # noqa: E402
import opt11p3_replay as rp # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import dayplan, homeview, modetrade, packmode # noqa: E402
from app import priceruns, solarfc, uitext # noqa: E402
@@ -662,14 +663,14 @@ def run_night(spec):
for d in ("2026-10-31", "2026-11-01", "2026-11-02")})
async def go():
real_dt, real_hv = m.datetime, homeview.datetime
real_dt, real_hv = main_files.live.datetime, homeview.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
main_files.live.datetime = homeview.datetime = _DT
try:
for t_pre in EVENING:
clock[0] = t_pre
@@ -722,7 +723,7 @@ def run_night(spec):
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
out["latch"] = getattr(c, "_charge_latch", None) is not None
finally:
m.datetime, homeview.datetime = real_dt, real_hv
main_files.live.datetime, homeview.datetime = real_dt, real_hv
return out
return asyncio.run(go())
+4 -5
View File
@@ -34,6 +34,8 @@ import sys
from datetime import datetime, timedelta, timezone
from math import inf
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "tools"))
import main_files # noqa: E402
from app import arbitrage, packshare
from named_absence import first, nth, present
@@ -1132,8 +1134,7 @@ check("...and misfiling it ON THE SITE is caught and REFUSED, not consumed. A "
# main.py's own AST, the way test_peakguard.py part 12i pins the arguments of
# `arbitrage.decide()`, and for the same reason: a call site that stopped
# passing it is invisible to every fixture.
_main_src = (Path(__file__).resolve().parent / "app" / "main.py").read_text(
encoding="utf-8")
_main_src = main_files.source()
_resolve_calls = [n for n in ast.walk(ast.parse(_main_src))
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
and n.func.id == "resolve"]
@@ -1158,9 +1159,7 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE
# cycle's value, and logs. What it does NOT grade is the sentence it logs or
# that a real cycle reaches it; that is stated here rather than implied.
_cycle_src = first(
f for cls in ast.walk(ast.parse(
(Path(__file__).resolve().parent / "app" / "main.py")
.read_text(encoding="utf-8")))
f for cls in ast.walk(main_files.tree())
if isinstance(cls, ast.ClassDef) and cls.name == "Controller"
for f in cls.body
if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle")
+3 -3
View File
@@ -22,6 +22,7 @@ import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
import main_files # noqa: E402
import safety79_identity as rig # noqa: E402
from named_absence import nth, present # noqa: E402
@@ -207,9 +208,8 @@ check("...and the user-facing peak notice built from the decision's limit "
# ============================================================================ #
print("\n4. each call site in main.py")
with open(os.path.join(HERE, "app", "main.py"), encoding="utf-8") as f:
_src = f.read()
_tree = ast.parse(_src)
_src = main_files.source()
_tree = main_files.tree(_src)
def calls(name):
+13 -6
View File
@@ -135,6 +135,9 @@ from app.arbiter import (
)
from app.control import Tuning, compute, peak_at_risk
from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w
import os # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
from app import main as app_main
from app.inverter import Inverter, build_roster
from app.main import Controller
@@ -1295,7 +1298,7 @@ print(" the wiring's own mutations - a default install, maintenance charging")
# `peak_at_risk()` answers False forever. That is the whole point - it is the
# configuration on which the old suspension could not fire.
MAINT_CHARGING = {"phase": "charge"}
_mainsrc = (HERE / "app" / "main.py").read_text(encoding="utf-8")
_mainsrc = main_files.source()
def maint_cycle(tag, want_floor_w=LIVE_MAX_W * 0.3, **kw):
@@ -2709,7 +2712,7 @@ CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
# silent gap in the scan.
CLAIM_BUILDERS = {"peak_claim": "peak", "arb_claim": "arbitrage"}
_main_tree = ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8"))
_main_tree = main_files.tree()
_cycle_def = first((
f
for cls in ast.walk(_main_tree)
@@ -2762,7 +2765,7 @@ def cycle_claims(run):
call site and nowhere else.
"""
seen = []
real = app_main.resolve
real = main_files.live.resolve
def _spy(claims, **kw):
# ⚠️ `**kw` PASSES THE ROSTER THROUGH UNREAD, AND THAT IS THE WHOLE
@@ -2776,11 +2779,11 @@ def cycle_claims(run):
seen.append(list(claims))
return real(claims, **kw)
app_main.resolve = _spy
main_files.live.resolve = _spy
try:
out = run()
finally:
app_main.resolve = real
main_files.live.resolve = real
return out, (seen[-1] if seen else [])
@@ -4977,7 +4980,11 @@ check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide()
# written, at every roster length, in every configuration, and with no fixture
# required.
_APP_SRC = {p.stem: p.read_text(encoding="utf-8")
for p in sorted((HERE / "app").glob("*.py"))}
for p in sorted((HERE / "app").glob("*.py"))
if p.name == "main.py" or p.name not in main_files.files()}
# MAINT-21: main.py and the files split from it, the mixins read as Controller.
_APP_SRC["main"] = re.sub(r"^class \w+Mixin:", "class Controller:",
main_files.source(), flags=re.M)
_ROSTER_OPTION = "inverters" # the ONE seed. Everything else is closure.
_WRAPPERS = {"list", "tuple", "sorted", "enumerate", "zip", "reversed",
"filter", "iter", "set", "dict"}
+4 -2
View File
@@ -118,6 +118,8 @@ from app.static_tariff import resolve_tariff
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
import main_files # noqa: E402
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
# point these fixtures build, so no period has ended and the whole series counts.
@@ -2015,8 +2017,8 @@ check("...so calling it without one is a TypeError, not a quiet regression",
transport=transport_for(_body_today),
now=NOW)), TypeError))
_MAIN = open(os.path.join(_APP, "main.py"), encoding="utf-8").read()
_main_tree = ast.parse(_MAIN)
_MAIN = main_files.source()
_main_tree = main_files.tree(_MAIN)
_refresh = first((n for n in ast.walk(_main_tree)
if isinstance(n, ast.AsyncFunctionDef)
and n.name == "run_tariff_refresh"), "run_tariff_refresh in main.py")
+8 -6
View File
@@ -44,6 +44,8 @@ import tempfile
from datetime import date, datetime, timedelta, timezone
from named_absence import nth
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
from app import main as _main
from app import messages, price as P, price_chain as C
from app.price import (
@@ -686,8 +688,8 @@ def controller(clock, o, tmp=None):
return ctl
_REAL_DT, _REAL_GET = _main.datetime, P.https_get
_real_resolve = _main.resolve_tariff
_REAL_DT, _REAL_GET = main_files.live.datetime, P.https_get
_real_resolve = main_files.live.resolve_tariff
class Wired:
@@ -701,7 +703,7 @@ class Wired:
self.refreshes = 0
def __enter__(self):
_main.datetime = FakeDT
main_files.live.datetime = FakeDT
P.https_get = self.net
W = self
@@ -709,12 +711,12 @@ class Wired:
r = await _real_resolve(*a, **k)
W.refreshes += 1
return r
_main.resolve_tariff = counting
main_files.live.resolve_tariff = counting
return self
def __exit__(self, *exc):
_main.datetime, P.https_get = _REAL_DT, _REAL_GET
_main.resolve_tariff = _real_resolve
main_files.live.datetime, P.https_get = _REAL_DT, _REAL_GET
main_files.live.resolve_tariff = _real_resolve
async def refresh(self, now=None):
if now is not None:
+7 -1
View File
@@ -692,7 +692,13 @@ def touches(src: str) -> bool:
or "from .pv import" in src
_touch = sorted(f.name for f in APP if touches(f.read_text(encoding="utf-8")))
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
# MAINT-21: the files split from app/main.py are counted as main.py.
_SPLIT = set(main_files.files()) - {"main.py"}
_touch = sorted({"main.py" if f.name in _SPLIT else f.name for f in APP
if touches(f.read_text(encoding="utf-8"))})
print(f" {len(APP)} modules under app/; TEL-11 material is in: {_touch}")
check(f"PRECONDITION: the scan found the whole package, not a subset - "
f"{len(APP)} modules", len(APP) >= 25 and "control.py" in
+7 -4
View File
@@ -142,8 +142,11 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
**LIVE), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE["max_w"], LIVE["max_w"]
import os # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
import app.main as m
real, real_dt = m.compute, m.datetime
real, real_dt = main_files.live.compute, main_files.live.datetime
clock = [0.0]
class _DT(real_dt):
@@ -153,9 +156,9 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
main_files.live.datetime = _DT
if old:
m.compute = lambda *a, **k: real(*a, **dict(k, refused=None))
main_files.live.compute = lambda *a, **k: real(*a, **dict(k, refused=None))
rows, trips, was = [], 0, False
try:
t = 0.0
@@ -174,7 +177,7 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
hass.cmd, c.i_w, c.loop_reason, c.batt_refused))
t += TICK
finally:
m.compute, m.datetime = real, real_dt
main_files.live.compute, main_files.live.datetime = real, real_dt
return rows, trips, c
return asyncio.run(go())
+6 -3
View File
@@ -50,6 +50,9 @@ from app import control
from app.control import PROBE_EVERY_S, PROBE_S, PROBE_W
from app.main import Controller
from app.arbiter import P_LOOP, P_PEAK, P_SAFETY, Claim, resolve
import os # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
import app.main as m
fails = []
@@ -177,7 +180,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
c.auto = True
if old:
_off(c)
real_dt = m.datetime
real_dt = main_files.live.datetime
clock = [0.0]
class _DT(real_dt):
@@ -185,7 +188,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
main_files.live.datetime = _DT
rows = []
trips = {u: 0 for u in UNITS}
was = {u: False for u in UNITS}
@@ -211,7 +214,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
db=c.inverters[1].delivery_wd.tripped))
t += dt
finally:
m.datetime = real_dt
main_files.live.datetime = real_dt
return rows, trips, c
return asyncio.run(go())
+6
View File
@@ -73,6 +73,7 @@ def check(name, cond):
print("1. the harness's own preconditions")
# --------------------------------------------------------------------------- #
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
import rehearse_roster as RR # noqa: E402
check("the tool refuses rather than passing when it cannot run: its four exit "
@@ -210,6 +211,11 @@ def mutant_tree(edits) -> str:
dest / "tools" / "predeploy_check.py")
shutil.copyfile(HERE / "config.yaml", dest / "config.yaml")
for rel, old, new in edits:
if rel == "app/main.py":
# MAINT-21: app/main.py stands for the files split from it.
where = [n for n in main_files.files() if (dest / "app" / n)
.read_text(encoding="utf-8").count(old) == 1]
rel = f"app/{where[0]}" if len(where) == 1 else rel
path = dest / rel
text = path.read_text(encoding="utf-8")
hits = text.count(old)
+4 -3
View File
@@ -35,6 +35,7 @@ logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import app.main as app_main # noqa: E402
import main_files # noqa: E402
from app import control # noqa: E402
from app.arbiter import Claim, P_ARBITRAGE # noqa: E402
from app.main import Controller # noqa: E402
@@ -61,7 +62,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
(a writer above the loop), or None."""
log, rows, now = [], [], [0.0]
real_power, real_tick = rig._Hass.power, rig.TICK
real_log, real_resolve, real_c = (Controller.log_event, app_main.resolve,
real_log, real_resolve, real_c = (Controller.log_event, main_files.live.resolve,
rig.Controller)
def power(self, u):
@@ -108,7 +109,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
extra.update(s79.P1, peak_cap_w=cap)
db = os.path.join(tempfile.mkdtemp(prefix="s107-"), "tariff.db")
rig._Hass.power, rig.TICK = power, tick
Controller.log_event, app_main.resolve = log_event, resolve
Controller.log_event, main_files.live.resolve = log_event, resolve
rig.Controller = functools.partial(real_c, peak_db_path=db)
try:
_, c = rig.run(n, extra=extra, dur=dur, load=house,
@@ -117,7 +118,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
store=s79.store_charging(n) if maint else rig._Store())
finally:
rig._Hass.power, rig.TICK = real_power, real_tick
Controller.log_event, app_main.resolve = real_log, real_resolve
Controller.log_event, main_files.live.resolve = real_log, real_resolve
rig.Controller = real_c
return rows, log, c
+7 -3
View File
@@ -19,6 +19,10 @@ import asyncio
import logging
from pathlib import Path
import os # noqa: E402
import sys # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
from app import main as _main
from app import messages, pv, validate
from app.main import Controller
@@ -217,7 +221,7 @@ check(f"one read of the forecast entity per read_inputs() ({h.reads})",
print("4. maintenance headroom and the charge-only clamp use watts")
seen = []
_real = _main.maintenance_charge_floor
_real = main_files.live.maintenance_charge_floor
def spy(charge_w, fc, cap):
@@ -226,7 +230,7 @@ def spy(charge_w, fc, cap):
return out
_main.maintenance_charge_floor = spy
main_files.live.maintenance_charge_floor = spy
try:
def cycle(fc, **kw):
c, h = make(fc, store={"phase": "charge"},
@@ -264,7 +268,7 @@ try:
check("no unit: forecast unused, clamp in place, as with the option empty",
clamp(st(6000)) is False)
finally:
_main.maintenance_charge_floor = _real
main_files.live.maintenance_charge_floor = _real
print("5. validator and strings")
def notes(unit, key="peak_forecast_entity"):
+6 -5
View File
@@ -59,6 +59,7 @@ logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import safety84_identity as s84 # noqa: E402
import main_files # noqa: E402
import app.main as app_main # noqa: E402
from app import messages # noqa: E402
from app.main import Controller # noqa: E402
@@ -413,8 +414,8 @@ _rg = {t: never(t, hw, seed=1, delay=0) for t, hw in RIG.items()}
_rg_old = {t: never(t, hw, fix=False, seed=1, delay=0)
for t, hw in RIG.items()}
# SAFETY-119 as merged (bee7431) at 5 s: a settle of 12 cycles, budget 8.
_rg_119 = patched(app_main, "HANDBACK_CYCLES", app_main.SHORT_CYCLES)(
lambda: patched(app_main, "ROLLING_BOUND_CYCLES", 8)(
_rg_119 = patched(main_files.live, "HANDBACK_CYCLES", app_main.SHORT_CYCLES)(
lambda: patched(main_files.live, "ROLLING_BOUND_CYCLES", 8)(
lambda: never(5.0, 5100.0, seed=1, delay=0)))
for _t in _rg:
print(f" rig shape, {_t:.0f} s meter, house {RIG[_t]:.0f} W: per "
@@ -464,10 +465,10 @@ check("...every command the same as without the fix; without it the false "
# the rig's shape (1 s, house 5050 W, seed 1).
rig.TICK = 2.0
_q25 = (run(house(25), delay=2),
patched(app_main, "ROLLING_BOUND_CYCLES", 8)(
patched(main_files.live, "ROLLING_BOUND_CYCLES", 8)(
lambda: run(house(25), delay=2)))
rig.TICK = 1.0
_m14 = patched(app_main, "ROLLING_BOUND_CYCLES", 14)(
_m14 = patched(main_files.live, "ROLLING_BOUND_CYCLES", 14)(
lambda: never(1.0, 5050.0, seed=1, delay=0))
_m11 = never(1.0, 5050.0, seed=1, delay=0)
print(f" 2 s, quarter edge, seed 25: {notes(_q25[0][1])}, budget 8 "
@@ -480,7 +481,7 @@ check("...the budget's margins: 11 is silent at 2 s seed 25 and warns every "
and every_quarter(_m11[1]) and not every_quarter(_m14[1]))
check("the rule is the one the sweep chose. Changing it means re-running "
"the sweep in DECISIONS/SAFETY-121-bound-every-quarter.md",
app_main.HANDBACK_CYCLES == 4 and app_main.ROLLING_BOUND_CYCLES == 11
main_files.live.HANDBACK_CYCLES == 4 and main_files.live.ROLLING_BOUND_CYCLES == 11
and app_main.ROLLING_S == 900.0 and app_main.SHORT_SPAN_S == 22.0)
# A start, or a blind cycle, is not a hand-back: the loop at its rail after
+4 -3
View File
@@ -37,6 +37,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import main_files # noqa: E402
import app.main as M # noqa: E402
from app import dayplan, modetrade, packmode, priceruns, solarfc # noqa: E402
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
@@ -492,13 +493,13 @@ for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00",
"2027-03-28T00:00:05+00:00"):
now = datetime.fromisoformat(iso)
c, hass = controller(MODES["Leftover solar"], now)
real_dt = M.datetime
real_dt = main_files.live.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None, _n=now):
return _n if tz is None else _n.astimezone(tz)
M.datetime = _DT
main_files.live.datetime = _DT
try:
async def one():
await c.read_inputs(now_mono=0.0)
@@ -510,7 +511,7 @@ for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00",
except Slow:
ok = False
finally:
M.datetime = real_dt
main_files.live.datetime = real_dt
check(f"a whole control cycle at {iso[:16]}Z (three forecast sensors a "
"day) returns with a day plan", ok)
+4 -3
View File
@@ -48,6 +48,7 @@ logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import opt11p3_replay as rp # noqa: E402
import safety84_identity as s84 # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
import app.p1 as p1mod # noqa: E402
from named_absence import first, nth, present # noqa: E402
@@ -164,14 +165,14 @@ def drive(o, *, peak=None, late=0.0, minutes=31, every=0, start=None,
c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0
c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0
c.auto = True
real = m.datetime, homeview.datetime, p1mod.time
real = main_files.live.datetime, homeview.datetime, p1mod.time
clock, simt = [t0], [0.0]
class _DT(real[0]):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
main_files.live.datetime = homeview.datetime = _DT
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0])
real_load = _price.load_prices, _st.load_prices
_price.load_prices = _st.load_prices = _no_fetch
@@ -223,7 +224,7 @@ def drive(o, *, peak=None, late=0.0, minutes=31, every=0, start=None,
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime, p1mod.time = real
main_files.live.datetime, homeview.datetime, p1mod.time = real
_price.load_prices, _st.load_prices = real_load
return c
c = asyncio.run(go())
+4 -3
View File
@@ -43,6 +43,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
import app.p1 as p1mod # noqa: E402
from app import homeview, messages, peakguard, tariff # noqa: E402
@@ -179,14 +180,14 @@ def drive(o, *, modes=OWNER, socs=(10.0, 10.0), house=600.0, minutes=46,
c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0
c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0
c.auto = True
real = m.datetime, homeview.datetime, p1mod.time
real = main_files.live.datetime, homeview.datetime, p1mod.time
clock, simt = [t0], [0.0]
class _DT(real[0]):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
main_files.live.datetime = homeview.datetime = _DT
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0])
real_load = _price.load_prices, _st.load_prices
_price.load_prices = _st.load_prices = _no_fetch
@@ -237,7 +238,7 @@ def drive(o, *, modes=OWNER, socs=(10.0, 10.0), house=600.0, minutes=46,
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime, p1mod.time = real
main_files.live.datetime, homeview.datetime, p1mod.time = real
_price.load_prices, _st.load_prices = real_load
return c
out["c"] = asyncio.run(go())
+8 -7
View File
@@ -38,6 +38,7 @@ logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import safety84_identity as s84 # noqa: E402
import main_files # noqa: E402
import app.main as app_main # noqa: E402
from app import clamp, messages # noqa: E402
from app.main import Controller # noqa: E402
@@ -202,7 +203,7 @@ check("⭐ N=2, A in its HOLD and B, which the loop drives, gives nothing: the "
check("...it starts no earlier than the saturation cycles allow (3 x 5 s)",
all(e.split(" ", 1)[0] >= "11:10:10" for c in (_c1, _c2)
for e in c.events if "OUTPUT_CLAMP" in e))
_quiet = patched(app_main, "delivery_short", lambda *a: False)(
_quiet = patched(main_files.live, "delivery_short", lambda *a: False)(
lambda: run(1, "drain", 1000.0, 20000, dur=1200.0, cut=gone(600, 1200)))
check("non-vacuous: without the delivery rule the N=1 drain that gives "
"nothing is silent (the loop's clamp never reaches its rail here)",
@@ -214,7 +215,7 @@ check("a dip of two cycles is not an alarm", warned(_dip[1]) == (0, 0, 0))
print("\n3. control coming back from maintenance does not jump")
END = {"maintenance_hold_min": 10}
_bounds = []
_real_compute = app_main.compute
_real_compute = main_files.live.compute
def _spy_compute(*a, **kw):
@@ -222,11 +223,11 @@ def _spy_compute(*a, **kw):
return _real_compute(*a, **kw)
app_main.compute = _spy_compute
main_files.live.compute = _spy_compute
try:
_h2 = run(2, "hold", 4500.0, 20000, dur=900.0, extra=END)
finally:
app_main.compute = _real_compute
main_files.live.compute = _real_compute
_h2_old = no_bound(lambda: run(2, "hold", 4500.0, 20000, dur=900.0,
extra=END))
_h1 = run(1, "hold", 4500.0, 20000, dur=900.0, extra=END)
@@ -380,8 +381,8 @@ def lag_runs():
_lag = lagging(lag_runs)
_lag_old = patched(app_main, "SHORT_CYCLES", 1)(
lambda: patched(app_main, "SHORT_SPAN_S", 0.0)(
_lag_old = patched(main_files.live, "SHORT_CYCLES", 1)(
lambda: patched(main_files.live, "SHORT_SPAN_S", 0.0)(
lambda: lagging(lag_runs)))
_lag_dead = lagging(lambda: run(1, "drain", 1000.0, 20000, dur=900.0,
cut=gone(600, 900)))
@@ -432,7 +433,7 @@ def both_ways(asked, given, margin):
_tp = taper(charge_runs)
_tp_old = patched(app_main, "delivery_short", both_ways)(
_tp_old = patched(main_files.live, "delivery_short", both_ways)(
lambda: taper(charge_runs))
print(f" a charge that tapers: {[warned(c) for _r, c in _tp]}, grading "
f"charges too {[warned(c) for _r, c in _tp_old]}")
+6 -2
View File
@@ -38,6 +38,7 @@ import types
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
import main_files # noqa: E402
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
@@ -703,8 +704,11 @@ check("⭐ the reserve alarm is graded on the real limit, not the aim: a "
_runs_src = {}
for _f in ("main.py", "homeview.py"):
with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh:
_src = _fh.read()
if _f == "main.py":
_src = main_files.source()
else:
with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh:
_src = _fh.read()
for _part in _src.split('out.append({')[1:]:
if '"runs": True' in _part.split("})", 1)[0]:
_runs_src.setdefault(_f, []).append(
+4 -3
View File
@@ -127,16 +127,17 @@ def without_fix(fn):
"""release/1.0 8c438ab: held only for the peak, a drain stays pinned, the
quarter's edge is not rolled over."""
real = (Controller._held_back, Controller._drain_rise)
import main_files # noqa: E402
import app.main as m
real_nq = m.next_quarter
real_nq = main_files.live.next_quarter
Controller._held_back = lambda self: self._peak_reserve()
Controller._drain_rise = lambda self, claims, results: None
m.next_quarter = lambda p, max_hold_s: p
main_files.live.next_quarter = lambda p, max_hold_s: p
try:
return fn()
finally:
Controller._held_back, Controller._drain_rise = real
m.next_quarter = real_nq
main_files.live.next_quarter = real_nq
def cmd(r):
+9 -8
View File
@@ -37,6 +37,7 @@ logging.disable(logging.CRITICAL)
import safety94_rig as rig # noqa: E402
import app.inverter as app_inverter # noqa: E402
import main_files # noqa: E402
import app.main as app_main # noqa: E402
from app import control # noqa: E402
from app.freshness import BOUNDS, Freshness # noqa: E402
@@ -59,19 +60,19 @@ class Before94(Freshness):
def without_fix(fn):
"""Run `fn` with the gate's old clock and the old refusal evidence."""
real_f, real_c = app_inverter.Freshness, app_main.compute
real_p = app_main.REFUSED_PROGRESS_W
real_f, real_c = app_inverter.Freshness, main_files.live.compute
real_p = main_files.live.REFUSED_PROGRESS_W
real_l = app_main.Controller._readings_late
# SAFETY-128: that tree had no late-readings gate either.
app_main.Controller._readings_late = lambda self, standing_w: None
app_inverter.Freshness = Before94
app_main.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None))
app_main.REFUSED_PROGRESS_W = float("inf")
main_files.live.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None))
main_files.live.REFUSED_PROGRESS_W = float("inf")
try:
return fn()
finally:
app_inverter.Freshness, app_main.compute = real_f, real_c
app_main.REFUSED_PROGRESS_W = real_p
app_inverter.Freshness, main_files.live.compute = real_f, real_c
main_files.live.REFUSED_PROGRESS_W = real_p
app_main.Controller._readings_late = real_l
@@ -315,7 +316,7 @@ check("compute(): a frozen loop at SETTLE_CYCLES reading -20 W engages with "
and control.compute(**args, gave_nothing=False).refused == 0)
# The evidence restarts on progress towards the ask, not on jitter.
print(f" REFUSED_PROGRESS_W {app_main.REFUSED_PROGRESS_W:.0f} W, span "
print(f" REFUSED_PROGRESS_W {main_files.live.REFUSED_PROGRESS_W:.0f} W, span "
f"{app_main.REFUSED_SPAN_S:.0f} s")
@@ -356,7 +357,7 @@ check("a command that swaps direction every cycle is never evidence, and a "
not any(flap) and 1 in away and away.index(1) == 22)
check("REFUSED_PROGRESS_W is under the band's width and over the live "
"readings' rest wander (25 W)",
25.0 < app_main.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W)
25.0 < main_files.live.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W)
print()
if fails:
+4 -3
View File
@@ -36,6 +36,7 @@ logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import main_files # noqa: E402
from app import main as app_main, packmode # noqa: E402
from app.arbiter import Claim, P_MAINTENANCE, P_SAFETY # noqa: E402
from app.main import Controller # noqa: E402
@@ -149,13 +150,13 @@ def run(n, house, *, store, extra=None, soc=None, reject=None, dead=None,
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, clock = app_main.datetime, [0.0]
real_dt, clock = main_files.live.datetime, [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return rig.BASE + timedelta(seconds=clock[0])
app_main.datetime = _DT
main_files.live.datetime = _DT
rows = []
try:
t = 0.0
@@ -182,7 +183,7 @@ def run(n, house, *, store, extra=None, soc=None, reject=None, dead=None,
else g.floor_w))
t += 2.0
finally:
app_main.datetime = real_dt
main_files.live.datetime = real_dt
return rows, c
return asyncio.run(go())
@@ -32,6 +32,7 @@ from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
fails = []
@@ -336,7 +337,7 @@ def run(coro):
return asyncio.run(coro)
_real_time = M.time
_real_time = main_files.live.time
class _FakeTime:
@@ -891,9 +892,9 @@ check("the units that raised, failed or hung are NOT marked handed back, so "
for i in (1, 2, 4)))
fb_raise.mode = "ok"
FT.t = _real_time.monotonic() + 11.0 # one heartbeat later
M.time = FT
main_files.live.time = FT
run(c.send_heartbeats(60_000.0, 10.0))
M.time = _real_time
main_files.live.time = _real_time
check("...and the next idle write (a heartbeat later) retries the one that "
"raised (now answers): released, and the healthy unit is not released a "
"second time",
@@ -955,7 +956,7 @@ for mode in ("false", "raise", "hang"):
# the cadence and the log, over N failed retries on a clock we move
c, h, f = failing_site("false")
M.time = FT
main_files.live.time = FT
FT.t = 5000.0
_cap.clear()
run(c.handle_action("auto_toggle")) # Stop: try 1 (forced)
@@ -997,7 +998,7 @@ _cap.clear()
run(c.handle_action("auto_toggle")) # Stop, fails again
check("a NEW failure streak is logged again, once",
len([1 for lv, m in _cap if lv >= logging.ERROR]) == 1)
M.time = _real_time
main_files.live.time = _real_time
# shutdown with a failed release: the one 0 W still goes out inside the bound
c, h, f = failing_site("hang")
@@ -1098,7 +1099,7 @@ check("the two new Last-event hints exist and are plain",
.endswith("trying again"))
# --- 3j. the call sites -------------------------------------------------------
src = (HERE / "app" / "main.py").read_text(encoding="utf-8")
src = main_files.source()
ss = src.splitlines()
@@ -1115,7 +1116,8 @@ check("main.py: the Stop button and shutdown() call command_site_release(); "
"the two maintenance-abort paths keep command_site_zero() (a third call "
"is the all-GoodWe branch inside command_site_release itself)",
len(rel) == 2 and len(zer) == 3
and "async def command_site_release" in "\n".join(ss[zer[1] - 30:zer[1]]))
and any("async def command_site_release" in "\n".join(ss[i - 30:i])
for i in zer))
# =============================================================================
print()
+181
View File
@@ -0,0 +1,181 @@
"""MAINT-21 tripwire: a test must patch a Controller global where it is read.
The Controller's code lives in app/main.py and in the files split from it
(app/ctl_*.py, app/rows.py). A function reads a module global from its own
file, so `app.main.NAME = fake` changes only the code that is still in
main.py. This file fails when a test or tool still does that:
1. it patches an `app.main` attribute that main.py's own functions no
longer read (a stale patch, silently inert), or
2. it patches an `app.main` attribute that another file also reads (the
patch reaches only part of the code).
The way to patch is `tools/main_files.py`: `main_files.live.NAME = fake` sets
NAME in every file that reads it and raises when none does.
Part 3 runs the scan on spoilt sources and the patch helper on the real tree.
Read MUTATION.md for the habit this follows. `python3 test_split_patches.py`
"""
import ast
import glob
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
import main_files # noqa: E402
fails = []
ALIASES = {"m", "M", "main", "app_main", "_main", "_main_mod", "main_mod"}
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def main_aliases(tree):
"""Names bound to the module app.main in one file."""
al = set()
for n in ast.walk(tree):
if isinstance(n, ast.Import):
al.update(a.asname for a in n.names
if a.name == "app.main" and a.asname)
elif isinstance(n, ast.ImportFrom) and n.module == "app":
al.update(a.asname or a.name for a in n.names if a.name == "main")
for n in ast.walk(tree):
if (isinstance(n, ast.Assign) and isinstance(n.value, ast.Name)
and n.value.id in al):
al.update(t.id for t in n.targets if isinstance(t, ast.Name))
# `for mod in (app_main, homeview): mod.datetime = fake`
if (isinstance(n, ast.For) and isinstance(n.target, ast.Name)
and isinstance(n.iter, (ast.Tuple, ast.List))
and any(isinstance(e, ast.Name) and e.id in al
for e in n.iter.elts)):
al.add(n.target.id)
return al
def flat(targets):
"""The assignment targets, tuples and lists taken apart."""
for t in targets:
if isinstance(t, (ast.Tuple, ast.List)):
yield from flat(t.elts)
else:
yield t
def patches(src):
"""[(line, name)]: every attribute of app.main a file assigns, or hands to
setattr / a patching helper as `(app.main alias, "name", value)`."""
tree = ast.parse(src)
al = main_aliases(tree)
out = []
for n in ast.walk(tree):
targets = []
if isinstance(n, ast.Assign):
targets = n.targets
elif isinstance(n, (ast.AugAssign, ast.AnnAssign)):
targets = [n.target]
for t in flat(targets):
# `app_main.NAME = x`, or `rig.m.NAME = x` where rig did
# `import app.main as m`
if isinstance(t, ast.Attribute) and (
(isinstance(t.value, ast.Name) and t.value.id in al)
or (isinstance(t.value, ast.Attribute)
and t.value.attr in ALIASES)):
out.append((n.lineno, t.attr))
if (isinstance(n, ast.Call) and len(n.args) >= 3
and isinstance(n.args[0], ast.Name) and n.args[0].id in al
and isinstance(n.args[1], ast.Constant)
and isinstance(n.args[1].value, str)
and ast.unparse(n.func) != "getattr"):
out.append((n.lineno, n.args[1].value))
return out
def problems(src, where="<source>", readers=main_files.reading):
"""Messages for each patch in `src` that the rule above rejects."""
out = []
for line, name in patches(src):
who = readers(name)
if "main" not in who:
out.append(f"{where}:{line} patches main.{name}, which main.py "
f"does not read" + (f" (read by {who})" if who else ""))
elif who != ["main"]:
out.append(f"{where}:{line} patches main.{name}, which "
f"{[w for w in who if w != 'main']} also read: use "
f"main_files.live.{name}")
return out
# --------------------------------------------------------------------------- #
print("1. no test and no tool patches a main global the code reads elsewhere")
sites = bad = 0
for path in sorted(glob.glob(os.path.join(HERE, "test_*.py"))
+ glob.glob(os.path.join(HERE, "tools", "*.py"))):
if os.path.basename(path) == "test_split_patches.py":
continue
with open(path, encoding="utf-8") as fh:
src = fh.read()
found = problems(src, os.path.relpath(path, HERE))
sites += len(patches(src))
for msg in found:
print(" " + msg)
bad += len(found)
check(f"{sites} patches of an app.main attribute found, {bad} stale or partial",
sites > 0 and bad == 0)
# --------------------------------------------------------------------------- #
print("\n2. the scan is not blind: spoilt sources are caught")
HEAD = "import app.main as m\n"
check("main.<a name nothing reads> is stale",
len(problems(HEAD + "m.no_such_global_anywhere = 1\n")) == 1)
check("main.datetime is partial (the clock is read in more than one file)"
if len(main_files.reading("datetime")) > 1 else
"main.datetime (one reader here, so nothing to catch yet)",
len(problems(HEAD + "m.datetime = 1\n")) ==
(1 if len(main_files.reading("datetime")) > 1 else 0))
check("the same through setattr, a helper and an alias",
len(problems(HEAD + "k = m\nsetattr(k, 'no_such_global_anywhere', 1)\n"
"patched(m, 'no_such_global_anywhere', 1)(f)\n")) == 2)
check("a patch through main_files.live is not flagged",
problems(HEAD + "import main_files\n"
"main_files.live.datetime = 1\n") == [])
check("a name only main reads may still be patched on main",
problems(HEAD + "m.load_options = 1\n") == [])
# --------------------------------------------------------------------------- #
print("\n3. main_files.live sets a global in every file that reads it")
import importlib # noqa: E402
live = main_files.live
for name in ("datetime", "compute", "_mono"):
readers = main_files.reading(name)
mods = [importlib.import_module("app." + r) for r in readers]
before = [getattr(m, name) for m in mods]
sentinel = object()
setattr(live, name, sentinel)
try:
hit = all(getattr(m, name) is sentinel for m in mods)
read_back = getattr(live, name) is sentinel
finally:
for m, old in zip(mods, before):
setattr(m, name, old)
check(f"{name}: set in all {len(mods)} reading file(s) {readers}, and "
f"read back", bool(mods) and hit and read_back)
try:
live.no_such_global_anywhere = 1
raised = False
except AttributeError:
raised = True
check("a name no file reads raises instead of patching nothing", raised)
print()
if fails:
print(f"FAILED: {len(fails)}")
sys.exit(1)
print("all ok")
+3 -1
View File
@@ -81,6 +81,8 @@ from app.static_tariff import (
from app.validate import validate_startup_config
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE / "tools"))
import main_files # noqa: E402
UTC = timezone.utc
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
@@ -893,7 +895,7 @@ check("the new module holds no option-defaults dict of its own, so this ticket "
check("...and it reads the option through price.price_option(), the framework's "
"single read path, rather than through opts.get(key, <a number>)",
"price_option(opts" in _SRC and 'opts.get("static_profile"' not in _SRC)
_MAIN = (HERE / "app" / "main.py").read_text(encoding="utf-8")
_MAIN = main_files.source()
check("...and main.py grew no `o.get(\"static_profile\", <a default>)` fallback "
"either - it calls static_status(o, now), and the option is read once, "
"inside the framework",
+4 -3
View File
@@ -40,6 +40,7 @@ logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import homeview, modetrade, packmode # noqa: E402
from app import price as _price, static_tariff as _st # noqa: E402
@@ -196,7 +197,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, real_hv = m.datetime, homeview.datetime
real_dt, real_hv = main_files.live.datetime, homeview.datetime
clock = [start]
class _DT(real_dt):
@@ -204,7 +205,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
main_files.live.datetime = homeview.datetime = _DT
_real_load = _price.load_prices, _st.load_prices
_price.load_prices = _st.load_prices = _no_fetch
started = task_started(o)
@@ -246,7 +247,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime = real_dt, real_hv
main_files.live.datetime, homeview.datetime = real_dt, real_hv
_price.load_prices, _st.load_prices = _real_load
return c, started
+4 -3
View File
@@ -47,6 +47,7 @@ logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
from named_absence import first, nth, present # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import arbitrage, homeview, modetrade, solarfc # noqa: E402
from app.main import Controller # noqa: E402
@@ -175,7 +176,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
# forecast IS lost, so it runs with a hold that never covers
# (test_opt27.py tests the cover).
c.fc_hold = _NoHold()
real_dt, real_hv = m.datetime, homeview.datetime
real_dt, real_hv = main_files.live.datetime, homeview.datetime
clock = [t0]
class _DT(real_dt):
@@ -183,7 +184,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
main_files.live.datetime = homeview.datetime = _DT
pts = None if points is None else points
task = None
if pts is None:
@@ -248,7 +249,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
await task
except asyncio.CancelledError:
pass
m.datetime, homeview.datetime = real_dt, real_hv
main_files.live.datetime, homeview.datetime = real_dt, real_hv
return c
return asyncio.run(go()), rows
+3 -1
View File
@@ -36,6 +36,7 @@ logging.disable(logging.WARNING)
import ux54_scenarios as S # noqa: E402
import app.main as M # noqa: E402
import main_files # noqa: E402
from app import arbitrage, dayplan, dayview, homeview, solarfc # noqa: E402
from app import tariff # noqa: E402
from app.daycurve import SolarToday # noqa: E402
@@ -90,7 +91,8 @@ class FakeDT(datetime):
return NOW[0].astimezone(tz) if tz else NOW[0].replace(tzinfo=None)
for _mod in (M, homeview, dayview): # what reads the wall clock
main_files.live.datetime = FakeDT # the Controller's clock
for _mod in (homeview, dayview): # the other readers of the clock
_mod.datetime = FakeDT
TZ_MODULES = (tariff, dayplan, homeview, solarfc)
+6 -3
View File
@@ -1161,9 +1161,12 @@ check("...and it does not raise the same card on every cycle - a per-PROCESS "
# SAFETY-34: a Core-ONLY restart. The add-on process lives on (measured on
# TEST-23), HA's memory is wiped, so the SAME process must re-assert the card.
import os # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
import main_files # noqa: E402
import app.main as _main_mod # noqa: E402
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
_main_mod._mono = lambda: _clock[0]
_real_mono, _clock = main_files.live._mono, [main_files.live._mono()]
main_files.live._mono = lambda: _clock[0]
try:
_h3 = _CircHass(a_w=0.0, b_w=0.0)
_b.hass = _h3
@@ -1173,7 +1176,7 @@ try:
asyncio.run(_b.check_circulating())
_late = [c for c in _h3.calls if c[0] == "persistent_notification"]
finally:
_main_mod._mono = _real_mono
main_files.live._mono = _real_mono
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise - no "
"storm on the event bus", len(_early) == 0)
check("SAFETY-34: a Core-only restart wipes the card and the add-on process "
+98
View File
@@ -0,0 +1,98 @@
"""MAINT-21: the Controller lives in app/main.py and the files split from it.
A test that reads `app/main.py` for a call, a comment or a def reads
`source()` instead; a test that replaces a global that `main` used to read
calls `patch_global()`, which sets it in each file whose code reads it. A name
no file reads is an error: a stale patch cannot pass unnoticed.
"""
import ast
import glob
import importlib
import os
import symtable
APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "app")
def files():
"""main.py, rows.py and ctl_*.py, in that order, as bare file names."""
split = ["rows.py"] + sorted(os.path.basename(p)
for p in glob.glob(os.path.join(APP, "ctl_*.py")))
return ["main.py"] + [f for f in split
if os.path.exists(os.path.join(APP, f))]
def text(name):
with open(os.path.join(APP, name), encoding="utf-8") as fh:
return fh.read()
def source():
"""All the files' text, one after the other."""
return "\n".join(text(f) for f in files())
def tree(src=None):
"""`ast.parse(source())` with every `*Mixin` class folded into
`Controller`, so `Controller` has all its methods. Positions refer to
`source()`."""
src = source() if src is None else src
mod = ast.parse(src)
ctl = next(n for n in mod.body
if isinstance(n, ast.ClassDef) and n.name == "Controller")
keep = []
for n in mod.body:
if isinstance(n, ast.ClassDef) and n.name.endswith("Mixin"):
ctl.body.extend(n.body)
else:
keep.append(n)
mod.body = keep
return mod
def _function_reads(table, acc):
for s in table.get_symbols():
if (table.get_type() == "function" and s.is_global()
and s.is_referenced()):
acc.add(s.get_name())
for child in table.get_children():
_function_reads(child, acc)
def reading(name):
"""The bare module names (`main`, `ctl_cycle` ...) whose functions read
`name` as a global."""
out = []
for f in files():
acc = set()
_function_reads(symtable.symtable(text(f), f, "exec"), acc)
if name in acc:
out.append(f[:-3])
return out
class _Live:
"""The globals the Controller's code reads, wherever they are read.
`live.NAME = value` sets NAME in every file whose functions read it;
`live.NAME` reads it. A name no file reads is an AttributeError, so a
patch that changes nothing cannot pass unnoticed.
"""
def __getattr__(self, name):
mods = reading(name)
if not mods:
raise AttributeError(f"no file under app/ reads the global "
f"{name!r}")
return getattr(importlib.import_module("app." + mods[0]), name)
def __setattr__(self, name, value):
mods = reading(name)
if not mods:
raise AttributeError(f"no file under app/ reads the global "
f"{name!r}: a patch of it changes nothing")
for stem in mods:
setattr(importlib.import_module("app." + stem), name, value)
live = _Live()
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""MAINT-21: prove that splitting app/main.py moved code and changed none.
python3 goodwe_controller/tools/maint21_same_ast.py [BASE_REF] [--docstrings]
Every module-level def, class and assignment of app/main.py at BASE_REF
(default release/1.0), and every method of its `Controller`, is looked up by
qualified name in the working tree: app/main.py, app/rows.py and the mixin
classes of app/ctl_*.py (a mixin's methods count as `Controller.<name>`).
Each must be found exactly once with an identical `ast.dump()`, docstrings
removed unless --docstrings. Allowed extras: the `Controller` class header,
`_LOG = logging.getLogger("goodwe")` in a module that logs, and import lines.
Prints one line per file and `OK` or the differences. The diff against
BASE_REF may touch only goodwe_controller/app, DECISIONS, test_*.py and
tools. Run from the repository root on a clean checkout (untracked files are
read, but the diff check only sees tracked ones). Exit 1 on any difference.
"""
import ast
import collections
import glob
import os
import re
import subprocess
import sys
APP = "goodwe_controller/app"
MIXIN = re.compile(r"^[A-Z][A-Za-z]*Mixin$")
def git(*a):
return subprocess.run(["git", *a], check=True, capture_output=True,
text=True).stdout
def bare(n, docstrings):
if not docstrings:
for x in ast.walk(n):
if isinstance(x, (ast.FunctionDef, ast.AsyncFunctionDef,
ast.ClassDef)) and x.body:
e = x.body[0]
if (isinstance(e, ast.Expr) and isinstance(e.value, ast.Constant)
and isinstance(e.value.value, str)):
x.body = x.body[1:] or [ast.Pass()]
return ast.dump(n)
def key_of(n):
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
return n.name
if isinstance(n, ast.Assign):
return "=" + ",".join(ast.unparse(t) for t in n.targets)
if isinstance(n, ast.AnnAssign):
return "=" + ast.unparse(n.target)
return "?" + ast.unparse(n)[:60]
def units(src, docstrings, mixins):
"""{qualified name: [dump, ...]} for one file's text."""
out = collections.defaultdict(list)
tree = ast.parse(src)
for n in tree.body:
if isinstance(n, (ast.Import, ast.ImportFrom)):
continue
if (isinstance(n, ast.Expr) and isinstance(n.value, ast.Constant)
and isinstance(n.value.value, str)):
continue
if isinstance(n, ast.ClassDef) and (
n.name == "Controller" or (mixins and MIXIN.match(n.name))):
for m in n.body:
out["Controller." + key_of(m)].append(bare(m, docstrings))
continue
out[key_of(n)].append(bare(n, docstrings))
return out
def main(argv):
docstrings = "--docstrings" in argv
argv = [a for a in argv if not a.startswith("--")]
base = argv[0] if argv else "release/1.0"
bad = 0
print(f"base {base} = {git('rev-parse', base).strip()}")
for f in git("diff", "--name-only", base).split():
if not re.match(r"^goodwe_controller/(app/[^/]+|DECISIONS/[^/]+|"
r"test_[^/]+\.py|tools/[^/]+)$", f):
print(f"FAIL: diff touches {f}")
bad += 1
old = units(git("show", f"{base}:{APP}/main.py"), docstrings, False)
files = ["main.py", "rows.py"] + sorted(
os.path.basename(p) for p in glob.glob(f"{APP}/ctl_*.py"))
where = collections.defaultdict(list) # key -> [(file, dump)]
for fn in files:
p = f"{APP}/{fn}"
if not os.path.exists(p):
continue
for k, dumps in units(open(p, encoding="utf-8").read(), docstrings,
True).items():
for d in dumps:
where[k].append((fn, d))
found = collections.Counter()
problems = collections.defaultdict(list)
for k, dumps in old.items():
if k == "Controller":
continue
got = where.get(k, [])
if k == "=_LOG": # one per module that logs, all the same
got = got[:1] * len(dumps) if all(
d == dumps[0] for _, d in got) else []
if len(got) != len(dumps):
problems["?"].append(f"{k}: {len(dumps)} in base, "
f"{len(got)} in head")
continue
for (fn, d), want in zip(got, dumps):
if d != want:
problems[fn].append(f"{k}: AST differs")
else:
found[fn] += 1
for k, got in where.items():
if k not in old and k != "=_LOG":
for fn, _ in got:
problems[fn].append(f"{k}: not in base")
for fn in files:
if not os.path.exists(f"{APP}/{fn}"):
continue
if problems.get(fn):
print(f"DIFFERS {fn}")
for line in problems[fn]:
print(" ", line)
bad += len(problems[fn])
else:
print(f"OK {fn} {found[fn]} units identical")
for line in problems.get("?", []):
print("DIFFERS ", line)
bad += 1
total = sum(found.values())
print(f"base units {sum(len(v) for k, v in old.items() if k != 'Controller')}"
f", identical in head {total}")
print("OK: code moved, none changed" if not bad else f"FAIL: {bad} problem(s)")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+4 -3
View File
@@ -26,6 +26,7 @@ from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import main_files # noqa: E402
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
@@ -157,7 +158,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt = m.datetime
real_dt = main_files.live.datetime
clock = [0.0]
class _DT(real_dt):
@@ -165,7 +166,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
main_files.live.datetime = _DT
rows = []
try:
t = 0.0
@@ -186,7 +187,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
on_cycle(c, t)
t += TICK
finally:
m.datetime = real_dt
main_files.live.datetime = real_dt
return rows, c
return asyncio.run(go())
+4 -3
View File
@@ -41,6 +41,7 @@ from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, HERE)
import main_files # noqa: E402
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
@@ -348,7 +349,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
if patch is not None:
patch(c)
c.auto = True
real_dt = m.datetime
real_dt = main_files.live.datetime
clock = [start]
class _DT(real_dt):
@@ -356,7 +357,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = _DT
main_files.live.datetime = _DT
plan_hour = None
try:
t = 0.0
@@ -416,7 +417,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
check(c, hass, now, pr)
t += tick
finally:
m.datetime = real_dt
main_files.live.datetime = real_dt
return c
c = asyncio.run(go())
return stats, c
+4 -3
View File
@@ -22,6 +22,7 @@ sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
import opt11_identity as rig # noqa: E402
import main_files # noqa: E402
from app import main as app_main, packmode # noqa: E402
from app.main import Controller # noqa: E402
from app.store import unit_key # noqa: E402
@@ -127,13 +128,13 @@ def run(n, house, *, before=off, dur=T + 90.0, tick=1.0, reg_s=10.0,
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, clock = app_main.datetime, [0.0]
real_dt, clock = main_files.live.datetime, [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return rig.BASE + timedelta(seconds=clock[0])
app_main.datetime = _DT
main_files.live.datetime = _DT
rows = []
try:
t = 0.0
@@ -161,7 +162,7 @@ def run(n, house, *, before=off, dur=T + 90.0, tick=1.0, reg_s=10.0,
reason=c.loop_reason))
t += tick
finally:
app_main.datetime = real_dt
main_files.live.datetime = real_dt
return rows, c
return asyncio.run(go())
+4 -3
View File
@@ -33,6 +33,7 @@ from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import main_files # noqa: E402
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
@@ -222,7 +223,7 @@ class Rig:
i.dev_min, i.dev_max = -MAXW[i.name[-1]], MAXW[i.name[-1]]
c.auto = True
self.c = c
real_dt = m.datetime
real_dt = main_files.live.datetime
clock = [self.start]
class _DT(real_dt):
@@ -230,7 +231,7 @@ class Rig:
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = _DT
main_files.live.datetime = _DT
floor = float(self.o.get("cycle_floor_s", 15))
last_grid, last_cycle, last_n = object(), None, None
try:
@@ -260,7 +261,7 @@ class Rig:
sample(self, c, t)
t += 1.0
finally:
m.datetime = real_dt
main_files.live.datetime = real_dt
return c
return asyncio.run(go())
+4 -3
View File
@@ -33,6 +33,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
import opt11p3_identity as p3 # noqa: E402
import safety79_identity as s79 # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import packmode # noqa: E402
from app.store import unit_key # noqa: E402
@@ -76,7 +77,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None):
if modes:
extra.update(p3.ARB, battery_modes_enabled=True)
seen, extra_rows = [], []
real_resolve = m.resolve
real_resolve = main_files.live.resolve
def spy(claims, **kw):
seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi,
@@ -96,7 +97,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None):
db = os.path.join(tempfile.mkdtemp(prefix="s78-"), "tariff.db")
real = rig.Controller
rig.Controller = functools.partial(real, peak_db_path=db)
m.resolve = spy
main_files.live.resolve = spy
try:
rows, c = rig.run(n, extra=extra, dur=dur, load=load, soc=s79.soc,
cut=lambda u, t: False,
@@ -105,7 +106,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None):
store=store_cheap(n) if modes else None)
finally:
rig.Controller = real
m.resolve = real_resolve
main_files.live.resolve = real_resolve
return [r + (x,) for r, x in zip(rows, extra_rows)], c
+4 -3
View File
@@ -29,6 +29,7 @@ from datetime import timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import packmode # noqa: E402
from app.main import Controller # noqa: E402
@@ -140,13 +141,13 @@ def run(load, *, margin=50, dur=2700.0, rating=2000.0, cap=3500.0,
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, clock = m.datetime, [0.0]
real_dt, clock = main_files.live.datetime, [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return rig.BASE + timedelta(seconds=clock[0])
m.datetime = _DT
main_files.live.datetime = _DT
rows, late = [], random.Random(8100)
errors = []
@@ -173,7 +174,7 @@ def run(load, *, margin=50, dur=2700.0, rating=2000.0, cap=3500.0,
rows.append((t, c.target, round(plant.hist[-1][1], 6)))
t += TICK
finally:
m.datetime = real_dt
main_files.live.datetime = real_dt
logging.getLogger("goodwe").removeHandler(h)
return plant, c, rows, errors
plant, c, rows, errors = asyncio.run(go())
+4 -3
View File
@@ -23,6 +23,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import main_files # noqa: E402
import app.main as m # noqa: E402
from app import tariff # noqa: E402
@@ -71,7 +72,7 @@ def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None,
"""Rows (opt11_identity's fields plus every claim, the guard and the
phases) and the controller. `month_peak` banks that peak this month."""
seen, extra_rows = [], []
real_resolve = m.resolve
real_resolve = main_files.live.resolve
def spy(claims, **kw):
seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi,
@@ -93,7 +94,7 @@ def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None,
"2026-09-02T10:00:00+00:00")).connection.commit()
real = rig.Controller
rig.Controller = functools.partial(real, peak_db_path=db)
m.resolve = spy
main_files.live.resolve = spy
try:
rows, c = rig.run(n, extra=dict(s79.P1, **OPT, **(extra or {})),
dur=dur, load=load, soc=soc or s79.soc,
@@ -102,7 +103,7 @@ def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None,
store=store or s79.store_charging(n))
finally:
rig.Controller = real
m.resolve = real_resolve
main_files.live.resolve = real_resolve
return [r + (x,) for r, x in zip(rows, extra_rows)], c
+4 -3
View File
@@ -25,6 +25,7 @@ from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import main_files # noqa: E402
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
@@ -169,7 +170,7 @@ def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None,
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt = m.datetime
real_dt = main_files.live.datetime
clock = [0.0]
class _DT(real_dt):
@@ -177,7 +178,7 @@ def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None,
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
main_files.live.datetime = _DT
out = {"stale": [], "refused": [], "rows": [], "c": c}
was = {i.name: False for i in c.inverters}
was_ref = {i.name: 0 for i in c.inverters}
@@ -213,7 +214,7 @@ def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None,
dict(plant.reg), [mm.phase for mm in c.maints]))
t += 1.0
finally:
m.datetime = real_dt
main_files.live.datetime = real_dt
return out
return asyncio.run(go())