Skip to content

fix(forecast): train load models on closed history intervals - #1754

Merged
frahlg merged 5 commits into
masterfrom
fix/load-models-use-history-intervals
Oct 20, 2026
Merged

frahlg merged 5 commits into
masterfrom
fix/load-models-use-history-intervals

Conversation

@frahlg

@frahlg frahlg commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Problem

Core worked out household load three ways:

Use Evidence before this PR
Daily Home use and History Each source integrated separately into 1-minute energy, balanced per interval (history_source_energy)
Energyplan forecast labels Its own ForecastAccumulator: 15-minute trapezoid means of aligned instant readings, any invalid instant drops the quarter
Built-in load model (loadmodel, mpc.Load) One instant HouseholdW reading per minute

So the models learned a different quantity from the Home use people see, and the built-in model learned from single instant readings. Fredrik asked for one common basis.

Two layers

Fredrik set the direction on 19 October: Home use has two layers.

  • Display (live and History, daily Home use) is lenient: a simple residual of grid, PV, battery and EV, with gaps bridged as well as possible and marked as estimates.
  • Training (the load models behind forecasts) is strict: only intervals measured in full.

The contract between them is the history_quality mask. Anything the display layer bridges or estimates carries EstimatedLoad; training leaves out every interval with MissingPower, BalanceMismatch or EstimatedLoad. PV at 0 W while the sun is well below the horizon is physics, not an estimate, so a PV inverter asleep all night must not remove the night from training (being settled in #1755).

Change

  • state.HouseholdIntervals returns closed, fully measured intervals from the same source energy as daily totals. An interval is closed once committed source progress passes its end, so a returned value never changes. Intervals with a missing source, a recording gap, an estimate or a negative balance are left out. It also returns where the closed range ends, so callers move past intervals left out.
  • loadmodel.Service trains once per closed quarter-hour on its mean instead of one instant reading per minute. The model already averages within a day per weekly hour, so the hourly means keep their meaning. Cold-day counters keep the minute units used by stored models: each new quarter adds 15 minute units to the count and both sums. This preserves partial-day evidence and the six-hour bound without resetting learning. It persists after each trained quarter.
  • The Energyplan tracker takes load labels from the same quarters (Quality: source_interval_balance_v1) and drops its household accumulator. PV keeps its accumulator, since PV labels need the curtailment flag. Observations are immutable per quarter, so PV waits until history decides the quarter (at most 2 minutes), and each quarter is archived once.
  • Both start at the next whole quarter after start or reconfiguration, so a quarter observed under an earlier revision never becomes a label.

Model state, feature hash and learning revisions are unchanged, so no site loses learning.

This follows docs/roadmap.md: household Load history balances source energy over the same interval, and every use is checked against the same stored evidence.

Relation to other work

Evidence

  • go test ./internal/state ./internal/loadmodel ./cmd/ftw ./internal/api ./internal/telemetry: all pass.
  • New tests:
    • TestHouseholdIntervalsMatchDailyTotal: the sum of training intervals equals DailyEnergy.LoadWh.
    • TestHouseholdIntervalsLeaveOutIncompleteEvidence: missing EV, a 3-minute recording gap and a negative balance give no label; neighbours keep theirs.
    • TestHouseholdIntervalsWaitForCommittedProgress: an open quarter is never returned.
    • TestTrainsOnceOnEachClosedValidHouseholdInterval, TestLearningCutoffSkipsIntervalsStartedBeforeIt, TestIntervalReadInFlightIsDiscarded for the built-in model.
    • TestForecastPVWaitsForTheHouseholdDecision, reworked TestForecastConfigChangeBreaksInterval and TestForecastIndependentPVIntervalsAndCurtailment for the tracker. Readings carry a wrong instant house value; labels must come from history.
  • Removed tests that asserted per-minute telemetry sampling in loadmodel; history now owns those validity rules and the state tests cover them.
  • TestRestoredMinuteHeatingBoundsKeepLearning: restores both learned profiles and the active profile unchanged from minute-based state, persists and reloads them after a new quarter, rejects 30 old minutes as a full cold day, and accepts 345 old minutes plus one new quarter as exactly six hours. It fails before the fix and passes after it.
  • go test -race ./internal/loadmodel: passed.
  • make verify: passed (vet, tests, build).
  • TestHouseholdIntervalsLeaveOutEstimatedMinutes: an estimated minute keeps its quarter out of training while daily Home use still shows it.
  • Codex found that cold-day heating counters on disk are in minutes; 44297c4 keeps that unit (a quarter counts 15 minutes), so an update in the middle of a cold day cannot close it after half an hour.
  • Home box (Fredrik's Pi 4): test builds from 18:08 and 19:27 on 19 October trained the built-in model and Energyplan once per closed quarter (e.g. 19:30–19:45 at 19:45:21 and 19:46:01), with no label or archive errors.
  • Simulation of 70 days with daily faults (restarts, unknown PV/EV/battery/meter, meter lag, bad values), same seed: labels 87.4 % here vs 86.7 % for the old collector, mean label error 3.3 W vs 4.2 W, load forecast error unchanged (185 vs 184 W).

Watch after the beta: loadmodel sample counts grow per quarter, and the Energyplan learning status stays healthy.

🤖 Generated with Claude Code

frahlg and others added 4 commits October 19, 2026 17:57
Core computed household load three ways: history balanced per-source
energy per interval, the Energyplan tracker kept its own quarter-hour
accumulator, and the built-in load model learned from one instant
reading per minute. The models learned a different quantity from the
Home use shown in History.

Add state.HouseholdIntervals, which returns closed, valid intervals
from the same source energy as daily totals. The built-in model trains
once per closed quarter on its mean, and the tracker takes its load
labels from the same quarters. PV keeps its accumulator for the
curtailment flag and waits until history decides the quarter, so each
immutable observation is written once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Short source gaps may become estimated history once #1753 lands, and
then a training label. Keep these tests about gaps that stay unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
History may show Home use across a bridged or estimated gap, but a load
model must not learn from it. Leave out intervals with EstimatedLoad or
BalanceMismatch as well as a missing source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 19, 2026
Load models train only on fully measured intervals (#1754). Marking every
dark PV snapshot as an estimate would drop each night with a sleeping
inverter from training.

Past civil dusk (zenith over 96°) no panel produces, so an unknown PV
source now reads as a known 0 W without EstimatedLoad: the night counts
in Home use and in training. Between sunset and civil dusk diffuse light
remains, and the 0 W stays a marked estimate. No new bit records the
physical zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 19, 2026
Load models train only on fully measured intervals (#1754). Marking every
dark PV snapshot as an estimate would drop each night with a sleeping
inverter from training.

Past civil dusk (zenith over 96°) no panel produces, so an unknown PV
source now reads as a known 0 W without EstimatedLoad: the night counts
in Home use and in training. Between sunset and civil dusk diffuse light
remains, and the 0 W stays a marked estimate. No new bit records the
physical zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 19, 2026
Load models train only on fully measured intervals (#1754). Marking every
dark PV snapshot as an estimate would drop each night with a sleeping
inverter from training.

Past civil dusk (zenith over 96°) no panel produces, so an unknown PV
source now reads as a known 0 W without EstimatedLoad: the night counts
in Home use and in training. Between sunset and civil dusk diffuse light
remains, and the 0 W stays a marked estimate. No new bit records the
physical zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg marked this pull request as ready for review October 20, 2026 04:33
@frahlg
frahlg merged commit 85fa9e1 into master Oct 20, 2026
23 checks passed
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T04:35:58.724608Z fe42b90 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

frahlg added a commit that referenced this pull request Oct 20, 2026
Load models train only on fully measured intervals (#1754). Marking every
dark PV snapshot as an estimate would drop each night with a sleeping
inverter from training.

Past civil dusk (zenith over 96°) no panel produces, so an unknown PV
source now reads as a known 0 W without EstimatedLoad: the night counts
in Home use and in training. Between sunset and civil dusk diffuse light
remains, and the 0 W stays a marked estimate. No new bit records the
physical zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 20, 2026
* fix(history): count unknown PV as 0 W after sunset

A PV inverter that sleeps at night is the most common remaining reason
for a day without Home use. Göran's SMA was silent for 145 s at 02:22 on
19 October with 0 W on both sides, longer than the 2-minute bridge.

While the sun is below the horizon at the site's map pin, Core now
records an unknown PV source as 0 W, marked EstimatedLoad, never as a
measurement. An inverter the watchdog took offline keeps its device in
the history topology, so the night is not cut out. The source writer
carries the marker into every interval an estimated snapshot bounds.

Without a pin (unset, 0,0 or the untouched default) nothing changes.
By day the gap stays unknown. Grid, battery and EV are not affected.

Relates to #1750.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(history): count PV past civil dusk as measured 0 W

Load models train only on fully measured intervals (#1754). Marking every
dark PV snapshot as an estimate would drop each night with a sleeping
inverter from training.

Past civil dusk (zenith over 96°) no panel produces, so an unknown PV
source now reads as a known 0 W without EstimatedLoad: the night counts
in Home use and in training. Between sunset and civil dusk diffuse light
remains, and the 0 W stays a marked estimate. No new bit records the
physical zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(history): a full night with a sleeping inverter stays measured

Rebased on the rewritten #1753, whose writer marks an interval as an
estimate at an unknown source, a stop, a long gap or a topology change.
A night past civil dusk therefore trains only if every snapshot is fully
known and the sleeping inverter keeps its device in the topology.

The devices line moves into historyDevices so the test drives the same
code as persistTelemetryTick. The test runs eight hours of a sleeping
inverter through it, with and without a map pin. TestDarkPVByTimeOfDay
now also checks that PV reads 0 W and that Load stays unknown for any
cause but PV; it replaces the two-hour series test, whose day-level
outcome the new writer decides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(history): let the dark rule touch only PV

#1753 (85100d6) no longer marks Load unknown when a single source fails,
so darkPV need not clear it. It now changes only the PV flow and its
marker; a configuration fault keeps Load unknown on its own. The night
test without a pin checks Home use again: the writer holds PV's last
value as an estimate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(telemetry): name offline drivers through a health-gated accessor

historyDevices read cached PV readings directly to find a sleeping
inverter. Move the health check into telemetry.OfflineDrivers, which
returns only driver names, so no caller handles a stale cached value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 20, 2026
History leaves an optional PV source out of Home use when it has never
reported, as a hybrid may have no panels. The forecast requires it, and
both load models now train on history intervals, so 2 kW of unseen PV
could become a 1 kW label for a 3 kW house (Codex review of #1754).

History options now carry the optional flows. While one has no live
reading, the snapshot is marked EstimatedLoad: Home use is unchanged
and training skips it. Past civil dusk the PV is 0 W by physics, so the
night still trains. Forecast revisions are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant