Step-by-step instructions for collecting data from a live Home Assistant install and replaying it through the planner. For why the harness works the way it does, see Planner Backtest Harness.
Commands run from the repository root unless stated otherwise.
The actuals come from the recorder, and 15-minute detail is only kept for
purge_keep_days (default 10). In configuration.yaml:
recorder:
purge_keep_days: 45 # or more — anything older is gone for goodIn configuration.yaml:
notify:
- platform: file
name: hsem_corpus
filename: hsem-corpus.jsonl
timestamp: falseAnd an automation:
automation:
- alias: HSEM backtest corpus
triggers:
- trigger: time_pattern
minutes: "/5" # match your HSEM update interval
actions:
- action: hsem.export_diagnostics
response_variable: dump
- action: notify.hsem_corpus
data:
message: "{{ dump | to_json }}"Restart Home Assistant. Two things that look right but are not:
- Call
notify.hsem_corpusas a service. The YAMLnotify:platform creates no entity, sonotify.send_messagewithtarget.entity_idsilently writes nothing. - Trigger on a time pattern. The working-mode sensor only changes when the recommendation changes, so a state trigger skips the stable stretches.
Confirm it is writing — about 12 lines an hour at 5-minute cycles:
wc -l /config/hsem-corpus.jsonlBudget about 120 KB per cycle, roughly 35 MB a day.
cp .env.example .env
chmod 600 .envThen edit .env. At minimum:
HA_URL=http://homeassistant.local:8123
HA_TOKEN=<long-lived access token>
TZ=Europe/Copenhagen
HSEM_BACKTEST_SITE=site-a
HSEM_ARCHIVE_ENTITIES=sensor.my_ev_charger_power,sensor.my_ev_soc,select.batteries_working_modeCreate the token under your HA profile → Security → Long-lived access
tokens. .env is gitignored; only .env.example is committed. The script
parses the file rather than sourcing it, so the token needs no escaping, and
anything you export in the shell overrides it. It warns if the file is readable
by other users.
To have the corpus copied off Home Assistant automatically, add the SSH details too (the SSH add-on's port; key-based login avoids a password prompt):
HA_SSH_HOST=homeassistant.local
HA_SSH_PORT=22
HA_SSH_USER=root
HA_CORPUS_PATH=/config/hsem-corpus.jsonlTo keep one .env for several checkouts, put it anywhere and point at it:
export HSEM_ENV_FILE=~/hsem-actuals/.envHSEM_BACKTEST_SITE says which installation the collection is from. It is
written to every committed cycle and actuals file, and a plan is only compared
with realized values that carry the same tag (issue #1225). The label ends up
in a public repository, so keep it meaningless: lower-case letters, digits and
hyphens, for example site-a. If you collect from a second installation, give
it its own .env with another tag. Without a tag the run still backtests every
cycle but commits nothing.
TZ must be Home Assistant's time zone, or every day boundary shifts.
HSEM_ARCHIVE_ENTITIES are downloaded but not converted — the recorder purges,
so anything you might want later has to be fetched now.
./scripts/backtest_update.shThat is the whole routine. It runs five steps and prints a summary:
- Copies the live corpus off Home Assistant with
scp(skipped whenHA_SSH_HOSTis empty — then copyhsem-corpus.jsonlinto~/hsem-actuals/corpus/yourself). - Collects actuals for the last 8 days (
collect_actuals.sh). - Backtests every new cycle against the planner spec and harvests the
ones worth keeping into
tests/backtest/corpus/, plus actuals for the days they cover intotests/backtest/actuals/. - Runs the backtest suite over the committed corpus.
- Scores every complete day of the actuals it has: what was paid, what a perfect-foresight oracle would have paid, and the share of the day's potential that was captured.
It never commits to git. The summary lists new files; review and commit them:
git add tests/backtest/corpus tests/backtest/actuals
git commit -m "test(backtest): add corpus cycles from <dates>"Run it as often as you like — weekly is plenty. Only cycles newer than the last
run are replayed (the resume point is ~/hsem-actuals/corpus/.harvested-until),
so each run takes minutes, not hours.
| Option | Use |
|---|---|
--dry-run |
Show what would be added; write nothing to the repository. |
--skip-fetch |
Use what is on disk: no scp, no Home Assistant calls. |
--all |
Replay the whole live corpus, ignoring the resume point. |
--days N |
Days of actuals to fetch (default 8). |
--max-new N |
New cycles per run (default 10). |
--max-corpus N |
Committed cycles in total (default 50). |
Only cycles that cover a new situation. Most cycles repeat one already in the corpus: 340 real cycles contained just 8 distinct situations. A situation is which plan won, which operating modes it uses, the starting SoC band (quartiles), and whether it involves EV charging, negative prices or a DST change. So the corpus grows by breadth, and the rare cases — negative prices, DST days, EV sessions — get added the first time they happen.
Only what the harness reads. A committed cycle keeps planner_input, the
version, timestamp and apply_result — about 44 KB instead of 135. It must
round-trip losslessly and contain no entity id. Dumps from before #1169 get the
site's TZ filled into time_zone.
Actuals for the days those cycles cover, one file per day, and only when
every energy series is complete. A day with a recorder gap is reported as
incomplete in the export and left out rather than committed short. Only
cycles with the site tag of the actuals count: a committed cycle from another
installation never pulls in a day of yours.
The site tag. Every committed cycle and actuals day carries
HSEM_BACKTEST_SITE as site. Actuals built before you set it carry none and
are not committed; collect_actuals.sh rebuilds actuals.json on every run,
so the next run fixes that.
Caps. Each committed cycle is replayed by every test run, so the corpus is capped at 50 cycles and 10 per run. When a cap stops a new situation, the report says so; raise the cap deliberately.
The run reports it, copies the cycle to ~/hsem-actuals/quarantine/, and exits
non-zero. It is not committed — that would turn CI red before the bug is
fixed. Replay it with scripts/replay_planner_input.py, and attach it to an
issue.
Replays lift production's solver time limit (2 s by default):
planner_input_from_dict() sets planner_solver_time_limit_s to 60 s. A real cycle can take
1.5 s to solve on an idle machine; on a busy one the same input returns a worse,
time-limited plan and fails an invariant for no reason in the planner's logic.
The backtest checks what the planner decides, not how fast the solver is.
Each step is a script you can run alone:
./scripts/collect_actuals.sh --days 8 --verify # actuals + price check
python3 scripts/backtest_harvest.py --dry-run # backtest + harvest
python3 scripts/backtest_corpus.py ~/hsem-actuals/corpus # replay everything
HSEM_BACKTEST_CORPUS=~/hsem-actuals/corpus python -m pytest tests/backtest/ -q
python3 scripts/backtest_score.py ~/hsem-actuals/actuals.json # score the daysbacktest_corpus.py replays every cycle regardless of the resume point and
names the first cycle for each violated invariant. collect_actuals.sh --verify also cross-checks recorded prices: systematic offset means stop —
a fee differs between what the planner used and what was recorded.
python3 scripts/backtest_score.py ~/hsem-actuals/actuals.jsonday realized oracle regret potential savings capture
2026-09-15 13.45 10.19 3.26 9.32 6.05 65.0%
Add --battery-flows soc to take the battery's flows from its SoC instead of
its energy meters. That is what the on-device
performance sensors do, so it is
the mode to use when comparing a day with them.
| Column | Meaning |
|---|---|
realized |
What the day cost: import paid minus export earned, at the recorded prices. |
oracle |
The cheapest it could have cost with perfect foresight, ending the day at least as full. |
regret |
realized − oracle. Never negative. |
potential |
What the best control could have saved over plain self-consumption. |
savings |
potential − regret: what HSEM saved over plain self-consumption. |
capture |
savings ÷ potential. Negative when the day went worse than self-consumption. |
The battery and grid limits come from a recorded planner input: by default the
newest committed cycle with the site tag of the actuals. Score a collection
from an installation that has no committed cycle with
--site path/to/its/dump.json; a dump straight from Home Assistant carries no
tag and is taken to be from HSEM_BACKTEST_SITE. A dump tagged for another
installation is refused. The time zone is read from the actuals files, then
from that dump; --tz overrides both.
Stage 2b explains what each number compares and why the end-of-day battery level is part of it.
python3 scripts/backtest_attribute.py ~/hsem-actuals/actuals.json \
--corpus ~/hsem-actuals/corpus --day 2026-09-29 --day 2026-09-30day realized forecast hindsight regret = execution + forecast + planner
2026-09-29 15.29 11.67 10.42 2.29 0.79 1.25 0.26
Without arguments the script attributes the recorded day committed in
tests/backtest/days/, which is the line above: no live system needed.
| Column | Meaning |
|---|---|
forecast (cost) |
Cost of replaying the day on the forecasts HSEM recorded. |
hindsight |
Cost of replaying it with the realized prices, PV and load in their place. |
execution |
Regret that comes from the real system not doing what the replay does. |
forecast (the split) |
Regret that better forecasts would have removed. |
planner |
Regret the planner keeps even with perfect inputs. |
Cycles and actuals must be from the same installation. The live corpus carries
no site tags, so its cycles are taken to be from HSEM_BACKTEST_SITE, the tag
your actuals were built with; cycles tagged otherwise are ignored.
A day of your own needs the live corpus: a cycle in at least half of the day's
slots. Pick a day from the scoring table with a high regret. About half a
minute per day.
The costs of the two replays are not comparable with each other or with
realized — the runs end the day with different stored energy — which is why
the split is in regret. --tz is needed when the cycles record no time zone
(builds before 7.0.0).
Stage 2c explains the replay and its limits.
| Output | Meaning | Action |
|---|---|---|
invariants: none violated |
The planner held the spec on every real cycle. | None. |
<invariant>: N |
A real input broke a spec rule. | Replay the named cycle with scripts/replay_planner_input.py and file an issue. This is what the harness is for. |
fidelity: … <field>: N cycle(s) |
The dumps predate or outlive a PlannerInput field. |
Expected after a planner change. The replay still runs, with that field at its default — see below. |
corrupt dump in the middle of the corpus |
A line other than the last is not valid JSON. | Lost data; the line number is in the message. |
HSEM_BACKTEST_CORPUS=… is not a directory |
The path is wrong or not created yet. | Create it and copy the corpus in. |
no price series hint |
The price sensors were added after these days were downloaded. | Re-run collect_actuals.sh with --refresh. |
<day> not scored: <series> missing in N/96 slot(s) |
The recorder has a gap on that day. | None. The day is left out; missing is never read as zero. |
note: measured battery flows leave the configured capacity by … |
The limits do not describe this battery: --site is from another installation, or the configured capacity or efficiency is far off. |
Score with a dump from the right installation. Small drifts are expected and not reported. |
capture is unknown |
No control could have saved anything that day. | None. |
<day> not attributed: only N of 96 slot(s) have a planner cycle recorded in them |
The corpus automation was not running for most of that day. | None. Attribute another day. |
note: realized values cover N % of the planning horizons |
The plans reach past the last recorded day. | Expected for the newest day; re-run once the following day's actuals exist. |
To make another day's attribution reproducible from the repository:
python3 scripts/backtest_harvest.py --day 2026-09-29 --dry-run # checks only
python3 scripts/backtest_harvest.py --day 2026-09-29It writes tests/backtest/days/<date>/ from the live corpus and
~/hsem-actuals/actuals.json: one slim cycle per slot and the actuals of the
day and of the following day. It needs TZ and HSEM_BACKTEST_SITE, a cycle
in at least half of the day's slots, and complete actuals for both days, so
the earliest day you can record is the day before yesterday. It refuses a day
that fails a check and says why.
It never commits. Read the three files first: about 2.5 MB of one home's data
for two days. Then add the day's numbers to tests/backtest/test_recorded_day.py
if they should be pinned. See
Recorded days.
A new PlannerInput field makes older dumps report it under fidelity, and
the pytest corpus tests fail on the committed cycles. Both are deliberate.
- The committed corpus: run
python3 scripts/backtest_harvest.py --refresh-corpusand commit the result with the change that added the field. It fills the new field with its default and lists what it filled — check that each default means "the feature did not exist yet".tests/backtest/corpus/README.mdexplains the one case so far where it did not (time_zone, #1169). - Recorded days: the same command refreshes
tests/backtest/days/too. When a planner change moves the committed day's attribution,test_recorded_day.pyfails on its pinned numbers: runpython3 scripts/backtest_attribute.py, check the change is the one you intended, and update them. - Your live corpus: nothing to do. New dumps carry the field once Home Assistant runs the new build; older ones replay with its default.
| Symptom | Cause |
|---|---|
hsem-corpus.jsonl never appears |
The automation targets notify.send_message with an entity. Call notify.hsem_corpus as a service. |
Automation error Type is not JSON serializable: numpy.float64 |
An HSEM build without the export_diagnostics serialisation fix (PR #1093). Update HSEM. |
First line of the corpus is Home Assistant notifications (Log started: …) |
Normal — the file notifier's header. It is skipped. |
[error] <day> failed from collect_actuals.sh |
Wrong HA_URL or HA_TOKEN, or the recorder no longer holds that day. |
not added: no site tag (set HSEM_BACKTEST_SITE) |
.env has no HSEM_BACKTEST_SITE. Set it; the next run commits the cycles. |
no actuals day committed / no committed cycle is from the installation of the actuals |
actuals.json was built before the tag was set. Re-run collect_actuals.sh (or the whole update). |