diff --git a/goodwe_controller/test_arbitrage.py b/goodwe_controller/test_arbitrage.py index 9a6e436..e128a26 100644 --- a/goodwe_controller/test_arbitrage.py +++ b/goodwe_controller/test_arbitrage.py @@ -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")) diff --git a/goodwe_controller/test_clamp.py b/goodwe_controller/test_clamp.py index f651a2d..e5713e3 100644 --- a/goodwe_controller/test_clamp.py +++ b/goodwe_controller/test_clamp.py @@ -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) diff --git a/goodwe_controller/test_defaults_pin.py b/goodwe_controller/test_defaults_pin.py index c8acc21..2d43e5c 100644 --- a/goodwe_controller/test_defaults_pin.py +++ b/goodwe_controller/test_defaults_pin.py @@ -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 = [] diff --git a/goodwe_controller/test_delivery.py b/goodwe_controller/test_delivery.py index 2688c84..3f6715a 100644 --- a/goodwe_controller/test_delivery.py +++ b/goodwe_controller/test_delivery.py @@ -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", diff --git a/goodwe_controller/test_inverter.py b/goodwe_controller/test_inverter.py index 4b7281e..217c7fc 100644 --- a/goodwe_controller/test_inverter.py +++ b/goodwe_controller/test_inverter.py @@ -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 diff --git a/goodwe_controller/test_maint10.py b/goodwe_controller/test_maint10.py index 6025c00..4a109c9 100644 --- a/goodwe_controller/test_maint10.py +++ b/goodwe_controller/test_maint10.py @@ -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) diff --git a/goodwe_controller/test_maint13.py b/goodwe_controller/test_maint13.py index 2e76f48..f510b57 100644 --- a/goodwe_controller/test_maint13.py +++ b/goodwe_controller/test_maint13.py @@ -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) diff --git a/goodwe_controller/test_messages.py b/goodwe_controller/test_messages.py index c007134..a4e5a3c 100644 --- a/goodwe_controller/test_messages.py +++ b/goodwe_controller/test_messages.py @@ -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 diff --git a/goodwe_controller/test_modes.py b/goodwe_controller/test_modes.py index 7f31f9b..026d16a 100644 --- a/goodwe_controller/test_modes.py +++ b/goodwe_controller/test_modes.py @@ -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): diff --git a/goodwe_controller/test_modes_p4.py b/goodwe_controller/test_modes_p4.py index 698be67..fb16d90 100644 --- a/goodwe_controller/test_modes_p4.py +++ b/goodwe_controller/test_modes_p4.py @@ -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, diff --git a/goodwe_controller/test_opt27.py b/goodwe_controller/test_opt27.py index 7f3809d..3670f12 100644 --- a/goodwe_controller/test_opt27.py +++ b/goodwe_controller/test_opt27.py @@ -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 diff --git a/goodwe_controller/test_opt34.py b/goodwe_controller/test_opt34.py index fdb4698..3db53fc 100644 --- a/goodwe_controller/test_opt34.py +++ b/goodwe_controller/test_opt34.py @@ -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: diff --git a/goodwe_controller/test_opt36.py b/goodwe_controller/test_opt36.py index 331f6ed..1b1d3da 100644 --- a/goodwe_controller/test_opt36.py +++ b/goodwe_controller/test_opt36.py @@ -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()) diff --git a/goodwe_controller/test_packshare.py b/goodwe_controller/test_packshare.py index 561e9ad..b4e3893 100644 --- a/goodwe_controller/test_packshare.py +++ b/goodwe_controller/test_packshare.py @@ -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") diff --git a/goodwe_controller/test_peak_margin.py b/goodwe_controller/test_peak_margin.py index 19f09d9..97aae45 100644 --- a/goodwe_controller/test_peak_margin.py +++ b/goodwe_controller/test_peak_margin.py @@ -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): diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index 42e2f52..f64701b 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -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"} diff --git a/goodwe_controller/test_price_history.py b/goodwe_controller/test_price_history.py index 33827f9..ce04bff 100644 --- a/goodwe_controller/test_price_history.py +++ b/goodwe_controller/test_price_history.py @@ -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") diff --git a/goodwe_controller/test_price_service.py b/goodwe_controller/test_price_service.py index bfe94ae..6b0d1c6 100644 --- a/goodwe_controller/test_price_service.py +++ b/goodwe_controller/test_price_service.py @@ -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: diff --git a/goodwe_controller/test_pv.py b/goodwe_controller/test_pv.py index ad71ba7..090575a 100644 --- a/goodwe_controller/test_pv.py +++ b/goodwe_controller/test_pv.py @@ -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 diff --git a/goodwe_controller/test_refusal.py b/goodwe_controller/test_refusal.py index a396727..04ddf02 100644 --- a/goodwe_controller/test_refusal.py +++ b/goodwe_controller/test_refusal.py @@ -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()) diff --git a/goodwe_controller/test_refusal_units.py b/goodwe_controller/test_refusal_units.py index 4366a7a..a6e689b 100644 --- a/goodwe_controller/test_refusal_units.py +++ b/goodwe_controller/test_refusal_units.py @@ -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()) diff --git a/goodwe_controller/test_rehearse.py b/goodwe_controller/test_rehearse.py index ded905a..4387c72 100644 --- a/goodwe_controller/test_rehearse.py +++ b/goodwe_controller/test_rehearse.py @@ -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) diff --git a/goodwe_controller/test_safety107.py b/goodwe_controller/test_safety107.py index e3f7516..e58c71f 100644 --- a/goodwe_controller/test_safety107.py +++ b/goodwe_controller/test_safety107.py @@ -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 diff --git a/goodwe_controller/test_safety118.py b/goodwe_controller/test_safety118.py index 30058a6..f6fe602 100644 --- a/goodwe_controller/test_safety118.py +++ b/goodwe_controller/test_safety118.py @@ -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"): diff --git a/goodwe_controller/test_safety119.py b/goodwe_controller/test_safety119.py index 069ddc6..91e1204 100644 --- a/goodwe_controller/test_safety119.py +++ b/goodwe_controller/test_safety119.py @@ -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 diff --git a/goodwe_controller/test_safety123.py b/goodwe_controller/test_safety123.py index 2708594..00827cd 100644 --- a/goodwe_controller/test_safety123.py +++ b/goodwe_controller/test_safety123.py @@ -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) diff --git a/goodwe_controller/test_safety125.py b/goodwe_controller/test_safety125.py index 0769255..9ca5fa2 100644 --- a/goodwe_controller/test_safety125.py +++ b/goodwe_controller/test_safety125.py @@ -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()) diff --git a/goodwe_controller/test_safety126.py b/goodwe_controller/test_safety126.py index 5d3275c..87834da 100644 --- a/goodwe_controller/test_safety126.py +++ b/goodwe_controller/test_safety126.py @@ -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()) diff --git a/goodwe_controller/test_safety85.py b/goodwe_controller/test_safety85.py index ed70653..9e4798f 100644 --- a/goodwe_controller/test_safety85.py +++ b/goodwe_controller/test_safety85.py @@ -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]}") diff --git a/goodwe_controller/test_safety87.py b/goodwe_controller/test_safety87.py index 71c70c6..d2be66c 100644 --- a/goodwe_controller/test_safety87.py +++ b/goodwe_controller/test_safety87.py @@ -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( diff --git a/goodwe_controller/test_safety91.py b/goodwe_controller/test_safety91.py index cd7ce12..6f477bb 100644 --- a/goodwe_controller/test_safety91.py +++ b/goodwe_controller/test_safety91.py @@ -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): diff --git a/goodwe_controller/test_safety94.py b/goodwe_controller/test_safety94.py index 512834c..bae8300 100644 --- a/goodwe_controller/test_safety94.py +++ b/goodwe_controller/test_safety94.py @@ -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: diff --git a/goodwe_controller/test_safety95.py b/goodwe_controller/test_safety95.py index acc1965..a1fdab7 100644 --- a/goodwe_controller/test_safety95.py +++ b/goodwe_controller/test_safety95.py @@ -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()) diff --git a/goodwe_controller/test_sma10_two_idle_states.py b/goodwe_controller/test_sma10_two_idle_states.py index b395333..d338938 100644 --- a/goodwe_controller/test_sma10_two_idle_states.py +++ b/goodwe_controller/test_sma10_two_idle_states.py @@ -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() diff --git a/goodwe_controller/test_split_patches.py b/goodwe_controller/test_split_patches.py new file mode 100644 index 0000000..7bd26bb --- /dev/null +++ b/goodwe_controller/test_split_patches.py @@ -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="", 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. 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") diff --git a/goodwe_controller/test_static_tariff.py b/goodwe_controller/test_static_tariff.py index 2537ac3..378a73d 100644 --- a/goodwe_controller/test_static_tariff.py +++ b/goodwe_controller/test_static_tariff.py @@ -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, )", "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\", )` fallback " "either - it calls static_status(o, now), and the option is read once, " "inside the framework", diff --git a/goodwe_controller/test_tariff24.py b/goodwe_controller/test_tariff24.py index f5b74a4..abf6110 100644 --- a/goodwe_controller/test_tariff24.py +++ b/goodwe_controller/test_tariff24.py @@ -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 diff --git a/goodwe_controller/test_tariff24_weather.py b/goodwe_controller/test_tariff24_weather.py index 5d69662..10c6322 100644 --- a/goodwe_controller/test_tariff24_weather.py +++ b/goodwe_controller/test_tariff24_weather.py @@ -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 diff --git a/goodwe_controller/test_ux54_dst.py b/goodwe_controller/test_ux54_dst.py index 850873b..288ce97 100644 --- a/goodwe_controller/test_ux54_dst.py +++ b/goodwe_controller/test_ux54_dst.py @@ -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) diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index 7389bf3..0d9e172 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -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 " diff --git a/goodwe_controller/tools/main_files.py b/goodwe_controller/tools/main_files.py new file mode 100644 index 0000000..4ef7056 --- /dev/null +++ b/goodwe_controller/tools/main_files.py @@ -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() diff --git a/goodwe_controller/tools/maint21_same_ast.py b/goodwe_controller/tools/maint21_same_ast.py new file mode 100644 index 0000000..7564a42 --- /dev/null +++ b/goodwe_controller/tools/maint21_same_ast.py @@ -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.`). +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:])) diff --git a/goodwe_controller/tools/opt11_identity.py b/goodwe_controller/tools/opt11_identity.py index f775816..4bf29be 100644 --- a/goodwe_controller/tools/opt11_identity.py +++ b/goodwe_controller/tools/opt11_identity.py @@ -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()) diff --git a/goodwe_controller/tools/opt11p3_replay.py b/goodwe_controller/tools/opt11p3_replay.py index a9eac56..589c22f 100644 --- a/goodwe_controller/tools/opt11p3_replay.py +++ b/goodwe_controller/tools/opt11p3_replay.py @@ -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 diff --git a/goodwe_controller/tools/safety103_plant.py b/goodwe_controller/tools/safety103_plant.py index 122f939..544b0df 100644 --- a/goodwe_controller/tools/safety103_plant.py +++ b/goodwe_controller/tools/safety103_plant.py @@ -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()) diff --git a/goodwe_controller/tools/safety128_rig.py b/goodwe_controller/tools/safety128_rig.py index 3d97eec..1e8898d 100644 --- a/goodwe_controller/tools/safety128_rig.py +++ b/goodwe_controller/tools/safety128_rig.py @@ -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()) diff --git a/goodwe_controller/tools/safety78_identity.py b/goodwe_controller/tools/safety78_identity.py index 06af703..8815a2f 100644 --- a/goodwe_controller/tools/safety78_identity.py +++ b/goodwe_controller/tools/safety78_identity.py @@ -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 diff --git a/goodwe_controller/tools/safety81_sim.py b/goodwe_controller/tools/safety81_sim.py index a0accfc..c3dcc12 100644 --- a/goodwe_controller/tools/safety81_sim.py +++ b/goodwe_controller/tools/safety81_sim.py @@ -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()) diff --git a/goodwe_controller/tools/safety84_identity.py b/goodwe_controller/tools/safety84_identity.py index 87730c0..ff1675c 100644 --- a/goodwe_controller/tools/safety84_identity.py +++ b/goodwe_controller/tools/safety84_identity.py @@ -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 diff --git a/goodwe_controller/tools/safety94_rig.py b/goodwe_controller/tools/safety94_rig.py index f832c5e..9e4bc65 100644 --- a/goodwe_controller/tools/safety94_rig.py +++ b/goodwe_controller/tools/safety94_rig.py @@ -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())