DATA-01: thirty days of the real meter, and what AC 3 actually measures
checks / unit (pull_request) Successful in 3m36s

TARIFF-01 and SAFETY-07 both merged with AC 3 - "monthly peak held under
the soft limit in >= 95 % of intervals over a 30-day simulation replay" -
honestly unmet, because two days of capture existed and it was believed
nothing longer could be produced. That belief was a true statement about
the past read as a claim about the future: HA's recorder still held the
whole month, and the reason nobody found out is that the 2026-08-25
export was a one-off script that left no artefact.

So the exporter is committed and runnable now.
scenarios/p1-net-30d-2026-08/ is 2026-08-01 to 2026-08-30, 485,841
samples, 30/30 days read, 0 failed, 4.13 MB gzipped, in the schema the
two committed days already fixed.

VERIFIED AGAINST THE DAYS THAT EXIST. `--verify` re-fetches 2026-08-20
and 2026-08-23 by this exact route and reproduces both committed files
BYTE FOR BYTE after decompression (865,024 B / 15,600 samples and
894,387 B / 16,351 samples). The gzip containers differ by design: this
exporter writes mtime 0 so its output is reproducible, where the one-off
wrote a wall clock.

A FAILED READ IS NOT AN OBSERVATION. A day whose read fails writes no
file, is recorded "read": "FAILED", and the run exits non-zero; the
entity is proved to exist through /api/states before any empty day is
believed, because a typo would otherwise answer 200 [] thirty times and
produce a consistent dataset of nothing. Nothing is interpolated, so the
month's 13 gaps (1291 s, 99.95 % covered) and its 3 `unavailable`
samples stay visible as holes.

SIZE: COMMITTED, DELIBERATELY, AND MEASURED. A git bundle of the whole
history goes from 1.58 MB (release/1.0 @ 86c43eb) to 5.28 MB - +3.70 MB,
x3.3 - and a Home Assistant supervisor clones that on every install.
Downsampling was not on the table: meter_max_age_s is 30 s, so any
coarser resample turns real coverage into modelled meter-gap and moves
the number being measured. "Outside the repo with a pointer" is what
already happened and is why this card exists. The files sit in a
SUBDIRECTORY so the non-recursive globs in test_tariff.py part 9 and
test_peakguard.py part 6 do not pick them up - CI keeps replaying the two
small days instead of growing ~50 s per PR.

