Compare commits

...
30 Commits
Author SHA1 Message Date
glenn schrooyenandClaude b2d3066966 CI-18: selector carries a change through a test that imports another test
checks / unit (pull_request) Waiting to run
infra-pin / fresh (pull_request) Waiting to run
A test first reached as text (a pattern reader) was never expanded again when a
later path reached it by import, so a test importing it was missed. Expanded and
reached are now tracked apart; the empty diff selects the safety set. Test added.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-11-15 21:17:26 +01:00
glenn schrooyenandClaude 17b65785b7 CI-18: checks.yml comments carry the measured run time and the worker count
infra-pin / fresh (pull_request) Waiting to run
checks / unit (pull_request) In progress
Comment-only: "~15 to run" was stale (runs took 27 min at 6 workers); the CI-18
run at 10 workers took 20 min 56 s (run 1682).

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-11-15 21:16:05 +01:00
glenn schrooyenandClaude a727dd7ee2 CI-18: affected-test selector for dev rounds; split the longest test files; 10 workers
infra-pin / fresh (pull_request) Successful in 2s
checks / unit (pull_request) Successful in 20m45s
- .gitea/ci/affected_tests.py: which test_*.py a diff reaches (static import
  graph plus names in strings, transitive), a fixed safety set, SELECT ALL for
  anything it cannot map. .gitea/ci/run-affected.sh runs the selection in the
  CI image. run-tests.sh takes CI_ONLY for that; checks.yml never sets it and
  test_affected_tests.py fails if it does. The merge gate still runs every file.
- test_modes_p4 -> p4/p4b/p4c/p4d, test_safety126 -> +b, test_safety125 -> +b:
  same check lines, none dropped (146, 35, 45 before and after).
