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
@@ -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