AC 3, MEASURED OVER 30 DAYS (tools/replay_ac3.py, which must reproduce
test_peakguard part 6's 189/190 to be believed):

  uncontrolled  2847/2879 = 98.89 % of quarters at or under 3500 W
  guarded       2850/2879 = 98.99 %
  month peak    5029.8 W -> 4107.3 W

Met on the ">= 95 % of intervals" reading - and the uncontrolled figure
is the finding, because a month with no controller at all passes it. Not
met on the other reading of the same sentence: 4107.3 W is still over the
soft limit. The guard pulls 31 of the 32 over-cap quarters down but lands
only 3 under the cap, because CEILING_MONTH_PEAK defends the running
month peak rather than the cap once one is set. Both suites' honesty
notes are corrected to point at the month instead of at its absence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
glenn schrooyen
2026-08-31 11:15:02 +02:00
co-authored by Claude Opus 5
parent 86c43eba24
commit 04292c4a08
37 changed files with 1779 additions and 16 deletions
@@ -0,0 +1,455 @@
#!/usr/bin/env python3
"""Export a P1 power signal out of Home Assistant's recorder into the committed
scenario format - one gzipped JSON object per line, one file per LOCAL day.
WHY THIS FILE EXISTS AT ALL
===========================
`scenarios/p1-net-2026-08-20.jsonl.gz` and `p1-net-2026-08-23.jsonl.gz` were
produced once, by hand, and the script was thrown away. The consequence was not
theoretical: TARIFF-01's and SAFETY-07's AC 3 ("monthly peak held under the soft
limit in >= 95 % of intervals over a 30-day simulation replay") were both merged
UNMET on the belief that thirty days could not be produced retroactively - a
belief that was simply wrong, and that survived because nobody could re-run the
export to find out. So the export is a committed, runnable artefact now, and the
next person does not have to re-derive it under time pressure while a retention
window rolls.
python3 tools/export_p1_history.py --days 30 --out-dir /some/where
THE TWO RULES THIS FILE IS BUILT AROUND
=======================================
1. A FAILED READ IS NOT AN OBSERVATION. An HTTP error, a timeout, a truncated
body, a non-JSON body - none of those are "the meter reported nothing that
hour". They are "we do not know what the meter reported that hour", and the
two must never end up looking the same in the output. So:
* every request is retried with backoff;
* a day whose read ultimately fails writes NO FILE and is recorded in the
manifest as `"read": "FAILED"` with the error text, and the process exits
non-zero;
* a day that reads back successfully but EMPTY writes an empty file and is
recorded as `"read": "OK", "samples": 0`. Empty-and-known and
unknown are therefore distinguishable both in the manifest and by the
presence or absence of the file;
* before any day is fetched, the entity is proved to exist through
/api/states/<entity>. Without that check a typo in --entity would return
200 + `[]` for all thirty days and produce a beautifully consistent
dataset of nothing.
2. GAPS ARE VISIBLE, NEVER INTERPOLATED. Nothing here carries a value forward,
fills, resamples or averages. A recorder outage or an HA restart shows up as
what it is: consecutive `t` values further apart than the meter's cadence.
The controller's own ingest already reads that correctly - `meter_max_age_s`
(30 s shipped) is exactly the age past which a held reading stops counting as
coverage - so an hour with no telegrams replays as an hour of NO COVERAGE
rather than as an hour of whatever the last sample said. The manifest lists
every gap over --gap-s so a reader does not have to diff timestamps to find
them.
DAY AT A TIME, AND WHY NOT SMALLER
==================================
One request per local day. That is gentle on a live HA (~1 MB of JSON per day)
and it is also the only chunking that reproduces the committed files: the
history API prepends the state as it stood AT the period start
(`include_start_time_state`), stamped with the start instant. Splitting a day
into six-hour chunks would therefore inject three synthetic boundary samples per
day that no meter ever sent. Days are the natural boundary because the committed
files are local days.
VERIFYING THE EXPORTER
======================
python3 tools/export_p1_history.py --verify scenarios/p1-net-2026-08-20.jsonl.gz
re-fetches that day by this exact route and compares the DECOMPRESSED bytes with
the committed file. Byte equality of the .gz containers is not expected and not
required: a gzip header carries an mtime, and this file writes mtime 0 so its
own output is reproducible. Content equality is the claim that matters.
"""
from __future__ import annotations
import argparse
import gzip
import hashlib
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
try: # py3.9+; the add-on image has it
from zoneinfo import ZoneInfo
except ImportError: # pragma: no cover
ZoneInfo = None # type: ignore
DEFAULT_URL = "http://192.168.2.158:8123"
DEFAULT_ENTITY = "sensor.p1_meter_active_power"
DEFAULT_TZ = "Europe/Brussels"
# The shipped meter_max_age_s. A hole longer than this is a hole the controller
# itself would refuse to see through, which makes it the honest gap threshold.
DEFAULT_GAP_S = 30.0
class ReadFailed(RuntimeError):
"""The read did not happen. NOT the same thing as an empty result."""
# ---------------------------------------------------------------------------
# transport
def _get(url: str, token: str, timeout: float):
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
})
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status != 200:
raise ReadFailed(f"HTTP {resp.status}")
return resp.read()
def fetch_json(url: str, token: str, *, timeout: float, retries: int,
backoff: float, log=print):
"""GET and parse, or raise ReadFailed. Never returns a partial answer."""
last = None
for attempt in range(1, retries + 1):
try:
raw = _get(url, token, timeout)
except urllib.error.HTTPError as exc:
last = f"HTTP {exc.code} {exc.reason}"
except Exception as exc: # URLError, timeout, ...
last = f"{type(exc).__name__}: {exc}"
else:
try:
return json.loads(raw.decode("utf-8")), len(raw), attempt
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
# A truncated body looks exactly like this. It is a failure.
last = f"unparseable body ({len(raw)} bytes): {exc}"
if attempt < retries:
wait = backoff * attempt
log(f" read failed ({last}); retry {attempt + 1}/{retries} "
f"in {wait:.0f} s")
time.sleep(wait)
raise ReadFailed(last or "unknown")
def assert_entity_exists(base: str, entity: str, token: str, *, timeout: float,
retries: int, backoff: float, log=print):
"""Prove the entity is real BEFORE trusting any empty day.
Without this, `--entity sensor.typo` answers 200 + [] for every day and the
exporter cheerfully writes thirty empty files.
"""
url = f"{base}/api/states/{urllib.parse.quote(entity)}"
doc, _, _ = fetch_json(url, token, timeout=timeout, retries=retries,
backoff=backoff, log=log)
if not isinstance(doc, dict) or doc.get("entity_id") != entity:
raise ReadFailed(f"{entity} is not a known entity")
return doc
# ---------------------------------------------------------------------------
# one day
def day_bounds(day: datetime, tz):
start = datetime(day.year, day.month, day.day, tzinfo=tz)
return start, start + timedelta(days=1)
def history_url(base: str, entity: str, start: datetime, end: datetime) -> str:
q = urllib.parse.urlencode({
"filter_entity_id": entity,
"end_time": end.isoformat(),
"minimal_response": "",
})
return (f"{base}/api/history/period/"
f"{urllib.parse.quote(start.isoformat())}?{q}")
def rows_from(doc, entity: str):
"""HA's shape -> [(iso_t, state_str)], plus the states that are not numbers.
`minimal_response` returns the first record in full and the rest as
{state, last_changed}. An entity that went unavailable reports the string
"unavailable" (or "unknown") where a number belongs. Those are dropped from
the numeric series - there is no float to write and the committed schema has
no slot for a non-number - but every one is returned here so it lands in the
manifest. Dropping them is what MAKES the outage visible: the series simply
has no samples across it, which is the same thing the meter falling silent
looks like, and is what the controller's freshness rule already handles.
"""
if not isinstance(doc, list):
raise ReadFailed(f"expected a list, got {type(doc).__name__}")
if not doc:
return [], []
series = doc[0]
if not isinstance(series, list):
raise ReadFailed("history series is not a list")
good, bad = [], []
for rec in series:
eid = rec.get("entity_id")
if eid is not None and eid != entity:
raise ReadFailed(f"unexpected entity {eid!r} in the response")
t = rec.get("last_changed") or rec.get("last_updated")
s = rec.get("state")
if t is None:
raise ReadFailed("a record carries no timestamp")
try:
w = float(s)
except (TypeError, ValueError):
bad.append({"t": t, "state": s})
continue
good.append((t, w))
return good, bad
def render(rows, *, newline: str) -> bytes:
"""The committed format, exactly: `{"t": <iso>, "w": <float>}` per line.
json.dumps' default separators (", " / ": ") and repr of a float are what
the two committed files contain; so is the CRLF, an artefact of the original
one-off having been run on Windows in text mode. It is reproduced rather
than tidied because reproducing the committed bytes is this exporter's
verification, and gzip.open(..., 'rt') strips it either way.
"""
out = bytearray()
for t, w in rows:
out += json.dumps({"t": t, "w": w}).encode("utf-8")
out += newline.encode("ascii")
return bytes(out)
def gz(payload: bytes) -> bytes:
"""gzip level 9, mtime 0 - so the same input always gives the same bytes."""
import io
buf = io.BytesIO()
with gzip.GzipFile(fileobj=buf, mode="wb", compresslevel=9, mtime=0) as f:
f.write(payload)
return buf.getvalue()
def gaps_in(rows, gap_s: float):
out = []
prev = None
for t, _ in rows:
cur = datetime.fromisoformat(t)
if prev is not None:
d = (cur - prev).total_seconds()
if d > gap_s:
out.append({"from": prev.isoformat(), "to": cur.isoformat(),
"seconds": round(d, 3)})
prev = cur
return out
def export_day(base, entity, token, day, tz, *, timeout, retries, backoff,
gap_s, newline, log=print):
"""Read one local day. Raises ReadFailed rather than returning a guess."""
start, end = day_bounds(day, tz)
doc, nbytes, attempts = fetch_json(
history_url(base, entity, start, end), token,
timeout=timeout, retries=retries, backoff=backoff, log=log)
rows, bad = rows_from(doc, entity)
entry = {
"date": day.date().isoformat(),
"window": [start.isoformat(), end.isoformat()],
"read": "OK",
"http_bytes": nbytes,
"attempts": attempts,
"samples": len(rows),
"non_numeric_states": bad,
"first": rows[0][0] if rows else None,
"last": rows[-1][0] if rows else None,
"gaps": gaps_in(rows, gap_s),
}
if rows:
span = (datetime.fromisoformat(rows[-1][0])
- datetime.fromisoformat(rows[0][0])).total_seconds()
entry["covered_span_s"] = round(span, 1)
entry["mean_cadence_s"] = round(span / max(1, len(rows) - 1), 3)
return rows, entry
# ---------------------------------------------------------------------------
# modes
def do_export(args, token, tz, log=print):
base = args.url.rstrip("/")
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
newline = "\r\n" if args.newline == "crlf" else "\n"
log(f" proving {args.entity} exists before trusting any empty day")
st = assert_entity_exists(base, args.entity, token, timeout=args.timeout,
retries=args.retries, backoff=args.backoff,
log=log)
log(f" {args.entity} = {st.get('state')} "
f"{st.get('attributes', {}).get('unit_of_measurement', '')}")
if args.end_date:
last = datetime.fromisoformat(args.end_date).replace(tzinfo=tz)
else: # yesterday: today is still being logged
last = datetime.now(tz).replace(hour=0, minute=0, second=0,
microsecond=0) - timedelta(days=1)
days = [last - timedelta(days=i) for i in range(args.days - 1, -1, -1)]
manifest = {
"generated": datetime.now(timezone.utc).isoformat(),
"generator": "tools/export_p1_history.py",
"source": f"{base}/api/history/period",
"entity": args.entity,
"timezone": args.tz,
"gap_threshold_s": args.gap_s,
"format": '{"t": <ISO-8601 UTC>, "w": <float watts>} per line, gzip',
"days": [],
}
failures = []
for day in days:
name = f"{args.prefix}{day.date().isoformat()}.jsonl.gz"
log(f" {name}")
try:
rows, entry = export_day(
base, args.entity, token, day, tz, timeout=args.timeout,
retries=args.retries, backoff=args.backoff, gap_s=args.gap_s,
newline=newline, log=log)
except ReadFailed as exc:
# NO FILE. A missing file is the honest record of a read that did
# not happen; an empty one would claim the meter was silent.
entry = {"date": day.date().isoformat(), "read": "FAILED",
"error": str(exc), "file": None}
manifest["days"].append(entry)
failures.append(entry)
log(f" READ FAILED: {exc} - no file written")
continue
payload = render(rows, newline=newline)
blob = gz(payload)
(out_dir / name).write_bytes(blob)
entry["file"] = name
entry["bytes_gz"] = len(blob)
entry["bytes_raw"] = len(payload)
entry["sha256_raw"] = hashlib.sha256(payload).hexdigest()
entry["sha256_gz"] = hashlib.sha256(blob).hexdigest()
manifest["days"].append(entry)
ng = len(entry.get("gaps", []))
log(f" {entry['samples']} samples, {len(blob)} B gz, "
f"{ng} gap(s) over {args.gap_s:g} s"
+ (f", {len(entry['non_numeric_states'])} non-numeric state(s)"
if entry["non_numeric_states"] else ""))
if day is not days[-1]:
time.sleep(args.delay)
ok = [d for d in manifest["days"] if d["read"] == "OK"]
manifest["summary"] = {
"days_requested": len(days),
"days_read": len(ok),
"days_failed": len(failures),
"samples": sum(d["samples"] for d in ok),
"bytes_gz": sum(d["bytes_gz"] for d in ok),
"empty_days": [d["date"] for d in ok if d["samples"] == 0],
}
# newline="\n" explicitly: write_text() on Windows emits CRLF, and
# the manifest would then differ from a Linux run of the same export
# for no reason visible in a diff.
with open(args.manifest or (out_dir / "manifest.json"), "w",
encoding="utf-8", newline="\n") as fh:
fh.write(json.dumps(manifest, indent=2) + "\n")
log(f" {manifest['summary']['days_read']}/{len(days)} days, "
f"{manifest['summary']['samples']} samples, "
f"{manifest['summary']['bytes_gz'] / 1e6:.2f} MB gz")
if failures:
log(f" {len(failures)} DAY(S) NOT READ - the dataset is incomplete "
f"and says so: " + ", ".join(d["date"] for d in failures))
return 1
return 0
def do_verify(args, token, tz, log=print):
"""Re-export a committed day by this route and diff it against the file."""
base = args.url.rstrip("/")
newline = "\r\n" if args.newline == "crlf" else "\n"
rc = 0
for target in args.verify:
path = Path(target)
stamp = path.name.split(".")[0].rsplit("-", 3)[-3:]
day = datetime(int(stamp[0]), int(stamp[1]), int(stamp[2]), tzinfo=tz)
log(f" {path.name} (local day {day.date().isoformat()})")
committed = gzip.decompress(path.read_bytes())
rows, entry = export_day(base, args.entity, token, day, tz,
timeout=args.timeout, retries=args.retries,
backoff=args.backoff, gap_s=args.gap_s,
newline=newline, log=log)
fresh = render(rows, newline=newline)
if fresh == committed:
log(f" IDENTICAL: {len(fresh)} bytes, {entry['samples']} "
f"samples, sha256 {hashlib.sha256(fresh).hexdigest()[:16]}")
continue
rc = 1
cl = committed.split(newline.encode())
fl = fresh.split(newline.encode())
log(f" DIFFERS: committed {len(committed)} B / {len(cl)} lines, "
f"re-export {len(fresh)} B / {len(fl)} lines")
shown = 0
for i in range(max(len(cl), len(fl))):
a = cl[i] if i < len(cl) else b"<absent>"
b = fl[i] if i < len(fl) else b"<absent>"
if a != b:
log(f" line {i}: committed {a!r}")
log(f" re-export {b!r}")
shown += 1
if shown >= args.max_diff:
log(f" ... stopping after {shown} differing lines")
break
return rc
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
p.add_argument("--url", default=DEFAULT_URL)
p.add_argument("--entity", default=DEFAULT_ENTITY)
p.add_argument("--token", help="bearer token; else $HASS_TOKEN")
p.add_argument("--tz", default=DEFAULT_TZ,
help="local timezone that defines a day boundary")
p.add_argument("--days", type=int, default=30)
p.add_argument("--end-date", help="last local day to export (YYYY-MM-DD); "
"default yesterday")
p.add_argument("--out-dir", default=".")
p.add_argument("--prefix", default="p1-net-")
p.add_argument("--manifest", help="path for the manifest JSON")
p.add_argument("--gap-s", type=float, default=DEFAULT_GAP_S)
p.add_argument("--newline", choices=("crlf", "lf"), default="crlf",
help="line terminator; crlf reproduces the committed files")
p.add_argument("--timeout", type=float, default=120.0)
p.add_argument("--retries", type=int, default=4)
p.add_argument("--backoff", type=float, default=5.0)
p.add_argument("--delay", type=float, default=2.0,
help="pause between days - this is a live HA")
p.add_argument("--verify", nargs="+", metavar="FILE",
help="re-export these committed days and diff them")
p.add_argument("--max-diff", type=int, default=20)
args = p.parse_args(argv)
token = args.token or __import__("os").environ.get("HASS_TOKEN")
if not token:
print("no token: pass --token or set HASS_TOKEN", file=sys.stderr)
return 2
if ZoneInfo is None:
print("zoneinfo unavailable", file=sys.stderr)
return 2
tz = ZoneInfo(args.tz)
try:
if args.verify:
return do_verify(args, token, tz)
return do_export(args, token, tz)
except ReadFailed as exc:
print(f"read failed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""Run TARIFF-01's and SAFETY-07's AC 3 over a directory of `p1-net-*.jsonl.gz`.
AC 3, both cards, same words: *"monthly peak held under the soft limit in
>= 95 % of intervals over a 30-day simulation replay."* Both cards merged with
it UNMET because two days of capture existed. DATA-01 exported thirty. This is
the thing that turns those thirty days into the number.
cd goodwe_controller
python3 tools/replay_ac3.py scenarios/p1-net-30d-2026-08
WHAT IT MEASURES
================
Two runs over the same telegrams, one file at a time in date order into a
SHARED peak store, so the 15-minute blocks accumulate into a real MONTH rather
than into 30 disconnected days:
UNCONTROLLED the recorded meter as it happened. Nothing was shaving peaks on
this house during August 2026, so this is not a simulation of
anything - it is what the month did. It is TARIFF-01's AC 3.
GUARDED the same month with `peakguard.decide()` in the loop and the
battery it asks for modelled, closed-loop: the meter the guard
sees is the recorded net MINUS the discharge it obtained. That
is SAFETY-07's AC 3.
Both numbers are `quarters <= peak_cap_w` over `quarters closed`, with the cap
read out of config.yaml rather than typed. Nothing here exits non-zero on 95 %:
a measurement below the criterion is a finding about the house and the guard,
and this tool's job is to report it, not to be graded by it.
FAITHFULNESS TO THE SUITE
=========================
The replay loop is `test_peakguard.py` part 6's, with `pack=None` (SoC pinned
above the floor, so SAFETY-24's energy floor is not the thing deciding the
answer). It is not imported from there because that file runs its whole suite on
import. The duplication is checked instead of asserted: pointed at `scenarios/`
- the two committed days - this tool must reproduce part 6's published totals,
and `--expect` makes that a hard check.
python3 tools/replay_ac3.py scenarios --expect 189/190
GAPS
====
Nothing is interpolated. A hole in the export is a hole here: the shipped
`meter_max_age_s` is what decides whether a held reading still counts as
coverage, so an hour with no telegrams closes no quarters and contributes to
neither numerator nor denominator. `--report-gaps` lists them.
"""
from __future__ import annotations
import argparse
import gzip
import json
import sys
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
HERE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(HERE))
from app.p1 import P1Ingest, make_sample, split_signed # noqa: E402
from app.peakguard import decide # noqa: E402
from app.tariff import PeakStore, PeakTracker, month_key # noqa: E402
def read_option(key: str):
"""The shipped default for `key`, out of config.yaml.
⚠️ `peak_cap_w:` appears TWICE in config.yaml - once under `options:` with
the default, once under `schema:` as `int(500,15000)`. The FIRST match is
the options block, which is the number an install actually lands on; taking
the last would try to float() the schema line. Same rule as the suites.
"""
for line in (HERE / "config.yaml").read_text(encoding="utf-8").splitlines():
if line.strip().startswith(f"{key}:"):
try:
return float(line.split(":", 1)[1].strip())
except ValueError:
continue
return None
def read_schema_max(key: str):
for line in (HERE / "config.yaml").read_text(encoding="utf-8").splitlines():
s = line.strip()
if s.startswith(f"{key}:") and "(" in s and ")" in s:
try:
return float(s[s.index("(") + 1:s.index(")")].split(",")[-1])
except ValueError:
continue
return None
def sample(t: datetime, net_w: float):
"""One telegram through the SHIPPED sign derivation - the suites' helper."""
imp, exp = split_signed(net_w)
return make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=t, ingest_mono=t.timestamp())
def rows(path):
"""Every sample in one file, in order, with the gaps left as gaps."""
with gzip.open(path, "rt", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
row = json.loads(line)
yield datetime.fromisoformat(row["t"]), float(row["w"])
def replay(paths, *, guarded: bool, cap_w, max_w, max_hold_s, cycle_s,
soc, soc_floor, tmp: Path, gap_s: float):
"""test_peakguard.py part 6's loop, over an arbitrary number of days.
⚠️ ONE STORE, AND AN INGEST THAT IS RESET ONLY AT A REAL DISCONTINUITY.
Neither half of that is a detail.
The STORE is shared across the files because "monthly peak" is what AC 3 is
about and a per-file store cannot express it.
The INGEST is carried across a file boundary when the next file's first
telegram follows the previous file's last one within `gap_s`, and rebuilt
when it does not. Both failure modes are real and were measured here:
* carried across a genuine hole, one averager manufactures quarters out of
nothing - streamed straight through the three-day gap between the two
committed days this reported 285/286 where the suite reports 189/190;
* rebuilt at every file, each day's LAST quarter (21:45-22:00 UTC) never
closes, because it closes only when a telegram past the boundary
arrives and that telegram is in the next file. That is why the suite's
two days give 95 quarters each rather than 96, and it silently discards
one quarter per day - 30 of them over a month.
So contiguous days are joined and non-contiguous ones are not, which gives
189/190 on the committed pair and a full 96 quarters a day on a run of
consecutive days.
"""
store = PeakStore(str(tmp / f"ac3-{'g' if guarded else 'u'}.db"))
tracker = PeakTracker(store, cap_w=cap_w)
engaged = blind = saturated = cycles = n = joins = 0
wh = 0.0
gaps = []
months = set()
ing = None
batt = 0.0 # delivered discharge, W (positive)
next_cycle = None
prev_t = None
for path in paths:
for i, (t, w) in enumerate(rows(path)):
if i == 0:
contiguous = (prev_t is not None
and 0 <= (t - prev_t).total_seconds() <= gap_s)
if contiguous:
joins += 1
else:
if ing is not None:
months |= {month_key(b.start) for b in ing.blocks}
ing = P1Ingest(phases=1, max_age_s=max_hold_s)
batt = 0.0
next_cycle = None
prev_t = None
if prev_t is not None:
dt = (t - prev_t).total_seconds()
if dt > gap_s:
gaps.append((prev_t, t, dt))
wh += batt * dt / 3600.0
prev_t = t
n += 1
ing.submit(sample(t, w - batt))
tracker.sync(ing)
if next_cycle is not None and t < next_cycle:
continue
next_cycle = t + timedelta(seconds=cycle_s)
cycles += 1
d = decide(PeakTracker.project(ing.averager, t), batt_w=batt,
cap_w=cap_w, reading=tracker.peak(t),
achievable_w=max_w, max_hold_s=max_hold_s, soc=soc,
soc_floor=soc_floor, frozen=False)
if d.engaged:
blind += 1 if d.blind else 0
engaged += 0 if d.blind else 1
saturated += 1 if d.saturated else 0
if not guarded:
continue
engaged_floor = d.engaged and d.floor_w is not None
batt = max(0.0, d.floor_w) if engaged_floor else 0.0
if ing is not None:
months |= {month_key(b.start) for b in ing.blocks}
out = {m: dict(store.blocks_in(m)) for m in sorted(months)}
return {"months": out, "samples": n, "cycles": cycles, "joins": joins,
"engaged": engaged, "blind": blind, "saturated": saturated,
"wh": wh, "gaps": gaps}
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
p.add_argument("directory", help="a directory of p1-net-*.jsonl.gz")
p.add_argument("--glob", default="p1-net-*.jsonl.gz")
p.add_argument("--expect", help="UNDER/TOTAL the uncontrolled run must "
"reproduce, e.g. 189/190")
p.add_argument("--report-gaps", action="store_true")
args = p.parse_args(argv)
paths = sorted(Path(args.directory).glob(args.glob))
if not paths:
print(f"no {args.glob} under {args.directory}", file=sys.stderr)
return 2
CAP_W = read_option("peak_cap_w")
MAX_HOLD_S = read_option("meter_max_age_s")
CYCLE_S = read_option("cycle_floor_s")
SOC_FLOOR = read_option("peak_soc_floor")
LIVE_MAX_W = read_schema_max("max_w")
if None in (CAP_W, MAX_HOLD_S, CYCLE_S, SOC_FLOOR, LIVE_MAX_W):
print("could not read the shipped options out of config.yaml",
file=sys.stderr)
return 2
# The suites' fixture SoC: comfortably above the floor, so SAFETY-24's
# energy floor is not what decides an AC 3 that is not about it.
SOC_OK = SOC_FLOOR + 30.0
days = len(paths)
span_first = span_last = None
print(f"AC 3 - {days} file(s) from {args.directory}")
print(f" shipped: peak_cap_w {CAP_W:.0f} W, meter_max_age_s "
f"{MAX_HOLD_S:.0f} s, cycle_floor_s {CYCLE_S:.0f} s, "
f"max_w schema ceiling {LIVE_MAX_W:.0f} W")
tmp = Path(tempfile.mkdtemp(prefix="goodwe-ac3-"))
kw = dict(cap_w=CAP_W, max_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
cycle_s=CYCLE_S, soc=SOC_OK, soc_floor=SOC_FLOOR, tmp=tmp,
gap_s=MAX_HOLD_S)
u = replay(paths, guarded=False, **kw)
g = replay(paths, guarded=True, **kw)
def flat(run):
return [w for m in run["months"].values() for w in m.values()]
uq, gq = flat(u), flat(g)
u_under = sum(1 for w in uq if w <= CAP_W)
g_under = sum(1 for w in gq if w <= CAP_W)
u_pct = 100.0 * u_under / max(len(uq), 1)
g_pct = 100.0 * g_under / max(len(gq), 1)
print(f" {u['samples']} samples, {len(uq)} closed quarters "
f"({len(uq) / 96.0:.1f} days' worth), {days - u['joins']} contiguous "
f"run(s) of days")
for m in sorted(u["months"]):
mu = u["months"][m]
mg = g["months"].get(m, {})
print(f" month {m}: {len(mu)} quarters, peak "
f"{max(mu.values()):.1f} W uncontrolled -> "
f"{max(mg.values()) if mg else 0.0:.1f} W guarded")
print()
print(f" TARIFF-01 AC 3 (UNCONTROLLED, what the month did): "
f"{u_under}/{len(uq)} = {u_pct:.2f} % of quarters at or under "
f"{CAP_W:.0f} W")
print(f" SAFETY-07 AC 3 (GUARDED, peakguard.decide in the loop): "
f"{g_under}/{len(gq)} = {g_pct:.2f} %")
print(f" guard cycles {g['cycles']}, engaged on the measurement "
f"{g['engaged']}, blind {g['blind']}, saturated {g['saturated']}, "
f"modelled discharge {g['wh']:.0f} Wh")
# AC 3'S WORDS SUPPORT TWO READINGS AND BOTH ARE REPORTED, because they
# disagree on this data and picking the flattering one is the failure mode
# this whole card exists to correct.
u_peak = max(uq) if uq else 0.0
g_peak = max(gq) if gq else 0.0
print(f" reading A - PERCENTAGE OF INTERVALS under the cap (the reading "
f"test_tariff.py part 9 already uses): uncontrolled {u_pct:.2f} %, "
f"guarded {g_pct:.2f} %")
print(f" reading B - THE MONTHLY PEAK ITSELF under the cap: "
f"{u_peak:.1f} W uncontrolled -> {g_peak:.1f} W guarded, against a "
f"{CAP_W:.0f} W soft limit "
f"({'under' if g_peak <= CAP_W else 'STILL OVER'})")
print(f" verdict at the 95 % criterion: uncontrolled "
f"{'MEETS' if u_pct >= 95.0 else 'DOES NOT MEET'}, guarded "
f"{'MEETS' if g_pct >= 95.0 else 'DOES NOT MEET'} "
f"({len(uq) / 96.0:.1f} days of replay)")
if u["gaps"]:
tot = sum(d for _, _, d in u["gaps"])
print(f" gaps over {MAX_HOLD_S:.0f} s: {len(u['gaps'])}, "
f"{tot:.0f} s total - NOT interpolated; the quarters they fall in "
f"simply carry less coverage")
if args.report_gaps:
for a, b, d in u["gaps"]:
print(f" {a.isoformat()} -> {b.isoformat()} {d:.0f} s")
if args.expect:
want_u, want_t = (int(x) for x in args.expect.split("/"))
ok = (u_under, len(uq)) == (want_u, want_t)
print(f" reproduces test_peakguard.py part 6's published "
f"{want_u}/{want_t}: {'yes' if ok else 'NO'} "
f"(got {u_under}/{len(uq)})")
return 0 if ok else 1
return 0
if __name__ == "__main__":
raise SystemExit(main())