- run-tests.sh: 10 workers instead of 6, new longest-first order.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-11-15 20:54:00 +01:00
admin 7513fd9439 Merge pull request 'EV-03: car charger seam, Alfen HA driver, read-only Car card' (#253) from EV-03 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-11-15 16:32:50 +01:00
glenn schrooyenandClaude Opus 5.5 d7b4298092 EV-03 review: one power-unit table for the probe and the reading
checks / unit (pull_request) Successful in 27m3s
infra-pin / fresh (pull_request) Successful in 1s
POWER_UNITS is now derived from _W_PER, so the start-up probe and the
reading cannot drift apart. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 16:05:19 +01:00
ev03-devandClaude af7e96e5e5 EV-03 round 4: a power entity that cannot be believed is unreachable
checks / unit (pull_request) Successful in 27m11s
infra-pin / fresh (pull_request) Successful in 2s
- every HA power unit is accepted and converted (mW, W, kW, MW), in the probe
  and in the reading
- a power entity that answers in another unit, with no unit, or not as a
  number makes the reading unavailable: after 30 s it is "Charger not
  reachable" (amber row, WARNING, notification after 5 min), with a Why that
  names the cause (EN and NL)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-15 15:06:24 +01:00
ev03-devandClaude 918d44ab33 EV-03 round 3: the power entity is required and reads in W or kW
checks / unit (pull_request) Successful in 27m42s
infra-pin / fresh (pull_request) Successful in 1s
A charger with no usable power entity is refused as "not set up", like a
missing setpoint, read-back or state entity, so House and the house profile
behave as with no charger. An entity that is merely unavailable at probe time
is still the unreachable path. NL: help text "met rust", chip "Laadpaalstoring".

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-15 13:35:09 +01:00
ev03-devandClaude 678ea13895 EV-03 round 2: a missing car power is unknown, and mode3 E/F has a state
checks / unit (pull_request) Successful in 26m50s
infra-pin / fresh (pull_request) Successful in 2s
- CarTracker.car_w returns None when the current reading carries no power
  (power entity unavailable or in a non-power unit); it can no longer raise
  into the control cycle, /status or the MQTT values.
- mode3 E and F are the card state "fault" (EN and NL).
- chargersim gains fault().

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-15 12:02:17 +01:00
ev03-devandClaude 984f4335a0 EV-03: the charger reader keeps its own power-unit rule
checks / unit (pull_request) Successful in 27m39s
infra-pin / fresh (pull_request) Successful in 1s
test_pv.py part 6 says only the PV reader's named modules may mention its
material, so chargers.py no longer imports pv.py.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-15 10:19:14 +01:00
ev03-devandClaude 89a2eb0b74 EV-03: car charger seam, Alfen HA driver, read-only Car card
checks / unit (pull_request) Failing after 37m42s
infra-pin / fresh (pull_request) Successful in 2s
Read-only: nothing in this change writes to the charger.
- app/chargers.py: ChargerDriver, AlfenHaDriver, UnavailableCharger, ChargerReading, CarTracker
- options ev_charger/ev_current_entity/ev_max_a/ev_phases (default off), Settings group, validator, census
- Car card on Home, unreachable row and notification, two-writer info row, MQTT sensors
- car out of the House tile and the house profile
- tools/chargersim.py charger model, test_ev03.py

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-15 09:52:15 +01:00
admin 6bf37597f8 Merge pull request 'CI-17: test_peakguard's real-cycle fixture runs at its own instant (host-load independent)' (#252) from CI-17 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-11-15 00:50:50 +01:00
glenn schrooyenandClaude 63043ea219 CI-17 round 2: stale/dead fixtures take rejected_since from the pinned instant; comment made true
checks / unit (pull_request) Successful in 36m34s
infra-pin / fresh (pull_request) Successful in 1s
The two fixtures ran their cycles at _SRC_NOW but set rejected_since from the
real clock, so the cycle saw inverter-b live while live_units() saw it stale.
Both now use _SRC_NOW, and a check asserts both clocks agree. run_cycle's
comment no longer claims ctl_cycle.py:49 is the only clock read.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-14 23:39:49 +01:00
glenn schrooyenandClaude 39ca1df232 CI-17: test_peakguard's real-cycle fixture reads the clock once, so host load cannot add peak@site
checks / unit (pull_request) Successful in 37m11s
infra-pin / fresh (pull_request) Successful in 2s
c_src's telegrams were laid up to one datetime.now(); each cycle() then read
the real clock again (ctl_cycle.py:49). Under --cpus 0.25 a cycle takes ~1.3 s,
so by the 23rd sweep cycle the last telegram was older than max_hold_s (30 s),
the guard counted the gap and filed peak@site. The cycles now run AT the
fixture's own instant. Test only; the exact-set checks are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-14 22:11:09 +01:00
admin 6c0546beb0 Merge pull request 'MAINT-28 fix-forward: the peak pause ends only on a settled estimate; a pack under its peak reserve gives nothing for the house' (#248) from MAINT-28-fix into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-11-14 19:16:38 +01:00
glenn schrooyenandClaude 79dfcde2bb MAINT-28: test_maint28b live try reads B's lowest SoC through min() (test_named_absence)
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 27m44s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-14 17:19:15 +01:00
glenn schrooyenandClaude de06a2c6f5 MAINT-28 fix round 5: while the pause holds, the batteries give only what the guard needs
checks / unit (pull_request) Failing after 38m55s
infra-pin / fresh (pull_request) Successful in 1s
H-1: one site limit while the pause holds (the period's largest floor, never under this cycle's), so the other pack no longer gives for the house. New live-try test (A 11 %, B 25 %, 90 min at 6.5 kW) red on 07d23d2. M-1: a 45-min rig-shape test that goes red when the settled check is forced True. M-2/L-2: DECISIONS. CHANGELOG line. test_safety84 n2_charge_banked re-pinned (quarters 3349 and 3950 W, aim 3950 W).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-14 16:40:54 +01:00
glenn schrooyenandClaude 9a76f1a968 MAINT-28 fix-forward rounds 1-4, re-applied after the release/1.0 merge
The same diff as f3f9483..07d23d2 (cap, settled rule, tests, DECISIONS). The merge took the release/1.0 tree, so the rounds are applied as one patch on top of the restored #247.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-14 16:07:12 +01:00
glenn schrooyenandClaude 0020756aab Revert "Revert "MAINT-28: during a real peak the maintenance charge stays paused (#247)""
Restores #247's code on this branch after the release/1.0 merge. The fix rounds follow in the next commit.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-14 16:07:12 +01:00
glenn schrooyenandClaude 0381fffbe1 Merge release/1.0 (0.22.0) into MAINT-28-fix
release/1.0 reverted #247 (7bc8ad2). The tree is taken as release/1.0 here; the next two commits revert that revert and re-apply this PR's four fix rounds, so no history is rewritten.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-14 16:07:06 +01:00
admin b700c0be92 Merge pull request 'Release 0.22.0' (#250) from release-0.22.0 into release/1.0
infra-pin / fresh (push) Successful in 2s
checks / unit (pull_request) Successful in 26m8s
infra-pin / fresh (pull_request) Successful in 1s
Release 0.22.0 (#250)
2026-11-14 13:21:22 +01:00
glenn schrooyenandClaude Opus 5.5 e79d403560 Release 0.22.0: CHANGELOG wording (SAFETY-136 row, SAFETY-111, MAINT-28, MAINT-26)
checks / unit (pull_request) Successful in 26m33s
infra-pin / fresh (pull_request) Successful in 2s
- SAFETY-136 row: the gain is at 10 s late; at 15 s a rig test sent more.
- SAFETY-111: the cycle needs more than 4 nights; the 36 h limit ends it.
- MAINT-28: rig counts were 393-468 flips per 45 min, not one figure.
- MAINT-26 / MAINT-25: the next try is the first start hour after the wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 12:54:18 +01:00
glenn schrooyenandClaude 634001cb26 Release 0.22.0: CHANGELOG fixes (duplicate Known issues heading, missing full stop)
checks / unit (pull_request) Successful in 27m4s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-11-14 11:52:41 +01:00
glenn schrooyenandClaude 94214d609e Release 0.22.0: version and CHANGELOG
checks / unit (pull_request) Successful in 36m47s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-11-14 10:44:36 +01:00
admin 4c0122a8b6 Merge pull request 'Revert MAINT-28 (PR #247) from release/1.0' (#249) from MAINT-28-revert into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-11-14 02:35:34 +01:00
glenn schrooyenandClaude 7bc8ad264b Revert "MAINT-28: during a real peak the maintenance charge stays paused (#247)"
checks / unit (pull_request) Successful in 27m32s
infra-pin / fresh (pull_request) Successful in 2s
Takes PR #247 (f3f9483) out of release/1.0 so the 19 Nov release ships
MAINT-24 + MAINT-25 without MAINT-28. The rig failed f3f9483
(reports/MAINT-28-test-report.md) and the fix-forward PR #248 review found
a High regression (reports/MAINT-28-fix-review-report.md). MAINT-28
continues on PR #248 after the release.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-14 01:33:24 +01:00
glenn schrooyenandClaude 07d23d2eed MAINT-28 fix-forward round 4: the paused pack's cap is its own part of the guard's floor, the largest of the period, per unit
checks / unit (pull_request) Successful in 27m27s
infra-pin / fresh (pull_request) Successful in 2s
Owner 2026-11-13 ("ok max", relayed by the manager): while the pause holds,
the pack in maintenance CHARGE gives at most the largest floor the guard
claimed in the quarter. The cap is now that pack's own part of the floor
(the floor less what the other units are placed at, split without the cap),
the largest in the 15-minute period, kept per unit (`_m28_hi`). 0 W while
the guard holds it at its reserve. test_maint28b: SoC check stated against
the owner-accepted give (0.70 point, measured 0.64); both-packs-able tests
(B on Cheap hours, B on Solar first); per-unit state and the one-slot
arbitrate. test_maint28 unchanged (5925 W). Pins: test_safety84
n2_charge_banked, test_packshare's one-resolve AST pin (a second call with
the same weights).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-13 23:42:23 +01:00
glenn schrooyenandClaude fb4a449591 MAINT-28 fix-forward round 3: the cap is the guard's largest claim of the period, 0 W for a pack the guard does not place on
checks / unit (pull_request) Successful in 28m17s
infra-pin / fresh (pull_request) Successful in 1s
test_maint28 quarter bound back to aim + 1 W (now 5925 W; the guard alone landed
5960.1 W): the pack's limit is the largest floor the guard claimed in the 15-minute
period. A pack in peak_guard.held (the guard places its claim on the others) gets
0 W. test_safety84 n2_charge_banked re-pinned again, its banked quarters asserted
under the aim. New test: guard claims on the other pack only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-13 21:21:14 +01:00
glenn schrooyenandClaude 776b84ca20 MAINT-28 fix-forward round 2: while the pause holds, the maintenance pack gives only what the peak guard claims
checks / unit (pull_request) Successful in 27m52s
infra-pin / fresh (pull_request) Successful in 1s
Owner go 2026-11-13. The pack in maintenance CHARGE gets a unit limit with hi
= the guard's own floor while the pause holds, at any SoC (replaces the
under-reserve limit). Sim 45 min: A give 2.39 -> 0.02 kWh, SoC 49.0 %, charge
back 4 s. test_safety84 n2_charge_banked re-pinned (192 of 480 rows),
test_maint28 quarter bound to the month peak. Tests: test_maint28b.py.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-13 19:49:19 +01:00
glenn schrooyenandClaude 34b81e8aeb MAINT-28 fix-forward: the peak pause ends only on a settled house estimate, and a pack under its peak reserve gives nothing for the house while it holds
checks / unit (pull_request) Successful in 28m10s
infra-pin / fresh (pull_request) Successful in 1s
Rig FAIL (TEST-23, f3f9483). F-1: the hold ended at each guard step because
grid + site_batt_w added a new meter reading to a battery reading up to 10 s
old. It now ends only when no battery reading may pre-date a step up of its
command (Controller._batts_settled()). F-2: while the pause holds, a pack in
maintenance CHARGE at or under its peak reserve is limited to 0 W, so it gives
only what the guard claims for the peak. Sim: Lag.batt_period_s (a held 10 s
register). Tests: test_maint28b.py. DECISIONS updated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-13 16:56:18 +01:00
admin f3f9483d9c Merge pull request 'MAINT-28: during a real peak the maintenance charge stays paused' (#247) from MAINT-28 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-11-13 06:23:12 +01:00
51 changed files with 6209 additions and 724 deletions
+403
View File
@@ -0,0 +1,403 @@
#!/usr/bin/env python3
"""CI-18: which test_*.py files does a diff affect? For DEV and REVIEW rounds only.
python3 .gitea/ci/affected_tests.py # merge-base(origin/release/1.0) .. working tree
python3 .gitea/ci/affected_tests.py --base A --head B # a committed range
python3 .gitea/ci/affected_tests.py --files f1 f2 # explicit changed paths (no git)
... --names # only the file names, one per line
It prints what it chose and why, then the verdict line `SELECT n of N` or
`SELECT ALL`. It never replaces the merge gate: the required context
`checks / unit (pull_request)` runs every test_*.py (checks.yml, run-tests.sh).
WHAT IS SELECTED (the union of):
1. every test file that reaches a changed file through the static graph of
the tree: imports (absolute, relative, `from pkg import mod`), plus any
STRING in the source that names a file (`"predeploy_check.py"`,
`"tools/x.py"`, a dotted `app.mqtt` as in `-m` or import_module). Tests
launch tools by path and read sources as text, so imports alone miss them.
Transitive: a module that imports a changed module carries its tests.
2. every changed test file;
3. the ALWAYS-RUN SAFETY SET below.
A test that walks a directory (`HERE / "app"`, `glob("test_*.py")`) names
that directory or a pattern in a string, so it depends on every file there.
FAILS OPEN. Anything it cannot map selects ALL: a changed file that is not
.py (config.yaml, translations, static, Dockerfile, run-tests.sh, docs, ...),
a deleted or renamed file, a .py nothing references, a file that does not
parse, a base ref that does not exist, a diff it cannot read. Doubt is never "skip". (An EMPTY diff selects the
safety set only: nothing changed.)
OVER-APPROXIMATION IS THE DESIGN. A false edge runs one more file; a missing
edge would skip one. The scan favours false edges (strings, not comments;
suffix matching of module names; any directory name a test spells out).
"""
import argparse
import ast
import fnmatch
import os
import re
import subprocess
import sys
# --- the always-run safety set -------------------------------------------------
# Name and reason, one each. They run on every selection, whatever changed.
# Add a file only with a reason and say it here.
SAFETY_SET = {
"test_bytecode.py": "grades the mutation harness itself; a stale-bytecode "
"survivor makes every other 'it went red' claim void",
"test_ci_gate.py": "grades the gate's checkout/guard shell; cheap, and it "
"is the file that says what a green means",
"test_split_patches.py": "tripwire that tests patch a Controller global "
"where it is read; spans app/main.py and ctl_*.py",
"test_defaults_pin.py": "pins every schema default against config.yaml "
"and scans all modules for literal fallbacks",
"test_docs.py": "operator docs must say what the code does (validator, "
"entity ids)",
}
SKIP_DIRS = {".git", "__pycache__", "node_modules"}
class Fail(Exception):
"""The selector cannot answer; the caller selects ALL."""
def repo_root(start):
p = subprocess.run(["git", "-C", start, "rev-parse", "--show-toplevel"],
capture_output=True, text=True)
if p.returncode != 0:
raise Fail("not inside a git work tree")
return p.stdout.strip()
def all_py(root):
out = []
for d, dirs, files in os.walk(root):
dirs[:] = [x for x in dirs if x not in SKIP_DIRS]
for f in files:
if f.endswith(".py"):
out.append(os.path.relpath(os.path.join(d, f), root))
return sorted(out)
def module_names(rel):
"""Every dotted suffix a file can be imported as: a/b/c.py -> c, b.c, a.b.c."""
parts = rel[:-3].split(os.sep)
if parts[-1] == "__init__":
parts = parts[:-1]
return {".".join(parts[i:]) for i in range(len(parts))} if parts else set()
class Index:
def __init__(self, files):
self.files = files
self.by_mod = {}
self.by_base = {}
self.dirs = {}
# directories that hold test files (the project root): naming one is
# naming the whole tree, which says nothing about a dependency
self.test_dirs = {os.path.basename(os.path.dirname(f))
for f in files
if os.path.basename(f).startswith("test_")}
for f in files:
for m in module_names(f):
self.by_mod.setdefault(m, set()).add(f)
self.by_base.setdefault(os.path.basename(f), set()).add(f)
for comp in os.path.dirname(f).split(os.sep):
if comp:
self.dirs.setdefault(comp, set()).add(f)
def parents(self, rel):
"""Package __init__ files an import of `rel` also runs."""
out = set()
parts = rel.split(os.sep)[:-1]
while parts:
init = os.sep.join(parts + ["__init__.py"])
if init in self.files:
out.add(init)
parts.pop()
return out
def docstring_nodes(tree):
ids = set()
for n in ast.walk(tree):
if isinstance(n, (ast.Module, ast.ClassDef, ast.FunctionDef,
ast.AsyncFunctionDef)) and n.body:
b = n.body[0]
if isinstance(b, ast.Expr) and isinstance(b.value, ast.Constant) \
and isinstance(b.value.value, str):
ids.add(id(b.value))
return ids
# Calls that list a directory: a file making one depends on what the
# directories it names contain (`ast.walk` is not one, and is excluded).
ENUM_CALLS = {"glob", "iglob", "rglob", "listdir", "scandir", "iterdir",
"walk", "copytree"}
# Edge reasons that mean "executes the other file" (they propagate through a
# test file); every other reason means "reads it as data or text".
IMPORT_KINDS = ("import ", "from ", "package of", "dynamic import")
DOTTED = re.compile(r"[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+")
IDENT = re.compile(r"^[A-Za-z_]\w*$")
def edges_of(rel, root, idx):
"""{file `rel` depends on: first reason}."""
path = os.path.join(root, rel)
try:
with open(path, encoding="utf-8") as fh:
src = fh.read()
tree = ast.parse(src, filename=rel)
except (OSError, SyntaxError, UnicodeDecodeError, ValueError) as e:
raise Fail("cannot parse %s: %s" % (rel, e))
deps = {}
def add(target, why):
if target != rel:
deps.setdefault(target, why)
def add_mod(name, why):
hit = idx.by_mod.get(name)
if hit:
for t in hit:
add(t, why)
for p in idx.parents(t):
add(p, "package of " + t)
here = os.path.dirname(rel).split(os.sep) if os.path.dirname(rel) else []
dynamic = False
enumerates = False
consts = []
skip = docstring_nodes(tree)
for n in ast.walk(tree):
if isinstance(n, ast.Import):
for a in n.names:
add_mod(a.name, "import " + a.name)
elif isinstance(n, ast.ImportFrom):
base = n.module or ""
if n.level:
up = here[:len(here) - (n.level - 1)] if n.level > 1 else here
base = ".".join(up + ([base] if base else []))
if base:
add_mod(base, "from %s import" % base)
for a in n.names:
if a.name != "*":
add_mod((base + "." if base else "") + a.name,
"from %s import %s" % (base, a.name))
elif isinstance(n, ast.Call):
fn = n.func
name = fn.attr if isinstance(fn, ast.Attribute) else \
fn.id if isinstance(fn, ast.Name) else ""
obj = fn.value.id if isinstance(fn, ast.Attribute) and \
isinstance(fn.value, ast.Name) else ""
if name in ("import_module", "__import__", "run_module",
"spec_from_file_location", "run_path"):
dynamic = True
if name in ENUM_CALLS and obj != "ast":
enumerates = True
elif isinstance(n, ast.Constant) and isinstance(n.value, str) \
and id(n) not in skip and len(n.value) <= 4000:
consts.append(n.value)
named_dirs = set()
for s in consts:
for m in re.finditer(r"[\w./-]*\.py\b", s):
base = os.path.basename(m.group(0))
for t in idx.by_base.get(base, ()):
add(t, "names file %r" % base)
for tok in DOTTED.findall(s):
add_mod(tok, "names module %r" % tok)
# `app.mqtt.Thing` or `tools.x.main`: shorten from the right
parts = tok.split(".")
for i in range(len(parts) - 1, 0, -1):
add_mod(".".join(parts[:i]), "names module %r" % tok)
if dynamic and IDENT.match(s):
add_mod(s, "dynamic import of %r" % s)
for piece in set(re.split(r"[/\\]", s)) if ("/" in s or "\\" in s) \
else {s}:
if piece in idx.dirs and piece not in idx.test_dirs:
named_dirs.add(piece)
# A file that enumerates directories depends on everything in the ones it
# names, and on what a glob pattern it spells matches in its own directory.
if enumerates:
for d in named_dirs:
for t in idx.dirs[d]:
add(t, "enumerates directory %r" % d)
for s in consts:
if "*" in s and s.endswith(".py"):
pat = os.path.basename(s)
# `test_*.py` globs the tests next to this file; any other
# pattern globs the directories this file names, and only when
# it names none, its own directory.
own = pat.startswith("test_") or not named_dirs
for t in idx.files:
inside = (own and os.path.dirname(t)
== os.path.dirname(rel)) or \
any(c in named_dirs for c in os.path.dirname(t)
.split(os.sep))
if inside and fnmatch.fnmatch(os.path.basename(t), pat):
add(t, "pattern %r" % s)
return deps
def build(root, files):
idx = Index(set(files))
return {f: edges_of(f, root, idx) for f in files}
def is_test(rel):
b = os.path.basename(rel)
return os.path.dirname(rel) == "goodwe_controller" and \
b.startswith("test_") and b.endswith(".py")
def git_changes(root, base, head):
"""[(status, path)] for base..head, or merge-base..working tree."""
def run(*a):
p = subprocess.run(["git", "-C", root] + list(a),
capture_output=True, text=True)
if p.returncode != 0:
raise Fail("git %s failed: %s" % (" ".join(a), p.stderr.strip()))
return p.stdout
if head:
mb = run("merge-base", base, head).strip()
out = run("diff", "--name-status", "--no-renames", "-z", mb, head)
else:
mb = run("merge-base", base, "HEAD").strip()
out = run("diff", "--name-status", "--no-renames", "-z", mb)
toks = out.split("\0")
ch = []
i = 0
while i + 1 < len(toks):
ch.append((toks[i][:1], toks[i + 1]))
i += 2
if not head:
for u in run("ls-files", "-o", "--exclude-standard", "-z").split("\0"):
if u:
ch.append(("A", u))
return ch
def select(root, changes):
"""-> (names, reasons, all_reason). all_reason is not None when ALL."""
files = all_py(root)
tests = sorted(f for f in files if is_test(f))
if not tests:
return None, None, "no test_*.py found under the tree"
reasons = {}
def why(t, r):
reasons.setdefault(t, []).append(r)
if not changes:
for t in SAFETY_SET:
if "goodwe_controller/" + t in tests:
why("goodwe_controller/" + t, "always-run safety set (empty "
"diff): " + SAFETY_SET[t])
return sorted(reasons), reasons, None
changed_py = []
for st, p in changes:
if st not in ("M", "A"):
return None, None, "%s %s (deleted/renamed/type change: the old " \
"importers are not in this tree)" % (st, p)
if not p.endswith(".py"):
return None, None, "changed file %s is not Python (nothing here " \
"can say which tests read it)" % p
if not os.path.exists(os.path.join(root, p)):
return None, None, "changed file %s is not in the work tree" % p
changed_py.append(p)
try:
graph = build(root, files)
except Fail as e:
return None, None, str(e)
rev = {}
for f, deps in graph.items():
for d, r in deps.items():
rev.setdefault(d, {})[f] = r
for p in changed_py:
if is_test(p):
why(p, "changed test file")
continue
# reverse BFS from each changed file
for p in changed_py:
seen = {p: None}
expanded = {p}
queue = [p]
while queue:
cur = queue.pop(0)
for f, r in rev.get(cur, {}).items():
if f not in seen:
seen[f] = (cur, r)
# A test that merely READS another file (names it, globs it)
# is affected by it, but is not a dependency of a third test:
# test_inverter reading every test_*.py as text must not make
# everything that imports a module "affected by test_inverter".
# Only an import carries the change on through a test file.
if f not in expanded and (not is_test(f)
or r.startswith(IMPORT_KINDS)):
expanded.add(f)
queue.append(f)
hit = [f for f in seen if is_test(f) and f != p]
if not hit and not is_test(p):
return None, None, "changed %s is referenced by no test, " \
"directly or through another file" % p
for t in hit:
chain, cur = [], t
while seen[cur]:
nxt, r = seen[cur]
chain.append("%s (%s)" % (os.path.basename(nxt), r))
cur = nxt
why(t, "reaches changed %s via %s" % (p, " <- ".join(
reversed(chain)) if len(chain) > 1 else chain[0]))
for t, r in SAFETY_SET.items():
if "goodwe_controller/" + t in tests:
why("goodwe_controller/" + t, "always-run safety set: " + r)
names = sorted(reasons)
return names, reasons, None
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--base", default="origin/release/1.0")
ap.add_argument("--head", default=None,
help="a ref; default: the working tree")
ap.add_argument("--files", nargs="*", help="changed paths, repo-relative")
ap.add_argument("--root", default=None)
ap.add_argument("--names", action="store_true",
help="print only the chosen file names (one per line), "
"or every test name on SELECT ALL")
a = ap.parse_args(argv)
try:
root = a.root or repo_root(os.getcwd())
changes = [("M", f) for f in a.files] if a.files is not None \
else git_changes(root, a.base, a.head)
names, reasons, all_reason = select(root, changes)
except Fail as e:
names, reasons, all_reason = None, None, str(e)
root = a.root or os.getcwd()
tests = sorted(f for f in all_py(root) if is_test(f)) \
if os.path.isdir(root) else []
if all_reason is not None:
if a.names:
print("\n".join(os.path.basename(t) for t in tests))
else:
print("SELECT ALL - %s" % all_reason)
print("SELECT ALL (%d of %d)" % (len(tests), len(tests)))
return 0
if a.names:
print("\n".join(os.path.basename(t) for t in names))
return 0
print("changed: %s" % (", ".join(p for _, p in changes) or "(nothing)"))
for t in names:
for r in reasons[t][:3]:
print(" %-34s %s" % (os.path.basename(t), r))
print("SELECT %d of %d" % (len(names), len(tests)))
return 0
if __name__ == "__main__":
sys.exit(main())
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# CI-18: run the test files a diff affects, in the CI image, for a dev or review
# round. NOT the merge gate: `checks / unit (pull_request)` runs all of them.
#
# sh .gitea/ci/run-affected.sh # merge-base(origin/release/1.0) .. working tree
# sh .gitea/ci/run-affected.sh --base A --head B
# INFRA_DIR=/path/to/admin-goodwe@pin sh .gitea/ci/run-affected.sh
#
# Arguments go to affected_tests.py (which prints what it chose and why). It
# selects ALL when it cannot map a change, so a run of this script is never a
# silent skip. Needs python3, git and docker on the host. INFRA_DIR is the
# admin/goodwe checkout at the commit in .gitea/infra-pin (test_freeze.py part 3
# needs sim/hwsim.py); without it test_freeze.py fails, as it does in CI without
# the checkout, and the script says so.
set -eu
repo=$(git rev-parse --show-toplevel)
cd "$repo"
python3 .gitea/ci/affected_tests.py "$@"
only=$(python3 .gitea/ci/affected_tests.py --names "$@" | tr '\n' ' ')
if [ -z "${only# }" ]; then echo "nothing selected"; exit 0; fi
IMG=${CI_IMAGE:-goodwe-addon-checks:1}
docker image inspect "$IMG" >/dev/null 2>&1 || docker build -q -t "$IMG" .gitea/ci >/dev/null
infra=
if [ -n "${INFRA_DIR:-}" ]; then
infra="-v $INFRA_DIR:/w/goodwe:ro"
else
echo "INFRA_DIR is not set: test_freeze.py will fail if it is selected (it needs admin/goodwe at the pin)."
fi
# --network none: the tests need none. --memory: a runaway test must not take the host.
# shellcheck disable=SC2086
exec docker run --rm ${CONTAINER_NAME:+--name "$CONTAINER_NAME"} --network none \
--memory "${CI_MEMORY:-16g}" -e PYTHONDONTWRITEBYTECODE=1 -e GOODWE_REQUIRE_HWSIM=1 \
-e CI_JOBS="${CI_JOBS:-6}" -e CI_ONLY="$only" \
-v "$repo:/w/goodwe-addon:ro" $infra "$IMG" \
sh -eu /w/goodwe-addon/.gitea/ci/run-tests.sh
+33 -10
View File
@@ -41,15 +41,20 @@
# Add a file here only with a measured reason, and say it in this list.
SERIAL="test_loop.py test_freeze.py test_safety87.py"
#
# LONGEST FIRST (SAFETY-125's CI run, job 2580; then a local parallel run, seconds:
# modes_p4 758, safety126 634 (SAFETY-126, local, one core), safety125 508 on hotfix-0.19.2 only, modes_p3 364, safety119 328,
# price_run_fixes 184, safety90 160, safety103 134, safety94 114, peakguard 113,
# opt20 102, safety97 101, safety81 100). With JOBS workers pulling from one
# queue, starting the longest files first keeps the tail short: the batch takes
# about as long as the longest file. A sort order, not a selection.
HEAVY="test_modes_p4.py test_safety126.py test_safety125.py test_modes_p3.py test_safety119.py test_price_run_fixes.py test_safety90.py test_safety103.py test_safety94.py test_peakguard.py test_opt20.py test_safety97.py test_safety81.py"
# LONGEST FIRST. With JOBS workers pulling from one queue, starting the longest
# files first keeps the tail short: the batch takes about as long as the longest
# file or the total work divided by JOBS, whichever is more. A sort order, not a
# selection. Seconds, CI-18 local runs at 10 workers on the 12-core host (one
# file's time grows with the load; the order is what matters): opt36 653,
# safety119 704, opt34 515, safety126 402, safety125 388, safety126b 364,
# modes_p3 377, safety94 360, modes_p4c 291, modes_p4b 238, tariff24_weather 235,
# modes_p4d 196, price_run_fixes 197, safety103 189, safety90 185, safety97 163.
# CI-18 split test_modes_p4, test_safety126 and test_safety125 so no one file
# is much over 400 s; test_opt34 and test_opt36 already fan out inside, and
# test_safety119 part 4 is one coupled block, so those three stay whole.
HEAVY="test_opt36.py test_safety119.py test_opt34.py test_safety126.py test_safety125.py test_safety126b.py test_modes_p3.py test_safety94.py test_modes_p4c.py test_modes_p4b.py test_tariff24_weather.py test_modes_p4d.py test_safety125b.py test_price_run_fixes.py test_safety103.py test_safety90.py test_safety97.py test_peakguard.py test_opt20.py test_safety81.py"
#
JOBS=${CI_JOBS:-6}
JOBS=${CI_JOBS:-10}
cd "${TESTS_DIR:-/w/goodwe-addon/goodwe_controller}"
# one FILE: run it, keep its output in its own file, print its block whole.
@@ -78,6 +83,21 @@ if [ "$1" = "test_*.py" ]; then
echo "NO test_*.py FOUND - the mount is empty or the layout moved. NOTHING has been checked."
exit 1
fi
ALL_COUNT=$#
# CI-18: CI_ONLY="test_a.py test_b.py" runs just those files, for a developer's
# local round (run-affected.sh sets it). checks.yml never sets it and
# test_affected_tests.py fails if it ever does: the merge gate is the glob above.
# Every name must be an existing test_*.py here, and the last line says PARTIAL.
PARTIAL=0
if [ -n "${CI_ONLY:-}" ]; then
PARTIAL=1
for f in $CI_ONLY; do
case "$f" in test_*.py) ;; *) echo "CI_ONLY: '$f' is not a test_*.py name"; exit 1 ;; esac
[ -f "$f" ] || { echo "CI_ONLY: '$f' does not exist in $(pwd)"; exit 1; }
done
set -- $CI_ONLY
echo "PARTIAL RUN (CI_ONLY): $# of $ALL_COUNT test files. NOT the merge gate."
fi
echo "$# test file(s) to run: $JOBS at a time, then the serial group"
RUN_DIR=$(mktemp -d /tmp/ci-tests.XXXXXX)
@@ -86,14 +106,14 @@ export RUN_DIR
: > "$RUN_DIR/ser"
for f in $HEAVY; do
case " $SERIAL " in *" $f "*) continue ;; esac
if [ -f "$f" ]; then echo "$f" >> "$RUN_DIR/par"; fi
case " $* " in *" $f "*) if [ -f "$f" ]; then echo "$f" >> "$RUN_DIR/par"; fi ;; esac
done
for f in "$@"; do
case " $SERIAL $HEAVY " in *" $f "*) continue ;; esac
echo "$f" >> "$RUN_DIR/par"
done
for f in $SERIAL; do
if [ -f "$f" ]; then echo "$f" >> "$RUN_DIR/ser"; fi
case " $* " in *" $f "*) if [ -f "$f" ]; then echo "$f" >> "$RUN_DIR/ser"; fi ;; esac
done
echo "parallel: $(wc -l < "$RUN_DIR/par") file(s); serial: $(wc -l < "$RUN_DIR/ser") file(s)"
@@ -121,5 +141,8 @@ for f in "$@"; do
fi
done
echo "$ran file(s), $bad failed, $(( $(date +%s) - t0 ))s wall"
if [ "$PARTIAL" = 1 ]; then
echo "PARTIAL RUN: $ran of $ALL_COUNT test files ran. This is NOT the merge gate; CI runs all $ALL_COUNT."
fi
if [ "$bad" = 0 ] && [ "$rc" = 0 ]; then exit 0; fi
exit 1
+3 -2
View File
@@ -209,7 +209,7 @@ on:
jobs:
unit:
runs-on: [self-hosted, goodwe-rig]
timeout-minutes: 40 # ~15 to run (CI-15: 6 files at a time; the serial suite was ~60 and hit the runner cap); the rest is margin for a busy host
timeout-minutes: 40 # ~21 to run, measured (CI-18: 10 files at a time; was ~27 at 6, ~60 serial, 22-37 on a busy host); the rest is margin for a busy host
env:
# ⚠️ UNDER /home/homelab, NOT $GITHUB_WORKSPACE (ENV-01, and re-measured
# during CI-01). act's workspace is `/root/.cache/act/<id>/hostexecutor`, a
@@ -534,7 +534,8 @@ jobs:
# shorter timeout - a 60 s limit was tried during CI-01 and reported
# exactly the false hang the docstring predicts.
#
# ⚠️ CI-15: THE FILES RUN SIX AT A TIME, NOT ONE AFTER ANOTHER. The serial
# ⚠️ CI-15: THE FILES RUN SEVERAL AT A TIME (TEN SINCE CI-18, SIX BEFORE),
# NOT ONE AFTER ANOTHER. The serial
# loop summed 3 557 s on SAFETY-125's run (job 2580) and ran past the
# runner's 60-minute cap. The loop now lives in .gitea/ci/run-tests.sh (it
# comes from the merged tree, like the Dockerfile), so the same script runs
+13 -1
View File
@@ -107,10 +107,22 @@ on real hardware, and the comments in `control.py` say which.
### CI and the infra pin
CI (`.gitea/workflows/checks.yml`) runs every `test_*.py` (six at a time, the wall-clock ones alone afterwards: `.gitea/ci/run-tests.sh`), with `admin/goodwe` checked out next to this repo at the commit in `.gitea/infra-pin`, so `test_freeze.py` grades against the real `sim/hwsim.py`. If that checkout fails, the job fails. The local stub is used only in local runs, and it prints `STUB USED` when it is.
CI (`.gitea/workflows/checks.yml`) runs every `test_*.py` (ten at a time, the wall-clock ones alone afterwards: `.gitea/ci/run-tests.sh`), with `admin/goodwe` checked out next to this repo at the commit in `.gitea/infra-pin`, so `test_freeze.py` grades against the real `sim/hwsim.py`. If that checkout fails, the job fails. The local stub is used only in local runs, and it prints `STUB USED` when it is.
The pin tracks `admin/goodwe` **release/1.0**, not master. Two non-required checks go red when release/1.0 has moved past the pin: `infra-pin / fresh` here, on any add-on PR or push to release/1.0, and `addon-pin / fresh` in `admin/goodwe`, on every push to its release/1.0. Nothing re-runs `addon-pin` after a bump, so it stays red on that admin/goodwe commit; `infra-pin / fresh` green on the bump is the all-clear.
**Bump the pin** whenever either is red. Set `.gitea/infra-pin` to the new release/1.0 SHA (`git ls-remote <gitea>/admin/goodwe.git refs/heads/release/1.0`) in a PR, and let `checks / unit` grade the tests against it. CI reads `admin/goodwe` with the Actions secret `INFRA_READ_TOKEN`.
### Running only the affected tests (dev and review rounds)
`checks / unit` runs every `test_*.py` and stays the only merge gate. For a local round, `sh .gitea/ci/run-affected.sh` runs just the files your diff reaches, in the same `goodwe-addon-checks:1` image and through the same `run-tests.sh`:
```bash
INFRA_DIR=~/path/to/admin-goodwe-at-the-pin sh .gitea/ci/run-affected.sh # release/1.0 .. working tree
sh .gitea/ci/run-affected.sh --base origin/release/1.0 --head my-branch # a committed range
python3 .gitea/ci/affected_tests.py --files goodwe_controller/app/price.py # only list, with the reasons
```
It prints each chosen file and why, then `SELECT n of N`. A test file is chosen if it imports a changed file, directly or through other modules, or names it in a string (a tool launched by path); a changed test file is chosen; and so is a fixed safety set (`SAFETY_SET` in `affected_tests.py`, one reason each). Anything it cannot map (a non-Python file such as `config.yaml`, a deletion, a `.py` no test reaches) prints `SELECT ALL`. Most modules are imported by `app/main.py`, so a change to `app/` usually selects nearly everything; the saving is for test-only, tool and helper changes. A partial run ends with `PARTIAL RUN ... NOT the merge gate`. `INFRA_DIR` is the `admin/goodwe` checkout at `.gitea/infra-pin`, which `test_freeze.py` needs.
## Compatibility
**Check the serial number, not the model name.** Characters 6-8 of the serial are
+126
View File
@@ -1,5 +1,131 @@
# Changelog
### Not in a release yet
- A new *Car* card on Home shows an Alfen charger: whether a car is connected, its power and today's energy. It only reads the charger, and the car's power no longer counts in the House tile or in what the batteries plan for the night. It is off until you choose a charger in Settings ([[EV-03]]).
- During a real peak, a battery in its maintenance charge now stays paused instead of switching between charging and giving, and the batteries give only what keeps the peak under the limit ([[MAINT-28]]).
## 0.22.0
**A minor bump: a battery's monthly charge now gets the cheap night, the plan knows about the 7-day wait, a slow meter is followed more gently, and one peak-guard stop no longer costs a night charge its whole period. No option changes.**
No option was added, removed or renamed, and no default changed (86 options). **Existing installs need no option change:** options that start 0.21.0 also start 0.22.0, and the Supervisor form is the same. It is a minor bump because control acts differently in the cases below; every saved setting still starts.
**A battery's monthly charge gets the cheap night.** While one battery is in the charge part of its monthly cycle, the other battery no longer grid-charges ahead of it, and the charge aims at this month's highest peak, as the peak guard does, not at the lower limit. In a rig test with the live setup (two batteries, one in its monthly charge), the cycling battery charged at about 1.5 kW in every cheap quarter (13.3 kWh in a night) and the other battery bought nothing; before, it charged at about 225 W or not at all. The peak limit was kept. At the start of a month, before a peak above the limit has been seen, this charge is much slower ([[SAFETY-111]]).
**The plan knows about the 7-day wait.** After a failed monthly cycle, Home no longer shows a cycle every night. It says the cycle waits, and shows the day the wait ends. The cycle tries again at the first start hour after the wait ([[MAINT-26]]). In a rig test no cycle was drawn during the wait, and none started.
**One peak-guard stop no longer costs the night charge its period.** With battery modes on, a night grid charge that the peak guard stops early in a 15-minute period used to stay stopped until the period ended. It may now start again after a minute, once per period, and not in the last minute. A real overload is cut as fast as before. In a rig test of the 01:00 case the night's charge was 0.45 to 0.46 kWh, against 0.42 to 0.44 kWh before. The status says "grid charge paused, it tries again in a minute" while it waits.
**A slow meter is followed more gently.** With a meter whose readings come about 4 s or more apart, such as a 5 s meter, control now takes smaller steps after a big change until the meter shows it. With Home Assistant readings 10 s late, less power goes to the grid when loads switch on and off: 44 % less in a simulation and 29 % less in a rig test. Meters of 1 or 2 s are left alone by design (the same commands in 63 simulation runs). With readings 15 s late the gain is smaller (19 % in a simulation), and a rig test sent more to the grid than before; see the known issues.
**No change in behaviour:** the comments in the main program file are shorter, and the file is split into smaller files by job. A rig test saw the same start-up, stops and commands.
Contents of this release, in merge order since 0.21.0 (`5e00cbb`):
| | |
|---|---|
| `56fc41f` | The comments in the main program file are shorter and clearer. There is no change in behaviour ([[MAINT-20]], #224) |
| `8508ac3` | The main program file is split into smaller files by job. There is no change in behaviour; a rig test saw the same start-up, stops and commands ([[MAINT-21]], #243) |
| `a8ecf17` | With a 5 s meter, control follows the meter more gently after a change. With Home Assistant readings 10 s late, less power goes to the grid when loads switch on and off. Meters of 1 or 2 s are left alone ([[SAFETY-136]], #244) |
| `02eac3b` | A night grid charge that the peak guard stops early in a 15-minute period may start again after a minute, once per period, instead of staying stopped until the period ends. A real overload is cut as fast as before. Battery modes only ([[SAFETY-135]], #245) |
| `1b5f7c9` | While a battery's monthly cycle charges, the other battery no longer grid-charges ahead of it, and the charge aims at this month's highest peak, so it gets the cheap night. After a failed cycle, the plan no longer shows a cycle every night and says the cycle waits ([[MAINT-24]], [[MAINT-25]], #246) |
| `f3f9483`, `4c0122a` | History only: MAINT-28 was merged (#247) and then taken out again (#249). The two cancel out and the code is the same as at `1b5f7c9`. MAINT-28 is not in this release ([[MAINT-28]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s. This is about the guard's discharge only; a grid charge is cut back since 0.19.2 ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long (seen on a site: about 1.2 kW instead of 2.5 kW; the peak limit was kept). At the start of a month, before a peak above the limit, that is about 2 kWh a night in a rig test: the cycle can reach its 36 h limit after about 4 nights and fail, and since 0.22.0 the other battery gets no grid charge on those nights ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
- A steady overload on a 1 s meter can warn, and send the Home Assistant notification, 2 or 3 times at the start of each 15-minute period instead of once. The peak limit is still kept ([[SAFETY-122]]).
- A fixed tariff is priced once per hour, so a price window that starts or ends at :15, :30 or :45 gets the wrong price for part of that hour. Windows that start and end on the hour are right ([[TARIFF-23]]).
- Step 6 of the setup wizard stays empty because of an error in the page, in English and in Dutch ([[UX-51]]).
- On Dutch pages, refusal texts, the last-event line under the Home buttons, the installer details and the trading status sentences are still English, and a few Dutch texts read badly, such as "1 metingen geweigerd" ([[UX-52]]).
- Some price texts say "hour" where the add-on acts on a 15-minute period, such as "cheapest hour" ([[UX-47]]).
- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]).
- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]).
- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]).
- In the last second of a 15-minute period, being a few watts over the aim can make the peak guard ask a battery for full power for one second. When a charge ends on a period edge, the last-second give can fade out over about 10 s, and a rig test exported up to 7.4 kW for about 8 s ([[SAFETY-124]]).
- During the night grid charge, a 15-minute period can end 6 to 14 W over the peak aim, because the battery charges harder than asked. This was seen in simulation; a rig test saw 2 to 8 W ([[OPT-35]]).
- The test rig could not bring back the false peak alarm that [[SAFETY-126]] fixes, so that fix is shown in simulation only. Watch the notifications for a few nights ([[TEST-35]]).
- The night cheap run is sized for the batteries' full charging power, not for what the peak limit leaves. Under a tight limit the run can end before the batteries reach their level, and Home can show a level they do not reach (79.9 % against a shown 95 % in a rig test) ([[OPT-25]]).
- By day, a Leftover solar plan that has only a daily forecast figure counts the sun from now, not from when the figure was read. It counts about 22 % too much for a figure one hour old; the night plan is not affected ([[OPT-30]]).
- The night charge can buy too much in a house that uses more at night than by day, such as one with a car charger or heat pump, and on a sunny day, because spare sun in one expensive hour does not count for the later ones ([[OPT-31]]).
- A cheap run that has already started can overlap a new give run after a re-plan, so a battery can charge from the grid at the dearest price for part of an hour. This was seen in a test case, not on a site. Also in 0.19.2 ([[OPT-32]]).
- If the solar forecast sensors stop updating before 17:00, as in winter or further north, there is no forecast from three hours after their last update until 20:00 ([[OPT-33]]).
- With a forecast that gives only a daily total, the sun is spread an hour off on the two clock-change days after the change. The day's total is right ([[OPT-24]]).
- The add-on learns the house's use only once a solar forecast is set, so a site that adds a forecast later waits one more day for its first plan ([[OPT-26]]).
- Home says "No trading on price yet: no prices have arrived" when every battery is on Solar first, though the prices are shown ([[UX-55]]).
- The Home plan header can say "Charges in the cheapest hour, gives power in the dearest" when Home draws no charge, and it says "hour" for 15-minute prices ([[UX-56]]).
- While the peak guard holds back charging, Home hides the rest of the cheap run and gives no reason. The charge starts again when the guard lets go ([[UX-57]]).
- With battery modes on and selling on, Home can draw a "Sells up to" block when the add-on judges the trade not worth it, and then it will not sell ([[UX-58]]).
- Home, the plan and the Forecast tab stop drawing a battery's give at the trading floor, though control goes on giving down to the battery's cut-out ([[UX-59]]).
- During a price outage, the Batteries screen says "Prices differ too little" and Home says "The price gap is too small" ([[UX-60]]).
- With battery modes on and no meter, or a meter that is a Home Assistant entity, a grid charge is not possible and nothing says so ([[UX-61]]).
- On the Batteries screen, a charge the add-on refused in a cheap run reads as "Saves its power for ...", for up to 15 minutes ([[UX-62]]).
- A Leftover solar battery with no capacity set never charges from the grid when a solar forecast is set, and nothing warns about it ([[UX-63]]).
- The "How the modes work" box on the Batteries screen does not describe Cheap hours only ([[UX-64]]).
- With battery modes on, Settings still shows "Trade on the price spread", which the modes ignore. A site with modes on and no price source starts, and the Batteries screen says "No prices, so it follows the house" without pointing to Settings ([[UX-65]]).
- With battery modes off and several equal prices in a row, Home and the Forecast tab draw one price quarter, while the add-on trades every quarter of the run ([[UX-67]]).
- Late in the day, the Forecast tab's "now" line and last bars sit over the axis labels at the right edge ([[UX-68]]).
- The Forecast tab's level curve charges at each battery's full rating. Under a peak limit the levels it draws are too high and a charge block can end early (54.6 % shown against 39.7 % in a rig test) ([[UX-70]]).
- The peak forecast check says maintenance charging is not kept under the peak limit when no peak forecast is set, but since 0.19.2 it is ([[UX-73]]).
- The peak guard row can show the same figure twice, such as "at this rate 3 500 W, over 3 500 W". Also in 0.19.2 ([[UX-74]]).
- If the inverter's setpoint entity appears in Home Assistant after the add-on has started, the page keeps saying "Inverter control: not found in Home Assistant" and the batteries say "An alarm stopped control" until a restart, though control runs ([[UX-75]]).
- In the last minute of a 15-minute period, with Home Assistant readings 30 s late, the period can end about 80 W over the peak aim and over the limit: 3 530 W against a 3 450 W aim and a 3 500 W limit in a rig test with a 2 kW load added 45 s before the end. The peak guard sees the load too late to hold it back ([[SAFETY-133]]).
- With Home Assistant readings 10 s late, the grid charge in a cheap run can swing by several kW. In a test the add-on asked for an 8.7 kW charge (9.6 kW on 0.20.0) ([[SAFETY-132]]).
- A plan rebuilt while the battery levels are missing for a moment can drop a cheap run that has not started yet, so the battery does not charge that day. Seen in a replay of real prices, where it cost about EUR 0.40 of extra grid use; the Leftover solar change in 0.20.1 no longer triggers it in those replays, but the cause stays ([[OPT-37]]).
- On a 5 s meter, Home Assistant readings 10 s late are still not caught, and readings 15 s late only after about 4 minutes. Control now follows the meter more gently, so less goes to the grid: in a simulation with a changing load, 340 Wh became 189 Wh at 10 s late and 363 Wh became 296 Wh at 15 s late. In a rig test, 15 s late exported 13 to 19 % more than before ([[SAFETY-136]], [[SAFETY-134]]).
- With readings 10 to 15 s late, the hold can end in the middle of the delay and the swing can come back. Seen in rig tests with a 2 s and a 5 s meter ([[SAFETY-134]]).
- With readings 10 s late on a 1 s meter, the grid can still move between -422 and +340 W while the batteries are held. With a very long delay (30 to 90 s) the request can swing by about 900 W every few seconds. Seen in review tests ([[SAFETY-130]]).
- A battery that answers very slowly (10 s or more) can be taken for late Home Assistant readings and held, and the page then blames Home Assistant. A GoodWe battery settles in about 6 s and this has not been seen on one ([[SAFETY-131]]).
- The page row for late readings reads awkwardly in English ("Home Assistant readings show requests about N seconds late, so control holds the batteries"), and the Dutch text misses a word ([[UX-78]]).
- When the add-on stops, a write that takes more than 2 s can still land after the 0 W, and the 30 s safety timer on the ESPHome board then sets 0 W. After the Stop button, a cycle that had already decided can send its share (560 to 670 W) just after the 0 W; the next cycle sends 0 W again ([[SAFETY-138]]).
- In the last hour of a cheap run, Home can draw a battery's grid charge up to an hour past the time control stops charging it. It only draws too much, never too little ([[UX-79]]).
- A tomorrow solar forecast sensor that reports just after midnight with yesterday's value can be used as the new day's forecast for up to 12 hours. This has not been seen on a site ([[OPT-39]]).
- During a cheap-hours charge, at some 15-minute boundaries the add-on sends 0 W for one tick ("no trade (headroom)") and then goes on. Seen in a rig test, in 5 of 8 cases; once, with a frozen battery reading, it left a battery at 0 W until 23:45 ([[OPT-40]]).
- During a real peak, a battery in its maintenance charge can switch between charging and giving, about 400 times in 45 minutes in rig tests. That means more wear, but no higher peak ([[MAINT-28]]).
- While one battery's monthly cycle charges, the other battery's card on Home says "Saves its power for higher prices", and the plan still shows its grid charge in the cheap hours, though it will not charge ([[UX-80]]).
- After a failed monthly cycle, the plan line shows the day the wait ends, such as 18 November, not when the cycle tries again, such as 19 November at 19:00 ([[MAINT-26]]).
- At the start of a cheap window the peak guard can step once although the period is under the aim, and the step can land on a battery in its monthly charge. In a rig test it gave 1.7 to 2.3 kW for 6 s ([[SAFETY-140]]).
- A grid charge at full power in the last seconds of a 15-minute period can run into the next period, where the peak guard cuts it back ([[SAFETY-139]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.21.0
**A minor bump: control now holds the batteries when Home Assistant readings come late, Home draws the whole night's grid charge, and the shutdown 0 W is sent last. No option changes.**
@@ -1,6 +1,6 @@
# MAINT-28 - during a real peak the maintenance charge stays paused
**Status: for review.** Tests: `test_maint28.py`. No option added; the peak aim, the month peak and the MAINT-13 numbers are unchanged. MAINT-24 follow-up (rig test finding F-1, `reports/MAINT-24-test-report.md`).
**Status: fix-forward after the rig FAIL (TEST-23, f3f9483).** Tests: `test_maint28.py`, `test_maint28b.py`. No option added; the peak aim, the month peak and the MAINT-13 numbers are unchanged. MAINT-24 follow-up (rig test finding F-1, `reports/MAINT-24-test-report.md`).
## What happened (rig x10, house 6.5 kW, month aim 5.95 kW, A in maintenance CHARGE)
`peakguard.shaping_suspended()` pauses the charge while the forecast is over the aim or the guard is engaged. MAINT-24 moved the aim from 3450 W to the month aim (SAFETY-80), so the pause no longer lasts the whole peak: the guard gives, its projection drops under 5950 W and it idles ("heading for 5 6xx W, under this month's peak"), the pause ends, the floor charges A 150-460 W, the grid goes to 6.3-6.9 kW, the guard trips again. A flipped charge/give 468 times in 45 min (base 02eac3b 11). The quarters stayed at the aim; the cost was a battery working against itself and a grid that swung 5.4-6.9 kW.
@@ -8,18 +8,18 @@
## The change
`peakguard.shaping_held(suspended, held, forecast, aim, house)`: `suspended` is the old `shaping_suspended()`, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. The hold is for N=2 and up, and only where MAINT-24 moved the aim (a banked month peak above `peak_cap_w`, so the aim is above cap minus margin). Elsewhere the old rule stays, byte for byte: SAFETY-87's N=1 digests (`n1_charge_low`) and SAFETY-119's N=2 charge at 5050 W over a 5000 W cap with no banked peak are pinned, and 1b5f7c9 behaves as 02eac3b there (a chase that was not a MAINT-24 regression).
Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house is the meter reading plus the batteries' power (`grid + site_batt_w`): the net demand the meter would see with no battery at work. Solar is already in it, and that is right: the aim is a limit on what the meter takes, so a sunny hour that lowers the net demand is real room, and a cloud that raises it brings the hold back at once. It is not the gross load, and it would be wrong to add PV back (that would hold the pause on room the meter really has). It stays put when A switches, except for the age of the battery reading (up to 10 s on the rig); the 300 W line is far larger than that lag moves it.
Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house is the meter reading plus the batteries' power (`grid + site_batt_w`): the net demand the meter would see with no battery at work. Solar is already in it, and that is right: the aim is a limit on what the meter takes, so a sunny hour that lowers the net demand is real room, and a cloud that raises it brings the hold back at once. It is not the gross load, and it would be wrong to add PV back (that would hold the pause on room the meter really has). It does not stay put when a battery steps: see "Fix-forward" below.
Why 300 W: the house wanders +-50 W; once the charge resumes it settles at (aim - house) / 2, so 300 W gives at least 150 W, three times the noise, and charging less than that is not worth a restart.
The flag lives only while the charge-only branch is taken: every cycle that reaches the maintenance loop starts it at False, so a maintenance that ended, a mode change or a missing aim cannot carry a stale True into the next charge (test_maint28.py part 1b). `cycle()` has two returns before that loop, and the flag is kept through them on purpose: "inputs missing" and "readings late" (SAFETY-128) are holds of under `stale_after` seconds that write nothing and decide nothing, so the next full cycle sees the same charge and the same peak. A missed poll must not end a peak pause; a reset there would restart the charge on it (`_maint_shape_suspended` is kept through them the same way).
It only ever keeps the maintenance charge off. No cut-back is delayed or weakened: the guard's claim is filed as before. While the pause is held the loop works as at 02eac3b, so A also gives for the house (quarters then sit under the aim, A's energy goes to the load: 1.67 kWh in the 45 min below, 0.45 kWh on 1b5f7c9). Measured alternative, A capped at 0 W while held and the guard idle (review scratch, same 45 min run): A gives 0.44 kWh in the load instead of 1.66, quarters 5919/5915 W, but A switches give/idle 32 times in 40 min. The uncapped give costs ~1.2 kWh round trip per 45-min peak (a few cents) and ~50 min more maintenance charge, far inside the 36 h timeout, so the steady give is kept.
It only ever keeps the maintenance charge off. No cut-back is delayed or weakened: the guard's claim is filed as before. While the pause is held, the pack in CHARGE gives only its share of the guard's floor (0 W if the guard holds it at its reserve), and the batteries together give only what the guard needs (see Fix-forward, F-2 and H-1). The first version let A give for the house; it was dropped.
## Numbers (sim, real `cycle()`, A rated 2500 W)
House 3000 -> 6500 W for 45 min -> 3000 W, +-50 W every 5 s, banked month peak 6000 W (aim 5950 W), forecast = quarter average so far plus the rest at the present grid (`scratch/MAINT-28/dev/m28_repro.py`; `test_maint28.py` runs the same shape for 15 min to keep the suite short).
| 45 min load | base 02eac3b | 1b5f7c9 | MAINT-28 |
| 45 min load | base 02eac3b | 1b5f7c9 | MAINT-28 (f3f9483, without the lag) |
|---|---|---|---|
| A charge/give reversals | 0 | 52 | 1 (the first trip) |
| A charging seconds in the load (of 2700) | 0 | 367 | 301 (before the first trip) |
@@ -34,3 +34,22 @@ Rig (MAINT-24 test report, F-1): 468 reversals in 45 min on 1b5f7c9, 11 on 02eac
## Not changed
MAINT-24's rate and its tests, N=1, the drain, modes off (digests of 1b5f7c9). The pause for a late or missing forecast.
## Fix-forward (rig FAIL F-1, F-2; `reports/MAINT-28-test-report.md`)
The sim had no battery-reading lag, and the first version said the 300 W line is far larger than the lag moves the estimate. That was wrong. The rig publishes a battery's power every 10 s; the meter is newer. After each guard step charge -> give (+500-1000 W) the next meter reading shows the give and the battery reading still shows the charge, so `grid + site_batt_w` drops by the step, under the 5650 W line: 69/69, 132/133, 215/215, 51/51 releases at a median 5430 W with the house at 6450 W or more. Test: `test_maint28b.py`, sim `Lag.batt_period_s`.
**F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 4 s after the load (12 s in test_maint28b), not 0: the first settled estimate. No cut-back is touched.
**F-2 and the cap.** Owner 2026-11-13 ("1" = cap): while the pause holds, the pack in maintenance CHARGE gives only what the guard claims for the peak, at any SoC; other packs unchanged (replaced by H-1 below). Owner 2026-11-13 ("ok max", relayed by the manager): accepted "while the hold is on, the pack in maintenance CHARGE gives at most the largest floor the peak guard claimed in the current quarter" (about 0.37 kWh per 45-min peak in the sim; quarters at or under the aim).
**H-1 (review 2026-11-14, owner decision 2026-11-14): the batteries together give only what the guard needs.** With the cap on A alone, the loop gave for the whole house with B (Cheap hours): in the live-try shape (A 11 % in CHARGE, B 25 %, house 6.5 kW for 90 min) B gave 4.6 kW, went from 25 to 10 % in 43 min, and two quarters were 6502 and 6499 W against the month peak 6000 W. So while the pause holds, `_m28_share()` also files one site limit (source `charge`, `P_MAINT_SHAPE`, per-site `_m28_site`): `hi` = the largest floor of the period, never under this cycle's floor. The guard's claim is not cut: `hi` is at least the floor, `P_PEAK` outranks `P_MAINT_SHAPE`, and `_peak_on_able` still places the whole floor. Outside the hold nothing is filed. Live try, same shape (test_maint28b): quarters 5357 to 5911 W (aim 5950), B 0.86 kWh and ends at 20.6 % (1b5f7c9: 1.0 kWh, 19.8 %), B switches give/idle once, A never. The re-pinned `n2_charge_banked` quarters are 3349 and 3950 W (aim 3950).
The rule as built (`Controller._m28_share()`): while the pause holds (N>=2, month aim above the cap) the pack gets a unit limit `hi` = the largest part of the guard's floor placed on it in this 15-minute period. Its part in a cycle is the floor less what the other units are placed at in this cycle's split without that limit, so a part the guard's split puts on the other pack is not this pack's. A pack the guard holds at its reserve (`peak_guard.held`) gets 0 W and its part starts again from 0 W. The largest part is kept per unit (`_m28_hi`, keyed by the unit) and dropped in a new period or when the pause ends; only one pack can be in maintenance CHARGE anyway (`maintenance.arbitrate()`, one slot per site).
Why the largest of the period and not the present part: the guard alone lands the quarter 9.5-10 W over its aim in the sim (SAFETY-126: its own projection ends on 5950.0 W but the meter reading it counts lags the true grid), and with the present part only, test_maint28 part 1 ends at 5960.1 W; the largest of the period lets the loop keep A up and it ends at 5925 W. Why not `hi` = 0 W: with the other pack under its reserve the guard needs this one (test_maint28b, house 12 kW, B at 18 %: A gives 1.07 kWh for the guard).
Tests (test_maint28b, 13-min load, A 5 kW): A at 49 %: SoC drop 0.64 point (the owner's 0.37 kWh per 45 min is 0.70 point here; the check is <= 0.70). Both packs able, B on Cheap hours: B is placed nothing, A's limit is the period's largest floor (3087 W) and A gives 3081 W. B on Solar first (it gives for the house): A's limit stays under the period's largest floor by 500 W or more in 740 of 744 paused cycles, and the quarters stay under the aim.
Sim, rig shape, 45 min, house 6500 W (load from minute 10), A in CHARGE at 49 %, B 30 % on Cheap hours: A gives 0.17 kWh (f3f9483 0.44), SoC 50.5 -> 49.0 %, charge/give flips 1, give/idle switches 1, quarters 5358 5897 5911 5176 4477 W, charge back 4 s after the load; B gives 0.23 kWh (before H-1 3.04, f3f9483 0.32). test_safety84 n2_charge_banked: banked quarters 3349 and 3950 W against the aim 3950 W.
Mutants, each red: the freshness check dropped (always settled); the cap dropped; the other units' placement dropped (part = the whole floor); one cap for the site instead of per unit; the 0 W for a held pack dropped (all in `test_maint28b.py`); the period's largest dropped (present part only: `test_maint28.py` part 1 quarter 5960 W); `_batts_settled()` replaced by True at the hold in ctl_cycle.py (the 45-min rig shape in `test_maint28b.py`, one stale release); the site limit dropped (the live try in `test_maint28b.py`: quarters 6502 and 6499 W, B 2.91 kWh to 10.0 %).
+64
View File
@@ -251,6 +251,10 @@ The label and description each setting shows on the Configuration tab, in tab or
| Safety | Opposing inverters time (s) | `circulation_hold_s` | For two or more inverters: how long they may push against each other before a safety stop. Derived value, leave it unchanged. |
| Safety | Not delivering alarm after (s) | `delivery_hold_s` | For two or more inverters: how long one may fall short of its share before an alarm, which changes nothing. Derived value, leave it unchanged. |
| Safety | Shadow mode | `shadow_mode` | The add-on makes every decision but keeps every inverter at 0 W. 'auto' does this while control is stopped, 'on' always, 'off' never. |
| Car charger | Charger | `ev_charger` | Which car charger the add-on reads. Off leaves everything as it is. This version only reads the charger and never changes it. |
| Car charger | Current setting entity | `ev_current_entity` | The entity that sets the charger's current. The add-on finds the charger's other entities from its name. |
| Car charger | Charger limit (A) | `ev_max_a` | The most current the charger may ever be asked for. Never above what the charger reports. Lower it for a weaker fuse or cable. |
| Car charger | Phases | `ev_phases` | How many phases the charger may use: 1, 3, or 1 and 3 (auto). |
| Advanced | Start control on boot | `auto_start` | Off by default. Leave it off until the site is commissioned, so someone presses Start after each restart. |
| Advanced | Control gain | `gain` | How much of the remaining error control corrects each cycle. Measured on real hardware, do not raise it. |
| Advanced | Largest change per cycle (W) | `slew_w` | How far the command may move in one cycle. 0 follows “Inverter rating” and is recommended. |
@@ -3269,6 +3273,57 @@ row is printed **always**, including when it is zero: a row that reports
staleness only once it is stale cannot be told from one that never reports
it.
### Car charger
The add-on can read your car charger and show it on **Home**, on a *Car* card. In
this version it only reads. It never changes the charger, so it is safe to run
beside your own automation. Later versions will add charging modes.
| option | meaning |
|---|---|
| `ev_charger` | `off` (the default: nothing changes at all) or `alfen_ha`, an Alfen charger through its Home Assistant integration. |
| `ev_current_entity` | The charger's current setting, for example `number.wallbox_power_connector_max_current_socket_1`. The add-on finds the charger's other entities from this name. |
| `ev_max_a` | The most current the charger may ever be asked for. It cannot be higher than the station limit the charger reports. |
| `ev_phases` | `1`, `3` or `auto` (1 and 3). |
**What the Car card shows.** The state (Charging, Connected waiting, No car
connected, Car is full, Charger not reachable), the power and current, and the
energy charged today. The car's power is taken out of the **House** tile and out
of the house profile that plans the batteries' night charge, so the batteries
no longer buy energy for a car that charged last night. The peak limit works as
before: the car is still part of what the grid meter sees.
**When the charger does not answer.** After 30 seconds the card says *Charger
not reachable* and one amber row appears. After 5 minutes one notification
follows. While the car reading is older than 15 seconds the House tile shows no
reading, and that moment is left out of the house profile, rather than guessing
how much the car took.
**Another controller.** If the charger's current changes while the add-on writes
nothing, something else sets it, usually your own automation. The page then shows
the grey row *Another controller changes the charger*. That is expected while
you migrate.
**Moving your own automation to the add-on.** Each step can be undone.
1. Install this version and set `ev_charger` to `alfen_ha`. The add-on reads the
charger and writes nothing. Compare the card with your automation for a few
days.
2. When a later version adds charging modes, switch your automation **off**
(do not delete it) in the evening with the car unplugged, then choose the
add-on's Manual mode with the current your helper held. Plug in and check the
current on the card and on the charger's display.
3. On the next sunny day, choose Solar, with *spare sun goes first to* set as
your priority switch was.
4. Keep your Home Assistant helpers for a week. To go back, set the add-on mode
to Off and switch the automation on. After that you can delete the helpers and
the automation.
Never let the automation and the add-on both write the charger.
**Home Assistant entities.** With MQTT the add-on adds three sensors:
`goodwe_car_power`, `goodwe_car_state` and `goodwe_car_today_kwh`.
### Site
| option | meaning |
@@ -3388,6 +3443,14 @@ battery goes on as before. Check that the add-on can reach the internet and that
the price service token is right. The notification goes by itself when a price
is back. It is shown only while the monthly battery cycle is switched on.
### Charger not reachable
The add-on has not been able to read the car charger for 5 minutes, because its
entities are unavailable or the charger's login is off. The charger keeps its
last setting. The add-on changes nothing on the charger in any case. Check the
charger's integration in Home Assistant. The notification goes by itself when
the charger answers again.
### For installers: notifications
A fixed tariff beside the price service raises no notification: it is the
@@ -3414,6 +3477,7 @@ default `info` hides it.
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
| Battery cycle did not finish | warning | `goodwe_maint_failed_<name>` | MAINT-13 |
| No prices | warning | `goodwe_no_prices` | PRICE-01 |
| Charger not reachable | warning | `goodwe_charger_unreachable` | EV-03 |
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
`/config/app/<slug>/documentation`; 2026.5 removed the old
+691
View File
@@ -0,0 +1,691 @@
"""EV-03: the car charger seam, and the Alfen driver behind Home Assistant.
SHAPED LIKE `app/drivers.py`'s `Driver` (DECISIONS/EV-03-charger-seam.md): one
actuator per site, a census key, a read, a clamped write that says whether the
charger took it, and a hand-back. A charger is a modulated actuator with a fail
state, not one of DEV-01's on/off loads.
THIS CARD NEVER WRITES TO THE CHARGER. `write_current`, `write_phases` and
`release` are defined so the seam is whole, and NOTHING in the controller calls
them: Off is the only mode. test_ev03.py greps the controller for a call and
proves a full cycle makes no service call that names a charger entity.
VALUES ARE BELIEVED ONLY IN THEIR OWN UNIT (as TEL-11 does for power): power in
a power unit, energy in a kWh unit, current in A. A reading in another unit is
absent, never converted by a guess. ABSENCE IS NEVER ZERO.
The pure half (`CarTracker`, the state words, the entity table) takes its clock
as an argument, so the checks need no sleep.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from .hass import BAD_STATES
from .solarfc import KWH_PER
OFF = "off"
ALFEN_HA = "alfen_ha"
#: the `ev_charger` values the options accept
SELECTABLE = (OFF, ALFEN_HA)
PHASES_CHOICES = ("1", "3", "auto")
# config.yaml's shipped defaults, mirrored (test_defaults_pin.py checks it).
EV_OPTION_DEFAULTS = {"ev_charger": "off", "ev_current_entity": "",
"ev_max_a": 16, "ev_phases": "auto"}
def max_a(opts: dict):
"""`ev_max_a` as a float, or None when it is not a number."""
raw = opts.get("ev_max_a", EV_OPTION_DEFAULTS["ev_max_a"])
if raw is None or raw == "":
raw = EV_OPTION_DEFAULTS["ev_max_a"]
try:
return float(raw)
except (TypeError, ValueError):
return None
MIN_A = 6.0 # IEC 61851: no car charges below 6 A
ENTITY_MAX_A = 40.0 # the Alfen current entity's own range
# Reachability and the car card's timers, in seconds.
UNREACHABLE_S = 30.0 # entities unavailable / login off this long
NOTIFY_S = 300.0 # then one warning notification
FULL_B_S = 60.0 # mode3 B with current allowed this long: car full
FULL_C_S = 600.0 # mode3 C under FULL_C_W this long: car full
FULL_C_W = 200.0
WRITER_HOLD_S = 3600.0 # "another controller" stays shown this long
IDLE_A = 1.0 # a phase under this draws nothing
# role -> (entity domain, suffix after `<x>_`). `current` is the setpoint, the
# one the installer names; the rest are derived by swapping its suffix.
SETPOINT_SUFFIX = "power_connector_max_current_socket_1"
ROLES = {
"current": ("number", SETPOINT_SUFFIX),
"readback": ("sensor", "main_active_max_current_socket_1"),
"state": ("sensor", "mode3_state_socket_1"),
"display": ("sensor", "display_state_socket_1"),
"power": ("sensor", "active_power_total_socket_1"),
"energy": ("sensor", "meter_reading_socket_1"),
"l1": ("sensor", "current_l1_socket_1"),
"l2": ("sensor", "current_l2_socket_1"),
"l3": ("sensor", "current_l3_socket_1"),
"station": ("number", "max_station_current"),
"station_active": ("sensor", "main_station_active_max_current_socket_1"),
"phases_set": ("select", "installation_max_allowed_phases"),
"phases_read": ("sensor", "connector_1_max_allowed_of_phases"),
"error": ("sensor", "display_error_number_socket_1"),
"login": ("binary_sensor", "https_api_login_status"),
}
#: a missing one of these refuses the charger (not the add-on). `power` is one
#: of them: without it the car's power is unknown for good, House would show no
#: reading for ever and the house profile would drop every sample.
#: The power entity must also read in a Home Assistant power unit (`POWER_UNITS`).
REQUIRED = ("current", "readback", "state", "power")
_SETPOINT_RE = re.compile(r"^number\.(?P<x>.+)_" + SETPOINT_SUFFIX + "$")
_ROLE_ORDER = tuple(ROLES)
#: power unit -> W. The probe and the reading both use this one table.
_W_PER = {"mW": 0.001, "W": 1.0, "kW": 1000.0, "MW": 1000000.0}
POWER_UNITS = tuple(_W_PER)
def census_key(entity) -> str:
"""The SAFETY-28 form: stripped, lowercase."""
return str(entity or "").strip().lower()
def entities_for(setpoint) -> dict:
"""role -> entity id, derived from the setpoint entity by its suffix.
Empty when the setpoint does not end the way the Alfen integration names it."""
m = _SETPOINT_RE.match(str(setpoint or "").strip())
if not m:
return {}
x = m.group("x")
return {role: f"{dom}.{x}_{suffix}" for role, (dom, suffix) in ROLES.items()}
# --------------------------------------------------------------------------- #
# What a read gives
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ChargerReading:
"""One read of the charger. None means "did not answer", never zero."""
available: bool = False # the integration answers
state: str | None = None # mode3: "A", "B", "C", ... or None
connected: bool | None = None
charging: bool | None = None
power_w: float | None = None
energy_kwh: float | None = None # the meter, total increasing
current_a: tuple = () # per phase, as many as read
setpoint_a: float | None = None # the entity the installer named
allowed_a: float | None = None # read-back of the limit the charger applies
phases_allowed: int | None = None
station_a: float | None = None # the charger's own station limit
error: str | None = None # None: no error (or not read)
display: str | None = None
why: str = "" # for the log only, "" when available
def _state_obj(obj):
"""(text, unit, attributes) of a state object, or (None, "", {})."""
if not isinstance(obj, dict):
return None, "", {}
raw = str(obj.get("state", "")).strip()
attrs = obj.get("attributes") if isinstance(obj.get("attributes"), dict) else {}
unit = str(attrs.get("unit_of_measurement") or "").strip()
return (None if raw.lower() in BAD_STATES else raw), unit, attrs
def _amps(obj):
raw, unit, _ = _state_obj(obj)
if raw is None:
return None
try:
v = float(raw)
except ValueError:
return None
if v != v:
return None
if unit == "A":
return v
if unit == "mA":
return v / 1000.0
return None
def _watts(obj):
"""Power in W, believed only in a power unit (its own copy of the rule: the
PV channel's reader stays out of the charger's, test_pv.py part 6)."""
raw, unit, _ = _state_obj(obj)
if raw is None or unit not in _W_PER:
return None
try:
v = float(raw) * _W_PER[unit]
except ValueError:
return None
return v if v == v else None
def _kwh(obj):
raw, unit, _ = _state_obj(obj)
if raw is None or unit not in KWH_PER:
return None
try:
v = float(raw) * KWH_PER[unit]
except ValueError:
return None
return v if v == v and v >= 0 else None
def _phases(obj):
raw, _u, _ = _state_obj(obj)
m = re.match(r"\s*([13])\s*phase", raw or "", re.I)
return int(m.group(1)) if m else None
_MODE3 = re.compile(r"^(?:STATE[_ ]?)?([A-F])$", re.I)
def mode3_letter(text) -> str | None:
m = _MODE3.match(str(text or "").strip())
return m.group(1).upper() if m else None
def display_class(text) -> str | None:
"""The fallback when mode3 is missing: firmware wording, so only the words
the owner's automation keys on. `connected`, `charging`, `none` or None."""
t = str(text or "").lower()
if not t:
return None
if "wait vehicle" in t or "ev connected" in t or "cable connected" in t:
return "connected"
if "charging" in t:
return "charging"
if t in ("available", "ready", "idle"):
return "none"
return None
def reading_from_states(states: dict) -> ChargerReading:
"""A `ChargerReading` from `{role: state object or None}`. Pure."""
def txt(role):
return _state_obj(states.get(role))[0]
login, state_o = txt("login"), states.get("state")
# Reachable: the three entities the card cannot do without answer, and the
# login sensor is not off.
gone = [r for r in REQUIRED if txt(r) is None]
if gone or (login or "").lower() == "off":
why = ("login off" if not gone else
"no answer from " + ", ".join(gone))
return ChargerReading(available=False, why=why)
# A power entity that answers but cannot be believed (a unit the add-on
# cannot convert, no unit, not a number) makes the reading UNAVAILABLE, so
# it goes down the visible "Charger not reachable" path instead of leaving
# the car's power unknown for good with nothing said.
if _watts(states.get("power")) is None:
return ChargerReading(available=False, why="power unit")
letter = mode3_letter(txt("state"))
disp = txt("display")
cls = display_class(disp)
power = _watts(states.get("power"))
currents = tuple(a for a in (_amps(states.get(k)) for k in ("l1", "l2", "l3"))
if a is not None)
if letter is not None:
connected, charging = letter in "BCD", letter in "CD"
elif cls is not None:
connected, charging = cls in ("connected", "charging"), cls == "charging"
else:
connected = charging = None
err = txt("error")
err_none = err is None or err.strip().startswith("0")
return ChargerReading(
available=True, state=letter, connected=connected, charging=charging,
power_w=power, energy_kwh=_kwh(states.get("energy")),
current_a=currents, setpoint_a=_amps(states.get("current")),
allowed_a=_amps(states.get("readback")),
phases_allowed=_phases(states.get("phases_read")),
station_a=_first(_amps(states.get("station_active")),
_amps(states.get("station"))),
error=None if err_none else err, display=disp)
def _first(*vals):
return next((v for v in vals if v is not None), None)
@dataclass(frozen=True)
class ChargerProbe:
"""A read-only look: which derived entities exist."""
setpoint: str = ""
found: tuple = () # ((role, entity), ...)
missing: tuple = ()
station_a: float | None = None
derived: bool = True # False: the setpoint's name does not fit
power_unit: str | None = None # the power entity's unit, when it is
# reading a number and that unit is not W or kW (an unavailable entity
# has no unit to judge: that is the unreachable path, not a refusal)
@property
def required_missing(self) -> tuple:
return tuple(r for r, _e in self.missing if r in REQUIRED)
@property
def ok(self) -> bool:
return (self.derived and not self.required_missing
and self.power_unit is None)
def probe_from(setpoint, exists) -> ChargerProbe:
"""`exists(entity) -> state object or None`. Pure; the caller reads."""
ents = entities_for(setpoint)
if not ents:
return ChargerProbe(setpoint=str(setpoint or ""), derived=False)
found, missing = [], []
for role in _ROLE_ORDER:
(found if exists(ents[role]) is not None else missing).append(
(role, ents[role]))
st = {r: exists(e) for r, e in ents.items()}
raw, unit, _ = _state_obj(st.get("power"))
bad_unit = None
if raw is not None and unit not in POWER_UNITS:
try:
float(raw)
bad_unit = unit or "none"
except ValueError:
pass
return ChargerProbe(str(setpoint), tuple(found), tuple(missing),
station_a=_first(_amps(st.get("station_active")),
_amps(st.get("station"))),
power_unit=bad_unit)
# --------------------------------------------------------------------------- #
# The seam
# --------------------------------------------------------------------------- #
class ChargerNotBound(RuntimeError):
pass
class ChargerDriver:
"""One charger's I/O. Constructed from the options with no Home Assistant;
`bind()` gives it one. Everything that talks to the world is a coroutine."""
name = ""
writer_key = "ev_current_entity"
brand = ""
words: dict = {}
@classmethod
def from_options(cls, opts: dict) -> "ChargerDriver":
raise NotImplementedError
def bind(self, hass) -> "ChargerDriver":
return self
def bind_getter(self, getter) -> "ChargerDriver":
return self
def bound(self) -> bool:
return True
def configured(self) -> bool:
raise NotImplementedError
def actuator_id(self) -> str:
raise NotImplementedError
def entities(self) -> dict:
return {}
async def limits(self) -> tuple:
"""`(min_a, max_a, phases)`; None where unread."""
raise NotImplementedError
async def read(self) -> ChargerReading:
raise NotImplementedError
# --- the write side. DEFINED, UNUSED BY THE CONTROLLER IN THIS CARD. ----
async def write_current(self, a: float) -> bool:
raise NotImplementedError
async def write_phases(self, n: int) -> bool:
raise NotImplementedError
async def release(self, mode: str) -> bool:
raise NotImplementedError
async def probe(self) -> ChargerProbe:
raise NotImplementedError
class AlfenHaDriver(ChargerDriver):
"""The Alfen behind its Home Assistant integration (`alfen_wallbox`)."""
name = ALFEN_HA
brand = "Alfen"
def __init__(self, current_entity="", limit_a=16.0):
self.current_entity = str(current_entity or "").strip()
self.max_a = float(limit_a)
self._ents = entities_for(self.current_entity)
self._hass = None
self._getter = None
@classmethod
def from_options(cls, opts: dict) -> "AlfenHaDriver":
amps = max_a(opts)
return cls(opts.get("ev_current_entity", ""),
EV_OPTION_DEFAULTS["ev_max_a"] if amps is None else amps)
@classmethod
def actuator_id_of(cls, opts: dict) -> str:
return str(opts.get("ev_current_entity") or "").strip()
def bind(self, hass):
self._hass, self._getter = hass, None
return self
def bind_getter(self, getter):
self._hass, self._getter = None, getter
return self
def bound(self) -> bool:
return self._hass is not None or self._getter is not None
def _h(self):
hass = self._getter() if self._getter is not None else self._hass
if hass is None:
raise ChargerNotBound("the charger has no Home Assistant")
return hass
def configured(self) -> bool:
return bool(self._ents)
def actuator_id(self) -> str:
return self.current_entity
def entities(self) -> dict:
return dict(self._ents)
async def _states(self, roles=_ROLE_ORDER) -> dict:
import asyncio
hass = self._h()
got = await asyncio.gather(*(hass.state(self._ents[r]) for r in roles))
return dict(zip(roles, got))
async def limits(self) -> tuple:
st = await self._states(("station", "station_active", "phases_read"))
return (MIN_A, _first(_amps(st["station_active"]), _amps(st["station"])),
_phases(st["phases_read"]))
async def read(self) -> ChargerReading:
if not self.configured():
return ChargerReading(why="not set up")
return reading_from_states(await self._states())
async def probe(self) -> ChargerProbe:
st = await self._states()
by_ent = {self._ents[r]: o for r, o in st.items()}
return probe_from(self.current_entity, by_ent.get)
# --- the write side, UNUSED in EV-03 --------------------------------------
async def write_current(self, a: float) -> bool:
a = max(0.0, min(float(a), self.max_a, ENTITY_MAX_A))
return await self._h().set_number(self.current_entity, a)
async def write_phases(self, n: int) -> bool:
if n not in (1, 3):
return False
return await self._h().call("select", "select_option", {
"entity_id": self._ents["phases_set"],
"option": "1 Phase" if n == 1 else "3 Phases"})
async def release(self, mode: str) -> bool:
return await self.write_current(0.0) if mode == "solar" else True
class UnavailableCharger(ChargerDriver):
"""No charger: `ev_charger` is off, or the charger is refused. `why` says
which, for the Car card; nothing is read and nothing is written."""
name = OFF
def __init__(self, why="off", wanted=""):
self.why = why
self.wanted = wanted
@classmethod
def from_options(cls, opts: dict):
return cls()
def configured(self) -> bool:
return False
def actuator_id(self) -> str:
return ""
async def limits(self) -> tuple:
return None, None, None
async def read(self) -> ChargerReading:
return ChargerReading(why=self.why)
async def write_current(self, a):
return False
async def write_phases(self, n):
return False
async def release(self, mode):
return False
async def probe(self) -> ChargerProbe:
return ChargerProbe()
def mode_of(opts: dict) -> str:
v = str((opts or {}).get("ev_charger") or OFF).strip()
return v if v in SELECTABLE else OFF
def build(opts: dict) -> ChargerDriver:
"""The driver the options name. Off, or a name this build lacks, is
`UnavailableCharger`; nothing here touches Home Assistant."""
which = mode_of(opts)
if which == ALFEN_HA:
drv = AlfenHaDriver.from_options(opts)
if drv.configured():
return drv
return UnavailableCharger("not set up", which)
return UnavailableCharger("off")
def census_entity(opts: dict) -> str:
"""The charger setpoint the SAFETY-28 census counts: only while a charger
is switched on (an off charger writes nothing)."""
if mode_of(opts) == OFF:
return ""
return str(opts.get("ev_current_entity") or "").strip()
# --------------------------------------------------------------------------- #
# The car's state, kept over time
# --------------------------------------------------------------------------- #
CHARGING, WAITING, NONE_, FULL, UNREACH, SETUP, FAULT = (
"charging", "waiting", "none", "full", "unreach", "setup", "fault")
@dataclass
class CarTracker:
"""What the cycle learns about the car from the readings. Pure, clocked by
the caller (monotonic seconds for timers, UTC datetimes for the words).
FRESHNESS (the rule applied, and the one decision EV-03 makes about a stale
car reading): the car's power counts only while the last GOOD reading is not
older than `stale_s`, the same `stale_input_s` bound the meter reading has.
Older than that, `fresh()` is False and the House tile and the house profile
say they do not know, instead of keeping the last car figure."""
stale_s: float = 15.0
reading: ChargerReading | None = None
last_ok: float | None = None
bad_since: float | None = None
bad_since_utc: object = None
notified: bool = False
state_since_utc: object = None
last_status: str | None = None
last_good: ChargerReading | None = None
last_good_utc: object = None
b_since: float | None = None
c_low_since: float | None = None
writer_at_utc: object = None
_prev: tuple | None = None
# today's energy
day: str | None = None
today_kwh: float | None = None
last_kwh: float | None = None
persist: bool = False # set when `today` changed, for the store
def observe(self, r: ChargerReading, now: float, now_utc, day: str):
self.reading = r
usable = r.available and r.power_w is not None
if r.available:
self.bad_since = None
self.bad_since_utc = None
elif self.bad_since is None:
self.bad_since, self.bad_since_utc = now, now_utc
if usable:
self.last_ok = now
self.last_good, self.last_good_utc = r, now_utc
if r.available:
self._writer(r, now_utc)
self._energy(r, day)
self._timers(r, now)
self._status_edge(now, now_utc)
# -- reachability ----------------------------------------------------------
def unreachable(self, now: float) -> bool:
return self.bad_since is not None and now - self.bad_since >= UNREACHABLE_S
def notify_due(self, now: float) -> bool:
return (self.bad_since is not None and not self.notified
and now - self.bad_since >= NOTIFY_S)
def fresh(self, now: float) -> bool:
return self.last_ok is not None and now - self.last_ok <= self.stale_s
def car_w(self, now: float):
"""The car's power now, or None (unknown) when the reading is not fresh
OR the current reading carries no power. Never the last good figure: a
read that says "no power" contradicts it, and absence is never zero. It
never raises (telemetry has no foot in the control path)."""
r = self.reading
if not self.fresh(now) or r is None or not r.available \
or r.power_w is None:
return None
try:
return max(0.0, float(r.power_w))
except (TypeError, ValueError):
return None
# -- the two-writer detector ----------------------------------------------
def _writer(self, r, now_utc):
cur = (r.setpoint_a, r.allowed_a)
prev = self._prev
if prev is not None:
for a, b in zip(prev, cur):
if a is not None and b is not None and a != b:
self.writer_at_utc = now_utc
# keep the last known value where one read came back empty
self._prev = tuple(b if b is not None else (prev[i] if prev else None)
for i, b in enumerate(cur))
def other_writer(self, now_utc) -> bool:
t = self.writer_at_utc
return t is not None and (now_utc - t).total_seconds() <= WRITER_HOLD_S
# -- today's kWh -----------------------------------------------------------
def load(self, saved):
if isinstance(saved, dict):
try:
self.day = str(saved["day"])
self.today_kwh = float(saved["kwh"])
self.last_kwh = (None if saved.get("last") is None
else float(saved["last"]))
except (KeyError, TypeError, ValueError):
self.day = self.today_kwh = self.last_kwh = None
def dump(self) -> dict:
return {"day": self.day, "kwh": round(self.today_kwh or 0.0, 3),
"last": self.last_kwh}
def _energy(self, r, day):
e = r.energy_kwh
if self.day != day:
self.day, self.today_kwh = day, 0.0
self.last_kwh = e
self.persist = True
return
if e is None:
return
if self.last_kwh is not None:
# a meter that goes back has been reset: count from its new zero
d = e - self.last_kwh if e >= self.last_kwh else e
if d:
self.today_kwh = (self.today_kwh or 0.0) + d
self.persist = True
self.last_kwh = e
if self.today_kwh is None:
self.today_kwh = 0.0
# -- the car's state -------------------------------------------------------
def _timers(self, r, now):
allowed = r.allowed_a is not None and r.allowed_a >= MIN_A
self.b_since = (self.b_since if self.b_since is not None else now) \
if (r.state == "B" and allowed) else None
low = (r.state == "C" and allowed and r.power_w is not None
and r.power_w < FULL_C_W)
self.c_low_since = (self.c_low_since if self.c_low_since is not None
else now) if low else None
def status(self, now: float, configured: bool = True):
"""One of the state words, or None before the first reading."""
if not configured:
return SETUP
if self.unreachable(now):
return UNREACH
r = self.reading
if r is None or not r.available:
return None if self.last_good is None else self.last_status
if r.state in ("E", "F"): # the charger's own error states
return FAULT
if r.state == "A" or (r.state is None and r.connected is False):
return NONE_
if r.state in ("B", "C", "D") or r.connected:
if self.b_since is not None and now - self.b_since >= FULL_B_S:
return FULL
if self.c_low_since is not None and \
now - self.c_low_since >= FULL_C_S:
return FULL
return CHARGING if r.charging else WAITING
return None
def _status_edge(self, now, now_utc):
s = self.status(now)
if s != self.last_status and s is not None:
self.last_status, self.state_since_utc = s, now_utc
elif s is None and self.last_status is None:
pass
def phases_in_use(self) -> int | None:
r = self.reading
if r is None or not r.current_a:
return None
n = sum(1 for a in r.current_a if a >= IDLE_A)
return n or None
+23
View File
@@ -401,6 +401,29 @@ class AlarmsMixin:
await self.hass.call("persistent_notification", "dismiss",
messages.prices_back().data())
async def check_charger(self) -> None:
"""EV-03. One warning notification when the car charger has not
answered for 5 minutes, taken down when it does; the WARNING log line
is edge-logged. READ ONLY: this raises a notification and calls no
service on the charger."""
if not self.charger.configured():
return
now = self.car_now()
self.car_log_edges(now)
if self.car.notify_due(now) and not self._car_announced:
self._car_announced = True
self.car.notified = True
since = brussels(self.car.bad_since_utc)
await self.hass.call("persistent_notification", "create",
messages.charger_unreachable(
f"{since:%H:%M}",
self.charger.brand or "Alfen").data())
elif self.car.bad_since is None and self._car_announced:
self._car_announced = False
self.car.notified = False
await self.hass.call("persistent_notification", "dismiss",
messages.charger_back().data())
async def check_fixed_note(self) -> None:
"""PRICE-01 G1. First cycle only: one INFO line when `static_profile`
sits beside `price_service` (the plan's last fallback, not a fault, so
+25 -6
View File
@@ -29,7 +29,7 @@ from . import shadow
from .price import Uncertainty
from .tariff import next_quarter
from .watchdog import loop_is_following
from .ctl_trade import pack_energy_of
from .ctl_trade import M28_REASON, pack_energy_of
from .rows import why_key, why_raised
_LOG = logging.getLogger("goodwe")
@@ -574,10 +574,11 @@ class CycleMixin:
else float(self.grid) + float(self.site_batt_w))
_susp = peakguard.shaping_suspended(
self.peak_guard, self.peak_fc, maint_aim)
_m28 = (len(self.inverters) > 1
and maint_aim > self.peak_aim_w())
self._maint_peak_hold = peakguard.shaping_held(
_susp, _peak_held and len(self.inverters) > 1
and maint_aim > self.peak_aim_w(),
self.peak_fc, maint_aim, house)
_susp, _peak_held and _m28, self.peak_fc, maint_aim,
house, self._batts_settled())
if self._maint_peak_hold:
# Edge-logged: level-logging here would write one line per
# cycle for as long as the event lasts and flush the 40-line
@@ -588,6 +589,18 @@ class CycleMixin:
"suspended so the peak claim is not "
"contradicted",
hint=messages.hint("maint_paused"))
# MAINT-28: while held this pack gives at most the most of
# the guard's floor placed on it this period
# (`_m28_share()`).
# DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md
shape = ((0.0 if self.peak_guard is not None
and inv.name in self.peak_guard.held
else max([c.lo for c in claims
if c.source == peakguard.SOURCE]
+ [self._m28_was(self.actuator_of(
"maintenance", inv), now)],
default=0.0), M28_REASON)
if _m28 else None)
else:
self._maint_shape_suspended = False
hi = 0.0
@@ -604,10 +617,12 @@ class CycleMixin:
floor = self._charge_headroom(floor, claims)
if floor > 0:
hi, reason = -floor, "cheap-window charge"
shape = (hi, reason)
if shape is not None:
# PER-INVERTER, like the SET above and for the same reason -
# the shaping is about the pack being charged, which is `inv`.
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi,
reason=reason,
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE,
hi=shape[0], reason=shape[1],
actuator=self.actuator_of("maintenance",
inv)))
@@ -855,6 +870,9 @@ class CycleMixin:
self.loop_clamp_facts = ()
self._loop_decision = None
# MAINT-28: a paused pack gives at most its largest part of the floor
# in this period.
self._m28_share(claims, now)
# SAFETY-28. A new resolution is a new tick, and the previous record is
# stale from this line onwards. Incremented BEFORE resolve() so that the
# record resolve() builds is stamped with the tick it is for.
@@ -1029,6 +1047,7 @@ class CycleMixin:
# Not a safety check, so it is one of the cheap ones: an attribute test
# and, on a change only, one Home Assistant call.
await self.check_price_gap()
await self.check_charger()
await self.check_fixed_note()
# SAFETY-31's alarm, and its boundary is the LATCH, not a number: it
+99
View File
@@ -13,7 +13,9 @@ from . import freshness
from . import lateness
from . import messages
from . import pv as pv_read
from . import chargers
from .price import brussels, price_option, provider_configured
from .hass import OUTAGE
from .price_chain import uses_chain
from .static_tariff import static_status
from .rows import tariff_plan_row
@@ -48,6 +50,10 @@ FC_HOLD_KEY = "solar_fc_hold"
FORECAST_READ_S = 60.0
# EV-03: the car's energy today, kept so a restart does not lose it.
CAR_TODAY_KEY = "car_today"
class InputsMixin:
def _readings_late(self, standing_w):
"""SAFETY-128: update the answer gate; seconds the late verdict has
@@ -102,6 +108,95 @@ class InputsMixin:
return "input"
# -- io ------------------------------------------------------------------
# -- EV-03: the car charger, READ ONLY ----------------------------------------
@property
def car_enabled(self) -> bool:
"""`ev_charger` is on, whether or not the charger could be set up."""
return chargers.mode_of(self.o) != chargers.OFF
def car_now(self) -> float:
return freshness.monotonic()
def car_term(self) -> tuple:
"""`(car_w, unknown)`: what the House tile and the house profile take
out for the car. `(0.0, False)` with no charger, so a site without one
is exactly as before. With one, a reading older than `stale_input_s`
(the meter's own bound) is UNKNOWN, never the last figure: the tile
shows no reading and the profile leaves that moment out."""
if not self.charger.configured():
return 0.0, False
w = self.car.car_w(self.car_now())
return (0.0, True) if w is None else (w, False)
async def _read_charger(self, now_mono: float) -> None:
"""One read of the charger, and what the tracker learns from it. It
never raises (telemetry has no foot in the control path) and it only
reads: nothing here, or anywhere in this card, writes to the charger."""
now_utc = datetime.now(timezone.utc)
day = brussels(now_utc).date().isoformat()
try:
if not self._charger_probed:
pr = await asyncio.wait_for(self.charger.probe(), 5.0)
if OUTAGE.waiting():
raise RuntimeError("Home Assistant is not answering")
self._charger_probed = True
self._judge_probe(pr)
if not self.charger.configured():
return
reading = await asyncio.wait_for(self.charger.read(), 5.0)
except Exception as err: # noqa: BLE001
_LOG.debug("The charger could not be read: %s", err)
reading = chargers.ChargerReading(why="no answer")
self.car.observe(reading, now_mono, now_utc, day)
if self.car.persist:
self.car.persist = False
self.store.set(CAR_TODAY_KEY, self.car.dump())
def _judge_probe(self, pr) -> None:
"""A charger whose setpoint, read-back or state entity is missing, or
whose limit is above the station limit, is REFUSED: the add-on starts
and the Car card says so. Said once."""
why = None
if not pr.ok and pr.power_unit is not None \
and not pr.required_missing:
why = "unit"
_LOG.warning("The car charger is not set up, because its power "
"has no power unit: CHARGER_POWER_UNIT.")
_LOG.debug("The charger power entity reads in %r.", pr.power_unit)
elif not pr.ok:
why = "not_found"
_LOG.warning("The car charger is not set up, because Home "
"Assistant has no entity it needs: CHARGER_NOT_FOUND.")
_LOG.debug("Charger entities missing: %s.",
", ".join(e for _r, e in pr.missing) or "the name")
else:
amps = chargers.max_a(self.o)
if pr.station_a is not None and amps is not None \
and amps > pr.station_a:
why = "limit"
_LOG.warning("The charger limit is above what the charger "
"allows, so the charger is not used: "
"CHARGER_LIMIT.")
_LOG.debug("ev_max_a %.0f A, station limit %.0f A.", amps,
pr.station_a)
if why is not None:
self.charger_refused = why
self.charger = chargers.UnavailableCharger(why, chargers.ALFEN_HA)
def car_log_edges(self, now_mono: float) -> None:
"""One WARNING when the charger goes unreachable, one INFO when it is
back. Edge-logged, never every cycle."""
down = self.car.unreachable(now_mono)
if down and not self._car_down_logged:
self._car_down_logged = True
_LOG.warning("The car charger does not answer, so the Car card has "
"no reading: CHARGER_UNREACHABLE.")
_LOG.debug("The charger entities read unavailable, or its login "
"is off, for %.0f s.", chargers.UNREACHABLE_S)
elif not down and self._car_down_logged and self.car.bad_since is None:
self._car_down_logged = False
_LOG.info("The car charger answers again.")
@property
def pv_roster(self) -> tuple:
"""Every configured PV production entity, or `()` (TEL-11/TEL-12). ONE
@@ -209,6 +304,10 @@ class InputsMixin:
_reads = [self._read_unit(inv, now_mono) for inv in self.inverters]
if self.pv_roster:
_reads.append(self._read_pv())
# EV-03: the charger, read only, in the same gather. Nothing is added
# without a charger, so a site with none runs exactly as before.
if self.charger.configured():
_reads.append(self._read_charger(now_mono))
# OPT-11 P4: the solar forecast, in the same gather (a slow sensor must
# not delay control), at most once per FORECAST_READ_S.
if self.fc_entity is not None and (
+44 -1
View File
@@ -15,7 +15,8 @@ from .freeze import rail_unreachable, rail_w
from .hass import OUTAGE
from .maintenance import IDLE
from .mqtt import (
announced_units, optional_keys, parse_command, unit_slugs, unit_state_key,
PAYLOAD_NONE, announced_units, optional_keys, parse_command, unit_slugs,
unit_state_key,
)
from . import peakguard
from . import pv as pv_read
@@ -135,6 +136,10 @@ class StatusMixin:
if "pv" in optional_keys(self.o):
values["pv"] = (None if self.pv_w is None
else round(self.pv_w, 1))
# EV-03: the car, only with a charger switched on (the same predicate
# that announces the three sensors). Absence is `None`, never 0.
if "car_state" in optional_keys(self.o):
values.update(self.car_values())
# ⚠️ ONLY when P1 ingestion is running: the ESP32's stale-input
# watchdog subscribes to sensor.goodwe_p1_sample_age and forces layer-1
# failsafe at max_age_s, so with meter_source off a published age would
@@ -161,6 +166,41 @@ class StatusMixin:
values.update(self.mode_values())
self.mqtt.publish(values)
# -- EV-03: the car charger ------------------------------------------------
def car_rows(self) -> list:
"""The charger's status rows: amber when it is not set up or does not
answer, grey while something else changes it."""
if not self.charger.configured():
return [{"ok": False, "warn": True, "id": "charger_setup",
"text": messages.row_charger_setup()}]
out = []
if self.car.unreachable(self.car_now()):
out.append({"ok": False, "warn": True, "id": "charger",
"text": messages.row_charger_unreach(
f"{brussels(self.car.bad_since_utc):%H:%M}")})
if self.car.other_writer(datetime.now(timezone.utc)):
out.append({"ok": True, "warn": True, "id": "charger_writer",
"text": messages.row_charger_writer()})
return out
def car_values(self) -> dict:
"""The MQTT values for the three car sensors."""
now = self.car_now()
state = self.car.status(now, self.charger.configured())
r = self.car.reading
down = state in (None, "unreach", "setup")
with homeview.in_lang("en"):
word = homeview.car_state_word(state)
return {
"car_state": word,
# `ABSENT_AS_UNKNOWN` stays the one PV key (test_pv.py): an absent
# car figure is HA's own "no value" payload, sent by name.
"car_power": (PAYLOAD_NONE if down or r is None
or r.power_w is None else round(r.power_w, 1)),
"car_today_kwh": (PAYLOAD_NONE if self.car.today_kwh is None
else round(self.car.today_kwh, 3)),
}
# -- UI ------------------------------------------------------------------
def checks(self, when: datetime | None = None) -> list:
"""The status rows. `when` is the reference time for anything billed by
@@ -613,6 +653,9 @@ class StatusMixin:
out.append({"ok": False, "warn": True, "id": "pv",
"text": messages.row_pv_stray(
_inv_list.index(ent) + 1)})
# EV-03: the car charger's rows, only with a charger switched on.
if self.car_enabled:
out.extend(self.car_rows())
return out
def status_payload(self, lang: str = "en") -> dict:
+94 -2
View File
@@ -10,7 +10,7 @@ from datetime import datetime, timedelta, timezone
from . import arbitrage
from .arbiter import (
ACTUATOR_SITE, Claim, P_MAINTENANCE, P_MAINT_SHAPE, P_PEAK, SET,
pre_resolve,
pre_resolve, resolve,
)
from . import allocator
from . import dayplan
@@ -19,6 +19,7 @@ from . import packmode
from . import priceruns
from . import solarfc
from . import control as _ctl
from . import freshness
from . import messages
from .maintenance import CHARGE, DRAIN, HOLD, IDLE, MaintConfig, POWER_MOVING
from .mqtt import MODE_LABELS, USE_OFF, USE_ON, unit_slugs, unit_state_key
@@ -49,6 +50,13 @@ EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
HOUSE_PROFILE_KEY = "house_profile"
# MAINT-28: a step of a battery's command, in W, after which its reading is not
# current until a changed one arrives (`Controller._batts_settled()`).
MAINT_STEP_W = 100.0
# MAINT-28: the reason on a paused pack's cap (`Controller._m28_share()`).
M28_REASON = "peak hold"
# OPT-34: the level of a night grid charge that has started, so it does not
# drop when the forecast goes (`Controller._latch_charge()`).
CHARGE_LATCH_KEY = "night_charge_latch"
@@ -380,8 +388,18 @@ class TradeMixin:
price_now = None
house = (None if self.grid is None or self.site_batt_w is None
else float(self.grid) + float(self.site_batt_w))
# EV-03: the car is not the house. Out of the profile that plans the
# batteries' night charge; with a charger whose reading is not fresh
# the moment is left out (None), not guessed.
# ONLY the profile's sample: `house` below stays what it was, since
# the battery decision's input is not part of this card.
house_sample = house
if house is not None:
_car_w, _car_unknown = self.car_term()
house_sample = (None if _car_unknown else max(0.0, house - _car_w)
if _car_w else house)
if self.house_profile is not None and \
self.house_profile.add(now, house):
self.house_profile.add(now, house_sample):
self.store.set(HOUSE_PROFILE_KEY, self.house_profile.dump())
targets = self.plan_targets()
if targets is None:
@@ -711,6 +729,80 @@ class TradeMixin:
lo = hi = float(inv.batt)
self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since)
def _batts_settled(self) -> bool:
"""MAINT-28: False while a battery reading may pre-date a step UP of
its command, so `grid + site_batt_w` is a new grid and an old battery.
DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md"""
now, ok = self._now_mono, True
if now is None:
return True
for inv in self.inverters:
cmd = float(inv.standing_w())
seen = self._m28_step.get(inv.name)
if seen is None:
seen = self._m28_step[inv.name] = (cmd, None)
elif abs(cmd - seen[0]) >= MAINT_STEP_W:
seen = self._m28_step[inv.name] = (
cmd, now if cmd > seen[0] else seen[1])
at = seen[1]
if at is None or now - at >= freshness.BATT_OBLIGED_S:
continue
got = inv.batt_fresh.changed_mono
if got is None or got < at + freshness.PLANT_SETTLE_S:
ok = False
return ok
def _m28_share(self, claims, now) -> None:
"""MAINT-28: each paused pack's cap (0 W if the guard holds it at its
reserve) set to the largest part of the guard's floor left to it in
this 15-minute period: the floor less what the other units are placed
at in this cycle's split without the cap. Kept per unit in `_m28_hi`,
dropped while the unit is not paused or is held. Also one site limit,
the period's largest floor, so the other pack does not give for the
house (`_m28_site`).
DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md"""
floor = max((c.lo for c in claims if c.source == peakguard.SOURCE),
default=0.0)
g = self.peak_guard
held = {self.actuator_of("maintenance", inv) for inv in self.inverters
if g is not None and inv.name in g.held}
q, seen = int(now.timestamp() // 900), set()
site = any(c.source == "maintenance" and c.reason == M28_REASON
and c.actuator != ACTUATOR_SITE for c in claims)
for i, c in enumerate(claims):
if c.source != "maintenance" or c.reason != M28_REASON \
or c.actuator == ACTUATOR_SITE or c.actuator in held:
continue
seen.add(c.actuator)
shares = resolve(claims[:i] + claims[i + 1:], units=self.units,
live=self.live_units(), weights=self.pack_weights,
reserve=self._held_back()).shares
part = max(0.0, floor - sum(float(w) for u, w in shares.items()
if u != c.actuator))
hi = max(part, self._m28_was(c.actuator, now))
self._m28_hi[c.actuator] = (q, hi)
claims[i] = dataclasses.replace(c, hi=hi)
# A unit not paused (or held at its reserve) starts again from 0 W.
for u in set(self._m28_hi) - seen:
del self._m28_hi[u]
# Site: while the pause holds, the packs together give at most the
# period's largest floor, never under this cycle's (the guard's claim
# and P_PEAK outrank this one anyway).
if not site:
self._m28_site = (None, 0.0)
return
hi = max(floor, self._m28_site[1] if self._m28_site[0] == q else 0.0)
self._m28_site = (q, hi)
claims.append(Claim.limit("charge", P_MAINT_SHAPE, hi=hi,
reason=M28_REASON,
actuator=ACTUATOR_SITE))
def _m28_was(self, u, now) -> float:
"""MAINT-28: unit `u`'s cap so far in this 15-minute period, 0 W in
a new one (`_m28_share()`)."""
q, hi = self._m28_hi.get(u, (None, 0.0))
return hi if q == int(now.timestamp() // 900) else 0.0
def _reseed_drop(self, live) -> None:
"""SAFETY-96, N>=2: a battery that drops out of the split (stale link)
leaves the loop's start point, so the others take over at one slew step
+109 -5
View File
@@ -72,16 +72,26 @@ def flow(w, *, shadow=False):
return "resting"
def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow, settled=True):
def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow, settled=True,
car_w=0.0, car_unknown=False):
"""The four tiles. `house_w` needs all three readings, so without a solar
reading it is None rather than a number that leaves the sun out."""
reading it is None rather than a number that leaves the sun out.
EV-03: `car_w` is what a car charger takes, and it is not the house, so it
is taken out of House (the Car card shows it). `car_unknown` is a charger
whose reading is not fresh: House then has no figure rather than one that
may hold the car. Both default to "no charger", the figure as before."""
house, disagree = None, False
if has_solar and None not in (grid_w, pv_w, batt_w):
house = float(grid_w) + float(pv_w) + float(batt_w)
house = float(grid_w) + float(pv_w) + float(batt_w) - float(car_w or 0.0)
if car_unknown:
house = None
# The three are read at slightly different moments, so the sum can dip
# under zero. Inside the idle band that is 0 W; beyond it the readings
# disagree and no house figure is shown.
if house < -IDLE_W:
if house is None:
pass
elif house < -IDLE_W:
house, disagree = None, True
elif house < 0.0:
house = 0.0
@@ -1070,6 +1080,7 @@ ATTN_DOCS = {
"not_keeping_up": messages.T_LOOP_BOUND,
"circulating": messages.T_CIRCULATING,
"delivery": messages.S_NOT_DELIVERING,
"charger": messages.T_CHARGER_UNREACH,
}
@@ -1187,7 +1198,10 @@ def _build(c, *, level, checks, now, held=None, waiting=False):
"sub": banner_sub(kind)},
"now": now_block(grid_w=c.grid, pv_w=c.pv_w, batt_w=c.site_batt_w,
has_solar=bool(c.pv_roster), shadow=shadow,
settled=settled),
settled=settled,
**dict(zip(("car_w", "car_unknown"),
c.car_term()))),
"car": car_block(c, now),
"batteries": rows,
"plan": plan,
"modes_on": bool(getattr(c, "modes_on", False)),
@@ -1208,6 +1222,89 @@ def _docs_url():
messages._site["ha"])
# --------------------------------------------------------------------------- #
# EV-03: the Car card. READ ONLY: Off is the only mode in this version.
# --------------------------------------------------------------------------- #
_CAR_KIND = {"charging": "ok", "full": "ok", "unreach": "warn",
"setup": "warn", "fault": "warn"}
def car_state_word(key) -> str:
return _t("car.state." + (key if key else "wait"))
def car_phases_word(n) -> str:
return _t("car.phase_one") if n == 1 else _t("car.phase_many", n=n)
def _car_big(key, c, r, good):
if key == "charging" and r is not None and r.power_w is not None:
parts = [_dec(f"{r.power_w / 1000:.1f}")
+ " kW"]
amps = (r.allowed_a if r.allowed_a is not None
else max(r.current_a) if r.current_a else None)
if amps is not None:
parts.append(f"{amps:.0f} A")
n = c.car.phases_in_use()
if n:
parts.append(car_phases_word(n))
return " · ".join(parts)
if key == "unreach":
t = c.car.last_good_utc
return (_t("car.big.seen", t=hhmm(t)) if t is not None
else _t("car.big.no_reading"))
if key == "charging": # the reading is too old
return _t("car.big.no_reading")
return _t("car.big." + (key or "wait"))
def _car_why(key, c, good):
brand = (getattr(c.charger, "brand", "") or "Alfen")
if key == "unreach":
r = c.car.reading
if r is not None and r.why == "power unit":
return _t("car.why.unreach_unit")
a = None if good is None else good.setpoint_a
if a is None and good is not None:
a = good.allowed_a
if a is not None:
return _t("car.why.unreach_a", a=f"{a:.0f}", brand=brand)
return _t("car.why.unreach", brand=brand)
if key == "setup":
why = {"not_found": "not_found", "limit": "limit",
"unit": "unit"}.get(
c.charger_refused or "", "name")
return _t("car.why." + why)
return _t("car.why." + (key if key else "wait"))
def car_block(c, now):
"""The Car card for `/status`, or None when no charger is switched on (the
page then draws nothing, as before)."""
if not getattr(c, "car_enabled", False):
return None
mono = c.car_now()
configured = c.charger.configured()
key = c.car.status(mono, configured)
r = c.car.reading if configured else None
good = c.car.last_good
fresh = configured and c.car.fresh(mono)
today = None
if configured and c.car.today_kwh is not None:
today = _t("car.today", kwh=_kwh(c.car.today_kwh))
return {
"state": key or "wait",
"chip": car_state_word(key),
"kind": _CAR_KIND.get(key, "muted"),
"big": _car_big(key, c, r if fresh else None, good),
"today": today,
"why": _car_why(key, c, good),
"mode": "off",
"power_w": (None if not fresh or r is None or r.power_w is None
else round(float(r.power_w), 1)),
}
# =========================================================================== #
# The words. STYLE.md: plain, short, present tense. A plan line is at most
# two sentences of at most 12 words each (UX-13 design, section 4); a title at
@@ -1715,6 +1812,13 @@ def _samples() -> list:
out += [("plan", day_note(k, pct=70, name="Battery A", at="08:15",
kwh="9.2 kWh", count="6.4 kWh"))
for k in words["day"]]
out += [("title", _t("car.state." + k)) for k in words["car"]["state"]]
out += [("plan", _t("car.big." + k, t="09:12")) for k in
words["car"]["big"]]
out += [("plan", _t("car.why." + k, a="10", brand="Alfen"))
for k in words["car"]["why"]]
out += [("plan", _t("car.today", kwh="14.2 kWh")),
("plan", "11.0 kW · 16 A · " + _t("car.phase_many", n=3))]
out += [("plan", line_no_trade(k)) for k in
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
"no_level")]
+15
View File
@@ -42,6 +42,7 @@ from .arbiter import (
P_SAFETY, SET, Claim, Resolution, actuator_for, pre_resolve, resolve,
)
from . import allocator
from . import chargers
from . import dayplan
from . import modetrade
from . import packmode
@@ -506,6 +507,9 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self._peak_needed = False
self._maint_shape_suspended = False
self._maint_peak_hold = False # MAINT-28
self._m28_step = {} # MAINT-28: unit -> (command, step at)
self._m28_hi = {} # MAINT-28: unit -> (period, cap)
self._m28_site = (None, 0.0) # MAINT-28: (period, site cap)
# OPT-11: battery modes, off by default (SAFETY-10 section 1). Each
# battery's mode and on/off switch live in the store, not in options,
# so a change applies at once (`pack_use`, `pack_mode`).
@@ -561,6 +565,17 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self._charge_latch = _load_latch(
(getattr(store, "data", None) or {}).get(CHARGE_LATCH_KEY),
datetime.now(timezone.utc))
# EV-03: the car charger, READ ONLY. Off builds `UnavailableCharger`,
# which reads nothing, so a site without a charger is unchanged.
self.charger = chargers.build(opts)
self.charger.bind_getter(lambda: self.hass)
self.car = chargers.CarTracker(
stale_s=float(opts.get("stale_input_s", 15) or 15))
self.car.load((getattr(store, "data", None) or {}).get("car_today"))
self.charger_refused = None
self._charger_probed = False
self._car_down_logged = False
self._car_announced = False
self.house_profile = None
if self.fc_entity is not None:
self.house_profile = dayplan.HouseProfile(
+53
View File
@@ -186,6 +186,8 @@ T_OUTPUT_CLAMP_NL = "Vermogensvraag te hoog"
T_PEAK_SATURATED_NL = "Belasting te hoog voor de batterij"
T_METER_FROZEN_NL = "Meter staat mogelijk vast"
TITLE_NL = {T_LOOP_BOUND: T_LOOP_BOUND_NL, T_OUTPUT_CLAMP: T_OUTPUT_CLAMP_NL}
T_CHARGER_UNREACH = "Charger not reachable"
T_CHARGER_UNREACH_NL = "Laadpaal onbereikbaar"
T_NO_PRICES = "No prices"
T_NO_PRICES_NL = "Prijzen ontbreken"
@@ -198,6 +200,7 @@ ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
ID_METER_FROZEN = "goodwe_meter_frozen"
ID_MAINT_FAILED = "goodwe_maint_failed_{slug}"
ID_NO_PRICES = "goodwe_no_prices"
ID_CHARGER_UNREACH = "goodwe_charger_unreachable"
# PRICE-01: an earlier build raised a card with this id. Nothing raises it now;
# the add-on only dismisses it at start.
OLD_FIXED_CARD_ID = "goodwe_fixed_tariff_unused"
@@ -465,6 +468,43 @@ def row_no_prices(since=None, now=None) -> str:
"Nog geen prijzen, onderhoud wacht.")
def charger_unreachable(since_text_, brand="Alfen") -> Notice:
"""EV-03: the car charger has not answered for 5 minutes. Said once."""
en = (f"The add-on cannot reach the car charger since {since_text_}, so "
f"check the {brand} integration in Home Assistant.")
nl = (f"De add-on kan de laadpaal niet bereiken sinds {since_text_}, dus "
f"controleer de {brand}-integratie in Home Assistant.")
return Notice(ID_CHARGER_UNREACH, WARNING, T_CHARGER_UNREACH, en,
body_nl=f"{T_CHARGER_UNREACH_NL}. {nl}")
def charger_back() -> Dismiss:
"""EV-03: takes the 'Charger not reachable' notification down."""
return Dismiss(ID_CHARGER_UNREACH)
def row_charger_unreach(since_text_) -> str:
"""EV-03: the amber row while the charger does not answer."""
return Bi(f"The add-on cannot reach the charger since {since_text_}.",
f"De add-on kan de laadpaal niet bereiken sinds {since_text_}.")
def row_charger_writer() -> str:
"""EV-03: the grey row while something else sets the charger's current."""
return Bi("Something else changes the charger, and this add-on only reads "
"it.",
"Iets anders wijzigt de laadpaal en deze add-on leest alleen.")
def row_charger_setup() -> str:
"""EV-03: the amber row when the charger's entities are not found."""
return Bi("The add-on cannot find the charger, so check its entity in "
"Settings.",
"De add-on vindt de laadpaal niet, dus controleer de entiteit "
"bij Instellingen.")
def row_maint_waits(until_text: str) -> str:
"""MAINT-13: the status row while a failed cycle waits."""
return Bi(f"Waits until {until_text} after a cycle that did not finish.",
@@ -1661,6 +1701,9 @@ def status_samples() -> list:
("row_no_prices", AMBER, row_no_prices(*_SINCE_SAMPLE)),
("row_no_prices", AMBER, row_no_prices(*_SINCE_OLDER)),
("row_no_prices", AMBER, row_no_prices()),
("row_charger_unreach", AMBER, row_charger_unreach("09:12")),
("row_charger_writer", GREEN, row_charger_writer()),
("row_charger_setup", AMBER, row_charger_setup()),
]
@@ -1696,6 +1739,7 @@ def samples() -> list:
no_prices(*_SINCE_SAMPLE),
no_prices(*_SINCE_OLDER),
no_prices(),
charger_unreachable("09:12"),
]
@@ -1758,6 +1802,11 @@ LABELS = {
"solar_trust_pct": "Trust in the forecast",
"p1_phase_import_entities": "Import entities per phase",
"p1_phase_export_entities": "Export entities per phase",
# EV-03
"ev_charger": "Charger",
"ev_current_entity": "Current setting entity",
"ev_max_a": "Charger limit",
"ev_phases": "Phases",
}
@@ -1838,6 +1887,10 @@ LABELS_NL = {
"solar_trust_pct": "Vertrouwen in de prognose",
"p1_phase_import_entities": "Afname-entiteiten per fase",
"p1_phase_export_entities": "Injectie-entiteiten per fase",
"ev_charger": "Laadpaal",
"ev_current_entity": "Entiteit van de laadstroom",
"ev_max_a": "Laadpaalgrens",
"ev_phases": "Fasen",
}
# The inverter list shows two of them under a shorter name.
UNIT_LABELS_NL = {"setpoint_entity": "Setpoint-entiteit",
+10 -1
View File
@@ -139,6 +139,11 @@ SENSORS = [
# a charge-power limit, or the optimiser declining to charge.
OPTIONAL_SENSORS = [
("pv", "goodwe_pv_power", "PV power", "W", "power", "measurement", None),
# EV-03: only with a car charger switched on. Read only: there are no
# command entities in this version.
("car_power", "goodwe_car_power", "Car power", "W", "power", "measurement", "mdi:car-electric"),
("car_state", "goodwe_car_state", "Car state", None, None, None, "mdi:ev-station"),
("car_today_kwh", "goodwe_car_today_kwh", "Car energy today", "kWh", "energy", "total_increasing", "mdi:ev-station"),
]
# ⚠️ HA's OWN "this sensor has no value" PAYLOAD, and the one place this add-on
@@ -192,7 +197,11 @@ def optional_keys(opts: dict) -> tuple:
# `_load()` is the same decision for the same kind of reason. Measured, not
# assumed: move this line to module scope and test_mqtt.py part 8 dies.
from . import pv as _pv
return ("pv",) if _pv.entities(opts) else ()
from . import chargers as _ch # EV-03: same reason
out = ("pv",) if _pv.entities(opts) else ()
if _ch.census_entity(opts):
out += ("car_power", "car_state", "car_today_kwh")
return out
BASE = "goodwe_ctl"
+8 -3
View File
@@ -450,18 +450,23 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
return decision is not None and decision.engaged
def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w) -> bool:
def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w,
settled=True) -> bool:
"""MAINT-28. `suspended` (`shaping_suspended()`'s answer), kept on while
`held` until the house is `MAINT_RESUME_UNDER_W` under `aim_w`
(`peak_forecast_w` stands in for an unread house; neither read: not held,
as SAFETY-63). Only ever pauses the maintenance charge, never a cut-back.
as SAFETY-63) and `settled` (no battery reading older than its last
command step, so `house_w` is not a new grid plus an old battery). Only
ever pauses the maintenance charge, never a cut-back.
DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md."""
if suspended:
return True
if not held:
return False
level = house_w if house_w is not None else peak_forecast_w
return level is not None and level > float(aim_w) - MAINT_RESUME_UNDER_W
if level is None:
return False
return level > float(aim_w) - MAINT_RESUME_UNDER_W or not settled
def cap_floor(decision, cap_w):
+13 -1
View File
@@ -82,7 +82,7 @@ import os
import aiohttp
from . import messages
from . import chargers, messages
from .configui import changed_keys
from .inverter import build_roster
from .validate import validate_startup_config, validate_unit_ranges
@@ -362,6 +362,18 @@ async def problems_and_ranges(opts: dict, hass=None, notes=None):
unread = [unit.driver.actuator_id() for unit in units
if unit.driver.configured() and unit.dev_min is None
and unit.dev_max is None]
# EV-03: the charger limit may not exceed the station limit the charger
# reports. A read only; a charger that does not answer skips the check.
if hass is not None and chargers.mode_of(opts) != chargers.OFF:
drv = chargers.build(opts)
if drv.configured():
_lo, station, _ph = await drv.bind(hass).limits()
amps = chargers.max_a(opts)
if station is not None and amps is not None and amps > station:
problems.append(
f"{messages.setting('ev_max_a')} is {amps:.0f} A, but the "
f"charger allows at most {station:.0f} A. Lower it to "
f"{station:.0f} A or less.")
return problems, unread
+5
View File
@@ -272,3 +272,8 @@ table.fct th,table.fct td{padding:4px 3px;text-align:right;border-bottom:1px sol
table.fct th:first-child,table.fct td:first-child{text-align:left}
table.fct th{color:var(--muted);font-weight:600}
.ymax{position:absolute;top:-2px;left:2px;font-size:10px;color:var(--faint);pointer-events:none}
/* EV-03: the Car card */
.car-big{font-size:20px;font-weight:700;line-height:1.3;margin:8px 0 2px;font-variant-numeric:tabular-nums}
.car-lab{margin:12px 0 4px;color:var(--muted)}
.car-why{display:grid;grid-template-columns:auto 1fr;gap:10px;margin-top:12px}
+16 -1
View File
@@ -113,7 +113,8 @@
const setup = S.setup_only === true;
banner($("#home-banner"), S.level, setup ? t("setup_only.name") : S.banner, structured ? S.state.sub : setup ? t("setup_only.description") : "", S.level === "bad" ? "alert" : null);
["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; });
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
if (structured) { renderNow(); renderCar(); renderPlan(); renderPeak(); renderAttention(); }
else $("#c-car").hidden = true;
renderControl(structured, setup);
renderDetails(!structured && !setup);
renderBatteries(force);
@@ -145,6 +146,20 @@
h("div", { class: "small muted", style: "grid-column:1/-1" }, b.now))));
}
/* ---------------- Home: the car (read only) ---------------- */
function renderCar() {
const c = S.car;
$("#c-car").hidden = !c;
if (!c) return;
const chip = $("#car-chip");
chip.textContent = c.chip;
chip.className = "chip" + (c.kind === "warn" ? " rs" : c.kind === "ok" ? " now" : "");
$("#car-big").textContent = c.big;
$("#car-today").textContent = c.today || "";
$("#car-today").hidden = !c.today;
$("#car-why").textContent = c.why;
}
/* ---------------- Home: today's plan ---------------- */
const LEGEND = [["cheap", "background:var(--cheap)"], ["dear", "background:repeating-linear-gradient(135deg,var(--dear) 0 3px,transparent 3px 5px)"],
["grid", "background:var(--accent)"], ["give", "background:repeating-linear-gradient(135deg,var(--house) 0 3px,transparent 3px 5px)"],
+10
View File
@@ -39,6 +39,16 @@
<div id="home-batts" style="margin-top:6px"></div>
</div>
<div class="card" id="c-car" hidden>
<div class="row spread"><h2 data-t="car.name"></h2><span class="chip" id="car-chip"></span></div>
<div class="car-big" id="car-big"></div>
<div class="small muted" id="car-today"></div>
<div class="small car-lab" data-t="car.mode"></div>
<div class="seg" role="radiogroup" id="car-mode" data-t-label="car.mode"><button type="button" role="radio" aria-checked="true" data-t="car.mode_off"></button></div>
<p class="tiny muted" style="margin:6px 0 0" data-t="car.help_off.description"></p>
<div class="car-why small"><span class="muted" data-t="car.why"></span><span id="car-why"></span></div>
</div>
<div class="card" id="c-plan">
<div class="row spread"><h2 data-t="plan.name"></h2><span class="chip tiny muted" id="verdict"></span></div>
<div class="small" id="plan-head"></div>
+29
View File
@@ -4,6 +4,7 @@
"inverters": {"name": "Inverters and batteries", "description": "The inverters the add-on controls, and your solar."},
"tariff": {"name": "Tariff and prices", "description": "What you pay and are paid per kWh."},
"safety": {"name": "Safety limits", "description": "The peak limit, starting and stopping, and the safety stops."},
"car": {"name": "Car charger", "description": "Which charger the add-on reads, and its limits."},
"maintenance": {"name": "Maintenance", "description": "The monthly battery cycle and the log."},
"tuning": {"name": "Control tuning", "description": "For support only, because these were measured on real hardware."}
},
@@ -33,6 +34,8 @@
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"},
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
"ev_charger": {"off": "None", "alfen_ha": "Alfen (Home Assistant integration)"},
"ev_phases": {"1": "1 phase", "3": "3 phases", "auto": "1 and 3"},
"meter_phases": {"1": "1 phase", "3": "3 phases"},
"shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"},
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file", "price_service": "Your own price service"},
@@ -87,6 +90,7 @@
"loading": "Loading the forecast."
},
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
"car": {"name": "Car", "mode": "Mode", "mode_off": "Off", "help_off": {"description": "The add-on leaves the charger alone."}, "why": "Why"},
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
"control": {"name": "Control", "stop": "Stop control", "start": "Start control", "maint_start": "Start a battery cycle now", "maint_abort": "Stop the battery cycle", "docs": "Add-on documentation", "more": "More actions"},
"details": {"name": "Details for installers", "rows": "Status rows", "checks": "Checks", "event": "Last event"},
@@ -296,6 +300,28 @@
"waiting": "Control resumes by itself when Home Assistant answers.",
"warning": "See Needs attention below."
},
"car": {
"state": {"charging": "Charging", "waiting": "Connected, waiting", "none": "No car connected", "full": "Car is full", "unreach": "Charger not reachable", "setup": "Charger not set up", "fault": "Charger fault", "wait": "No reading yet"},
"big": {"waiting": "Connected, not charging.", "none": "Nothing to charge.", "full": "The car is full.", "seen": "Last seen at {t}.", "no_reading": "No reading.", "setup": "Not set up.", "fault": "The charger reports a fault.", "wait": "No reading yet."},
"phase_one": "1 phase",
"phase_many": "{n} phases",
"today": "{kwh} charged today.",
"why": {
"charging": "The charger is charging the car. The add-on leaves it alone.",
"waiting": "The car is connected and not charging. The add-on leaves the charger alone.",
"none": "No car is connected.",
"full": "The car stopped charging by itself.",
"unreach_a": "The charger keeps its last setting, {a} A. Check the {brand} integration in Home Assistant.",
"unreach_unit": "The charger's power is in a unit the add-on cannot use. Check it in Home Assistant.",
"unreach": "The add-on cannot read the charger. Check the {brand} integration in Home Assistant.",
"not_found": "The add-on cannot find the charger. Check the current setting entity in Settings.",
"unit": "The charger power has no power unit. Check the charger in Settings.",
"limit": "The charger limit is above what the charger allows. Lower it in Settings.",
"name": "The current setting entity does not fit the charger. Check it in Settings.",
"fault": "The charger reports a fault. Check it at the charger itself.",
"wait": "Waiting for the first reading."
}
},
"tile": {
"not_set_up": "Not set up",
"no_reading": "No reading",
@@ -499,6 +525,9 @@
"estop": "Emergency stop",
"pv": "Solar reading",
"solar_forecast": "Solar forecast",
"charger": "Charger not reachable",
"charger_writer": "Something else changes the charger",
"charger_setup": "Charger not set up",
"none": "Inverter connection"
},
"months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
+29
View File
@@ -4,6 +4,7 @@
"inverters": {"name": "Omvormers en batterijen", "description": "De omvormers die de add-on regelt, en uw zonnepanelen."},
"tariff": {"name": "Tarief en prijzen", "description": "Wat u per kWh betaalt en terugkrijgt."},
"safety": {"name": "Veiligheidsgrenzen", "description": "De piekgrens, starten en stoppen, en de veiligheidsstops."},
"car": {"name": "Laadpaal", "description": "Welke laadpaal de add-on leest, en haar grenzen."},
"maintenance": {"name": "Onderhoud", "description": "De maandelijkse batterijcyclus en het logboek."},
"tuning": {"name": "Afstelling van de regeling", "description": "Alleen voor support, want deze zijn gemeten op echte hardware."}
},
@@ -33,6 +34,8 @@
"fallback": {"none": "Geen", "fixed": "Eén prijs", "daynight": "Dag en nacht", "custom": "Andere tijdvakken"},
"region": {"flanders": "Vlaanderen, Fluvius Antwerpen", "own": "Ik vul zelf een bedrag in"},
"meter_source": {"off": "Eén entiteit voor netvermogen", "ha_dsmr": "Entiteiten voor afname en injectie", "mqtt_p1": "Meter via MQTT", "ha_signed": "Eén entiteit voor nettovermogen", "homewizard_local": "HomeWizard-meter op het netwerk"},
"ev_charger": {"off": "Geen", "alfen_ha": "Alfen (Home Assistant-integratie)"},
"ev_phases": {"1": "1 fase", "3": "3 fasen", "auto": "1 en 3"},
"meter_phases": {"1": "1 fase", "3": "3 fasen"},
"shadow_mode": {"auto": "Automatisch", "on": "Altijd aan", "off": "Uit"},
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand", "price_service": "Uw eigen prijsdienst"},
@@ -84,6 +87,7 @@
"loading": "De prognose wordt geladen."
},
"peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"},
"car": {"name": "Auto", "mode": "Modus", "mode_off": "Uit", "help_off": {"description": "De add-on laat de laadpaal met rust."}, "why": "Waarom"},
"attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"},
"control": {"name": "Regeling", "stop": "Regeling stoppen", "start": "Regeling starten", "maint_start": "Nu een batterijcyclus starten", "maint_abort": "Batterijcyclus stoppen", "docs": "Documentatie van de add-on", "more": "Meer acties"},
"details": {"name": "Details voor installateurs", "rows": "Statusregels", "checks": "Controles", "event": "Laatste gebeurtenis"},
@@ -250,6 +254,28 @@
"verdict": {"held": "Op 0 W gehouden", "stopped": "Gestopt", "cycle": "Vandaag maandelijkse cyclus", "trade": "Handelt op prijs", "house": "Volgt het huis"},
"pill": {"running": "Actief", "shadow": "Schaduwmodus", "stopped": "Gestopt", "alarm": "Heeft u nu nodig", "waiting": "Wacht op Home Assistant", "warning": "Actief, met een waarschuwing"},
"banner_sub": {"running": "De batterijen volgen het plan hieronder.", "shadow": "De plannen hieronder tonen wat de add-on zou doen.", "stopped": "Start de regeling hieronder als u klaar bent.", "alarm": "Zie Vraagt aandacht hieronder voor wat u moet doen.", "waiting": "De regeling gaat vanzelf verder zodra Home Assistant antwoordt.", "warning": "Zie Vraagt aandacht hieronder."},
"car": {
"state": {"charging": "Laadt", "waiting": "Aangesloten, wacht", "none": "Geen auto aangesloten", "full": "Auto is vol", "unreach": "Laadpaal onbereikbaar", "setup": "Laadpaal niet ingesteld", "fault": "Laadpaalstoring", "wait": "Nog geen meting"},
"big": {"waiting": "Aangesloten, laadt niet.", "none": "Niets te laden.", "full": "De auto is vol.", "seen": "Laatst gezien om {t}.", "no_reading": "Geen meting.", "setup": "Niet ingesteld.", "fault": "De laadpaal meldt een storing.", "wait": "Nog geen meting."},
"phase_one": "1 fase",
"phase_many": "{n} fasen",
"today": "Vandaag {kwh} geladen.",
"why": {
"charging": "De laadpaal laadt de auto. De add-on laat haar met rust.",
"waiting": "De auto is aangesloten en laadt niet. De add-on laat de laadpaal met rust.",
"none": "Er is geen auto aangesloten.",
"full": "De auto is zelf gestopt met laden.",
"unreach_a": "De laadpaal houdt haar laatste instelling, {a} A. Controleer de {brand}-integratie in Home Assistant.",
"unreach_unit": "De laadpaal meldt vermogen in een onbruikbare eenheid. Controleer de eenheid in Home Assistant.",
"unreach": "De add-on kan de laadpaal niet lezen. Controleer de {brand}-integratie in Home Assistant.",
"not_found": "De add-on vindt de laadpaal niet. Controleer de entiteit van de laadstroom bij Instellingen.",
"unit": "Het vermogen van de laadpaal heeft geen vermogenseenheid. Controleer de laadpaal bij Instellingen.",
"limit": "De laadpaalgrens is hoger dan de laadpaal toestaat. Verlaag ze bij Instellingen.",
"name": "De entiteit van de laadstroom past niet bij de laadpaal. Controleer ze bij Instellingen.",
"fault": "De laadpaal meldt een storing. Controleer ze aan de laadpaal zelf.",
"wait": "Wacht op de eerste meting."
}
},
"tile": {"not_set_up": "Niet ingesteld", "no_reading": "Geen meting", "producing": "Produceert", "not_producing": "Produceert niet", "needs_solar": "Zonnemeting nodig", "not_adding_up": "Metingen kloppen niet", "using": "Verbruikt", "taking": "Neemt af van het net", "feeding": "Injecteert", "balanced": "In evenwicht", "giving": "Levert stroom", "charging": "Laadt", "resting": "Rust"},
"now": {
"no_reading": "Geen meting van deze batterij.",
@@ -420,6 +446,9 @@
"estop": "Noodstop",
"pv": "Meting van de zonnepanelen",
"solar_forecast": "Zonneprognose",
"charger": "Laadpaal onbereikbaar",
"charger_writer": "Iets anders wijzigt de laadpaal",
"charger_setup": "Laadpaal niet ingesteld",
"none": "Verbinding met de omvormer"
},
"months": ["januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"]
+12
View File
@@ -77,6 +77,7 @@ _MAINT = (("maintenance_enabled", "==", True),)
_ONE = (("inverter_count", "==", 1),)
_SEVERAL = (("inverter_count", ">", 1),)
_PHASES = (("meter_phases", ">", 1),)
_CAR = (("ev_charger", "!=", "off"),)
# One inverter stored flat has no name or capacity (uimodel._roster_encode).
_LISTED = (("inverter_shape", "!=", "flat"),)
@@ -222,6 +223,17 @@ GROUPS = (
Field("delivery_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
), collapsed=True),
)),
# EV-03: the car charger, read only. Everything after the choice shows
# only while a charger is switched on.
Group("car", (
Part("", (
Field("ev_charger", CHOICE, simple=True),
Field("ev_current_entity", ENTITY, kind="number", simple=True,
show=_CAR),
Field("ev_max_a", SLIDER, "A", simple=True, show=_CAR),
Field("ev_phases", SEGMENTED, simple=True, show=_CAR),
)),
)),
Group("maintenance", (
Part("", (
Field("maintenance_enabled", TOGGLE),
+140 -1
View File
@@ -332,7 +332,7 @@ import math
import re
from typing import NamedTuple
from . import dayplan, messages, pv, solarfc
from . import chargers, dayplan, messages, pv, solarfc
from .arbitrage import enabled as arbitrage_on
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
from . import drivers
@@ -667,6 +667,34 @@ def _labels(keys, joiner: str = "and") -> str:
return f" {joiner} ".join(S(k) for k in keys)
def _charger_problems(opts: dict, writers: dict) -> list[str]:
"""EV-03: the car charger options. A charger whose entities are missing is
refused at run time (the Car card says so), not here: it never stops the
add-on. What stops it is a setpoint that an inverter also writes."""
out: list[str] = []
raw = opts.get("ev_charger", chargers.OFF)
if raw not in (None, "") and str(raw).strip() not in chargers.SELECTABLE:
out.append(f"{S('ev_charger')} is {messages.shown(raw)}. Choose "
f"{', '.join(chargers.SELECTABLE)}.")
return out
if chargers.mode_of(opts) == chargers.OFF:
return out
ph = opts.get("ev_phases", chargers.EV_OPTION_DEFAULTS["ev_phases"])
if str(ph).strip() not in chargers.PHASES_CHOICES:
out.append(f"{S('ev_phases')} is {messages.shown(ph)}. Choose 1, 3 or "
"auto.")
amps = chargers.max_a(opts)
if amps is None or not chargers.MIN_A <= amps <= chargers.ENTITY_MAX_A:
out.append(f"{S('ev_max_a')} is {messages.shown(opts.get('ev_max_a'))}"
f". Set it from {chargers.MIN_A:.0f} to "
f"{chargers.ENTITY_MAX_A:.0f} A.")
entity = chargers.census_entity(opts)
if entity and drivers.census_key(entity) in writers:
out.append(f"{S('ev_current_entity')} is {entity}, which an inverter "
"also uses. Give the charger its own entity.")
return out
def _roster_problems(opts: dict) -> list[str]:
"""Rule 9 (CFG-04): the `inverters:` roster, refused where it cannot mean
what it says.
@@ -956,6 +984,12 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
f"{actuator}: {', '.join(who)}. Give each inverter its own "
"setpoint entity.")
# --- EV-03: the car charger's setpoint is a writer too ---------------------
# Counted in the census beside the inverters', and kept apart from
# `actuator_writers()` so setup-only mode still reads "no inverter". An
# inverter's entity named as the charger is two writers on one actuator.
problems.extend(_charger_problems(opts, writers))
# --- 9. CFG-04's roster, graded BEFORE anything reads a per-unit number --
# ⚠️ FIRST, THOUGH IT IS THE NEWEST RULE. Rules 5 and 6 below now ask the
# roster what one unit may be commanded, so a roster that is nonsense has to
@@ -1921,6 +1955,7 @@ def config_notes(opts: dict, *, entities: dict | None = None,
notes: list[Note] = []
if entities is not None:
_entity_notes(notes, opts, entities)
_charger_notes(notes, opts, entities)
cap = _number(opts, "peak_cap_w", None)
if month_peak_w is not None and cap is not None and cap < month_peak_w:
_advise(notes, "warn", ("peak_cap_w",),
@@ -1932,6 +1967,110 @@ def config_notes(opts: dict, *, entities: dict | None = None,
return notes
def charger_probe_rows(opts: dict, entities: dict | None) -> dict:
"""EV-03: the settings panel's read-only look at the charger. `entities` is
Home Assistant's list by id (None: it did not answer). Nothing is written.
`{"ok", "found": [[role, entity]], "missing": [[role, entity]], "limit_a",
"state"}`; `state` is `off`, `no_name` (the setpoint is not named the way
the charger's integration names it), `unread`, `ok` or `missing`."""
if chargers.mode_of(opts) == chargers.OFF:
return {"ok": False, "found": [], "missing": [], "limit_a": None,
"state": "off"}
setpoint = str(opts.get("ev_current_entity") or "").strip()
if not chargers.entities_for(setpoint):
return {"ok": False, "found": [], "missing": [], "limit_a": None,
"state": "no_name" if setpoint else "empty"}
if entities is None:
return {"ok": False, "found": [], "missing": [], "limit_a": None,
"state": "unread"}
def state_obj(e):
row = entities.get(e)
return None if row is None else {
"state": row.get("state"),
"attributes": {"unit_of_measurement": row.get("unit")}}
pr = chargers.probe_from(setpoint, state_obj)
return {"ok": pr.ok, "found": [list(x) for x in pr.found],
"missing": [list(x) for x in pr.missing], "limit_a": pr.station_a,
"power_unit": pr.power_unit,
"state": "ok" if pr.ok else "missing"}
# The roles the "Found:" note names, in words (EV-03).
_CHARGER_WORDS = (
("state", "car state", "autostatus"), ("power", "power", "vermogen"),
("energy", "energy", "energie"), ("readback", "read-back", "terugmelding"),
("phases_read", "phases", "fasen"),
("station", "the charger limit", "de laadpaalgrens"))
def charger_probe_for(setpoint: str, entities: dict | None) -> dict:
"""`charger_probe_rows()` for a setpoint entity typed on the page."""
return charger_probe_rows({"ev_charger": chargers.ALFEN_HA,
"ev_current_entity": setpoint}, entities)
def _charger_notes(notes, opts: dict, entities: dict) -> None:
"""EV-03: a note for each charger entity Home Assistant does not have, and
one when the limit is above the charger's own. Never a refusal."""
look = charger_probe_rows(opts, entities)
key = ("ev_current_entity",)
if look["state"] == "no_name":
_advise(notes, "warn", key,
f"{S('ev_current_entity')} must be named like the Alfen "
"integration's current setting, so the charger is not read.",
nl=f"{SN('ev_current_entity')} moet heten zoals de "
"stroominstelling van de Alfen-integratie, dus de laadpaal "
"wordt niet gelezen.")
return
if look["state"] == "empty":
_advise(notes, "warn", key,
f"{S('ev_current_entity')} is empty, so the charger is not "
"read.",
nl=f"{SN('ev_current_entity')} is leeg, dus de laadpaal wordt "
"niet gelezen.")
return
if look["state"] not in ("ok", "missing"):
return
for role, entity in look["missing"]:
required = role in chargers.REQUIRED
# A missing required entity (current, read-back, car state or power)
# means the charger is not used: the Car card says "not set up".
if required:
_advise(notes, "warn", key,
f"Home Assistant has no entity {entity}, which the "
"charger needs, so it is not used.",
nl=f"Home Assistant kent {entity} niet, die de laadpaal "
"nodig heeft, dus ze wordt niet gebruikt.")
else:
_advise(notes, "info", key,
f"Home Assistant has no entity {entity}, which the "
"charger can also use.",
nl=f"Home Assistant kent {entity} niet, die de laadpaal "
"ook kan gebruiken.")
if look.get("power_unit") is not None:
_advise(notes, "warn", key,
"The charger's power does not read in a power unit, so the "
"charger is not used.",
nl="Het vermogen van de laadpaal heeft geen vermogenseenheid, dus "
"ze wordt niet gebruikt.")
lim = look["limit_a"]
if look["ok"]:
have = {r for r, _e in look["found"]}
words = [(w_en, w_nl) for r, w_en, w_nl in _CHARGER_WORDS if r in have]
tail_en = f", {lim:.0f} A" if lim is not None else ""
_advise(notes, "ok", key,
"Found: " + ", ".join(w[0] for w in words) + tail_en + ".",
nl="Gevonden: " + ", ".join(w[1] for w in words) + tail_en
+ ".")
amps = chargers.max_a(opts)
if lim is not None and amps is not None and amps > lim:
_advise(notes, "warn", ("ev_max_a",),
f"{S('ev_max_a')} is above the {lim:.0f} A the charger "
"reports, so lower it.",
nl=f"{SN('ev_max_a')} is hoger dan de {lim:.0f} A die de "
"laadpaal meldt, dus verlaag ze.")
def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
"""A note for each entity these options read that Home Assistant does not
have, or that reads in kW where watts are taken. A missing entity whose
+11 -1
View File
@@ -51,7 +51,8 @@ from aiohttp import web
from . import messages, price, selfconfig, uipage, uitext
from .haentities import EntityCache
from .homeview import priced_hours, region_of
from .validate import config_notes, note_text, setup_checks
from .validate import (charger_probe_for, config_notes, note_text,
setup_checks)
from .config_page import CONFIG_PAGE
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
problems_by_field, secret_keys)
@@ -474,6 +475,14 @@ def build_app(controller, selfcfg=None) -> web.Application:
return web.json_response(await entities.reading(one))
return web.json_response(await entities.payload(kind))
async def config_charger(request):
"""EV-03: the settings page's read-only look at the car charger: which
entities Home Assistant has and which it lacks. Reads the cached entity
list and writes nothing, anywhere."""
rows, _age = await entities.get(max_age=5.0)
return web.json_response(charger_probe_for(
str(request.query.get("entity") or "")[:255], rows))
app = web.Application()
app.add_routes([
web.get("/", index),
@@ -486,6 +495,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
web.post("/config/save", config_save),
web.post("/config/restart", config_restart),
web.get("/ha/entities", ha_entities),
web.get("/config/charger", config_charger),
])
return app
+24 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.21.0"
version: "0.22.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
@@ -872,6 +872,18 @@ options:
# be reached by accident.
shadow_mode: "auto"
# ==========================================================================
# CAR CHARGER - read only (EV-03)
# ==========================================================================
# `off` is the shipped value and means NO CHANGE AT ALL: nothing is read,
# shown or announced, and the House tile and the house profile are as before.
# `alfen_ha` reads the charger through its Home Assistant integration. THIS
# VERSION NEVER WRITES TO THE CHARGER; later cards add the modes.
ev_charger: "off"
ev_current_entity: ""
ev_max_a: 16
ev_phases: "auto"
# ==========================================================================
# ADVANCED - the control law and diagnostics
# ==========================================================================
@@ -1249,6 +1261,17 @@ schema:
# long as nobody notices.
shadow_mode: list(auto|on|off)
# ==========================================================================
# CAR CHARGER - read only (EV-03)
# ==========================================================================
# `ev_current_entity` is the charger's current setpoint; the other entities
# are found from its name. `ev_max_a` may not exceed the charger's own station
# limit (the settings page and the start check read it).
ev_charger: list(off|alfen_ha)
ev_current_entity: str?
ev_max_a: int(6,40)
ev_phases: list(1|3|auto)
# ==========================================================================
# ADVANCED - the control law and diagnostics
# ==========================================================================
+268
View File
@@ -0,0 +1,268 @@
"""Runnable check for CI-18's selector. `python3 test_affected_tests.py`
`.gitea/ci/affected_tests.py` says which test_*.py files a diff affects, so a
developer's round can skip the rest. It is used ONLY for local rounds; the merge
gate (`checks / unit (pull_request)`) runs every file. So the properties graded
here are the ones whose failure would be a missed test:
1. A synthetic tree: transitive imports, a tool launched by path, a module
named in a string, and what must NOT be chosen.
2. FAIL OPEN: a non-Python file, a deletion, a .py nothing references, a
file that does not parse, a base that does not exist, all select ALL.
3. The real tree: the safety set names files that exist, config.yaml and
run-tests.sh select ALL, a changed test file is chosen.
4. THE MUTANT: break a real module in a private copy of the tree; the
selector must choose a test that goes red, and the unbroken copy is green.
5. The merge gate cannot be partial: checks.yml never mentions CI_ONLY and
still runs the unfiltered run-tests.sh line.
Read MUTATION.md before you report a survivor.
"""
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
SEL = os.path.join(REPO, ".gitea", "ci", "affected_tests.py")
sys.path.insert(0, os.path.dirname(SEL))
import affected_tests as sel # noqa: E402
fails = []
tmps = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def mk(root, files):
for rel, text in files.items():
p = os.path.join(root, rel)
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w", encoding="utf-8") as fh:
fh.write(text)
def tmp():
d = tempfile.mkdtemp(prefix="test_affected.")
tmps.append(d)
return d
def choose(root, paths, status="M"):
names, _r, why = sel.select(root, [(status, p) for p in paths])
return ([os.path.basename(n) for n in names] if names is not None else None,
why)
# ---------------------------------------------------------------- 1. synthetic
print("1. a synthetic tree")
R = tmp()
P = "goodwe_controller/"
mk(R, {
P + "app/__init__.py": "",
P + "app/low.py": "def f():\n return 1\n",
P + "app/mid.py": "from app import low\n\ndef g():\n return low.f()\n",
P + "app/top.py": "from app.mid import g\n",
P + "app/lonely.py": "X = 1\n",
P + "app/viastr.py": "Y = 2\n",
P + "tools/launched.py": "print('hi')\n",
P + "tools/reader.py": "print('r')\n",
P + "test_a.py": "from app import top\n",
P + "test_b.py": "from app import lonely\n",
P + "test_c.py": "import subprocess\nsubprocess.run(['python3', 'tools/launched.py'])\n",
P + "test_d.py": "import importlib\nm = importlib.import_module('app.viastr')\n",
# reads every test_*.py as text: affected by a changed test, but it is a
# reader, not a dependency of anything that imports a module
P + "test_reader.py": "import glob\nfor f in glob.glob('test_*.py'):\n open(f).read()\n",
P + "test_unrelated.py": "print('x')\n",
# a test imported by another test carries the change on (an import runs it)
P + "test_base.py": "from app import lonely\n",
P + "test_chain.py": "import test_base\n",
})
n, why = choose(R, [P + "app/low.py"])
check("a change two imports down chooses the test that imports the top (transitive)",
n is not None and "test_a.py" in n)
check("...and not tests that do not reach it",
n is not None and not {"test_b.py", "test_c.py", "test_unrelated.py"} & set(n))
n, why = choose(R, [P + "app/lonely.py"])
check("a direct importer is chosen", n is not None and "test_b.py" in n)
check("a test that imports another test is chosen with it, even though "
"test_reader (a pattern reader) also reaches test_base as text",
n is not None and "test_chain.py" in n and "test_base.py" in n)
n, why = choose(R, [P + "tools/launched.py"])
check("a tool launched by path in a string chooses the launching test",
n is not None and "test_c.py" in n)
n, why = choose(R, [P + "app/viastr.py"])
check("a module imported dynamically by name chooses its test",
n is not None and "test_d.py" in n)
n, why = choose(R, [P + "test_unrelated.py"])
check("a changed test file is chosen", n is not None and "test_unrelated.py" in n)
check("a test that only READS test files by pattern is chosen with it",
n is not None and "test_reader.py" in n)
check("...but a reader does not drag in the importers of an unrelated module",
n is not None and "test_a.py" not in n and "test_b.py" not in n)
n, why = choose(R, [P + "app/low.py", P + "tools/launched.py"])
check("two changes choose the union",
n is not None and {"test_a.py", "test_c.py"} <= set(n))
# the safety set is always in, whatever changed
R2 = tmp()
files = {P + "app/__init__.py": "", P + "app/m.py": "A = 1\n",
P + "test_m.py": "from app import m\n", P + "test_zzz.py": "print(1)\n"}
for name in sel.SAFETY_SET:
files[P + name] = "print('safety')\n"
mk(R2, files)
n, why = choose(R2, [P + "app/m.py"])
check("every safety-set file is chosen on a one-module change",
n is not None and set(sel.SAFETY_SET) <= set(n))
check("...with a reason printed for each", all(sel.SAFETY_SET.values()))
check("...and a test outside the set that does not reach it stays out",
n is not None and "test_zzz.py" not in n)
# ------------------------------------------------------------ 2. fails open
print("\n2. it fails open")
for label, paths, status in (
("config.yaml (not Python)", [P + "config.yaml"], "M"),
("a translation file", [P + "translations/en.yaml"], "M"),
("run-tests.sh itself", [".gitea/ci/run-tests.sh"], "M"),
("a Dockerfile", [P + "Dockerfile"], "M"),
("a markdown file", ["README.md"], "M"),
("a deleted module", [P + "app/low.py"], "D"),
("a renamed module", [P + "app/low.py"], "R"),
("a .py nothing references", [P + "app/never_imported.py"], "M")):
if label.startswith("a .py"):
mk(R, {P + "app/never_imported.py": "Z = 1\n"})
n, why = choose(R, paths, status)
check(f"{label}: SELECT ALL", n is None and why)
mk(R, {P + "app/broken.py": "def (:\n"})
n, why = choose(R, [P + "app/low.py"])
check("a file in the tree that does not parse: SELECT ALL", n is None and why)
os.remove(os.path.join(R, P + "app/broken.py"))
n, why = choose(tmp(), [P + "app/low.py"])
check("a tree with no test files: SELECT ALL", n is None and why)
# the CLI: --names prints every test on SELECT ALL, and a bad base is ALL
g = tmp()
mk(g, {P + "app/__init__.py": "", P + "app/m.py": "A = 1\n",
P + "test_m.py": "from app import m\n"})
env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
def git(*a):
return subprocess.run(["git", "-C", g] + list(a), env=env,
capture_output=True, text=True)
git("init", "-q", "-b", "base")
git("add", "-A")
git("commit", "-q", "-m", "base")
git("checkout", "-q", "-b", "work")
mk(g, {P + "app/m.py": "A = 2\n"})
git("commit", "-q", "-am", "work")
r = subprocess.run([sys.executable, SEL, "--root", g, "--base", "base",
"--head", "work", "--names"], capture_output=True, text=True)
check("a committed range via git: the importer is chosen",
r.returncode == 0 and r.stdout.split() == ["test_m.py"])
r = subprocess.run([sys.executable, SEL, "--root", g, "--base", "nosuchref",
"--head", "work"], capture_output=True, text=True)
check("a base that does not exist: SELECT ALL, not an error and not nothing",
r.returncode == 0 and "SELECT ALL" in r.stdout)
mk(g, {P + "app/m.py": "A = 3\n"})
r = subprocess.run([sys.executable, SEL, "--root", g, "--base", "base",
"--names"], capture_output=True, text=True)
check("uncommitted work is part of the diff (no --head)",
r.returncode == 0 and r.stdout.split() == ["test_m.py"])
mk(g, {P + "app/newmod.py": "B = 1\n", P + "test_new.py": "from app import newmod\n"})
r = subprocess.run([sys.executable, SEL, "--root", g, "--base", "base",
"--names"], capture_output=True, text=True)
check("an untracked new module and its new test are in the diff",
r.returncode == 0 and set(r.stdout.split()) == {"test_m.py", "test_new.py"})
# ---------------------------------------------------------------- 3. real tree
print("\n3. the real tree")
tests = sorted(f for f in os.listdir(HERE)
if f.startswith("test_") and f.endswith(".py"))
check("every safety-set name is a test file that exists",
all(f in tests for f in sel.SAFETY_SET))
for p in ("goodwe_controller/config.yaml", ".gitea/ci/run-tests.sh",
".gitea/workflows/checks.yml", "goodwe_controller/DOCS.md",
"goodwe_controller/translations/en.yaml"):
n, why = choose(REPO, [p])
check(f"{p}: SELECT ALL", n is None and why)
n, why = choose(REPO, ["goodwe_controller/app/maintenance.py"])
check("app/maintenance.py chooses test_maint25.py (it imports it through main)",
n is not None and "test_maint25.py" in n)
n, why = choose(REPO, ["goodwe_controller/test_maint25.py"])
check("a changed test chooses itself and the safety set",
n is not None and "test_maint25.py" in n and set(sel.SAFETY_SET) <= set(n))
check("...and far fewer than all",
n is not None and len(n) < len(tests) // 2)
n, why = choose(REPO, [".gitea/ci/affected_tests.py"])
check("the selector's own file chooses this file",
n is not None and "test_affected_tests.py" in n)
# ------------------------------------------------------------------ 4. mutant
print("\n4. the mutant: break a module, the selector must choose a red test")
M = tmp()
ign = shutil.ignore_patterns(".git", "scenarios", "__pycache__", "*.pyc")
shutil.copytree(REPO, os.path.join(M, "repo"), ignore=ign, symlinks=True)
mroot = os.path.join(M, "repo")
cwd = os.path.join(mroot, "goodwe_controller")
env2 = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
def run_test(name):
r = subprocess.run([sys.executable, "-B", name], cwd=cwd, env=env2,
capture_output=True, text=True, timeout=600)
return r.returncode
check("control: test_maint25.py is green on the unbroken copy",
run_test("test_maint25.py") == 0)
path = os.path.join(cwd, "app", "maintenance.py")
with open(path, encoding="utf-8") as fh:
src = fh.read()
old = " return until if now < until else None\n"
check("the line to break is where this test expects it", src.count(old) == 1)
with open(path, "w", encoding="utf-8") as fh:
fh.write(src.replace(old, " return None\n"))
n, why = choose(mroot, ["goodwe_controller/app/maintenance.py"])
check("the selector chooses test_maint25.py for the broken module",
n is not None and "test_maint25.py" in n)
check("...and running the chosen file is RED", run_test("test_maint25.py") != 0)
with open(path, "w", encoding="utf-8") as fh:
fh.write(src)
check("put back: GREEN again", run_test("test_maint25.py") == 0)
# --------------------------------------------- 5. the gate cannot be partial
print("\n5. the merge gate runs everything")
with open(os.path.join(REPO, ".gitea", "workflows", "checks.yml"),
encoding="utf-8") as fh:
wf = fh.read()
check("checks.yml never mentions CI_ONLY", "CI_ONLY" not in wf)
check("checks.yml runs run-tests.sh with no file list",
"sh -eu /w/goodwe-addon/.gitea/ci/run-tests.sh\n" in wf)
check("checks.yml never calls the selector or the local wrapper",
"affected_tests" not in wf and "run-affected" not in wf)
with open(os.path.join(REPO, ".gitea", "ci", "run-tests.sh"),
encoding="utf-8") as fh:
rt = fh.read()
check("run-tests.sh takes CI_ONLY from the environment only and says PARTIAL",
"PARTIAL RUN" in rt and 'set -- test_*.py' in rt)
for d in tmps:
shutil.rmtree(d, ignore_errors=True)
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
File diff suppressed because it is too large Load Diff
+485
View File
@@ -0,0 +1,485 @@
"""Runnable check for MAINT-28 fix-forward. `python3 test_maint28b.py`
The rig failed MAINT-28 (TEST-23, f3f9483): the hold of the maintenance pause
ended at almost every guard step (F-1), and while it stuck, the pack in
maintenance CHARGE gave its full rating for the house below its peak reserve
(F-2). test_maint28.py missed both because its battery reading has no lag. Here
the battery register is published every 10 s and held (`Lag.batt_period_s`),
the meter reading is 2 s old, as on the rig; the rating is the rig's A (5 kW).
All on the real Controller through `cycle()` (tools/opt20_scen.py): cheap
window, cap 3500 / margin 50, a month peak of 6000 W banked (aim 5950 W), a
peak forecast like the live sensor, house +-50 W every 5 s, 3000 W then
6500 W for 13 min (over a quarter boundary), then 3000 W.
1. A real peak, A in CHARGE above its peak reserve: A flips charge/give a few
times (base 1b5f7c9: dozens), no release of the pause on an estimate that
mixes a new grid with an old battery reading, quarters at or under the aim,
A's charge back after the load.
2. F-2 and the cap (owner 2026-11-13): while the pause holds, A gives at most
the largest part of the guard's floor placed on it in the 15-min period,
at any SoC (never under its reserve; SoC drop within the owner-accepted
give); with both packs able, never over the period's largest floor, and
when B is placed part of it A's limit is its own part, not the whole floor;
one unit's limit is its own (two packs in CHARGE never share it); with the
other pack under its reserve, A still gives for the guard.
3. The mirror: B in CHARGE, under its reserve at the load: it gives nothing
for the house.
4. Unchanged: the MAINT-24 live shape (A 1475 W, B no grid trade), with the
same lag on.
5. `peakguard.shaping_held()` with `settled`, and `Controller._batts_settled()`.
6. H-1 (owner 2026-11-14): while the pause holds the batteries together give
only what the guard needs. The live try (A 11 %, B 25 %, 90 min at 6.5 kW)
and, for the F-1 wiring, the 45-min rig shape.
Mutations: DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md.
Read MUTATION.md before you report a survivor.
"""
import logging
import os
import random
import sys
from datetime import timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
import opt20_scen as sc # noqa: E402
from app import (ctl_cycle, ctl_trade, freshness, maintenance, # noqa: E402
packmode, peakguard, tariff)
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
rp.RATING, rp.CAP_KWH = 5000.0, 15.36 # the rig's A
LAG = rp.Lag(dead_s=1.0, meter_s=2.0, batt_s=1.0, p1_s=0.35, tick=1.0,
tau_s=2.0, ramp_w_s=1e9, batt_period_s=10.0)
CH = sc.MAINT_CHARGE
MONTH_PEAK = 6000.0
AIM = MONTH_PEAK - 50.0
LINE = AIM - getattr(peakguard, "MAINT_RESUME_UNDER_W", 300.0)
T0 = sc.at(2.0) # a quarter boundary
PRE, LOAD, POST = 120.0, 780.0, 120.0 # s (CI time: short runs)
RESERVE = 20.0 # peak_soc_floor default
def stepped_house(base_w, hi_w, load=LOAD, pre=PRE):
rnd = random.Random(28)
out = [(sc.UTC0.timestamp() - 10, base_w)]
for t in range(0, int(pre + load + POST), 5):
w = hi_w if pre <= t < pre + load else base_w
out.append((T0.timestamp() + t, w + rnd.uniform(-50.0, 50.0)))
return out
def scen(*, unit="a", soc=49.0, other=30.0, house=None, house_w=3000.0,
peak=MONTH_PEAK, dur=PRE + LOAD + POST, fc="quarter", n=2,
choice=None):
"""`unit` is in maintenance CHARGE (forced result), at `soc`, the other at
`other`. Rows: the plants' power and SoC, the pause flag, the add-on's house
estimate and the real house."""
rows = []
q = dict(q=None, s=0.0, last=None, start=None)
idx = 0 if unit == "a" else 1
o = "b" if unit == "a" else "a"
def patch(c):
c.maints[idx].tick = (
lambda now, soc_, blocked_by=None, gate_open=True: CH)
if peak is not None:
orig_peak = c.peak.peak
def peak_(now):
r = orig_peak(now)
return tariff.PeakReading(tariff.OBSERVED,
getattr(r, "month", ""), peak)
c.peak.peak = peak_
orig = c.cycle
async def cyc():
c.cheap = True
if fc and c.grid is not None:
g = float(c.grid)
now = ctl_cycle.datetime.now() # the replay's clock
qi = int(now.timestamp() // 900)
if q["q"] != qi:
q.update(q=qi, s=0.0, last=now, start=now)
el = (now - q["start"]).total_seconds()
q["s"] += g * (now - q["last"]).total_seconds()
q["last"] = now
c.peak_fc = (q["s"] + g * (900.0 - el)) / 900.0
await orig()
c.cycle = cyc
def cb(c, h, now, pr):
est = (None if c.grid is None or c.site_batt_w is None
else float(c.grid) + float(c.site_batt_w))
g = c.peak_guard
floor = (float(g.floor_w) if g is not None and g.engaged
and g.floor_w is not None else 0.0)
sh = (dict(c.allocation.shares) if c.allocation is not None
and c.allocation.shares else {})
au = c.actuator_of("maintenance", c.inverters[idx])
ao = c.actuator_of("maintenance", c.inverters[1 - idx])
rows.append(dict(
t=(now - T0).total_seconds(), grid=h.grid(), u=h.plants[unit].p,
o=h.plants[o].p, soc=h.plants[unit].soc, soc_o=h.plants[o].soc,
hold=bool(getattr(c, "_maint_peak_hold", False)), est=est,
floor=floor, share_u=float(sh.get(au, 0.0)),
hi_u=next((float(pl.hi) for pl in (c.allocation.plans
if c.allocation else ())
if pl.unit == au), None),
share_o=float(sh.get(ao, 0.0)),
house=h.grid() + sum(p.p for p in h.plants.values())))
src = house if house is not None else [(sc.UTC0.timestamp() - 10, house_w)]
ch = dict(sc.CH_CH if choice is None else choice)
stats, c = rp.run("p3_ch_sell", src, sc.WPTS, T0, dur, check=cb, lag=LAG,
n=n, soc0={unit: soc, o: other}, choice=ch,
refuse=(), patch=patch,
extra={"cycle_cost_threshold_eur": 0.10})
return rows, stats, c
def flips(rs, k="u"):
n, s = 0, 0
for r in rs:
v = 1 if r[k] > 50 else -1 if r[k] < -50 else 0
if v and s and v != s:
n += 1
if v:
s = v
return n
def releases(rows):
"""Cycles in the load where the pause ended: (row, estimate under the
release line while the real house was over it)."""
out = []
for p, r in zip(rows, rows[1:]):
if PRE <= r["t"] < PRE + LOAD and p["hold"] and not r["hold"]:
out.append((r, r["est"] is not None and r["est"] < LINE
and r["house"] >= LINE))
return out
def quarters_ok(st):
# SAFETY-126: a landing 0-7 W over the aim is not an overrun.
return (max(a[0] / a[1] for a in st["quarters"].values())
<= AIM + peakguard.ALARM_MISS_W)
def show(tag, rows, st):
load = [r for r in rows if PRE <= r["t"] < PRE + LOAD]
rel = releases(rows)
print(f" {tag}: flips {flips(load)}, charge s "
f"{sum(1 for r in load if r['u'] < -50)}, releases {len(rel)} "
f"(stale {sum(1 for _r, s in rel if s)}), lowest SoC "
f"{min(r['soc'] for r in rows):.1f} %, give kWh "
f"{sum(r['u'] for r in load if r['u'] > 0) / 3.6e6:.2f}, quarters "
f"{[round(a[0] / a[1], 1) for a in st['quarters'].values()]}")
# ============================================================================ #
print("1. a real peak, A in CHARGE at 49 % (above its reserve), the battery "
"reading lagging")
rows, st, c = scen(house=stepped_house(3000.0, 6500.0))
show("A 49 %", rows, st)
load = [r for r in rows if PRE <= r["t"] < PRE + LOAD]
rel = releases(rows)
after = [r for r in rows if r["t"] >= PRE + LOAD]
back = next((i for i, r in enumerate(after) if r["u"] < -50), None)
print(f" A's charge back {back} s after the load")
check("A flips charge/give 3 times or fewer in the 13 min (1b5f7c9: 60, "
"f3f9483: 11)", flips(load) <= 3)
check("the pause never ends on an estimate under the release line while the "
"real house is over it", not any(s for _r, s in rel))
check("every quarter at or under the month aim",
quarters_ok(st))
check("after the load A's charge is back within 20 cycles (1 s each: a 10 s "
"register period plus its settle time, 16 s)",
back is not None and back <= 20)
# M-1 (review 2026-11-14): the F-1 wiring, `_batts_settled()` read at the hold
# in ctl_cycle.py. With `settled=True` there the 13-min load above still passes;
# the rig shape (the load starts 10 min into the quarter, 45 min long) does not.
RIG_PRE, RIG_LOAD = 600.0, 2700.0
rows, st, c = scen(house=stepped_house(3000.0, 6500.0, RIG_LOAD, RIG_PRE),
dur=RIG_PRE + RIG_LOAD + POST)
load45 = [r for r in rows if RIG_PRE <= r["t"] < RIG_PRE + RIG_LOAD]
rel45 = [(r, s_) for p, r in zip(rows, rows[1:])
if RIG_PRE <= r["t"] < RIG_PRE + RIG_LOAD and p["hold"]
and not r["hold"]
for s_ in [r["est"] is not None and r["est"] < LINE
and r["house"] >= LINE]]
print(f" A 49 %, 45 min: flips {flips(load45)}, releases {len(rel45)} "
f"(stale {sum(1 for _r, s_ in rel45 if s_)}), quarters "
f"{[round(a[0] / a[1]) for a in st['quarters'].values()]}")
check("45 min: A flips charge/give 3 times or fewer (f3f9483: 148)",
flips(load45) <= 3)
check("45 min: no release of the pause on a stale estimate (f3f9483: 83 of 83)",
not any(s_ for _r, s_ in rel45))
check("45 min: every quarter at or under the month aim", quarters_ok(st))
# ============================================================================ #
print("\n2. the cap: while the pause holds, A gives at most the largest part "
"of the guard's floor placed on it this period")
rows, st, c = scen(soc=RESERVE + 1.0, house=stepped_house(3000.0, 6500.0))
show("A 21 %", rows, st)
check("A gives only the guard's claim, under 0.15 kWh (f3f9483 0.94)",
sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.15)
check("A never goes under its peak reserve in the peak (rig: to 10 %)",
min(r["soc"] for r in rows) >= RESERVE - 0.5)
check("every quarter at or under the month aim", quarters_ok(st))
rows, st, c = scen(house=stepped_house(3000.0, 6500.0))
soc_start = max(r["soc"] for r in rows if r["t"] <= PRE + 5.0)
drop = soc_start - min(r["soc"] for r in rows if r["t"] >= PRE)
print(f" A at 49 %: SoC drop {drop:.2f} point")
# Owner 2026-11-13 accepted about 0.37 kWh per 45-min peak with the cap
# (DECISIONS): 0.37 * 13 / 45 = 0.107 kWh in this load, 0.70 point of
# 15.36 kWh. Measured 0.64.
check("A at 49 %: its SoC drops no more than the owner-accepted give allows "
"(0.70 point in the 13 min; f3f9483 6.1)", drop <= 0.70)
rows, st, c = scen(soc=49.0, other=18.2, house=stepped_house(3000.0, 12000.0))
show("A 49 %, B 18 % under its reserve, house 12000 W", rows, st)
load = [r for r in rows if PRE <= r["t"] < PRE + LOAD]
check("the guard's own claim stands: with B under its reserve A gives for "
"the peak (over 0.2 kWh; the cap yields to the guard)",
sum(r["u"] for r in load if r["u"] > 0) / 3.6e6 > 0.2)
g = [("give" if r["u"] > 50 else "idle") for r in load]
sw = sum(1 for x, y in zip(g, g[1:]) if x != y)
print(f" give/idle switches of A in the load: {sw}")
check("...give/idle switches of A at most 10 in the 13 min", sw <= 10)
def period_max(rows, f):
"""Per row: the largest f(row) over the paused rows of its 15-minute
period so far (0 W outside the pause), as the owner rule reads."""
out, q, m = [], None, 0.0
for r in rows:
qi = int((T0.timestamp() + r["t"]) // 900)
if qi != q or not r["hold"]:
q, m = qi, 0.0
if r["hold"]:
m = max(m, f(r))
out.append(m)
return out
for tag, ch, hi_w in (("B on Cheap hours", None, 9000.0),
("B on Solar first (it gives for the house too)",
{"a": sc.CH_CH["a"],
"b": (True, packmode.SOLAR_FIRST, False)},
12000.0)):
rows, st, c = scen(soc=49.0, other=40.0, choice=ch,
house=stepped_house(3000.0, hi_w))
show(f"A 49 %, B 40 %, both able, {tag}, house {hi_w:.0f} W", rows, st)
mf = period_max(rows, lambda r: r["floor"])
held_rows = [(r, m) for r, m in zip(rows, mf)
if r["hold"] and r["floor"] > 0.0 and r["hi_u"] is not None]
n = max(1, len(held_rows))
over = sum(1 for r, m in held_rows if r["share_u"] > m + 2.0)
# the whole floor, as far as A's rating lets it be A's limit
under = sum(1 for r, m in held_rows
if r["hi_u"] < min(m, rp.RATING) - 500.0)
print(f" {len(held_rows)} paused cycles with a guard floor (mean "
f"{sum(r['floor'] for r, _m in held_rows) / n:.0f} W, period's "
f"largest {sum(m for _r, m in held_rows) / n:.0f} W): A's limit "
f"{sum(r['hi_u'] for r, _m in held_rows) / n:.0f} W, A "
f"{sum(r['share_u'] for r, _m in held_rows) / n:.0f} W, B "
f"{sum(r['share_o'] for r, _m in held_rows) / n:.0f} W; A over the "
f"period's largest floor in {over}; A's limit 500 W or more under it"
f" (or A's rating) in {under}")
check(f"{tag}: the pause holds with a guard floor for over 5 min",
len(held_rows) >= 300)
check("...A never gives more than the largest guard floor of the period "
"(owner 2026-11-13)", over == 0)
if ch is not None:
check("...B is placed part of the floor, so A's limit is its own part,"
" 500 W or more under the period's largest floor (or A's "
"rating) in 9 of 10 cycles (not the whole floor)",
under >= 0.9 * len(held_rows))
check("...every quarter at or under the month aim", quarters_ok(st))
rows, st, c = scen(soc=18.2, other=30.0, house=stepped_house(3000.0, 12000.0))
show("A 18 % under its reserve, B 30 % able, house 12000 W", rows, st)
check("the guard places its claim on the other pack: A under its reserve "
"gives under 0.05 kWh and its SoC stays at 18 %",
sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.05
and min(r["soc"] for r in rows) >= 18.0)
# H-1 (review 2026-11-14, owner decision): while the pause holds, the packs
# together give only what the guard needs. The live try: A 11 % in CHARGE (under
# its reserve), B 25 % on Cheap hours, 90 min at 6.5 kW.
LIVE = 5400.0
rp.CAP_KWH = 20.48 # the rig's B; A gives nothing here
rows, st, c = scen(soc=11.0, other=25.0,
house=stepped_house(3000.0, 6500.0, LIVE, RIG_PRE),
dur=RIG_PRE + LIVE + POST)
rp.CAP_KWH = 15.36
qs = [a[0] / a[1] for a in st["quarters"].values()]
load = [r for r in rows if RIG_PRE <= r["t"] < RIG_PRE + LIVE]
gb = sum(r["o"] for r in load if r["o"] > 0) / 3.6e6
def switches(k):
g = [("give" if r[k] > 50 else "idle") for r in load]
return sum(1 for x, y in zip(g, g[1:]) if x != y)
b_low = min((r["soc_o"] for r in rows), default=0.0)
print(f" live try: quarters {[round(q) for q in qs]}, B gives {gb:.2f} kWh, "
f"B lowest {b_low:.1f} %, give/idle switches A "
f"{switches('u')}, B {switches('o')}")
check("live try: every quarter at or under the aim + 10 W (07d23d2: 6502 and "
"6499 W against the month peak 6000 W)", max(qs) <= AIM + 10.0)
check("live try: B gives about its guard part, under 1.5 kWh (1b5f7c9 1.0; "
"07d23d2 4.6 kW, 25 -> 10 % in 43 min)", gb < 1.5)
check("live try: B's SoC never goes under 19.8 % (1b5f7c9 19.8)",
b_low >= 19.8)
check("live try: give/idle switches of B at most 40 in the 90 min",
switches("o") <= 40)
# ============================================================================ #
print("\n3. mirror: B in CHARGE at 18 % (under its reserve), A on Cheap hours")
rows, st, c = scen(unit="b", soc=18.2, other=30.0,
house=stepped_house(3000.0, 6500.0))
show("B 18 %", rows, st)
check("B gives nothing for the house: its SoC never goes under 18 %",
min(r["soc"] for r in rows) >= 18.0)
check("every quarter at or under the month aim", quarters_ok(st))
# ============================================================================ #
print("\n4. unchanged: the MAINT-24 live shape with the lagging reading")
rl, sl, cl = scen(house_w=3000.0, dur=600.0)
body = rl[len(rl) // 6:]
mean_a = sum(r["u"] for r in body) / len(body)
mean_b = sum(r["o"] for r in body) / len(body)
print(f" A {mean_a:+.0f} W, B {mean_b:+.0f} W, pause "
f"{sum(r['hold'] for r in rl)} cycles")
check("A charges about 1475 W (-1500..-1440) and the pause never comes",
-1500.0 <= mean_a <= -1440.0 and not any(r["hold"] for r in rl))
check("B does not trade with the grid (Cheap hours, nothing to charge it)",
abs(mean_b) < 100.0)
# ============================================================================ #
print("\n5. peakguard.shaping_held() with `settled`; Controller._batts_settled()")
check("the rule exists (MAINT_STEP_W, _batts_settled)",
hasattr(ctl_trade, "MAINT_STEP_W") and hasattr(ctl_trade.TradeMixin,
"_batts_settled"))
if not hasattr(ctl_trade, "MAINT_STEP_W"):
print(f"\n{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
U = peakguard.MAINT_RESUME_UNDER_W
idle = peakguard.PeakDecision(engaged=False, floor_w=None, ceiling_w=6000.0,
ceiling_from="x", target_w=AIM)
def held(was, fc, house, settled):
return peakguard.shaping_held(
peakguard.shaping_suspended(idle, fc, AIM), was, fc, AIM, house,
settled)
check("held, house under the line but not settled: stays paused",
held(True, AIM - 1, AIM - U - 100.0, False))
check("held, house under the line and settled: resumes",
not held(True, AIM - 1, AIM - U - 100.0, True))
check("not held: settled makes no difference",
not held(False, AIM - 1, 6500.0, False))
check("neither the house nor the forecast read: no pause, settled or not "
"(SAFETY-63)",
not held(True, None, None, False))
class _Fresh:
def __init__(self, changed):
self.changed_mono = changed
class _Inv:
def __init__(self, name, cmd, changed):
self.name, self.cmd = name, cmd
self.batt_fresh = _Fresh(changed)
def standing_w(self):
return self.cmd
class _Ctl:
_batts_settled = getattr(ctl_trade.TradeMixin, "_batts_settled", None)
def __init__(self, invs):
self.inverters, self._m28_step, self._now_mono = invs, {}, 0.0
S = freshness.PLANT_SETTLE_S
a = _Inv("a", -1500.0, 0.0)
k = _Ctl([a, _Inv("b", 0.0, 0.0)])
check("no step seen yet: settled", k._batts_settled())
a.cmd = 500.0
k._now_mono = 100.0
check("a step up of the command: not settled", not k._batts_settled())
k._now_mono = 100.0 + S - 0.5
a.batt_fresh.changed_mono = 100.0 + S - 1.0
check("...a reading that arrived before step + settle does not count",
not k._batts_settled())
a.batt_fresh.changed_mono = 100.0 + S
k._now_mono = 100.0 + S
check("...one that arrived at step + settle does", k._batts_settled())
a.cmd = 3000.0
k._now_mono = 200.0
check("a second step up; the old reading is not current",
not k._batts_settled())
k._now_mono = 200.0 + freshness.BATT_OBLIGED_S
check("...and a reading that never changes is waited for no longer than "
"BATT_OBLIGED_S", k._batts_settled())
a.cmd = -1500.0
k._now_mono = 400.0
check("a step down (less give: the estimate errs high) is no step",
k._batts_settled())
a2 = _Inv("a", -1500.0, 0.0)
k2 = _Ctl([a2])
k2._batts_settled()
a2.cmd, k2._now_mono = -1500.0 + ctl_trade.MAINT_STEP_W - 1.0, 1.0
check("a move under ctl_trade.MAINT_STEP_W is no step", k2._batts_settled())
a2.cmd = -1500.0 + ctl_trade.MAINT_STEP_W
check("...one of ctl_trade.MAINT_STEP_W is", not k2._batts_settled())
# Per unit: the cap so far lives under each unit's own key, and only one pack
# can be in maintenance CHARGE at all (maintenance.arbitrate(): one slot).
au, ab = (cl.actuator_of("maintenance", i) for i in cl.inverters)
t = T0 + timedelta(seconds=60)
cl._m28_hi = {au: (int(t.timestamp() // 900), 3000.0)}
check("the period's cap is kept per unit: A's 3000 W is not B's",
cl._m28_was(au, t) == 3000.0 and cl._m28_was(ab, t) == 0.0)
check("...and it starts again from 0 W in the next 15-minute period",
cl._m28_was(au, t + timedelta(seconds=900)) == 0.0)
class _M:
def __init__(self, name):
self.name, self.phase = name, maintenance.CHARGE
check("maintenance.arbitrate(): with two packs in CHARGE only the first keeps "
"the slot", maintenance.arbitrate([_M("a"), _M("b")], t)[1] != ""
and maintenance.arbitrate([_M("a"), _M("b")], t)[0] == "")
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
+5 -335
View File
@@ -22,7 +22,11 @@ record.
8. Options: validation of the trust and the entities.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
This file is parts 1-5 and 8. Part 6 (the real controller over a night and a day,
the slow one) and part 7 are in test_modes_p4b.py, test_modes_p4c.py and
test_modes_p4d.py, one group of scenarios each, so CI can run them side by side
(CI-18). Every check is in exactly one of the four files."""
import asyncio
import logging
@@ -549,340 +553,6 @@ e = modetrade.energy([pk(B, packmode.LEFTOVER_SOLAR, 30.0)], 20.0, 95.0,
check("the cheap run is sized by the shortfall (1.5 kWh), not the room to "
"95 % (6.5 kWh)", abs(e[0] - 1.5) < 1e-9)
# ============================================================================ #
print("6. the real controller over a night and a day")
AIM = rp.PEAK_CAP - 50
FC_KWH = [None] # today's forecast in the fake sensor (None: down)
async def _sensor(self, e):
now = rp_start[0] + timedelta(seconds=self.t)
kwh = FC_KWH[0](now) if callable(FC_KWH[0]) else FC_KWH[0]
if kwh is None:
return {"state": "unavailable", "attributes": {},
"last_updated": now.isoformat()}
if e.endswith("tomorrow"):
return None
loc = now.astimezone(L)
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
wh = {(d0 + timedelta(hours=h)).astimezone(timezone.utc).isoformat():
(kwh * 100.0 if 8 <= h < 18 else 0.0) for h in range(24)}
left = sum(v for k, v in wh.items()
if datetime.fromisoformat(k) >= now) / 1000.0
return {"state": f"{left:.3f}",
"attributes": {"unit_of_measurement": "kWh", "wh_period": wh},
"last_updated": now.isoformat(), "last_reported": now.isoformat()}
rp.Hass.state = _sensor
rp_start = [UTC0]
FC_OPTS = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
"cycle_cost_threshold_eur": 0.20}
# TARIFF-24 round 2: with a forecast read, a price mode needs the cycle cost
# itself (0.20 here), no longer 1.5 times it (0.30). These scenarios were
# written for a 0.30 gap, and a midday 0.21 gap now plans a grid top-up they
# are not about, so they ask for 0.30 with the forecast; the no-forecast leg
# (`rows_nf`) keeps 0.20, which is 0.30 widened: the same gate on both sides.
GATE_030 = {"cycle_cost_threshold_eur": 0.30}
def seed_profile(c):
if c.house_profile is not None:
c.house_profile = profile(day_w=600.0, evening=3000.0)
def house_flat(w):
return [(UTC0.timestamp() - 10, float(w))]
def run_day(choice, *, fc, soc0, hours=21.0, start_h=0.75, house_w=800.0,
refuse=(), extra=None, patch=seed_profile):
FC_KWH[0] = fc
start = UTC0 + timedelta(hours=start_h)
rp_start[0] = start
rows = []
def cb(c, h, now, pr):
sh = dict(c.allocation.shares) if c.allocation is not None and \
c.allocation.shares else {}
rows.append(dict(
now=now, grid=h.grid(), shares=sh,
action=getattr(c.arb, "action", None),
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
soc={u: p.soc for u, p in h.plants.items()},
targets=c.plan_targets(), plan=c.day_plan, runs=c.mode_runs))
st_, c = rp.run("p3_ls", house_flat(house_w), WPTS, start, 3600 * hours,
check=cb, soc0=soc0, choice=choice, refuse=refuse,
extra=dict(FC_OPTS, **(extra or {})), patch=patch)
return rows, st_, c
SF_LS = {"a": (True, "solar_first", False), "b": (True, "leftover_solar",
False)}
SF_CH = {"a": (True, "solar_first", False), "b": (True, "cheap_hours",
False)}
def invariants(tag, rows, c):
charge = [r for r in rows if r["action"] == arbitrage.CHARGE]
mixed = [r for r in rows if any(v > 0 for v in r["shares"].values())
and any(v < 0 for v in r["shares"].values())]
check(f"{tag}: no opposite-direction split on any cycle", not mixed)
check(f"{tag}: Solar first never charges from the grid (A's share >= 0 "
"while the site charges)",
all(r["shares"].get(A, 0) >= 0 for r in charge))
q = {int(r["now"].timestamp() // 900) for r in charge}
banked = [b.offtake_avg_w for b in c.p1.blocks
if int(b.start.timestamp() // 900) in q]
check(f"{tag}: every banked quarter with a grid charge <= cap - margin "
f"(max {max(banked, default=0):.1f} W, aim {AIM})",
not banked or max(banked) <= AIM + 0.5)
return charge
# Sunny: 40 kWh forecast. B (Leftover solar) at 30 % needs nothing.
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0},
extra=GATE_030)
charge = invariants("sunny", rows, c)
_last = nth(rows, -1, "a cycle")
print(f" sunny: verdict {present(_last) and _last['plan'] and _last['plan'].verdict}, "
f"{len(charge)} charge cycles")
check("sunny: a day plan is made, and says sunny",
any(r["plan"] is not None and r["plan"].verdict == dayplan.SUNNY
for r in rows))
check("sunny: no grid charge at all", not charge)
# TARIFF-24 round 2: the same day at the cycle cost 0.20 itself. With the
# forecast read the gap needed is 0.20 (not 1.5 x = 0.30), so the midday
# quarter 0.339 against the evening 0.551 (gap 0.21, between 1x and 1.5x) is
# a planned top-up for B (Leftover solar) up to its planned level.
rows_g, st_, c_g = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
charge_g = invariants("gap between 1x and 1.5x", rows_g, c_g)
_first_g = nth(charge_g, 0, "a charge cycle")
_gaps = [min(g.mean_eur_kwh for g in r["runs"].give)
- r["runs"].cheap.mean_eur_kwh for r in charge_g
if r["runs"] is not None and r["runs"].give]
print(f" gap 0.20 <= gap < 0.30: {len(charge_g)} charge cycles, run gap "
f"{min(_gaps, default=0):.3f}, B to "
f"{max((r['soc']['b'] for r in rows_g), default=0):.1f} %")
check("a forecast day whose price gap lies between 1x and 1.5x the cycle "
"cost (0.20 < 0.21 < 0.30) plans a cheap run: B charges from the grid "
"inside it, only B, and only up to its planned level",
present(_first_g) and len(_gaps) == len(charge_g)
and all(0.20 <= g < 0.30 for g in _gaps)
and all(r["runs"].cheap.holds(r["now"]) for r in charge_g)
and all(r["roles"][B] == modetrade.CHARGE for r in charge_g)
and all(r["roles"][A] != modetrade.CHARGE for r in charge_g)
and max(r["soc"]["b"] for r in rows_g) <= 45.64 + 2.0)
check("...and the same day with no forecast read does not (it needs 0.30): "
"the old gate, kept for a day without a forecast",
not [r for r in run_day(SF_LS, fc=None,
soc0={"a": 60.0, "b": 30.0})[0]
if r["action"] == arbitrage.CHARGE
and r["now"].astimezone(L).hour == 12])
# Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run.
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0})
charge = invariants("dark", rows, c)
tgt = nth([r["targets"][B] for r in rows if r["targets"]], 0, "a level")
top = max(r["soc"]["b"] for r in rows)
print(f" dark: B level {tgt:.1f} %, B reached {top:.1f} %, "
f"{len(charge)} charge cycles")
check("dark: Leftover solar charges from the grid in the cheap run",
charge and all(r["runs"].cheap.holds(r["now"]) for r in charge)
and all(r["roles"][B] == modetrade.CHARGE for r in charge))
check("dark: ...only up to its level (the shortfall), not 95 %",
present(tgt) and tgt < 60.0 and top <= tgt + 2.0)
check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST
for r in charge))
# Cheap hours on a partly sunny day: ignores the plan, fills as in P3.
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0},
extra=GATE_030)
charge = invariants("cheap hours, partly", rows, c)
top = max(r["soc"]["b"] for r in rows)
print(f" cheap hours partly: B reached {top:.1f} %")
check("Cheap hours gets no planned level",
not any(r["targets"] and B in r["targets"] for r in rows))
rows_nf, _s, c_nf = run_day(SF_CH, fc=None, soc0={"a": 60.0, "b": 30.0})
check("Cheap hours with a sun forecast charges as with none (P3)",
charge and abs(max(r["soc"]["b"] for r in rows_nf) - top) < 1.0
and top > 80.0)
# The forecast goes away mid-run: a charge under way goes on to its level
# (OPT-34; it stopped at once, P3 option A, before). With no charge under way
# the levels go with the forecast (below, and test_opt27.py).
# OPT-27: a sensor that is unavailable is covered by its last fresh reading
# for up to 3 h (`solarfc.Hold`), so "lost" here is a hold that never covers;
# the next scenario is the same loss with the hold (test_opt27.py).
DROP = UTC0 + timedelta(hours=1, minutes=30)
class _NoHold(solarfc.Hold):
def stand_in(self, *a, **k):
return None
def seed_profile_no_hold(c):
seed_profile(c)
c.fc_hold = _NoHold()
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
soc0={"a": 60.0, "b": 10.0}, patch=seed_profile_no_hold)
invariants("forecast lost mid-run", rows, c)
before = [r for r in rows if r["now"] < DROP and r["action"]
== arbitrage.CHARGE]
late = [r for r in rows if r["now"] > DROP + timedelta(
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE]
check("forecast lost mid-run (OPT-34): Leftover solar was charging, and goes "
"on charging past a forecast read (60 s) and a cycle, to its level",
before and late)
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
soc0={"a": 60.0, "b": 10.0})
invariants("forecast unavailable mid-run, covered", rows, c)
check("forecast unavailable mid-run (OPT-27): the last reading covers it, so "
"Leftover solar goes on charging past the point where it was lost",
[r for r in rows if r["now"] < DROP and r["action"]
== arbitrage.CHARGE]
and [r for r in rows if r["now"] > DROP + timedelta(
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE])
# The forecast is far too high (40 kWh, and there is no sun at all): no grid
# charge, so B covers less of the evening - a money risk, never a safety one.
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0},
extra=GATE_030)
invariants("forecast too high", rows, c)
check("forecast too high: no grid charge, and B still keeps its power for "
"the dear run", not [r for r in rows if r["action"]
== arbitrage.CHARGE]
and all(r["roles"].get(B) != modetrade.GIVE for r in rows
if r["now"].astimezone(L).hour not in (19,)))
# A refusal wins over the plan.
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
refuse=("b",), extra=GATE_030)
invariants("refusal", rows, c)
check("refusal: B refuses to charge, so the plan's charge ends and B is "
"never a charger after it", rows and not [
r for r in rows[len(rows) // 3:]
if r["roles"].get(B) == modetrade.CHARGE])
# Off wins over the plan.
rows, st_, c = run_day({"a": (True, "solar_first", False),
"b": (False, "leftover_solar", False)},
fc=1.0, soc0={"a": 60.0, "b": 30.0})
check("Off: an Off battery never charges from the grid, whatever the plan",
not [r for r in rows if r["action"] == arbitrage.CHARGE]
and all(abs(r["shares"].get(B, 0)) < 1e-6 for r in rows))
# No house profile yet (first day): no plan, P3 option A.
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
patch=None)
check("no house profile yet: no plan, and Leftover solar takes solar only",
all(r["plan"] is None for r in rows)
and not [r for r in rows if r["action"] == arbitrage.CHARGE])
check("...while the profile learns and the store keeps each closed hour",
c.house_profile is not None and len(
c.store.data.get("house_profile") or {}) >= 1)
check("selling stays off (option A)", packmode.SELLING_ENABLED is False)
# A forecast arriving mid-period makes the plan at once, not at the next
# 15-minute period.
COME = datetime(2026, 9, 21, 1, 3, tzinfo=L).astimezone(timezone.utc)
rows, st_, c = run_day(SF_LS, fc=lambda now: None if now < COME else 1.0,
soc0={"a": 60.0, "b": 30.0}, hours=0.5)
first = next((r["now"] for r in rows if r["targets"]), None)
check("a forecast that arrives at 01:03 makes the plan before 01:15",
first is not None and first < COME + timedelta(minutes=3))
# Battery modes off: the forecast is never read.
READS = [0]
_real_sensor = rp.Hass.state
async def _counted(self, e):
READS[0] += 1
return await _real_sensor(self, e)
rp.Hass.state = _counted
FC_KWH[0] = 1.0
rp.run("modes_off", house_flat(800.0), WPTS, UTC0 + timedelta(hours=1),
600.0, extra=dict(FC_OPTS))
rp.Hass.state = _real_sensor
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)
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:")
and "we count on" in sb["line"] and sb["state"] == "plan")
why = homeview.day_key(c, B)
check("B's Why key (plan, dark) is tops_up", why == "tops_up")
check("...and says so in plain words, with the level and what it covers "
"(OPT-28)", homeview.why_words(why, pct="82 %", kwh="9.4 kWh")
== "It charges from the grid to 82 %. That covers 9.4 kWh of "
"expensive hours the sun will not.")
_d0, _d1 = homeview.local_day(c.solar_fc.at)
_blocks, _ = homeview.mode_runs(c, _d0, _d1, c.solar_fc.at, False)
_grid = [b for b in _blocks if b["kind"] == "grid" and b["battery"] == 1]
_need = homeview.plan_need(c, B)
check(f"Today: B's grid block is the planned shortfall ({_need:.2f} kWh), "
"not its room to 95 %", _grid and abs(_grid[0]["kwh"] - _need) < 0.01
and _need < 3.0)
_run = c.mode_runs.cheap
_packs = c._mode_packs()
_m1 = c._run_mark("charge", _run, _packs, lambda p: True, ((B, 40.0),))
_m2 = c._run_mark("charge", _run, _packs, lambda p: False, ((B, 50.0),))
check("a level the hysteresis moved opens the run to that battery again",
_m1 and not _m2)
latch, c._charge_latch = c._charge_latch, None
c.solar_fc = None
check("with no charge under way, the levels are dropped as soon as the "
"forecast goes, even before the plan is rebuilt",
c._plan_targets and c.plan_targets() is None)
check("a lost forecast says so: no forecast, Leftover solar solar only",
homeview.solar_block(c, datetime.now(timezone.utc))["state"]
== "no_forecast" and homeview.day_key(c, B) == "no_forecast")
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 = 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])
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,
running=True, held=False, refused=0, peak=False,
sf_ahead=True, role="hold", has_at=True,
day="sun_fills") == "sun_fills"
and homeview.battery_why(modes_on=True, mode="leftover_solar",
off=False, reading=True, phase="idle",
shadow=False, running=True, held=False,
refused=0, peak=False, sf_ahead=True,
role="charge", has_at=True, day="tops_up")
== "grid_charge")
for lang in ("en", "nl"):
homeview._LANG[0] = lang
t = [homeview.why_words(k, pct="82 %", kwh="9.4 kWh")
for k in ("sun_fills", "tops_up", "no_forecast")]
t += [homeview._t("solar.no_forecast"), homeview._t("solar.learning"),
homeview.line_solar(9.2, 6.4, "10:15")]
check(f"{lang}: every P4 sentence is there and short (<= 2 x 12 words)",
all(t) and all(len(s.split()) <= 24 for s in t))
homeview._LANG[0] = "en"
from app import uilayout # noqa: E402
simple = [f.key for _g, _p, f in uilayout.fields() if f.simple]
check("the Simple view has the solar forecast card",
"pv_forecast_entity" in simple)
# ============================================================================ #
print("8. options")
+225
View File
@@ -0,0 +1,225 @@
"""Runnable check for OPT-11 P4, part 6a: the sunny day, the 0.20 gap between 1x and 1.5x, the dark day.
`python3 test_modes_p4b.py`
Split out of test_modes_p4.py by CI-18 so the slow part 6 runs in pieces (CI
runs files side by side; the one file took ~13 min). The docstring there describes
the whole. Same fake Home Assistant, same engine (tools/opt11p3_replay.py), same
checks, none dropped: the four files print exactly the check lines the one file did.
Read MUTATION.md before you report a survivor."""
import asyncio
import logging
import os
import random
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
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
from app.tariff import BILLING_TZ # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
L = timezone(timedelta(hours=2))
A, B = "inverter-a", "inverter-b"
D0 = datetime(2026, 9, 21, tzinfo=L)
UTC0 = D0.astimezone(timezone.utc)
# ============================================================================ #
def profile(day_w=None, days=1, evening=None, start=UTC0 - timedelta(days=1)):
hp = dayplan.HouseProfile()
t = start
end = start + timedelta(days=days)
while t < end:
h = t.astimezone(L).hour
w = (evening if evening is not None and 17 <= h < 22 else
day_w if day_w is not None else 600.0)
hp.add(t, w)
t += timedelta(seconds=5)
hp.add(t, None)
return hp
P = modetrade.Pack
def pk(name, mode, soc, cap=10.0, **kw):
return P(name, True, mode, False, 2500.0, soc, cap, **kw)
# Prices: cheap 01:00-03:00, dear 19:00-20:00 local (as in test_modes_p3).
DAYW = [0.20] * 96 * 2
for i in list(range(4, 12)) + list(range(100, 108)):
DAYW[i] = 0.02
for i in list(range(76, 80)) + list(range(172, 176)):
DAYW[i] = 0.40
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p) for i, p in enumerate(DAYW)]
from app.price import PricePoint # noqa: E402
# ============================================================================ #
print("6. the real controller over a night and a day")
AIM = rp.PEAK_CAP - 50
FC_KWH = [None] # today's forecast in the fake sensor (None: down)
async def _sensor(self, e):
now = rp_start[0] + timedelta(seconds=self.t)
kwh = FC_KWH[0](now) if callable(FC_KWH[0]) else FC_KWH[0]
if kwh is None:
return {"state": "unavailable", "attributes": {},
"last_updated": now.isoformat()}
if e.endswith("tomorrow"):
return None
loc = now.astimezone(L)
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
wh = {(d0 + timedelta(hours=h)).astimezone(timezone.utc).isoformat():
(kwh * 100.0 if 8 <= h < 18 else 0.0) for h in range(24)}
left = sum(v for k, v in wh.items()
if datetime.fromisoformat(k) >= now) / 1000.0
return {"state": f"{left:.3f}",
"attributes": {"unit_of_measurement": "kWh", "wh_period": wh},
"last_updated": now.isoformat(), "last_reported": now.isoformat()}
rp.Hass.state = _sensor
rp_start = [UTC0]
FC_OPTS = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
"cycle_cost_threshold_eur": 0.20}
# TARIFF-24 round 2: with a forecast read, a price mode needs the cycle cost
# itself (0.20 here), no longer 1.5 times it (0.30). These scenarios were
# written for a 0.30 gap, and a midday 0.21 gap now plans a grid top-up they
# are not about, so they ask for 0.30 with the forecast; the no-forecast leg
# (`rows_nf`) keeps 0.20, which is 0.30 widened: the same gate on both sides.
GATE_030 = {"cycle_cost_threshold_eur": 0.30}
def seed_profile(c):
if c.house_profile is not None:
c.house_profile = profile(day_w=600.0, evening=3000.0)
def house_flat(w):
return [(UTC0.timestamp() - 10, float(w))]
def run_day(choice, *, fc, soc0, hours=21.0, start_h=0.75, house_w=800.0,
refuse=(), extra=None, patch=seed_profile):
FC_KWH[0] = fc
start = UTC0 + timedelta(hours=start_h)
rp_start[0] = start
rows = []
def cb(c, h, now, pr):
sh = dict(c.allocation.shares) if c.allocation is not None and \
c.allocation.shares else {}
rows.append(dict(
now=now, grid=h.grid(), shares=sh,
action=getattr(c.arb, "action", None),
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
soc={u: p.soc for u, p in h.plants.items()},
targets=c.plan_targets(), plan=c.day_plan, runs=c.mode_runs))
st_, c = rp.run("p3_ls", house_flat(house_w), WPTS, start, 3600 * hours,
check=cb, soc0=soc0, choice=choice, refuse=refuse,
extra=dict(FC_OPTS, **(extra or {})), patch=patch)
return rows, st_, c
SF_LS = {"a": (True, "solar_first", False), "b": (True, "leftover_solar",
False)}
SF_CH = {"a": (True, "solar_first", False), "b": (True, "cheap_hours",
False)}
def invariants(tag, rows, c):
charge = [r for r in rows if r["action"] == arbitrage.CHARGE]
mixed = [r for r in rows if any(v > 0 for v in r["shares"].values())
and any(v < 0 for v in r["shares"].values())]
check(f"{tag}: no opposite-direction split on any cycle", not mixed)
check(f"{tag}: Solar first never charges from the grid (A's share >= 0 "
"while the site charges)",
all(r["shares"].get(A, 0) >= 0 for r in charge))
q = {int(r["now"].timestamp() // 900) for r in charge}
banked = [b.offtake_avg_w for b in c.p1.blocks
if int(b.start.timestamp() // 900) in q]
check(f"{tag}: every banked quarter with a grid charge <= cap - margin "
f"(max {max(banked, default=0):.1f} W, aim {AIM})",
not banked or max(banked) <= AIM + 0.5)
return charge
# Sunny: 40 kWh forecast. B (Leftover solar) at 30 % needs nothing.
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0},
extra=GATE_030)
charge = invariants("sunny", rows, c)
_last = nth(rows, -1, "a cycle")
print(f" sunny: verdict {present(_last) and _last['plan'] and _last['plan'].verdict}, "
f"{len(charge)} charge cycles")
check("sunny: a day plan is made, and says sunny",
any(r["plan"] is not None and r["plan"].verdict == dayplan.SUNNY
for r in rows))
check("sunny: no grid charge at all", not charge)
# TARIFF-24 round 2: the same day at the cycle cost 0.20 itself. With the
# forecast read the gap needed is 0.20 (not 1.5 x = 0.30), so the midday
# quarter 0.339 against the evening 0.551 (gap 0.21, between 1x and 1.5x) is
# a planned top-up for B (Leftover solar) up to its planned level.
rows_g, st_, c_g = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
charge_g = invariants("gap between 1x and 1.5x", rows_g, c_g)
_first_g = nth(charge_g, 0, "a charge cycle")
_gaps = [min(g.mean_eur_kwh for g in r["runs"].give)
- r["runs"].cheap.mean_eur_kwh for r in charge_g
if r["runs"] is not None and r["runs"].give]
print(f" gap 0.20 <= gap < 0.30: {len(charge_g)} charge cycles, run gap "
f"{min(_gaps, default=0):.3f}, B to "
f"{max((r['soc']['b'] for r in rows_g), default=0):.1f} %")
check("a forecast day whose price gap lies between 1x and 1.5x the cycle "
"cost (0.20 < 0.21 < 0.30) plans a cheap run: B charges from the grid "
"inside it, only B, and only up to its planned level",
present(_first_g) and len(_gaps) == len(charge_g)
and all(0.20 <= g < 0.30 for g in _gaps)
and all(r["runs"].cheap.holds(r["now"]) for r in charge_g)
and all(r["roles"][B] == modetrade.CHARGE for r in charge_g)
and all(r["roles"][A] != modetrade.CHARGE for r in charge_g)
and max(r["soc"]["b"] for r in rows_g) <= 45.64 + 2.0)
check("...and the same day with no forecast read does not (it needs 0.30): "
"the old gate, kept for a day without a forecast",
not [r for r in run_day(SF_LS, fc=None,
soc0={"a": 60.0, "b": 30.0})[0]
if r["action"] == arbitrage.CHARGE
and r["now"].astimezone(L).hour == 12])
# Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run.
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0})
charge = invariants("dark", rows, c)
tgt = nth([r["targets"][B] for r in rows if r["targets"]], 0, "a level")
top = max(r["soc"]["b"] for r in rows)
print(f" dark: B level {tgt:.1f} %, B reached {top:.1f} %, "
f"{len(charge)} charge cycles")
check("dark: Leftover solar charges from the grid in the cheap run",
charge and all(r["runs"].cheap.holds(r["now"]) for r in charge)
and all(r["roles"][B] == modetrade.CHARGE for r in charge))
check("dark: ...only up to its level (the shortfall), not 95 %",
present(tgt) and tgt < 60.0 and top <= tgt + 2.0)
check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST
for r in charge))
print()
if fails:
print(f"FAILED: {len(fails)}")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")
+232
View File
@@ -0,0 +1,232 @@
"""Runnable check for OPT-11 P4, part 6b: cheap hours on a partly sunny day, the forecast lost mid-run, a forecast far too high.
`python3 test_modes_p4c.py`
Split out of test_modes_p4.py by CI-18 so the slow part 6 runs in pieces (CI
runs files side by side; the one file took ~13 min). The docstring there describes
the whole. Same fake Home Assistant, same engine (tools/opt11p3_replay.py), same
checks, none dropped: the four files print exactly the check lines the one file did.
Read MUTATION.md before you report a survivor."""
import asyncio
import logging
import os
import random
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
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
from app.tariff import BILLING_TZ # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
L = timezone(timedelta(hours=2))
A, B = "inverter-a", "inverter-b"
D0 = datetime(2026, 9, 21, tzinfo=L)
UTC0 = D0.astimezone(timezone.utc)
# ============================================================================ #
def profile(day_w=None, days=1, evening=None, start=UTC0 - timedelta(days=1)):
hp = dayplan.HouseProfile()
t = start
end = start + timedelta(days=days)
while t < end:
h = t.astimezone(L).hour
w = (evening if evening is not None and 17 <= h < 22 else
day_w if day_w is not None else 600.0)
hp.add(t, w)
t += timedelta(seconds=5)
hp.add(t, None)
return hp
P = modetrade.Pack
def pk(name, mode, soc, cap=10.0, **kw):
return P(name, True, mode, False, 2500.0, soc, cap, **kw)
# Prices: cheap 01:00-03:00, dear 19:00-20:00 local (as in test_modes_p3).
DAYW = [0.20] * 96 * 2
for i in list(range(4, 12)) + list(range(100, 108)):
DAYW[i] = 0.02
for i in list(range(76, 80)) + list(range(172, 176)):
DAYW[i] = 0.40
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p) for i, p in enumerate(DAYW)]
from app.price import PricePoint # noqa: E402
# ============================================================================ #
print("6. the real controller over a night and a day")
AIM = rp.PEAK_CAP - 50
FC_KWH = [None] # today's forecast in the fake sensor (None: down)
async def _sensor(self, e):
now = rp_start[0] + timedelta(seconds=self.t)
kwh = FC_KWH[0](now) if callable(FC_KWH[0]) else FC_KWH[0]
if kwh is None:
return {"state": "unavailable", "attributes": {},
"last_updated": now.isoformat()}
if e.endswith("tomorrow"):
return None
loc = now.astimezone(L)
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
wh = {(d0 + timedelta(hours=h)).astimezone(timezone.utc).isoformat():
(kwh * 100.0 if 8 <= h < 18 else 0.0) for h in range(24)}
left = sum(v for k, v in wh.items()
if datetime.fromisoformat(k) >= now) / 1000.0
return {"state": f"{left:.3f}",
"attributes": {"unit_of_measurement": "kWh", "wh_period": wh},
"last_updated": now.isoformat(), "last_reported": now.isoformat()}
rp.Hass.state = _sensor
rp_start = [UTC0]
FC_OPTS = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
"cycle_cost_threshold_eur": 0.20}
# TARIFF-24 round 2: with a forecast read, a price mode needs the cycle cost
# itself (0.20 here), no longer 1.5 times it (0.30). These scenarios were
# written for a 0.30 gap, and a midday 0.21 gap now plans a grid top-up they
# are not about, so they ask for 0.30 with the forecast; the no-forecast leg
# (`rows_nf`) keeps 0.20, which is 0.30 widened: the same gate on both sides.
GATE_030 = {"cycle_cost_threshold_eur": 0.30}
def seed_profile(c):
if c.house_profile is not None:
c.house_profile = profile(day_w=600.0, evening=3000.0)
def house_flat(w):
return [(UTC0.timestamp() - 10, float(w))]
def run_day(choice, *, fc, soc0, hours=21.0, start_h=0.75, house_w=800.0,
refuse=(), extra=None, patch=seed_profile):
FC_KWH[0] = fc
start = UTC0 + timedelta(hours=start_h)
rp_start[0] = start
rows = []
def cb(c, h, now, pr):
sh = dict(c.allocation.shares) if c.allocation is not None and \
c.allocation.shares else {}
rows.append(dict(
now=now, grid=h.grid(), shares=sh,
action=getattr(c.arb, "action", None),
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
soc={u: p.soc for u, p in h.plants.items()},
targets=c.plan_targets(), plan=c.day_plan, runs=c.mode_runs))
st_, c = rp.run("p3_ls", house_flat(house_w), WPTS, start, 3600 * hours,
check=cb, soc0=soc0, choice=choice, refuse=refuse,
extra=dict(FC_OPTS, **(extra or {})), patch=patch)
return rows, st_, c
SF_LS = {"a": (True, "solar_first", False), "b": (True, "leftover_solar",
False)}
SF_CH = {"a": (True, "solar_first", False), "b": (True, "cheap_hours",
False)}
def invariants(tag, rows, c):
charge = [r for r in rows if r["action"] == arbitrage.CHARGE]
mixed = [r for r in rows if any(v > 0 for v in r["shares"].values())
and any(v < 0 for v in r["shares"].values())]
check(f"{tag}: no opposite-direction split on any cycle", not mixed)
check(f"{tag}: Solar first never charges from the grid (A's share >= 0 "
"while the site charges)",
all(r["shares"].get(A, 0) >= 0 for r in charge))
q = {int(r["now"].timestamp() // 900) for r in charge}
banked = [b.offtake_avg_w for b in c.p1.blocks
if int(b.start.timestamp() // 900) in q]
check(f"{tag}: every banked quarter with a grid charge <= cap - margin "
f"(max {max(banked, default=0):.1f} W, aim {AIM})",
not banked or max(banked) <= AIM + 0.5)
return charge
# Cheap hours on a partly sunny day: ignores the plan, fills as in P3.
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0},
extra=GATE_030)
charge = invariants("cheap hours, partly", rows, c)
top = max(r["soc"]["b"] for r in rows)
print(f" cheap hours partly: B reached {top:.1f} %")
check("Cheap hours gets no planned level",
not any(r["targets"] and B in r["targets"] for r in rows))
rows_nf, _s, c_nf = run_day(SF_CH, fc=None, soc0={"a": 60.0, "b": 30.0})
check("Cheap hours with a sun forecast charges as with none (P3)",
charge and abs(max(r["soc"]["b"] for r in rows_nf) - top) < 1.0
and top > 80.0)
# The forecast goes away mid-run: a charge under way goes on to its level
# (OPT-34; it stopped at once, P3 option A, before). With no charge under way
# the levels go with the forecast (below, and test_opt27.py).
# OPT-27: a sensor that is unavailable is covered by its last fresh reading
# for up to 3 h (`solarfc.Hold`), so "lost" here is a hold that never covers;
# the next scenario is the same loss with the hold (test_opt27.py).
DROP = UTC0 + timedelta(hours=1, minutes=30)
class _NoHold(solarfc.Hold):
def stand_in(self, *a, **k):
return None
def seed_profile_no_hold(c):
seed_profile(c)
c.fc_hold = _NoHold()
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
soc0={"a": 60.0, "b": 10.0}, patch=seed_profile_no_hold)
invariants("forecast lost mid-run", rows, c)
before = [r for r in rows if r["now"] < DROP and r["action"]
== arbitrage.CHARGE]
late = [r for r in rows if r["now"] > DROP + timedelta(
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE]
check("forecast lost mid-run (OPT-34): Leftover solar was charging, and goes "
"on charging past a forecast read (60 s) and a cycle, to its level",
before and late)
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
soc0={"a": 60.0, "b": 10.0})
invariants("forecast unavailable mid-run, covered", rows, c)
check("forecast unavailable mid-run (OPT-27): the last reading covers it, so "
"Leftover solar goes on charging past the point where it was lost",
[r for r in rows if r["now"] < DROP and r["action"]
== arbitrage.CHARGE]
and [r for r in rows if r["now"] > DROP + timedelta(
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE])
# The forecast is far too high (40 kWh, and there is no sun at all): no grid
# charge, so B covers less of the evening - a money risk, never a safety one.
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0},
extra=GATE_030)
invariants("forecast too high", rows, c)
check("forecast too high: no grid charge, and B still keeps its power for "
"the dear run", not [r for r in rows if r["action"]
== arbitrage.CHARGE]
and all(r["roles"].get(B) != modetrade.GIVE for r in rows
if r["now"].astimezone(L).hour not in (19,)))
print()
if fails:
print(f"FAILED: {len(fails)}")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")
+294
View File
@@ -0,0 +1,294 @@
"""Runnable check for OPT-11 P4, part 6c and 7: a refusal, Off, no house profile, a forecast arriving mid-period, battery modes off; then the Batteries screen and the Simple view.
`python3 test_modes_p4d.py`
Split out of test_modes_p4.py by CI-18 so the slow part 6 runs in pieces (CI
runs files side by side; the one file took ~13 min). The docstring there describes
the whole. Same fake Home Assistant, same engine (tools/opt11p3_replay.py), same
checks, none dropped: the four files print exactly the check lines the one file did.
Read MUTATION.md before you report a survivor."""
import asyncio
import logging
import os
import random
import sys
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
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
from app.tariff import BILLING_TZ # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
L = timezone(timedelta(hours=2))
A, B = "inverter-a", "inverter-b"
D0 = datetime(2026, 9, 21, tzinfo=L)
UTC0 = D0.astimezone(timezone.utc)
# ============================================================================ #
def profile(day_w=None, days=1, evening=None, start=UTC0 - timedelta(days=1)):
hp = dayplan.HouseProfile()
t = start
end = start + timedelta(days=days)
while t < end:
h = t.astimezone(L).hour
w = (evening if evening is not None and 17 <= h < 22 else
day_w if day_w is not None else 600.0)
hp.add(t, w)
t += timedelta(seconds=5)
hp.add(t, None)
return hp
P = modetrade.Pack
def pk(name, mode, soc, cap=10.0, **kw):
return P(name, True, mode, False, 2500.0, soc, cap, **kw)
# Prices: cheap 01:00-03:00, dear 19:00-20:00 local (as in test_modes_p3).
DAYW = [0.20] * 96 * 2
for i in list(range(4, 12)) + list(range(100, 108)):
DAYW[i] = 0.02
for i in list(range(76, 80)) + list(range(172, 176)):
DAYW[i] = 0.40
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p) for i, p in enumerate(DAYW)]
from app.price import PricePoint # noqa: E402
# ============================================================================ #
print("6. the real controller over a night and a day")
AIM = rp.PEAK_CAP - 50
FC_KWH = [None] # today's forecast in the fake sensor (None: down)
async def _sensor(self, e):
now = rp_start[0] + timedelta(seconds=self.t)
kwh = FC_KWH[0](now) if callable(FC_KWH[0]) else FC_KWH[0]
if kwh is None:
return {"state": "unavailable", "attributes": {},
"last_updated": now.isoformat()}
if e.endswith("tomorrow"):
return None
loc = now.astimezone(L)
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
wh = {(d0 + timedelta(hours=h)).astimezone(timezone.utc).isoformat():
(kwh * 100.0 if 8 <= h < 18 else 0.0) for h in range(24)}
left = sum(v for k, v in wh.items()
if datetime.fromisoformat(k) >= now) / 1000.0
return {"state": f"{left:.3f}",
"attributes": {"unit_of_measurement": "kWh", "wh_period": wh},
"last_updated": now.isoformat(), "last_reported": now.isoformat()}
rp.Hass.state = _sensor
rp_start = [UTC0]
FC_OPTS = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
"cycle_cost_threshold_eur": 0.20}
# TARIFF-24 round 2: with a forecast read, a price mode needs the cycle cost
# itself (0.20 here), no longer 1.5 times it (0.30). These scenarios were
# written for a 0.30 gap, and a midday 0.21 gap now plans a grid top-up they
# are not about, so they ask for 0.30 with the forecast; the no-forecast leg
# (`rows_nf`) keeps 0.20, which is 0.30 widened: the same gate on both sides.
GATE_030 = {"cycle_cost_threshold_eur": 0.30}
def seed_profile(c):
if c.house_profile is not None:
c.house_profile = profile(day_w=600.0, evening=3000.0)
def house_flat(w):
return [(UTC0.timestamp() - 10, float(w))]
def run_day(choice, *, fc, soc0, hours=21.0, start_h=0.75, house_w=800.0,
refuse=(), extra=None, patch=seed_profile):
FC_KWH[0] = fc
start = UTC0 + timedelta(hours=start_h)
rp_start[0] = start
rows = []
def cb(c, h, now, pr):
sh = dict(c.allocation.shares) if c.allocation is not None and \
c.allocation.shares else {}
rows.append(dict(
now=now, grid=h.grid(), shares=sh,
action=getattr(c.arb, "action", None),
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
soc={u: p.soc for u, p in h.plants.items()},
targets=c.plan_targets(), plan=c.day_plan, runs=c.mode_runs))
st_, c = rp.run("p3_ls", house_flat(house_w), WPTS, start, 3600 * hours,
check=cb, soc0=soc0, choice=choice, refuse=refuse,
extra=dict(FC_OPTS, **(extra or {})), patch=patch)
return rows, st_, c
SF_LS = {"a": (True, "solar_first", False), "b": (True, "leftover_solar",
False)}
SF_CH = {"a": (True, "solar_first", False), "b": (True, "cheap_hours",
False)}
def invariants(tag, rows, c):
charge = [r for r in rows if r["action"] == arbitrage.CHARGE]
mixed = [r for r in rows if any(v > 0 for v in r["shares"].values())
and any(v < 0 for v in r["shares"].values())]
check(f"{tag}: no opposite-direction split on any cycle", not mixed)
check(f"{tag}: Solar first never charges from the grid (A's share >= 0 "
"while the site charges)",
all(r["shares"].get(A, 0) >= 0 for r in charge))
q = {int(r["now"].timestamp() // 900) for r in charge}
banked = [b.offtake_avg_w for b in c.p1.blocks
if int(b.start.timestamp() // 900) in q]
check(f"{tag}: every banked quarter with a grid charge <= cap - margin "
f"(max {max(banked, default=0):.1f} W, aim {AIM})",
not banked or max(banked) <= AIM + 0.5)
return charge
# A refusal wins over the plan.
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
refuse=("b",), extra=GATE_030)
invariants("refusal", rows, c)
check("refusal: B refuses to charge, so the plan's charge ends and B is "
"never a charger after it", rows and not [
r for r in rows[len(rows) // 3:]
if r["roles"].get(B) == modetrade.CHARGE])
# Off wins over the plan.
rows, st_, c = run_day({"a": (True, "solar_first", False),
"b": (False, "leftover_solar", False)},
fc=1.0, soc0={"a": 60.0, "b": 30.0})
check("Off: an Off battery never charges from the grid, whatever the plan",
not [r for r in rows if r["action"] == arbitrage.CHARGE]
and all(abs(r["shares"].get(B, 0)) < 1e-6 for r in rows))
# No house profile yet (first day): no plan, P3 option A.
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
patch=None)
check("no house profile yet: no plan, and Leftover solar takes solar only",
all(r["plan"] is None for r in rows)
and not [r for r in rows if r["action"] == arbitrage.CHARGE])
check("...while the profile learns and the store keeps each closed hour",
c.house_profile is not None and len(
c.store.data.get("house_profile") or {}) >= 1)
check("selling stays off (option A)", packmode.SELLING_ENABLED is False)
# A forecast arriving mid-period makes the plan at once, not at the next
# 15-minute period.
COME = datetime(2026, 9, 21, 1, 3, tzinfo=L).astimezone(timezone.utc)
rows, st_, c = run_day(SF_LS, fc=lambda now: None if now < COME else 1.0,
soc0={"a": 60.0, "b": 30.0}, hours=0.5)
first = next((r["now"] for r in rows if r["targets"]), None)
check("a forecast that arrives at 01:03 makes the plan before 01:15",
first is not None and first < COME + timedelta(minutes=3))
# Battery modes off: the forecast is never read.
READS = [0]
_real_sensor = rp.Hass.state
async def _counted(self, e):
READS[0] += 1
return await _real_sensor(self, e)
rp.Hass.state = _counted
FC_KWH[0] = 1.0
rp.run("modes_off", house_flat(800.0), WPTS, UTC0 + timedelta(hours=1),
600.0, extra=dict(FC_OPTS))
rp.Hass.state = _real_sensor
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)
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:")
and "we count on" in sb["line"] and sb["state"] == "plan")
why = homeview.day_key(c, B)
check("B's Why key (plan, dark) is tops_up", why == "tops_up")
check("...and says so in plain words, with the level and what it covers "
"(OPT-28)", homeview.why_words(why, pct="82 %", kwh="9.4 kWh")
== "It charges from the grid to 82 %. That covers 9.4 kWh of "
"expensive hours the sun will not.")
_d0, _d1 = homeview.local_day(c.solar_fc.at)
_blocks, _ = homeview.mode_runs(c, _d0, _d1, c.solar_fc.at, False)
_grid = [b for b in _blocks if b["kind"] == "grid" and b["battery"] == 1]
_need = homeview.plan_need(c, B)
check(f"Today: B's grid block is the planned shortfall ({_need:.2f} kWh), "
"not its room to 95 %", _grid and abs(_grid[0]["kwh"] - _need) < 0.01
and _need < 3.0)
_run = c.mode_runs.cheap
_packs = c._mode_packs()
_m1 = c._run_mark("charge", _run, _packs, lambda p: True, ((B, 40.0),))
_m2 = c._run_mark("charge", _run, _packs, lambda p: False, ((B, 50.0),))
check("a level the hysteresis moved opens the run to that battery again",
_m1 and not _m2)
latch, c._charge_latch = c._charge_latch, None
c.solar_fc = None
check("with no charge under way, the levels are dropped as soon as the "
"forecast goes, even before the plan is rebuilt",
c._plan_targets and c.plan_targets() is None)
check("a lost forecast says so: no forecast, Leftover solar solar only",
homeview.solar_block(c, datetime.now(timezone.utc))["state"]
== "no_forecast" and homeview.day_key(c, B) == "no_forecast")
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 = 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])
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,
running=True, held=False, refused=0, peak=False,
sf_ahead=True, role="hold", has_at=True,
day="sun_fills") == "sun_fills"
and homeview.battery_why(modes_on=True, mode="leftover_solar",
off=False, reading=True, phase="idle",
shadow=False, running=True, held=False,
refused=0, peak=False, sf_ahead=True,
role="charge", has_at=True, day="tops_up")
== "grid_charge")
for lang in ("en", "nl"):
homeview._LANG[0] = lang
t = [homeview.why_words(k, pct="82 %", kwh="9.4 kWh")
for k in ("sun_fills", "tops_up", "no_forecast")]
t += [homeview._t("solar.no_forecast"), homeview._t("solar.learning"),
homeview.line_solar(9.2, 6.4, "10:15")]
check(f"{lang}: every P4 sentence is there and short (<= 2 x 12 words)",
all(t) and all(len(s.split()) <= 24 for s in t))
homeview._LANG[0] = "en"
from app import uilayout # noqa: E402
simple = [f.key for _g, _p, f in uilayout.fields() if f.simple]
check("the Simple view has the solar forecast card",
"pv_forecast_entity" in simple)
print()
if fails:
print(f"FAILED: {len(fails)}")
for f in fails:
print(" -", f)
sys.exit(1)
print("all checks passed")
+4 -1
View File
@@ -1148,7 +1148,10 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE
"could do - and no fixture anywhere could see that, because every check "
"calls `resolve()` for itself. A method, not a dict, because the "
"weighting is direction-dependent and the direction is stage 2's output",
len(_resolve_calls) == 1 and _w_arg == ["self.pack_weights"])
# MAINT-28 added a second call (`_m28_share()`'s split without the
# paused pack's cap); it must pass the same method, as the cycle's does.
len(_resolve_calls) >= 1
and _w_arg == ["self.pack_weights"] * len(_resolve_calls))
# ⚠️ AC 3 SAYS `unmet_w` MUST BE **VISIBLE**, AND `Allocation.explain()` IS ONLY
# HALF OF THAT. The other half is main.py putting it in the action log, which no
+38 -16
View File
@@ -116,6 +116,7 @@ from math import inf, isinf
from pathlib import Path
from app import messages
from app import ctl_cycle as _ctl_cycle
from app import peakguard
from named_absence import first, given, nth, present
@@ -2787,9 +2788,22 @@ def cycle_claims(run):
return out, (seen[-1] if seen else [])
def run_cycle(ctl):
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
ctl.cycle())
def run_cycle(ctl, at=None):
"""One `Controller.cycle()`. `at` pins the `now` that cycle() takes
(ctl_cycle.py:49) so a fixture laid up to `at` does not age with host load.
Other modules still read the real clock. CI-17."""
real_dt = _ctl_cycle.datetime
if at is not None:
class _Pinned(datetime):
@classmethod
def now(cls, tz=None):
return at if tz is None else at.astimezone(tz)
_ctl_cycle.datetime = _Pinned
try:
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
ctl.cycle())
finally:
_ctl_cycle.datetime = real_dt
return ctl
@@ -2801,11 +2815,15 @@ def run_cycle(ctl):
EVE_MEAN_W = sum(EVE_W) / len(EVE_W)
c_src, _ = built(TMP / "sources.db")
c_src.auto, c_src.batt, c_src.soc = True, 0.0, 100.0
# CI-17: ONE read of the clock, and every cycle on c_src runs AT it. The
# telegrams end here; `cycle()` used to read the real clock again, 1.3 s later
# per cycle under --cpus 0.25, so by the 23rd cycle of the sweep the meter's
# last telegram was older than `max_hold_s` (30 s), the guard counted the gap at
# its ceiling and filed `peak@site` (ctl_cycle.py:49 -> p1.py max_hold_s).
_SRC_NOW = datetime.now(timezone.utc)
_blk_src = datetime.fromtimestamp(
int(datetime.now(timezone.utc).timestamp() // QUARTER_S) * QUARTER_S,
timezone.utc)
for _i in range(0, int((datetime.now(timezone.utc)
- _blk_src).total_seconds()) + 1, 5):
int(_SRC_NOW.timestamp() // QUARTER_S) * QUARTER_S, timezone.utc)
for _i in range(0, int((_SRC_NOW - _blk_src).total_seconds()) + 1, 5):
c_src.p1.submit(sample(_blk_src + timedelta(seconds=_i), EVE_MEAN_W))
c_src.grid = EVE_MEAN_W
@@ -2817,7 +2835,7 @@ SRC_SWEEP = sorted({float(s) for s in range(0, 101, 5)}
IDLE_SRC = {}
for _soc in SRC_SWEEP:
c_src.soc = _soc
_, _cl = cycle_claims(lambda: run_cycle(c_src))
_, _cl = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
IDLE_SRC[_soc] = frozenset(c.source for c in _cl)
IDLE_SETS = set(IDLE_SRC.values())
print(f" a real idle cycle at {len(SRC_SWEEP)} states of charge "
@@ -3141,7 +3159,7 @@ _b2, _restore_b2 = second_inverter(c_src)
_b2.bms_soc_floor = c_src.inverters[0].bms_soc_floor
_n2_writes_before = len(c_src.hass.per_entity)
try:
_, _cl2 = cycle_claims(lambda: run_cycle(c_src))
_, _cl2 = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
_n2_target, _n2_reason = c_src.target, c_src.reason
_n2_events = list(c_src.events)
_n2_writes = c_src.hass.per_entity[_n2_writes_before:]
@@ -3337,7 +3355,7 @@ check("...and the action log no longer carries the 'no writer' refusal, "
_nw, _restore_nw = second_inverter(c_src, name="inverter-c", setpoint="")
_nw_writes_before = len(c_src.hass.per_entity)
try:
_, _cl_nw = cycle_claims(lambda: run_cycle(c_src))
_, _cl_nw = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
_nw_target, _nw_reason = c_src.target, c_src.reason
_nw_events = list(c_src.events)
_nw_writes = c_src.hass.per_entity[_nw_writes_before:]
@@ -3382,16 +3400,21 @@ check("...and the 0 W is NOT what the site target happened to be - the same "
# method is asked, which is what makes this a check on `live_units()` instead of
# a check on a lambda this file wrote.
_bs, _restore_bs = second_inverter(c_src)
_bs.rejected_since = datetime.now(timezone.utc) - timedelta(
seconds=app_main.LINK_STALE_S + 5)
# CI-17: from `_SRC_NOW`, the instant the pinned cycles run at; the real clock
# is later, so `live_units()` (real clock) and the cycle (pinned) agree.
_bs.rejected_since = _SRC_NOW - timedelta(seconds=app_main.LINK_STALE_S + 5)
_bs.last_write_ok = False
_stale_writes_before = len(c_src.hass.per_entity)
try:
check("CI-17: the pinned clock and the real clock agree that inverter B's "
"link is stale, so the cycle and `live_units()` see the same roster",
not _bs.is_live(_SRC_NOW)[0]
and not _bs.is_live(datetime.now(timezone.utc))[0])
check("PRECONDITION: the SHIPPED `live_units()` - not a lambda - drops the "
"inverter whose writes have been rejected for a whole layer-1 window, "
"and keeps the healthy one",
c_src.live_units() == ("inverter-a",))
_, _cl_stale = cycle_claims(lambda: run_cycle(c_src))
_, _cl_stale = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
_stale_target = c_src.target
_stale_alloc = c_src.allocation
_stale_events = list(c_src.events)
@@ -3433,14 +3456,13 @@ check("⚠️ AND IT DOES NOT ABSORB IT SILENTLY, which is the whole of AC 6 and
# state `live_units()`'s own docstring warns about - the sole inverter dropping
# takes the SITE to 0 W - so it is driven here rather than left as a sentence.
_bd, _restore_bd = second_inverter(c_src)
_gone = datetime.now(timezone.utc) - timedelta(
seconds=app_main.LINK_STALE_S + 5)
_gone = _SRC_NOW - timedelta(seconds=app_main.LINK_STALE_S + 5) # CI-17: as above
_bd.rejected_since, _bd.last_write_ok = _gone, False
c_src.inverters[0].rejected_since = _gone
c_src.inverters[0].last_write_ok = False
_dead_writes_before = len(c_src.hass.per_entity)
try:
_, _cl_dead = cycle_claims(lambda: run_cycle(c_src))
_, _cl_dead = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
_dead_target, _dead_reason = c_src.target, c_src.reason
_dead_alloc = c_src.allocation
_dead_events = list(c_src.events)
+6 -195
View File
@@ -30,7 +30,12 @@ live), inverter-b Solar first, a house of 1 kW, cap 3500 W, margin 50 W.
quarter's last second.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
CI-18: this file holds parts 1, 2 and 8 (part 8 grades part 2's runs). Parts 3-7 are in
test_safety125b.py, split out so CI can run the pieces side by side (the one
file took ~10 min). Every check is in exactly one file; the two print exactly the
check lines the one file did."""
import asyncio
import logging
@@ -319,201 +324,7 @@ check("the forecast entity is read, in kW, as the live template (SAFETY-118), "
"month peak 5413 W banked, P1 projection"][1]])
# ============================================================================ #
print("\n3. the same for a price trade (modes off) and a maintenance charge")
_ca, _ra, _fa = drive(opts(modes=False, arb=True), peak=PEAK, late=30.0)[:3]
check("modes off, price trading on, a reading 30 s late: every quarter at "
"or under the aim", graded("trade", _fa, AIM_PEAK)
and any(r[3] == "ARBITRAGE_CHARGE" for r in _ra))
def late_feed(gap, every=0.0, look=None):
"""safety84's cheap-window feed, no telegram in each quarter's last
`gap` s, and one only every `every` s (0: every cycle). `look(c, t)`
runs first, on the state the previous cycle left."""
feed = s84.feed(True)
def before(c, t):
if look is not None:
look(c, t)
if ((rig.BASE.timestamp() + t) % QUARTER_S < QUARTER_S - gap
and (not every or t % every < 1e-9)) \
or not getattr(c.hass, "_s84", False):
feed(c, t)
return before
def rig_quarters(rows, load):
"""The safety84 rig's whole quarters: the true average import (W)."""
qs = {}
for r in rows:
q = int((rig.BASE.timestamp() + r[0]) // QUARTER_S)
a = qs.setdefault(q, [0.0, 0])
a[0] += max(0.0, load(r[0]) - sum(v for _u, v in r[2]))
a[1] += 1
return {datetime.fromtimestamp(q * QUARTER_S, L).strftime("%H:%M"):
s / k for q, (s, k) in sorted(qs.items())
if k * rig.TICK >= QUARTER_S}
for peak, house, aim in ((5413.0, 4000.0, 5363.0), (None, 2500.0, AIM_CAP)):
_mr, _mc = s84.run(2, load=lambda t, h=house: h, dur=2700.0,
month_peak=peak, before=late_feed(30.0))
_mf = rig_quarters(_mr, lambda t, h=house: h)
check(f"N=2 maintenance charge, house {house:.0f} W, "
f"{'peak banked' if peak else 'no peak banked'}, a reading 30 s "
f"late: every quarter at or under the aim",
graded("maintenance", _mf, aim)
and any(mm.phase == "charge" for mm in _mc.maints))
# ============================================================================ #
print("\n4. a healthy meter: the quarters, and the words")
for name, kw, aim, kw_text in (("month peak banked", dict(peak=PEAK),
AIM_PEAK, "5.4 kW"),
("no peak banked", dict(), AIM_CAP, "3.5 kW")):
_c, _rows, _full, _s = drive(opts(), every=10, **kw)
check(f"{name}: every quarter at or under the aim",
graded(name, _full, aim))
over = [s for s in _s if not s[2] and s[0] > s[1]]
under = [s for s in _s if not s[2] and s[0] <= s[1]]
_o, _u = first(iter(over), "an over sample"), \
first(iter(under), "an under sample")
print(f" over the limit at this rate ({len(over)} samples): "
f"{_o[3] if present(_o) else _o!s} / "
f"{_o[4] if present(_o) else _o!s}")
print(f" under it ({len(under)}): "
f"{_u[3] if present(_u) else _u!s} / "
f"{_u[4] if present(_u) else _u!s}")
check(f"{name}: over the limit at this rate the guard row never says "
f"'under'; it says the grid charge is limited",
present(_o) and all(
"under" not in s[3] and "Peak guard: grid charge limited, "
"at this rate" in s[3] and " over " in s[3] for s in over))
check(f"{name}: ...and the month-peak line says the rate, and that the "
f"grid charge is cut back to stay under {kw_text}",
present(_o) and all(
s[4].startswith("At this rate this 15-minute period reaches")
and s[4].endswith("The grid charge is cut back to stay "
f"under {kw_text}.") for s in over))
check(f"{name}: under the limit both read as before",
present(_u) and all(
s[3].startswith("Peak guard: idle, heading for")
and " under " in s[3]
and s[4].startswith("This 15-minute period is heading for")
and s[4].endswith(f"The batteries step in above {kw_text}.")
for s in under))
# ============================================================================ #
print("\n5. the words on their own")
_en = messages.row_peak_guard_idle(5814.0, 5413.0, month_peak=True)
_cut = messages.row_peak_guard_idle(5814.0, 5413.0, month_peak=True,
cut=True)
_ok = messages.row_peak_guard_idle(5200.0, 5413.0, month_peak=True)
print(f" {_en}\n {_cut}\n {_ok}")
check("over, nothing cut: 'idle, at this rate ..., over ...'",
_en == "Peak guard: idle, at this rate 5 814 W, over 5 413 W")
check("over, a grid charge cut",
_cut == "Peak guard: grid charge limited, at this rate 5 814 W, "
"over 5 413 W")
check("under: the 0.19.1 sentence, unchanged",
_ok == "Peak guard: idle, heading for 5 200 W, under this month's "
"5 413 W peak")
check("the Dutch forms say the same",
_cut.nl == "Piekbewaking: laden beperkt, aan dit tempo 5 814 W, "
"boven 5 413 W"
and _en.nl.startswith("Piekbewaking: in rust, aan dit tempo"))
check("the month-peak line in shadow mode says 'would'",
homeview.line_period(5814.0, 5413.0, True, cut=True).endswith(
"The grid charge would be cut back to stay under 5.4 kW.")
and homeview.line_period(5814.0, 5413.0, False).endswith(
"The batteries step in above 5.4 kW."))
# ============================================================================ #
print("\n6. one inverter: the maintenance charge is sized to the room too")
# The start-up quarter on a 10 s meter: the charge's first step is not seen
# until the second reading, so it may land up to START_TOL over the aim; the
# same at N=2 and before this fix. Every later quarter is graded at TOL.
START_TOL = 15.0
N1 = {}
for peak, house, aim in ((5413.0, 4000.0, 5363.0), (None, 2500.0, AIM_CAP)):
for late, every in ((0.0, 0.0), (30.0, 0.0), (20.0, 10.0)):
_r, _c = s84.run(1, load=lambda t, h=house: h, dur=2700.0,
month_peak=peak, before=late_feed(late, every))
_q = rig_quarters(_r, lambda t, h=house: h)
N1[peak, late, every] = (_r, _c)
_vals = list(_q.values())
_tag = (f"N=1, {'peak banked' if peak else 'no peak banked'}, "
f"{'10 s' if every else '5 s'} meter, "
f"{'late ' + format(late, '.0f') + ' s' if late else 'healthy'}")
print(f" {_tag}: quarters "
f"{ {k: round(v, 1) for k, v in _q.items()} } W, aim {aim:.0f}")
check(f"{_tag}: every quarter at or under the aim",
len(_vals) == 3
and all(v <= aim + TOL for v in _vals[1:])
and nth(_vals, 0, "a first quarter")
<= aim + (START_TOL if every else TOL)
and any(mm.phase == "charge" for mm in _c.maints))
_hr, _hc = N1[5413.0, 0.0, 0.0]
_eng = sum(1 for r in _hr if r[-1][1] and r[-1][1][0])
print(f" healthy, peak banked: guard engaged on {_eng} of {len(_hr)} "
f"cycles; A charges on "
f"{sum(1 for r in _hr if dict(r[2])['a'] < -1.0)} cycles")
check("N=1: the guard no longer steps in during the charge (no hand-back), "
"and the battery charges on every cycle, at no more than its rate",
_eng == 0 and all(-2000.0 <= dict(r[2])["a"] < -1.0 for r in _hr)
and not any("suspended" in e for e in _hc.events))
# ============================================================================ #
print("\n7. the row during an N=2 maintenance charge and in shadow mode")
_rows7 = []
def _look(c, t):
pg = c.peak_guard
if t >= QUARTER_S and pg is not None and pg.projected_w is not None \
and not pg.engaged and pg.projected_w > pg.ceiling_w:
_rows7.append(first((r["text"] for r in c.checks()
if r["id"] == "peak_guard"), "guard row"))
def _steps(t):
"""4000 W, 800 W more for the first 30 s of every minute."""
return 4000.0 + (800.0 if t % 60.0 < 30.0 else 0.0)
_m2r, _m2c = s84.run(2, load=_steps, dur=1800.0, month_peak=5413.0,
before=late_feed(0.0, look=_look))
_m2q = rig_quarters(_m2r, _steps)
print(f" N=2 maintenance charge, house steps 4000/4800 W: "
f"{len(_rows7)} over-the-limit rows, e.g. "
f"{first(iter(_rows7), 'a row')}; quarters "
f"{ {k: round(v, 1) for k, v in _m2q.items()} } W")
check("N=2 maintenance charge, over the limit at this rate: the row says the "
"grid charge is limited",
present(first(iter(_rows7), "a row")) and all(
r.startswith("Peak guard: grid charge limited, at this rate")
for r in _rows7)
and all(v <= 5363.0 + TOL for v in _m2q.values()))
_sh_o = dict(opts(), shadow_mode="on")
_csh, _rsh, _fsh, _ssh = drive(_sh_o, peak=PEAK, minutes=17, every=10,
plant=OnItsOwn)
_sh_over = [s for s in _ssh if not s[2] and s[0] > s[1]]
_sh1 = first(iter(_sh_over), "an over sample")
print(f" shadow mode, the inverter charging 5000 W on its own: "
f"{len(_sh_over)} over samples, e.g. "
f"{_sh1[3] if present(_sh1) else _sh1!s} / "
f"{_sh1[4] if present(_sh1) else _sh1!s}")
check("shadow mode: nothing is cut on the wire, so the row says 'idle', "
"never 'grid charge limited', and the line says 'would'",
_csh.shadow and present(_sh1) and all(
s[3].startswith("Peak guard: idle, at this rate")
and "would" in s[4] for s in _sh_over))
# ============================================================================ #
print("\n8. edge cases")
_e45 = drive(opts(), peak=PEAK, late=45.0)
+436
View File
@@ -0,0 +1,436 @@
"""Runnable check for SAFETY-125, a part. `python3 test_safety125b.py`
Split out of test_safety125.py by CI-18 so CI can run the pieces side by side (the one file
took ~10 min). The docstring there describes the whole. It holds parts 3-7 (a price trade and a maintenance charge, a healthy meter, the words, one inverter, N=2 and shadow). Same helpers,
same checks, none dropped: the pieces print exactly the check lines the one file did.
Read MUTATION.md before you report a survivor."""
import asyncio
import logging
import os
import sys
import tempfile
import types
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
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
from app import homeview, messages, peakguard, tariff # noqa: E402
from app import price as _price, static_tariff as _st # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
from app.tariff import QUARTER_S, Projection # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
PEAK = 5413.17 # October's banked peak on the live site
AIM_PEAK = PEAK - 50.0
AIM_CAP = 3500.0 - 50.0
TOL = 10.0 # W over the aim a quarter may land (SAFETY-81)
FC = "sensor.piek_prognose_kwartier"
L = timezone(timedelta(hours=1))
START = datetime(2026, 10, 29, 0, 0, tzinfo=L).astimezone(timezone.utc)
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.2333},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.2801}]}')
def opts(*, fc=False, modes=True, arb=False):
o = dict(rp.options(modes, arb, 2))
o.update(cycle_cost_threshold_eur=0.03, static_profile=PROFILE,
peak_cap_w=3500, peak_margin_w=50)
o["inverters"][0].update(max_w=5000, capacity_kwh=15.36)
o["inverters"][1].update(max_w=4600, capacity_kwh=20.48)
if fc:
o["peak_forecast_entity"] = FC
return o
class Harder(rp.Plant):
"""Charges harder than asked, as inverter-a did live (asked -4271 W,
measured about -4750 W; asked -5000 W, about -5210 W)."""
def step(self, dt, floor):
c = self.cmd
if c < 0:
self.cmd = max(c * 1.11, -5216.0)
try:
super().step(dt, floor)
finally:
self.cmd = c
async def _no_fetch(*a, **k):
raise AssertionError("the tariff task reached for a price feed")
class OnItsOwn(rp.Plant):
"""Charges 5000 W whatever it is asked (shadow mode: nothing is
written, the inverter charges on its own)."""
def step(self, dt, floor):
self.cmd = -5000.0
super().step(dt, floor)
def drive(o, *, peak=None, late=0.0, minutes=31, every=0, start=None,
period=0.0, fc_unit="kW", plant=Harder):
"""The real Controller from `start` (00:00 local) for `minutes` at a 1 s
cycle (OPT-15's lag). `late`: no P1 telegram in each quarter's last
`late` s; `period`: one telegram every `period` s (0: every cycle).
-> (controller, rows, {quarter start: true grid average}, samples), the
samples taken every `every` cycles from the second quarter (the first
starts before the first reading): (projection, ceiling, engaged,
peak_guard row, month-peak line)."""
store = rp._Store()
for u, mode in (("a", "cheap_hours"), ("b", "solar_first")):
store.data[unit_key(f"inverter-{u}", "pack_use")] = True
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
store.data[unit_key(f"inverter-{u}", "pack_sell")] = False
rows, samples, sums = [], [], {}
t0 = start or START
async def go():
lag = rp.Lag()
hass = rp.Hass(("a", "b"), lambda t: 1000.0, {"a": 11.0, "b": 10.0},
limits={"a": (-5000.0, 5000.0),
"b": (-4600.0, 4600.0)}, lag=lag)
hass.plants["a"].__class__ = plant
fc_kw = [0.0]
async def state(e):
# The HA quarter projection, in kW like the live template.
return None if e != FC else {
"state": (f"{fc_kw[0]:.3f}" if fc_unit == "kW"
else f"{fc_kw[0] * 1000.0:.0f}"),
"attributes": {"unit_of_measurement": fc_unit}}
hass.state = state
tmp = tempfile.mkdtemp(prefix="s125-")
db = os.path.join(tmp, "tariff.db")
if peak is not None:
tariff.PeakStore(db)._db.execute(
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
(tariff.month_key(t0), peak,
"2026-10-21T20:15:00+00:00")).connection.commit()
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
peak_db_path=db,
price_history_path=os.path.join(tmp, "ph.json"))
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 = 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)
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
task = asyncio.create_task(c.run_tariff_refresh())
try:
hass.advance(0.0, 10.0)
t, n = 0.0, 0
while t < minutes * 60:
now = t0 + timedelta(seconds=t)
hass.t, simt[0], clock[0] = t, t, now
if t == 0.0:
for _ in range(500):
if c.tariff_plan is not None:
break
await asyncio.sleep(0)
q = int(now.timestamp() // QUARTER_S)
acc = sums.setdefault(q, [0.0, 0])
g_now = hass.grid()
fc_kw[0] = (acc[0] + g_now * (QUARTER_S - acc[1])) \
/ QUARTER_S / 1000.0
if now.timestamp() % QUARTER_S < QUARTER_S - late \
and t >= lag.p1_s \
and (not period or t % period < 1e-9):
imp, exp = split_signed(hass._past(lag.meter_s
+ lag.p1_s)[0])
c.p1.submit(make_sample(
"ha_signed", imp, exp, phases=1,
ingest_ts=now - timedelta(seconds=lag.p1_s),
ingest_mono=1e6 + t))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(lag.tick, 10.0)
acc[0] += max(0.0, hass.grid())
acc[1] += 1
rows.append((now, c.target, hass.plants["a"].p,
getattr(c.arb, "action", None)))
pg = c.peak_guard
if every and n % every == 0 and t >= QUARTER_S \
and pg is not None and pg.projected_w is not None:
row = first((r["text"] for r in c.checks(now)
if r["id"] == "peak_guard"), "guard row")
samples.append((pg.projected_w, pg.ceiling_w, pg.engaged,
row, homeview.month_peak(c, now)["line"]))
t += lag.tick
n += 1
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
main_files.live.datetime, homeview.datetime, p1mod.time = real
_price.load_prices, _st.load_prices = real_load
return c
c = asyncio.run(go())
full = {datetime.fromtimestamp(q * QUARTER_S, L).strftime("%H:%M"):
s / k for q, (s, k) in sorted(sums.items()) if k >= QUARTER_S}
return c, rows, full, samples
def into(now):
return now.timestamp() % QUARTER_S
# ============================================================================ #
def graded(label, full, aim):
print(f" {label}: quarters "
f"{ {k: round(v, 1) for k, v in full.items()} } W, aim {aim:.0f}")
return len(full) >= 2 and all(v <= aim + TOL for v in full.values())
def cut_and_back(rows):
"""The charge is off in each quarter's last 5 s, and back (A charging
over 2 kW) within the next quarter's first minute."""
ends = [r for r in rows if into(r[0]) >= QUARTER_S - 5]
starts = {}
for r in rows:
if into(r[0]) < 60:
q = int(r[0].timestamp() // QUARTER_S)
starts[q] = starts.get(q, False) or r[2] < -2000.0
return (present(first(iter(ends), "a quarter end"))
and all(r[1] > -1.0 for r in ends)
and len(starts) >= 2 and all(starts.values()))
# ============================================================================ #
print("\n3. the same for a price trade (modes off) and a maintenance charge")
_ca, _ra, _fa = drive(opts(modes=False, arb=True), peak=PEAK, late=30.0)[:3]
check("modes off, price trading on, a reading 30 s late: every quarter at "
"or under the aim", graded("trade", _fa, AIM_PEAK)
and any(r[3] == "ARBITRAGE_CHARGE" for r in _ra))
def late_feed(gap, every=0.0, look=None):
"""safety84's cheap-window feed, no telegram in each quarter's last
`gap` s, and one only every `every` s (0: every cycle). `look(c, t)`
runs first, on the state the previous cycle left."""
feed = s84.feed(True)
def before(c, t):
if look is not None:
look(c, t)
if ((rig.BASE.timestamp() + t) % QUARTER_S < QUARTER_S - gap
and (not every or t % every < 1e-9)) \
or not getattr(c.hass, "_s84", False):
feed(c, t)
return before
def rig_quarters(rows, load):
"""The safety84 rig's whole quarters: the true average import (W)."""
qs = {}
for r in rows:
q = int((rig.BASE.timestamp() + r[0]) // QUARTER_S)
a = qs.setdefault(q, [0.0, 0])
a[0] += max(0.0, load(r[0]) - sum(v for _u, v in r[2]))
a[1] += 1
return {datetime.fromtimestamp(q * QUARTER_S, L).strftime("%H:%M"):
s / k for q, (s, k) in sorted(qs.items())
if k * rig.TICK >= QUARTER_S}
for peak, house, aim in ((5413.0, 4000.0, 5363.0), (None, 2500.0, AIM_CAP)):
_mr, _mc = s84.run(2, load=lambda t, h=house: h, dur=2700.0,
month_peak=peak, before=late_feed(30.0))
_mf = rig_quarters(_mr, lambda t, h=house: h)
check(f"N=2 maintenance charge, house {house:.0f} W, "
f"{'peak banked' if peak else 'no peak banked'}, a reading 30 s "
f"late: every quarter at or under the aim",
graded("maintenance", _mf, aim)
and any(mm.phase == "charge" for mm in _mc.maints))
# ============================================================================ #
print("\n4. a healthy meter: the quarters, and the words")
for name, kw, aim, kw_text in (("month peak banked", dict(peak=PEAK),
AIM_PEAK, "5.4 kW"),
("no peak banked", dict(), AIM_CAP, "3.5 kW")):
_c, _rows, _full, _s = drive(opts(), every=10, **kw)
check(f"{name}: every quarter at or under the aim",
graded(name, _full, aim))
over = [s for s in _s if not s[2] and s[0] > s[1]]
under = [s for s in _s if not s[2] and s[0] <= s[1]]
_o, _u = first(iter(over), "an over sample"), \
first(iter(under), "an under sample")
print(f" over the limit at this rate ({len(over)} samples): "
f"{_o[3] if present(_o) else _o!s} / "
f"{_o[4] if present(_o) else _o!s}")
print(f" under it ({len(under)}): "
f"{_u[3] if present(_u) else _u!s} / "
f"{_u[4] if present(_u) else _u!s}")
check(f"{name}: over the limit at this rate the guard row never says "
f"'under'; it says the grid charge is limited",
present(_o) and all(
"under" not in s[3] and "Peak guard: grid charge limited, "
"at this rate" in s[3] and " over " in s[3] for s in over))
check(f"{name}: ...and the month-peak line says the rate, and that the "
f"grid charge is cut back to stay under {kw_text}",
present(_o) and all(
s[4].startswith("At this rate this 15-minute period reaches")
and s[4].endswith("The grid charge is cut back to stay "
f"under {kw_text}.") for s in over))
check(f"{name}: under the limit both read as before",
present(_u) and all(
s[3].startswith("Peak guard: idle, heading for")
and " under " in s[3]
and s[4].startswith("This 15-minute period is heading for")
and s[4].endswith(f"The batteries step in above {kw_text}.")
for s in under))
# ============================================================================ #
print("\n5. the words on their own")
_en = messages.row_peak_guard_idle(5814.0, 5413.0, month_peak=True)
_cut = messages.row_peak_guard_idle(5814.0, 5413.0, month_peak=True,
cut=True)
_ok = messages.row_peak_guard_idle(5200.0, 5413.0, month_peak=True)
print(f" {_en}\n {_cut}\n {_ok}")
check("over, nothing cut: 'idle, at this rate ..., over ...'",
_en == "Peak guard: idle, at this rate 5 814 W, over 5 413 W")
check("over, a grid charge cut",
_cut == "Peak guard: grid charge limited, at this rate 5 814 W, "
"over 5 413 W")
check("under: the 0.19.1 sentence, unchanged",
_ok == "Peak guard: idle, heading for 5 200 W, under this month's "
"5 413 W peak")
check("the Dutch forms say the same",
_cut.nl == "Piekbewaking: laden beperkt, aan dit tempo 5 814 W, "
"boven 5 413 W"
and _en.nl.startswith("Piekbewaking: in rust, aan dit tempo"))
check("the month-peak line in shadow mode says 'would'",
homeview.line_period(5814.0, 5413.0, True, cut=True).endswith(
"The grid charge would be cut back to stay under 5.4 kW.")
and homeview.line_period(5814.0, 5413.0, False).endswith(
"The batteries step in above 5.4 kW."))
# ============================================================================ #
print("\n6. one inverter: the maintenance charge is sized to the room too")
# The start-up quarter on a 10 s meter: the charge's first step is not seen
# until the second reading, so it may land up to START_TOL over the aim; the
# same at N=2 and before this fix. Every later quarter is graded at TOL.
START_TOL = 15.0
N1 = {}
for peak, house, aim in ((5413.0, 4000.0, 5363.0), (None, 2500.0, AIM_CAP)):
for late, every in ((0.0, 0.0), (30.0, 0.0), (20.0, 10.0)):
_r, _c = s84.run(1, load=lambda t, h=house: h, dur=2700.0,
month_peak=peak, before=late_feed(late, every))
_q = rig_quarters(_r, lambda t, h=house: h)
N1[peak, late, every] = (_r, _c)
_vals = list(_q.values())
_tag = (f"N=1, {'peak banked' if peak else 'no peak banked'}, "
f"{'10 s' if every else '5 s'} meter, "
f"{'late ' + format(late, '.0f') + ' s' if late else 'healthy'}")
print(f" {_tag}: quarters "
f"{ {k: round(v, 1) for k, v in _q.items()} } W, aim {aim:.0f}")
check(f"{_tag}: every quarter at or under the aim",
len(_vals) == 3
and all(v <= aim + TOL for v in _vals[1:])
and nth(_vals, 0, "a first quarter")
<= aim + (START_TOL if every else TOL)
and any(mm.phase == "charge" for mm in _c.maints))
_hr, _hc = N1[5413.0, 0.0, 0.0]
_eng = sum(1 for r in _hr if r[-1][1] and r[-1][1][0])
print(f" healthy, peak banked: guard engaged on {_eng} of {len(_hr)} "
f"cycles; A charges on "
f"{sum(1 for r in _hr if dict(r[2])['a'] < -1.0)} cycles")
check("N=1: the guard no longer steps in during the charge (no hand-back), "
"and the battery charges on every cycle, at no more than its rate",
_eng == 0 and all(-2000.0 <= dict(r[2])["a"] < -1.0 for r in _hr)
and not any("suspended" in e for e in _hc.events))
# ============================================================================ #
print("\n7. the row during an N=2 maintenance charge and in shadow mode")
_rows7 = []
def _look(c, t):
pg = c.peak_guard
if t >= QUARTER_S and pg is not None and pg.projected_w is not None \
and not pg.engaged and pg.projected_w > pg.ceiling_w:
_rows7.append(first((r["text"] for r in c.checks()
if r["id"] == "peak_guard"), "guard row"))
def _steps(t):
"""4000 W, 800 W more for the first 30 s of every minute."""
return 4000.0 + (800.0 if t % 60.0 < 30.0 else 0.0)
_m2r, _m2c = s84.run(2, load=_steps, dur=1800.0, month_peak=5413.0,
before=late_feed(0.0, look=_look))
_m2q = rig_quarters(_m2r, _steps)
print(f" N=2 maintenance charge, house steps 4000/4800 W: "
f"{len(_rows7)} over-the-limit rows, e.g. "
f"{first(iter(_rows7), 'a row')}; quarters "
f"{ {k: round(v, 1) for k, v in _m2q.items()} } W")
check("N=2 maintenance charge, over the limit at this rate: the row says the "
"grid charge is limited",
present(first(iter(_rows7), "a row")) and all(
r.startswith("Peak guard: grid charge limited, at this rate")
for r in _rows7)
and all(v <= 5363.0 + TOL for v in _m2q.values()))
_sh_o = dict(opts(), shadow_mode="on")
_csh, _rsh, _fsh, _ssh = drive(_sh_o, peak=PEAK, minutes=17, every=10,
plant=OnItsOwn)
_sh_over = [s for s in _ssh if not s[2] and s[0] > s[1]]
_sh1 = first(iter(_sh_over), "an over sample")
print(f" shadow mode, the inverter charging 5000 W on its own: "
f"{len(_sh_over)} over samples, e.g. "
f"{_sh1[3] if present(_sh1) else _sh1!s} / "
f"{_sh1[4] if present(_sh1) else _sh1!s}")
check("shadow mode: nothing is cut on the wire, so the row says 'idle', "
"never 'grid charge limited', and the line says 'would'",
_csh.shadow and present(_sh1) and all(
s[3].startswith("Peak guard: idle, at this rate")
and "would" in s[4] for s in _sh_over))
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
+5 -122
View File
@@ -25,7 +25,11 @@ quarter's edge as on the rig.
4. The page and the "Why" on a decision within the aim.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
CI-18: this file holds parts 1 and 4 (part 4 grades part 1's nights). Parts 2 and 3 are in test_safety126b.py, split out so
CI can run the pieces side by side (the one file took ~13 min). Every check is in
exactly one file; the two print exactly the check lines the one file did."""
import asyncio
import dataclasses
@@ -307,127 +311,6 @@ check("fix reverted, at 0.01 s: the ERROR, the notification and the "
_b["errors"] and _b["notes"] and any(s[3] for s in _b["seen"]))
# ============================================================================ #
print("\n2. a real overrun under the reserve still alarms")
def evening(t):
"""600 W, then an oven and a car charger from 20 minutes on."""
return 600.0 if t < 1200 else 6500.0
for tag, onset, kw in (
("night grid charge, then 6500 W", 1200.0, dict(house=evening)),
("modes off, 5500 W all along", 0.0,
dict(house=5500.0, modes=None, socs=(15.0, 12.0)))):
o = opts(modes=kw.get("modes", OWNER) is not None)
head = drive(o, minutes=35, **kw)
base = without_fix(lambda: drive(o, minutes=35, **kw))
show("head " + tag, head)
show("fix reverted", base)
# From the load's onset: before it, the fix reverted shows the night's
# false alarm (part 1).
first_h = next((s[0] for s in head["seen"] if s[6] and s[0] >= onset),
None)
first_b = next((s[0] for s in base["seen"] if s[6] and s[0] >= onset),
None)
check(f"⭐ {tag}: the ERROR and the notification, naming both "
f"batteries",
head["errors"] and head["notes"]
and all("inverter-a and inverter-b" in n for _t, n in head["notes"]))
_th = [t for t, _n in head["notes"]]
_tb = [t for t, _n in base["notes"] if t >= onset]
print(f" notifications at {_th} s; with the fix reverted {_tb} s")
check(f"{tag}: the first at the same cycle as before the fix "
f"({first_h} s, {first_b} s), and none that was not raised before",
first_h is not None and first_h == first_b
and present(nth(_th, 0)) and nth(_th, 0) == nth(_tb, 0)
and set(_th) <= set(_tb))
check(f"{tag}: the red peak row and its banner while it stands",
any(s[4] for s in head["seen"]))
def burst(t):
"""600 W, a 6 500 W load from 20 to 25 minutes, then 600 W again while
the night charge goes on."""
return 6500.0 if 1200 <= t < 1500 else 600.0
# F3 (review): `_peak_landing()` resets its latch when the guard lets go, so
# a real overrun does not keep the later landing errors alarming.
_bh = drive(opts(), house=burst, off=0.99, minutes=61)
_bb = without_fix(lambda: drive(opts(), house=burst, off=0.99,
minutes=61))
show("head real overrun, then the night again", _bh)
show("fix reverted", _bb)
# The burst banked a new month peak, which the later quarters stay under.
_new_peak = _bh["c"].peak_guard.ceiling_w
def _late(r):
"""From the next quarter on: the overrun's own quarter ends over."""
return [x for x in r["seen"] if x[0] >= 1800]
print(f" notifications at {[t for t, _n in _bh['notes']]} s; with the fix "
f"reverted {[t for t, _n in _bb['notes']]} s; limit after the burst "
f"{_new_peak:.0f} W")
check("⭐ after a real overrun ends, the latch is reset: the alarm came "
"during the overrun, and the later quarters' landings are quiet "
"(no notification, no 'Why' peak, no red row) and under the new peak",
any(1200 <= t < 1500 for t, _n in _bh["notes"])
and not [t for t, _n in _bh["notes"] if t >= 1800]
and not any(x[3] or x[4] or x[5] for x in _late(_bh))
and present(nth([v for k, v in _bh["quarters"].items()
if k >= "22:30"], 0))
and all(v <= _new_peak for k, v in _bh["quarters"].items()
if k >= "22:30"))
check("...not vacuous: the guard wanted power at those landings, and with "
"the fix reverted they show the 'Why' peak or an alarm",
any(x[2] for x in _late(_bh))
and (any(x[3] or x[4] or x[5] for x in _late(_bb))
or bool([t for t, _n in _bb["notes"] if t >= 1800])))
# ============================================================================ #
print("\n3. misses_aim() on its own")
START = datetime(2026, 10, 30, 21, 15, tzinfo=timezone.utc)
def proj(projected, elapsed, held):
return Projection(projected, projected, elapsed, 1.0, True, START, held)
check("the rig's case: 3452 W against 3450 W, 0.3 s left, charging "
"1250 W from the grid: not a miss",
not peakguard.misses_aim(proj(3452.0, 899.7, 3452.0), batt_w=-1250.0,
target_w=AIM_CAP))
check("...although decide() wants power there (its gain, 900 / 0.3 s)",
(peakguard.decide(proj(3452.0, 899.7, 3452.0), batt_w=-1250.0,
cap_w=3500, margin_w=50, reading=None,
achievable_w=0.0, soc=10.0, soc_floor=20.0)
.wanted_w or 0.0) > 1000.0)
check("a quarter heading 11 W over the aim with the batteries at 0 W is a "
"miss", peakguard.misses_aim(proj(3461.0, 899.7, 3461.0), batt_w=0.0,
target_w=AIM_CAP))
check("...and 10 W is not (ALARM_MISS_W, as for 'Load too high')",
not peakguard.misses_aim(proj(3460.0, 899.7, 3460.0), batt_w=0.0,
target_w=AIM_CAP))
check("the add-on's own grid charge counts as stoppable: 4000 W on the "
"meter mid-quarter, 3000 W of it charging, is not a miss",
not peakguard.misses_aim(proj(2500.0, 450.0, 4000.0), batt_w=-3000.0,
target_w=AIM_CAP))
check("...and a house of 6000 W the batteries are covering is one",
peakguard.misses_aim(proj(1000.0, 450.0, 0.0), batt_w=6000.0,
target_w=AIM_CAP))
check("no battery reading, or no seconds left: not a miss (decide() files "
"nothing there)",
not peakguard.misses_aim(proj(9000.0, 450.0, 9000.0), batt_w=None,
target_w=AIM_CAP)
and not peakguard.misses_aim(proj(9000.0, 900.0, 9000.0), batt_w=0.0,
target_w=AIM_CAP))
# ============================================================================ #
print("\n4. the page and the 'Why' on a decision within the aim")
_c = NIGHTS["aim 3450, cycle 0.01 s before the edge"][0]["c"]
+382
View File
@@ -0,0 +1,382 @@
"""Runnable check for SAFETY-126, a part. `python3 test_safety126b.py`
Split out of test_safety126.py by CI-18 so CI can run the pieces side by side (the one file
took ~13 min). The docstring there describes the whole. It holds parts 2 and 3 (a real overrun still alarms, misses_aim()). Same helpers,
same checks, none dropped: the pieces print exactly the check lines the one file did.
Read MUTATION.md before you report a survivor."""
import asyncio
import dataclasses
import io
import logging
import os
import sys
import tempfile
import types
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
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
from app import price as _price, static_tariff as _st # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
from app.tariff import QUARTER_S, Projection # noqa: E402
logging.disable(logging.NOTSET)
logging.getLogger().setLevel(logging.CRITICAL)
_ERRORS = io.StringIO()
_h = logging.StreamHandler(_ERRORS)
_h.setLevel(logging.ERROR)
logging.getLogger("goodwe").addHandler(_h)
logging.getLogger("goodwe").setLevel(logging.ERROR)
logging.getLogger("goodwe").propagate = False
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
PEAK = 5413.17 # October's banked peak on the live site
AIM_CAP = 3500.0 - 50.0
AIM_PEAK = PEAK - 50.0
TOL = 10.0 # W over the aim a quarter may land (SAFETY-81)
L = timezone(timedelta(hours=1))
NIGHT = datetime(2026, 10, 30, 22, 0, tzinfo=L).astimezone(timezone.utc)
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.2333},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.2801}]}')
OWNER = {"a": "leftover_solar", "b": "cheap_hours"}
def opts(*, modes=True, arb=False):
o = dict(rp.options(modes, arb, 2))
o.update(cycle_cost_threshold_eur=0.03, static_profile=PROFILE,
peak_cap_w=3500, peak_margin_w=50)
o["inverters"][0].update(max_w=5000, capacity_kwh=15.36)
o["inverters"][1].update(max_w=4600, capacity_kwh=20.48)
return o
class Harder(rp.Plant):
"""Charges harder than asked, as inverter-a did live (test_safety125.py:
asked -4271 W, measured about -4750 W; asked -5000 W, about -5210 W), up
to `cap` W. So a charge sized to the aim lands a few W over it, as on the
rig (worst +7 W)."""
cap = -5216.0
def step(self, dt, floor):
c = self.cmd
if c < 0:
self.cmd = max(c * 1.11, self.cap)
try:
super().step(dt, floor)
finally:
self.cmd = c
async def _no_fetch(*a, **k):
raise AssertionError("the tariff task reached for a price feed")
def without_fix(fn):
"""release/1.0 cc9bbeb: no decision is ever within the aim."""
real = getattr(Controller, "_peak_landing", None)
if real is None:
return fn()
Controller._peak_landing = lambda self, decision: decision
try:
return fn()
finally:
Controller._peak_landing = real
def drive(o, *, modes=OWNER, socs=(10.0, 10.0), house=600.0, minutes=46,
start=NIGHT, off=0.7, peak=None):
"""The real Controller from `start` + `off` s for `minutes` at a 1 s
cycle with OPT-15's lag, so a cycle lands `1 - off` s before each
quarter's end. `house` is W or a function of t. Returns a dict: the
quarters' true grid averages, the commands, the reserve notifications,
the ERROR lines, and per cycle where the guard engaged: (t, floor
limited, within the aim, battery "Why" peak, red reserve row, red
saturated row, alarm latched)."""
store = rp._Store()
for u, mode in (modes or {}).items():
store.data[unit_key(f"inverter-{u}", "pack_use")] = True
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
store.data[unit_key(f"inverter-{u}", "pack_sell")] = False
load = house if callable(house) else (lambda t: float(house))
t0 = start + timedelta(seconds=off)
out = {"cmds": [], "notes": [], "seen": [], "sums": {}}
_ERRORS.seek(0)
_ERRORS.truncate()
async def go():
lag = rp.Lag()
hass = rp.Hass(("a", "b"), load, {"a": socs[0], "b": socs[1]},
limits={"a": (-5000.0, 5000.0),
"b": (-4600.0, 4600.0)}, lag=lag)
for u, cap in (("a", -5216.0), ("b", -4800.0)):
hass.plants[u].__class__ = Harder
hass.plants[u].cap = cap
real_set = hass.set_number
async def set_number(e, v):
out["cmds"].append((round(hass.t, 3), e, float(v)))
return await real_set(e, v)
async def call(*a):
if a[:2] == ("persistent_notification", "create") \
and a[2]["notification_id"] == messages.ID_PEAK_RESERVE:
out["notes"].append((hass.t, a[2]["message"]))
return True
hass.set_number, hass.call = set_number, call
tmp = tempfile.mkdtemp(prefix="s126-")
db = os.path.join(tmp, "tariff.db")
if peak is not None:
tariff.PeakStore(db)._db.execute(
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
(tariff.month_key(t0), peak,
"2026-10-21T20:15:00+00:00")).connection.commit()
c = Controller(dict(o), hass, store, rp._Pub(), stop=asyncio.Event(),
peak_db_path=db,
price_history_path=os.path.join(tmp, "ph.json"))
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 = 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)
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
task = (asyncio.create_task(c.run_tariff_refresh())
if o.get("battery_modes_enabled") or o.get("arbitrage_enabled")
else None)
try:
hass.advance(0.0, 10.0)
t = 0.0
while t < minutes * 60:
now = t0 + timedelta(seconds=t)
hass.t, simt[0], clock[0] = t, t, now
if t == 0.0 and task is not None:
for _ in range(500):
if c.tariff_plan is not None:
break
await asyncio.sleep(0)
q = int(now.timestamp() // QUARTER_S)
acc = out["sums"].setdefault(q, [0.0, 0])
if t >= lag.p1_s:
imp, exp = split_signed(hass._past(lag.meter_s
+ lag.p1_s)[0])
c.p1.submit(make_sample(
"ha_signed", imp, exp, phases=1,
ingest_ts=now - timedelta(seconds=lag.p1_s),
ingest_mono=1e6 + t))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(lag.tick, 10.0)
acc[0] += max(0.0, hass.grid())
acc[1] += 1
g = c.peak_guard
if g is not None and g.engaged and g.blind is None:
rows = {r["id"]: r for r in c.checks(now)}
row = rows.get("peak_guard", {})
red = row.get("ok") is False and not row.get("warn")
out["seen"].append((
t, g.floor_limited, getattr(g, "within_aim", False),
homeview.peak_engaged(c),
red and "banner" in row,
red and "banner" not in row,
c._peak_floor_limited))
t += lag.tick
finally:
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
main_files.live.datetime, homeview.datetime, p1mod.time = real
_price.load_prices, _st.load_prices = real_load
return c
out["c"] = asyncio.run(go())
out["quarters"] = {
datetime.fromtimestamp(q * QUARTER_S, L).strftime("%H:%M"): s / k
for q, (s, k) in sorted(out["sums"].items()) if k >= QUARTER_S - 1}
out["errors"] = [x for x in _ERRORS.getvalue().splitlines()
if "PEAK_GUARD_FLOOR_LIMITED" in x]
return out
def show(tag, r):
print(f" {tag}: quarters "
f"{ {k: round(v, 1) for k, v in r['quarters'].items()} }, "
f"guard cycles {len(r['seen'])}, floor-limited "
f"{sum(1 for s in r['seen'] if s[1])}, within-aim "
f"{sum(1 for s in r['seen'] if s[2])}, Why peak "
f"{sum(1 for s in r['seen'] if s[3])}, notes {len(r['notes'])}, "
f"errors {len(r['errors'])}")
def quiet(r):
"""No reserve alarm in any form: ERROR, notification, red row, latch,
"Why" peak, and no red saturated row."""
return (not r["errors"] and not r["notes"]
and not any(s[3] or s[4] or s[5] or s[6] for s in r["seen"]))
def on_aim(r, aim):
q = r["quarters"]
return len(q) >= 3 and all(v <= aim + TOL for v in q.values())
# ============================================================================ #
print("\n2. a real overrun under the reserve still alarms")
def evening(t):
"""600 W, then an oven and a car charger from 20 minutes on."""
return 600.0 if t < 1200 else 6500.0
for tag, onset, kw in (
("night grid charge, then 6500 W", 1200.0, dict(house=evening)),
("modes off, 5500 W all along", 0.0,
dict(house=5500.0, modes=None, socs=(15.0, 12.0)))):
o = opts(modes=kw.get("modes", OWNER) is not None)
head = drive(o, minutes=35, **kw)
base = without_fix(lambda: drive(o, minutes=35, **kw))
show("head " + tag, head)
show("fix reverted", base)
# From the load's onset: before it, the fix reverted shows the night's
# false alarm (part 1).
first_h = next((s[0] for s in head["seen"] if s[6] and s[0] >= onset),
None)
first_b = next((s[0] for s in base["seen"] if s[6] and s[0] >= onset),
None)
check(f"⭐ {tag}: the ERROR and the notification, naming both "
f"batteries",
head["errors"] and head["notes"]
and all("inverter-a and inverter-b" in n for _t, n in head["notes"]))
_th = [t for t, _n in head["notes"]]
_tb = [t for t, _n in base["notes"] if t >= onset]
print(f" notifications at {_th} s; with the fix reverted {_tb} s")
check(f"{tag}: the first at the same cycle as before the fix "
f"({first_h} s, {first_b} s), and none that was not raised before",
first_h is not None and first_h == first_b
and present(nth(_th, 0)) and nth(_th, 0) == nth(_tb, 0)
and set(_th) <= set(_tb))
check(f"{tag}: the red peak row and its banner while it stands",
any(s[4] for s in head["seen"]))
def burst(t):
"""600 W, a 6 500 W load from 20 to 25 minutes, then 600 W again while
the night charge goes on."""
return 6500.0 if 1200 <= t < 1500 else 600.0
# F3 (review): `_peak_landing()` resets its latch when the guard lets go, so
# a real overrun does not keep the later landing errors alarming.
_bh = drive(opts(), house=burst, off=0.99, minutes=61)
_bb = without_fix(lambda: drive(opts(), house=burst, off=0.99,
minutes=61))
show("head real overrun, then the night again", _bh)
show("fix reverted", _bb)
# The burst banked a new month peak, which the later quarters stay under.
_new_peak = _bh["c"].peak_guard.ceiling_w
def _late(r):
"""From the next quarter on: the overrun's own quarter ends over."""
return [x for x in r["seen"] if x[0] >= 1800]
print(f" notifications at {[t for t, _n in _bh['notes']]} s; with the fix "
f"reverted {[t for t, _n in _bb['notes']]} s; limit after the burst "
f"{_new_peak:.0f} W")
check("⭐ after a real overrun ends, the latch is reset: the alarm came "
"during the overrun, and the later quarters' landings are quiet "
"(no notification, no 'Why' peak, no red row) and under the new peak",
any(1200 <= t < 1500 for t, _n in _bh["notes"])
and not [t for t, _n in _bh["notes"] if t >= 1800]
and not any(x[3] or x[4] or x[5] for x in _late(_bh))
and present(nth([v for k, v in _bh["quarters"].items()
if k >= "22:30"], 0))
and all(v <= _new_peak for k, v in _bh["quarters"].items()
if k >= "22:30"))
check("...not vacuous: the guard wanted power at those landings, and with "
"the fix reverted they show the 'Why' peak or an alarm",
any(x[2] for x in _late(_bh))
and (any(x[3] or x[4] or x[5] for x in _late(_bb))
or bool([t for t, _n in _bb["notes"] if t >= 1800])))
# ============================================================================ #
print("\n3. misses_aim() on its own")
START = datetime(2026, 10, 30, 21, 15, tzinfo=timezone.utc)
def proj(projected, elapsed, held):
return Projection(projected, projected, elapsed, 1.0, True, START, held)
check("the rig's case: 3452 W against 3450 W, 0.3 s left, charging "
"1250 W from the grid: not a miss",
not peakguard.misses_aim(proj(3452.0, 899.7, 3452.0), batt_w=-1250.0,
target_w=AIM_CAP))
check("...although decide() wants power there (its gain, 900 / 0.3 s)",
(peakguard.decide(proj(3452.0, 899.7, 3452.0), batt_w=-1250.0,
cap_w=3500, margin_w=50, reading=None,
achievable_w=0.0, soc=10.0, soc_floor=20.0)
.wanted_w or 0.0) > 1000.0)
check("a quarter heading 11 W over the aim with the batteries at 0 W is a "
"miss", peakguard.misses_aim(proj(3461.0, 899.7, 3461.0), batt_w=0.0,
target_w=AIM_CAP))
check("...and 10 W is not (ALARM_MISS_W, as for 'Load too high')",
not peakguard.misses_aim(proj(3460.0, 899.7, 3460.0), batt_w=0.0,
target_w=AIM_CAP))
check("the add-on's own grid charge counts as stoppable: 4000 W on the "
"meter mid-quarter, 3000 W of it charging, is not a miss",
not peakguard.misses_aim(proj(2500.0, 450.0, 4000.0), batt_w=-3000.0,
target_w=AIM_CAP))
check("...and a house of 6000 W the batteries are covering is one",
peakguard.misses_aim(proj(1000.0, 450.0, 0.0), batt_w=6000.0,
target_w=AIM_CAP))
check("no battery reading, or no seconds left: not a miss (decide() files "
"nothing there)",
not peakguard.misses_aim(proj(9000.0, 450.0, 9000.0), batt_w=None,
target_w=AIM_CAP)
and not peakguard.misses_aim(proj(9000.0, 900.0, 9000.0), batt_w=0.0,
target_w=AIM_CAP))
# ============================================================================ #
print()
if fails:
print(f"{len(fails)} SAFETY-126 check(s) FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all SAFETY-126 checks passed")
+15 -2
View File
@@ -484,7 +484,14 @@ print("\n8. the N=1 and N=2 digests")
# MAINT-28 re-pinned n2_charge_banked: in the 5000 W load (over the 3950 W
# aim) the maintenance charge no longer restarts at -125 W after each guard
# stop (t=1550 s, 64 of 480 rows); the pause is held until the load is gone
# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). The other five are equal.
# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). MAINT-28 fix-forward
# re-pinned it again: while the pause holds A gives at most the largest part
# of the guard's floor placed on it this period, so rows 288-479 (t = 1440 s
# on, 192 of 480) differ: a "peak hold" limit claim on A and A's share. Round 5
# (H-1) re-pinned it again: one site limit, the period's largest floor, so the
# batteries give only what the guard needs. Its banked quarters stay at or
# under the aim 3950 W (3349 and 3950 W; f3f9483 3349 and 2527 W), graded
# below. The other five are equal.
PINNED = {
"n1_charge":
"00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9",
@@ -497,8 +504,14 @@ PINNED = {
"n2_charge_dear":
"aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db",
"n2_charge_banked":
"5d523562219245c135a4e9aa856826bc2939a3157c6ae0320cafc2cd1a8a6cf1",
"358ebd2a23fe7c7f52f441b60391e40c6605e32bcfb109fa0ff4502d4070745e",
}
_nb_rows, _nb = s84.run(**s84.SCENARIOS["n2_charge_banked"])
print(f" n2_charge_banked banked quarters {[round(v, 1) for v in banked(_nb)]}"
f" W (aim 3950)")
check("...and in the re-pinned n2_charge_banked every banked quarter is at or "
"under the aim 3950 W",
len(banked(_nb)) >= 2 and all(v <= 3950.0 + 1.0 for v in banked(_nb)))
_now = s84.digests()
for k in PINNED:
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'} "
+3 -2
View File
@@ -637,9 +637,10 @@ _routes = sorted({(r.method, r.get_info().get("path"))
for r in _app.router.routes() if r.method != "HEAD"})
print(" " + " ".join(f"{m} {p}" for m, p in _routes))
check("the configuration surface adds five routes, UX-13 one GET and "
"UX-54 one more GET (the Forecast tab), and none touches the three "
"UX-54 one more GET (the Forecast tab), EV-03 one read-only GET (the car "
"charger probe), and none touches the three "
"that were there",
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/model"),
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/charger"), ("GET", "/config/model"),
("GET", "/forecast"), ("GET", "/ha/entities"),
("GET", "/status"),
("POST", "/action"), ("POST", "/config/restart"),
+5 -4
View File
@@ -80,12 +80,13 @@ for _g in LAYOUT["groups"]:
_flat += [(_g["id"], _s) for _s in _f.get("fields", [])]
_keys = [f["key"] for _g, f in _flat]
FIELD = {f["key"]: f for _g, f in _flat}
check("the six groups, in the design's order",
check("the seven groups, in the design's order",
[g["id"] for g in LAYOUT["groups"]]
== ["meter", "inverters", "tariff", "safety", "maintenance", "tuning"]
== ["meter", "inverters", "tariff", "safety", "car", "maintenance",
"tuning"]
and [g["name"] for g in LAYOUT["groups"]]
== ["Meter", "Inverters and batteries", "Tariff and prices",
"Safety limits", "Maintenance", "Control tuning"])
"Safety limits", "Car charger", "Maintenance", "Control tuning"])
check(f"every field of the table is on the page once ({len(_keys)} fields)",
sorted(_keys) == sorted(f.key for _g, _p, f in L.fields())
and len(_keys) == len(set(_keys)))
@@ -146,7 +147,7 @@ check("a page-only field names the option keys it writes, for the grey key "
== ["pv_entities", "pv_entity", "pv_invert_entities"]
and "static_profile" in field(FIELD, "ui.contract")["writes"])
check("without a Supervisor schema the page still draws, with no ranges",
len(uipage.layout([], {})["groups"]) == 6)
len(uipage.layout([], {})["groups"]) == 7)
# --------------------------------------------------------------------------- #
print("\n2. the page script names no option key")
+3 -2
View File
@@ -205,10 +205,11 @@ print("\n4. task ordering, and the labels that carry it")
# prefixes appear in the file as CONTIGUOUS RUNS in the task order. A group
# whose members are scattered renders as a jumble however good each label is.
GROUPS = ["Hardware", "Meter", "Limits", "Tariff", "Maintenance", "Safety",
"Advanced"]
"Car charger", "Advanced"]
NL_GROUPS = {"Hardware": "Hardware", "Meter": "Meter", "Limits": "Grenzen",
"Tariff": "Tarief", "Maintenance": "Onderhoud",
"Safety": "Veiligheid", "Advanced": "Geavanceerd"}
"Safety": "Veiligheid", "Car charger": "Laadpaal",
"Advanced": "Geavanceerd"}
_prefixes = []
for key in ORDER:
name = TR["en"]["configuration"][key]["name"]
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""EV-03: a model of an Alfen car charger behind Home Assistant, for the unit
tests and, later, for TEST-23.
WHERE IT LIVES, AND WHY. The rig's hardware sims (`hwsim.py`, `plantsim.py`) are
in the infra repo admin/goodwe under `sim/`, pinned by `.gitea/infra-pin`; there
is no `sim/` in this repository. This card may write only to this repository, so
the model sits beside the add-on's other tools. Moving it next to `hwsim.py` is a
copy: it imports nothing from the add-on.
WHAT IT MODELS (every entity the add-on's Alfen driver reads, with the names and
units the leeyuentuen/alfen_wallbox integration gives, from the owner's export):
* mode3 state A (no car), B (connected, not charging), C (charging)
* power that RAMPS to what the car takes (`RAMP_W_S`), 1 or 3 phases
* the current setpoint, its read-back, the station limit (16 A at the owner's)
* a full car (it stops by itself: state B with the current still allowed)
* the integration going unavailable, and the login sensor off
from chargersim import SimCharger, FakeHa
c = SimCharger("wallbox"); c.plug_in(); c.set_current(10); c.tick(30)
ha = FakeHa(c) # a Home Assistant that serves its states
`FakeHa` has the methods the add-on calls (`state`, `number`, `states`,
`set_number`, `call`) and RECORDS every write, so a test can prove none happened.
`python3 chargersim.py --selftest` runs the model's own checks. `serve(port)`
answers the REST calls the add-on makes (`GET /api/states[/<id>]`), for a rig.
"""
import json
import sys
VOLTS = 230.0
RAMP_W_S = 3000.0 # how fast the power follows its target
MIN_A = 6.0
class SimCharger:
def __init__(self, name="wallbox", station_a=16.0, setpoint_a=10.0,
phases_allowed=1, kwh=4004.85):
self.x = name
self.station_a = float(station_a)
self.setpoint_a = float(setpoint_a)
self.readback_a = float(setpoint_a)
self.phases_allowed = phases_allowed
self.car_phases = 3 # what the car can use
self.state = "A"
self.full = False
self.power_w = 0.0
self.kwh = float(kwh)
self.unavailable = False
self.login_on = True
self.error = "0: No Error"
# -- the world acts on it -------------------------------------------------
def plug_in(self, car_phases=3):
self.car_phases = car_phases
self.full = False
self.state = "B"
def unplug(self):
self.state, self.power_w = "A", 0.0
def car_full(self):
self.full = True
def set_current(self, a):
"""What an automation (or, later, this add-on) does to the setpoint."""
a = max(0.0, min(float(a), self.station_a))
self.setpoint_a = self.readback_a = a
def set_phases(self, n):
self.phases_allowed = 3 if n == 3 else 1
def fault(self, letter="E"):
"""mode3 E or F: the charger's own error states."""
self.state, self.power_w = letter, 0.0
def go_unavailable(self, on=True):
self.unavailable = bool(on)
def login(self, on=True):
self.login_on = bool(on)
# -- time -----------------------------------------------------------------
@property
def phases_used(self):
return min(self.car_phases, self.phases_allowed)
def target_w(self):
if self.state == "A" or self.full or self.setpoint_a < MIN_A:
return 0.0
return self.setpoint_a * VOLTS * self.phases_used
def tick(self, seconds=1.0):
for _ in range(int(max(1, round(seconds)))):
if self.state in ("E", "F"): # a fault: nothing flows, it stays
self.power_w = 0.0
continue
if self.state != "A":
self.state = "C" if self.target_w() > 0 else "B"
tgt = self.target_w()
step = RAMP_W_S
if self.power_w < tgt:
self.power_w = min(tgt, self.power_w + step)
else:
self.power_w = max(tgt, self.power_w - step)
self.kwh += self.power_w / 3.6e6
# -- what Home Assistant would serve ---------------------------------------
def _amps(self):
per = (self.power_w / (VOLTS * self.phases_used)
if self.phases_used and self.power_w else 0.0)
return [per if i < self.phases_used else 0.0 for i in range(3)]
def states(self) -> dict:
x = self.x
bad = "unavailable" if self.unavailable else None
def st(eid, value, unit=None, **attrs):
a = dict(attrs)
if unit:
a["unit_of_measurement"] = unit
return eid, {"entity_id": eid,
"state": bad or str(value), "attributes": a}
amps = self._amps()
rows = [
st(f"number.{x}_power_connector_max_current_socket_1",
f"{self.setpoint_a:g}", "A", min=0, max=40, step=1),
st(f"sensor.{x}_main_active_max_current_socket_1",
f"{self.readback_a:g}", "A"),
st(f"sensor.{x}_mode3_state_socket_1", f"STATE_{self.state}"),
st(f"sensor.{x}_display_state_socket_1",
{"A": "Available", "B": "EV Connected",
"C": "Charging Normal"}.get(self.state, "Error")),
st(f"sensor.{x}_active_power_total_socket_1",
f"{self.power_w:.0f}", "W"),
st(f"sensor.{x}_meter_reading_socket_1", f"{self.kwh:.2f}",
"kWh"),
st(f"sensor.{x}_current_l1_socket_1", f"{amps[0]:.1f}", "A"),
st(f"sensor.{x}_current_l2_socket_1", f"{amps[1]:.1f}", "A"),
st(f"sensor.{x}_current_l3_socket_1", f"{amps[2]:.1f}", "A"),
st(f"number.{x}_max_station_current", f"{self.station_a:g}", "A"),
st(f"sensor.{x}_main_station_active_max_current_socket_1",
f"{self.station_a:g}", "A"),
st(f"select.{x}_installation_max_allowed_phases",
"3 Phases" if self.phases_allowed == 3 else "1 Phase"),
st(f"sensor.{x}_connector_1_max_allowed_of_phases",
"3 Phases" if self.phases_allowed == 3 else "1 Phase"),
st(f"sensor.{x}_display_error_number_socket_1", self.error),
(f"binary_sensor.{x}_https_api_login_status",
{"entity_id": f"binary_sensor.{x}_https_api_login_status",
"state": bad or ("on" if self.login_on else "off"),
"attributes": {"device_class": "connectivity"}}),
]
return dict(rows)
class FakeHa:
"""A Home Assistant that serves a `SimCharger` (and any `extra` states) and
records every service call and number write it is given."""
session = None
token = ""
read_only = False
def __init__(self, charger=None, extra=None):
self.charger = charger
self.extra = dict(extra or {})
self.calls = [] # (domain, service, data)
self.sets = [] # (entity, value)
def _all(self):
out = dict(self.extra)
if self.charger is not None:
out.update(self.charger.states())
return out
async def state(self, entity_id):
return self._all().get(entity_id)
async def states(self):
return list(self._all().values())
async def number(self, entity_id, invert=False):
s = self._all().get(entity_id)
try:
v = float(s["state"])
except (TypeError, ValueError, KeyError):
return None
return -v if invert else v
async def limits(self, _entity_id):
return (-5000.0, 5000.0, 1.0)
async def set_number(self, entity_id, value):
self.sets.append((entity_id, value))
return True
async def call(self, domain, service, data=None):
self.calls.append((domain, service, data))
return True
def charger_writes(self):
"""Every write or service call that names a charger entity."""
x = self.charger.x if self.charger else "\0"
hits = [s for s in self.sets if x in s[0]]
hits += [c for c in self.calls if x in json.dumps(c[2] or {})]
return hits
def serve(sim, port=8123):
"""Answer `GET /api/states` and `GET /api/states/<id>` from `sim`, for a
rig. Reads only: any POST answers 405, so nothing can write the model."""
import http.server
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
rows = sim.states()
path = self.path.split("?")[0].rstrip("/")
if path.endswith("/api/states"):
body = list(rows.values())
else:
body = rows.get(path.rsplit("/api/states/", 1)[-1])
self.send_response(200 if body is not None else 404)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(body).encode())
def do_POST(self):
self.send_response(405)
self.end_headers()
def log_message(self, *_a):
pass
srv = http.server.ThreadingHTTPServer(("0.0.0.0", port), H)
return srv
def _selftest():
c = SimCharger()
assert c.states()[f"sensor.wallbox_mode3_state_socket_1"]["state"] == "STATE_A"
c.plug_in(); c.tick(1)
assert c.state == "B" or c.state == "C"
c.set_current(10); c.set_phases(3); c.tick(5)
assert c.state == "C" and abs(c.power_w - 10 * 230 * 3) < 1, c.power_w
c.set_phases(1); c.tick(5)
assert abs(c.power_w - 2300) < 1
c.set_current(5); c.tick(5)
assert c.state == "B" and c.power_w == 0 # under 6 A: paused
c.set_current(10); c.car_full(); c.tick(5)
assert c.state == "B" and c.power_w == 0 and c.readback_a == 10
c.go_unavailable()
assert c.states()[f"sensor.wallbox_active_power_total_socket_1"]["state"] \
== "unavailable"
c.go_unavailable(False); c.login(False)
assert c.states()["binary_sensor.wallbox_https_api_login_status"]["state"] == "off"
print("chargersim selftest ok")
if __name__ == "__main__":
if "--selftest" in sys.argv:
_selftest()
else:
srv = serve(SimCharger(), int(sys.argv[1]) if len(sys.argv) > 1 else 8123)
print("serving the charger model, GET only; Ctrl-C stops it")
srv.serve_forever()
+12 -2
View File
@@ -116,10 +116,15 @@ class Lag:
after the reading (the HA source's debounce), so a cycle just after a
quarter boundary can still see the old block; `tick` the cycle period;
`tau_s` / `ramp_w_s` the battery's own lag (TEST-23's plant: `dead_s` 1,
no lag or ramp)."""
no lag or ramp). `batt_period_s` (MAINT-28): the battery register is
published every that many seconds and held between (the rig: 10 s), so its
reading is `batt_s` plus up to a period older than the meter's; None keeps
the constant age."""
def __init__(self, dead_s=1.0, meter_s=1.0, batt_s=1.0, p1_s=0.35,
tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0):
tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0,
batt_period_s=None):
self.batt_period_s = batt_period_s
self.dead_s, self.meter_s, self.batt_s = dead_s, meter_s, batt_s
self.p1_s, self.tick, self.sub = p1_s, tick, sub
self.tau_s, self.ramp_w_s = tau_s, ramp_w_s
@@ -211,6 +216,11 @@ class Hass:
if e.startswith("sensor.batt"):
# A real reading jitters by a few watts; a dead-flat one is a
# stale register to the add-on (SAFETY-37), which is not modelled.
if self.lag is not None and self.lag.batt_period_s:
per = self.lag.batt_period_s
ts = math.floor((self.t - self.lag.batt_s) / per) * per
j = (ord(u) * 31 + int(ts)) % 7 - 3
return float(round(self._past(self.t - ts)[1][u]) + j)
j = (ord(u) * 31 + int(self.t)) % 7 - 3
p = self.plants[u].p if self.lag is None else \
self._past(self.lag.batt_s)[1][u]
+79 -3
View File
@@ -36,7 +36,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
from aiohttp import web as aioweb # noqa: E402
from app import homeview, packmode, web # noqa: E402
from app import chargers, freshness, homeview, packmode, web # noqa: E402
from app.main import Controller # noqa: E402
from app.maintenance import DRAIN # noqa: E402
from app.setuponly import SetupOnly # noqa: E402
@@ -44,6 +44,7 @@ from app.peakguard import PeakDecision # noqa: E402
from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402
from app.static_tariff import TariffPlan # noqa: E402
import translations_lint as TL # noqa: E402
from chargersim import SimCharger # noqa: E402
PRICES = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15,
.14, .16, .19, .23, .28, .33, .36, .34, .29, .26, .24]
@@ -104,6 +105,61 @@ class Hass:
return STATES
# EV-03: the car states. A charger model behind the demo's Home Assistant; the
# real `Controller._read_charger()` reads it, so the card is what the add-on
# draws, not a picture.
CAR_STATES = ("car", "car_none", "car_waiting", "car_full", "car_unreach",
"car_setup")
CAR_ENTITY = "number.wallbox_power_connector_max_current_socket_1"
SIM = SimCharger()
class CarHass(Hass):
async def state(self, e):
return SIM.states().get(e) or await super().state(e)
async def states(self):
return list(STATES) + list(SIM.states().values())
def car_setup(state):
"""Put the model in the state's situation."""
global SIM
SIM = SimCharger(name="other" if state == "car_setup" else "wallbox")
if state in ("car", "car_unreach"):
SIM.plug_in()
SIM.set_phases(3)
SIM.set_current(16)
elif state == "car_waiting":
SIM.plug_in()
SIM.set_current(0)
elif state == "car_full":
SIM.plug_in()
SIM.set_current(10)
SIM.car_full()
SIM.tick(12)
async def car_feed(c, state):
"""The cycle's own read, once a second, like the control loop."""
backdated = False
n = 0
while True:
n += 1
if state == "car_unreach" and n == 4:
SIM.go_unavailable()
SIM.tick(1)
await c._read_charger(freshness.monotonic())
if state == "car_unreach" and c.car.bad_since is not None \
and not backdated:
backdated = True # 12 minutes in, so the row and the note show
c.car.bad_since -= 720.0
c.car.bad_since_utc -= timedelta(seconds=720)
if state == "car_full" and c.car.b_since is not None and n < 3:
c.car.b_since -= 120.0 # the full dwell has passed
await asyncio.sleep(1.0)
KETTLE_READS = (-10, 1990, 1990, -10)
KETTLE_COUNT = [0]
@@ -244,7 +300,13 @@ def controller(state):
opts = dict(OPTS, shadow_mode="on" if state == "shadow" else "off")
if state in P3:
opts["battery_modes_enabled"] = True
c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event())
hass = Hass()
if state in CAR_STATES:
car_setup(state)
opts.update(ev_charger="alfen_ha", ev_current_entity=CAR_ENTITY,
ev_max_a=16, ev_phases="auto")
hass = CarHass()
c = Controller(opts, hass, Store(), Pub(), stop=asyncio.Event())
c.auto = True
now = datetime.now(timezone.utc)
day0, _day1 = homeview.local_day(now)
@@ -264,6 +326,12 @@ def controller(state):
inv.last_write = 0.0
c.grid = -10.0 if state != "shadow" else -2810.0
c.pv_w = 3900.0
if state in CAR_STATES:
# 11 kW to the car on top of a 1.6 kW house, all from the roof
c.grid, c.pv_w = 0.0, (12600.0 if state == "car" else 2400.0)
for inv in c.inverters:
inv.batt = 0.0
c.car.stale_s = 15.0
if state == "alarm":
c.inverters[0].sign_wd.tripped = True
if state in P3:
@@ -327,9 +395,17 @@ async def main(state, port, host="127.0.0.1"):
battery_modes_enabled=state in P3,
inverters=[dict(i, bms_soc_floor=10)
for i in OPTS["inverters"]])
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
stored.update(ev_charger=c.o.get("ev_charger", "off"),
ev_current_entity=c.o.get("ev_current_entity", ""),
ev_max_a=c.o.get("ev_max_a", 16),
ev_phases=c.o.get("ev_phases", "auto"))
app = web.build_app(c, Supervisor(stored))
runner = aioweb.AppRunner(app)
await runner.setup()
await aioweb.TCPSite(runner, host, port).start()
if state in CAR_STATES:
asyncio.get_running_loop().create_task(car_feed(c, state))
main.app = app
print(f"serving {state} on http://127.0.0.1:{port}/", flush=True)
await asyncio.Event().wait()
+12
View File
@@ -283,6 +283,18 @@ configuration:
shadow_mode:
name: "Safety · Shadow mode"
description: "The add-on makes every decision but keeps every inverter at 0 W. 'auto' does this while control is stopped, 'on' always, 'off' never."
ev_charger:
name: "Car charger · Charger"
description: "Which car charger the add-on reads. Off leaves everything as it is. This version only reads the charger and never changes it."
ev_current_entity:
name: "Car charger · Current setting entity"
description: "The entity that sets the charger's current. The add-on finds the charger's other entities from its name."
ev_max_a:
name: "Car charger · Charger limit (A)"
description: "The most current the charger may ever be asked for. Never above what the charger reports. Lower it for a weaker fuse or cable."
ev_phases:
name: "Car charger · Phases"
description: "How many phases the charger may use: 1, 3, or 1 and 3 (auto)."
auto_start:
name: "Advanced · Start control on boot"
description: "Off by default. Leave it off until the site is commissioned, so someone presses Start after each restart."
+12
View File
@@ -264,6 +264,18 @@ configuration:
shadow_mode:
name: "Veiligheid · Schaduwmodus"
description: "De add-on neemt elke beslissing maar houdt elke omvormer op 0 W. 'auto' doet dit zolang de regeling gestopt is, 'on' altijd, 'off' nooit."
ev_charger:
name: "Laadpaal · Laadpaal"
description: "Welke laadpaal de add-on leest. Uit laat alles zoals het is. Deze versie leest de laadpaal alleen en wijzigt niets."
ev_current_entity:
name: "Laadpaal · Entiteit van de laadstroom"
description: "De entiteit die de stroom van de laadpaal instelt. De add-on vindt de andere entiteiten van de laadpaal via de naam."
ev_max_a:
name: "Laadpaal · Laadpaalgrens (A)"
description: "De hoogste stroom die aan de laadpaal gevraagd mag worden. Nooit hoger dan wat de laadpaal meldt. Verlaag ze voor een zwakkere zekering of kabel."
ev_phases:
name: "Laadpaal · Fasen"
description: "Hoeveel fasen de laadpaal mag gebruiken: 1, 3, of 1 en 3 (auto)."
auto_start:
name: "Geavanceerd · Regeling starten bij opstarten"
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is, zodat iemand na elke herstart op Start drukt."