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:
co-authored by
Claude
parent
56fc41fffa
commit
0d49027daa
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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]}")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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()
|
||||
@@ -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:]))
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user