Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/caution-off-visible.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

When the planning style asks for forecast caution and the plan has to use the point forecast instead, the plan now says so: the box page shows "Caution off" and the app's plan message carries `cautionOff`. It clears once caution works again. A cautious plan is no longer refused just because the car charges while the battery discharges: dispatch already keeps the battery off a protected car. With a car plugged in, an accepted cautious plan now takes one Energyplan solve instead of two; a refused one still takes two. The forecast archive no longer credits the live meter's current load to Core's recovery, and a lasting caution fault no longer rewrites its failure record on every replan.
6 changes: 3 additions & 3 deletions go/cmd/ftw/forecast_archive_retry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func TestForecastArchiveSurvivesWriterContention(t *testing.T) {
f := trackerFixture(at)
f.store = st
slots := trackerSlots(at.Add(time.Minute), 193)
f.Snapshot(at, trackerWeather(at.Add(time.Minute), at)).Record(slots, slots, "contended-decision", at.UnixMilli())
f.Snapshot(at, trackerWeather(at.Add(time.Minute), at)).Record(slots, slots, nil, "contended-decision", at.UnixMilli())
frozen := <-f.queue
f.queue <- frozen
workerCtx, stop := context.WithCancel(ctx)
Expand All @@ -73,7 +73,7 @@ func TestForecastArchiveSurvivesWriterContention(t *testing.T) {
if _, err := conn.ExecContext(ctx, "ROLLBACK"); err != nil {
t.Fatal(err)
}
f.Snapshot(at.Add(time.Second), trackerWeather(at.Add(time.Minute), at)).Record(slots, slots, "next-decision", at.Add(time.Second).UnixMilli())
f.Snapshot(at.Add(time.Second), trackerWeather(at.Add(time.Minute), at)).Record(slots, slots, nil, "next-decision", at.Add(time.Second).UnixMilli())

deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
Expand Down Expand Up @@ -128,7 +128,7 @@ func TestForecastArchiveRetryPreservesFrozenIssue(t *testing.T) {
at := time.Now().UTC().Truncate(time.Minute)
f := trackerFixture(at)
slots := trackerSlots(at.Add(time.Minute), 193)
f.Snapshot(at, trackerWeather(at.Add(time.Minute), at)).Record(slots, slots, "frozen-decision", at.UnixMilli())
f.Snapshot(at, trackerWeather(at.Add(time.Minute), at)).Record(slots, slots, nil, "frozen-decision", at.UnixMilli())
frozen := (<-f.queue).issue
calls := 0
err = saveForecastIssueWithRetry(ctx, frozen, func(writeCtx context.Context, issue forecasting.Issue) error {
Expand Down
2 changes: 1 addition & 1 deletion go/cmd/ftw/forecast_horizon_native_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func TestForecastPrimaryNativeFullHorizonArchivesEveryInterval(t *testing.T) {
t.Fatalf("cold load replaced site prior at interval %d of %d", i, tc.count)
}
}
inputs.Record(resolved, resolved, "full-horizon-"+tc.name, origin.Add(time.Second).UnixMilli())
inputs.Record(resolved, resolved, nil, "full-horizon-"+tc.name, origin.Add(time.Second).UnixMilli())
issue := (<-tracker.queue).issue
if err := issue.Validate(); err != nil {
t.Fatalf("composed forecast rejected: %v", err)
Expand Down
63 changes: 63 additions & 0 deletions go/cmd/ftw/forecast_live_archive_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package main

import (
"context"
"path/filepath"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/mpc"
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/telemetry"
)

// The live meter replaces the in-flight slot's load on every replan. That is
// a measurement, not Core's recovery, so the archive keeps the model's source
// for it and for every later slot whose forecast passed unchanged.
func TestForecastArchiveKeepsModelSourceUnderLiveLoad(t *testing.T) {
at := time.Now().UTC().Truncate(15 * time.Minute)
f := primaryFixture(at, primaryReply)
st, err := state.Open(filepath.Join(t.TempDir(), "plan.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
var prices []state.PricePoint
for i := 0; i < 8; i++ {
prices = append(prices, state.PricePoint{Zone: "SE3", SlotTsMs: at.Add(time.Duration(i) * 15 * time.Minute).UnixMilli(),
SlotLenMin: 15, SpotOreKwh: 50, TotalOreKwh: 100 + float64(i)*10, Source: "test", FetchedAtMs: at.UnixMilli()})
}
if err := st.SavePrices(prices); err != nil {
t.Fatal(err)
}
svc := mpc.New(st, nil, "SE3", mpc.Params{Mode: mpc.ModeSelfConsumption, SoCLevels: 11, ActionLevels: 5,
CapacityWh: 10000, InitialSoC: .5, SoCMin: .1, SoCMax: .9, MaxChargeW: 3000, MaxDischargeW: 3000,
ChargeEfficiency: .95, DischargeEfficiency: .95})
svc.BaseLoad = 500
svc.ForecastSnapshot = f.Snapshot
svc.HouseholdMeasurement = func() telemetry.ForecastReading {
return telemetry.ForecastReading{HouseholdW: 2345, Valid: true}
}
if plan := svc.Replan(context.Background()); plan == nil {
t.Fatalf("no plan: %+v", svc.PlanSnapshot())
}
issue := (<-f.queue).issue
points := primarySeries(t, issue, "champion").Points
if len(points) < 4 {
t.Fatalf("short archive: %d points", len(points))
}
if points[0].LoadW != 2345 {
t.Fatalf("slot 0 load %g, want the live 2345 W", points[0].LoadW)
}
for i, p := range points[:4] {
if p.LoadSource != "energyplan" || p.ModelLoad == nil || p.LoadQuality == "fallback" {
t.Fatalf("slot %d attributed to recovery: source=%q quality=%q model=%v", i, p.LoadSource, p.LoadQuality, p.ModelLoad)
}
}
if points[1].LoadW == points[2].LoadW {
t.Fatalf("fixture loads do not vary: %+v", points[:3])
}
if err := issue.Validate(); err != nil {
t.Fatal(err)
}
}
4 changes: 2 additions & 2 deletions go/cmd/ftw/forecast_load_risk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestForecastPrimaryLoadQualityKeepsSitePriorUntilLearning(t *testing.T) {
if got[0].LoadW != want || got[0].PVW != -100 {
t.Fatalf("load selection changed the site prior or independent PV: %+v", got)
}
in.Record(got, got, "load-quality", at.UnixMilli())
in.Record(got, got, nil, "load-quality", at.UnixMilli())
issue := (<-f.queue).issue
point := primarySeries(t, issue, "champion").Points[0]
if point.LoadW != want || point.LoadSource != source {
Expand Down Expand Up @@ -135,7 +135,7 @@ func TestForecastLoadRiskBeforeNetCalibration(t *testing.T) {
if !reflect.DeepEqual(base, unchanged) {
t.Fatal("risk changed the issued forecast")
}
in.Record(base, planning, "load-risk", at.UnixMilli())
in.Record(base, planning, nil, "load-risk", at.UnixMilli())
issue := (<-f.queue).issue
if primarySeries(t, issue, "champion").Points[0].LoadW != 1000 || primarySeries(t, issue, "planning").Points[0].LoadW != tc.wantLoad {
t.Fatal("archive lost the difference between forecast and planning margin")
Expand Down
2 changes: 1 addition & 1 deletion go/cmd/ftw/forecast_occupancy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func TestForecastOccupancySnapshotSurvivesIntentChange(t *testing.T) {
inputs := tracker.Snapshot(at, trackerWeather(at, at))
away = false
slots := trackerSlots(at, 4)
inputs.Record(slots, slots, "occupancy-frozen", at.UnixMilli())
inputs.Record(slots, slots, nil, "occupancy-frozen", at.UnixMilli())
job := <-tracker.queue
if len(job.issue.Occupancy) != 193 {
t.Fatalf("captured quarter count=%d", len(job.issue.Occupancy))
Expand Down
27 changes: 16 additions & 11 deletions go/cmd/ftw/forecast_primary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func TestForecastPrimaryRustControlsSlotsAndArchivesFrozenLegacy(t *testing.T) {
*weather[0].SolarWm2 = 999
f.pv.SetRated(100000)
f.load.SetHeatingCoef(900)
in.Record(base, planning, "decision", at.Add(time.Second).UnixMilli())
in.Record(base, planning, nil, "decision", at.Add(time.Second).UnixMilli())
issue := (<-f.queue).issue
if err := issue.Validate(); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -107,7 +107,7 @@ func TestForecastPrimarySelectsEachSignalAndRealZero(t *testing.T) {
if got[0].PVW != legacy[0].PVW || got[0].LoadW != 1100 || got[1].PVW != 0 || got[1].LoadW != legacy[1].LoadW {
t.Fatalf("signal fallback or real zero wrong: %+v", got)
}
in.Record(got, got, "decision", at.UnixMilli())
in.Record(got, got, nil, "decision", at.UnixMilli())
points := primarySeries(t, (<-f.queue).issue, "champion").Points
if points[0].PVSource != "legacy" || points[0].ModelPV != nil || points[0].PVKnown || points[1].LoadSource != "legacy" || points[1].ModelLoad != nil || !points[1].PVKnown || points[1].PVQuality != "cold_start" {
t.Fatalf("fallback fabricated knowledge or retained wrong model range: %+v", points)
Expand Down Expand Up @@ -144,7 +144,7 @@ func TestForecastPrimaryUnavailableRetainsLegacy(t *testing.T) {
if !reflect.DeepEqual(got, legacy) {
t.Fatalf("unavailable worker changed legacy fallback: %+v", got)
}
in.Record(got, got, "decision", at.UnixMilli())
in.Record(got, got, nil, "decision", at.UnixMilli())
issue := (<-f.queue).issue
if primarySeries(t, issue, "champion").Points[0].LoadSource != "legacy" {
t.Fatal("fallback not explicit")
Expand All @@ -163,7 +163,7 @@ func TestForecastPrimaryPartialCurrentIntervalUsesRustRemainder(t *testing.T) {
if got[0].PVW != -100 || got[0].LoadW != 1100 || got[1].PVW != -200 || got[2].LoadW != 1300 {
t.Fatalf("current interval did not use Rust remainder: %+v", got)
}
in.Record(got, got, "partial-decision", at.UnixMilli())
in.Record(got, got, nil, "partial-decision", at.UnixMilli())
issue := (<-f.queue).issue
if err := issue.Validate(); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -202,7 +202,7 @@ func TestForecastPrimaryNativeColdLoadKeepsHeatingPriorUntilLearning(t *testing.
if got[0].LoadW != legacy[0].LoadW {
t.Fatalf("cold model replaced heating prior: %v -> %v", legacy[0].LoadW, got[0].LoadW)
}
in.Record(got, got, "native-decision", at.Add(time.Second).UnixMilli())
in.Record(got, got, nil, "native-decision", at.Add(time.Second).UnixMilli())
issue := (<-f.queue).issue
point := primarySeries(t, issue, "champion").Points[0]
if point.LoadSource != "legacy" || !point.LoadKnown || point.ModelLoad != nil {
Expand All @@ -217,7 +217,7 @@ func TestForecastPrimaryNativeColdLoadKeepsHeatingPriorUntilLearning(t *testing.
if next[0].LoadW >= 1000 {
t.Fatalf("measured low load did not replace the prior: %+v", next[0])
}
learned.Record(next, next, "learned-decision", at.UnixMilli())
learned.Record(next, next, nil, "learned-decision", at.UnixMilli())
if p := primarySeries(t, (<-f.queue).issue, "champion").Points[0]; p.LoadSource != "energyplan" || p.LoadQuality != "learning" {
t.Fatalf("learned load did not take over: %+v", p)
}
Expand Down Expand Up @@ -250,7 +250,7 @@ func TestForecastPrimaryResolveUsesCapturedStateAndFeatures(t *testing.T) {
if got[0].PVW != -100 || string(request.State) != `{"epoch":1}` || request.Horizon[0].Home == nil || !*request.Horizon[0].Home || *request.Horizon[0].GHIWm2 != 500 {
t.Fatalf("live state or features leaked into primary request: %+v", request)
}
in.Record(got, got, "decision", at.Add(time.Second).UnixMilli())
in.Record(got, got, nil, "decision", at.Add(time.Second).UnixMilli())
issue := (<-f.queue).issue
for _, model := range issue.Models {
if model.Name == "energyplan" && string(model.State) != `{"epoch":1}` {
Expand Down Expand Up @@ -281,7 +281,7 @@ func TestForecastPrimaryNativeCurrentIntervalReplaysAfterNewObservation(t *testi
if got[0].LoadW == legacy[0].LoadW {
t.Fatal("first current control interval still used legacy load")
}
in.Record(got, got, "native-partial", origin.Add(time.Second).UnixMilli())
in.Record(got, got, nil, "native-partial", origin.Add(time.Second).UnixMilli())
issue := (<-f.queue).issue
primary := primarySeries(t, issue, "champion").Points[0]
if primary.PredictionStartMS != origin.UnixMilli() || primary.LoadSource != "energyplan" {
Expand Down Expand Up @@ -323,15 +323,20 @@ func TestForecastRecoveryArchiveDoesNotClaimRejectedModel(t *testing.T) {
in := f.Snapshot(at, trackerWeather(at, at))
base := in.Resolve(context.Background(), trackerSlots(at, 4))
rawLoad := base[0].LoadW
for i := range base {
recovered := []bool{true, true, true, false}
for i := range base[:3] {
base[i].LoadW = 800
}
in.Record(base, base, "recovered-decision", at.UnixMilli())
in.Record(base, base, recovered, "recovered-decision", at.UnixMilli())
issue := (<-f.queue).issue
champion := primarySeries(t, issue, "champion").Points[0]
points := primarySeries(t, issue, "champion").Points
champion := points[0]
if champion.LoadW != 800 || champion.LoadSource != "core_recovery" || champion.ModelLoad != nil || champion.LoadQuality != "fallback" {
t.Fatalf("recovery was attributed to rejected model: %+v", champion)
}
if kept := points[3]; kept.LoadSource != "energyplan" || kept.ModelLoad == nil {
t.Fatalf("unrecovered slot lost its model: %+v", kept)
}
if raw := primarySeries(t, issue, "energyplan").Points[0]; raw.LoadW != rawLoad || raw.LoadSource == "core_recovery" {
t.Fatal("raw model prediction was overwritten")
}
Expand Down
10 changes: 5 additions & 5 deletions go/cmd/ftw/forecast_source_choice_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func TestForecastSourceChoiceSteersResolve(t *testing.T) {
if got[4].PVW != -100 {
t.Fatalf("legacy PV without weather replaced Energyplan PV: %+v", got[4])
}
in.Record(got, got, "decision", at.UnixMilli())
in.Record(got, got, nil, "decision", at.UnixMilli())
points := primarySeries(t, (<-f.queue).issue, "champion").Points
if p := points[0]; p.PVSource != "legacy" || p.LoadSource != "energyplan" || p.LoadQuality != "cold_start" || p.ModelLoad == nil {
t.Fatalf("chosen sources not recorded: %+v", p)
Expand Down Expand Up @@ -184,7 +184,7 @@ func TestForecastPVHorizonChoiceReachesPlannerAndArchive(t *testing.T) {
if resolved[0].PVW != -2000 || resolved[1].PVW != -100 {
t.Fatalf("per-horizon PV did not reach MPC: %+v", resolved)
}
in.Record(resolved, resolved, "horizon-choice", at.UnixMilli())
in.Record(resolved, resolved, nil, "horizon-choice", at.UnixMilli())
issue := (<-f.queue).issue
champion := primarySeries(t, issue, "champion").Points
if champion[0].PVSource != "legacy" || champion[0].ModelPV != nil || champion[1].PVSource != "energyplan" || champion[1].ModelPV == nil {
Expand Down Expand Up @@ -212,7 +212,7 @@ func TestForecastLoadHorizonChoiceReachesPlannerAndArchive(t *testing.T) {
if resolved[0].LoadW != legacy[0].LoadW || resolved[1].LoadW != 1100 {
t.Fatalf("per-horizon load did not reach MPC: %+v", resolved)
}
in.Record(resolved, resolved, "load-horizon-choice", at.UnixMilli())
in.Record(resolved, resolved, nil, "load-horizon-choice", at.UnixMilli())
issue := (<-f.queue).issue
champion := primarySeries(t, issue, "champion").Points
if champion[0].LoadSource != "legacy" || champion[0].ModelLoad != nil || champion[1].LoadSource != "energyplan" || champion[1].ModelLoad == nil {
Expand Down Expand Up @@ -240,7 +240,7 @@ func TestForecastColdLoadCannotBorrowAnotherHorizonsEvidence(t *testing.T) {
if got[0].LoadW != 6000 || got[1].LoadW != 1100 {
t.Fatalf("cold load borrowed another horizon's evidence: %+v", got)
}
in.Record(got, got, "cold-load-horizon", at.UnixMilli())
in.Record(got, got, nil, "cold-load-horizon", at.UnixMilli())
points := primarySeries(t, (<-f.queue).issue, "champion").Points
if points[0].LoadSource != "legacy" || points[0].ModelLoad != nil || points[1].LoadSource != "energyplan" {
t.Fatalf("cold load fallback provenance lost: %+v", points)
Expand Down Expand Up @@ -331,7 +331,7 @@ func TestForecastDaytimeRiskAndArchivedBandsExcludeNightErrors(t *testing.T) {
if planning[0].PVW != 0 || planning[0].LoadW != 2000 {
t.Fatalf("night errors narrowed the daytime margin: base %+v planning %+v", base[0], planning[0])
}
in.Record(base, planning, "daytime-risk", at.UnixMilli())
in.Record(base, planning, nil, "daytime-risk", at.UnixMilli())
issue := (<-f.queue).issue
point := primarySeries(t, issue, "energyplan").Points[0]
if point.PVBand.Samples != daylightHours || point.NetBand.Samples != daylightHours || point.PVBand.Method != forecasting.BandMethodEmpirical || point.NetBand.HighW != 2000 {
Expand Down
21 changes: 7 additions & 14 deletions go/cmd/ftw/forecast_tracking.go
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,7 @@ func (f *forecastTracker) Snapshot(_ time.Time, weather []state.ForecastPoint) m
}
}
}
in.Record = func(base, planning []mpc.Slot, decisionID string, issued int64) {
in.Record = func(base, planning []mpc.Slot, recoveredLoad []bool, decisionID string, issued int64) {
issue := makeIssue(base, issued)
issue.DecisionID = decisionID
if prediction != nil {
Expand All @@ -686,7 +686,7 @@ func (f *forecastTracker) Snapshot(_ time.Time, weather []state.ForecastPoint) m
issue.Series[0].ModelVersion = version
// Core may recover with a prior after the selected model fails. Keep
// its accepted inputs separate from the raw worker and legacy series.
selected = recoveredForecastSources(base, selected)
selected = recoveredForecastSources(selected, recoveredLoad)
issue.Series[0].Points = primaryForecastPoints(base, issue.Series[0].Points, selected)
issue.Series = append(issue.Series, forecasting.Series{Name: "planning", ModelVersion: version,
Points: primaryForecastPoints(planning, forecastPoints(planning, frozen, site, origin, load), selected)})
Expand Down Expand Up @@ -724,22 +724,15 @@ func usablePrimaryForecast(known bool, quality string, watts float64) bool {
return known && (quality == "cold_start" || quality == "learning" || quality == "ready") && watts >= 0 && !math.IsNaN(watts) && !math.IsInf(watts, 0)
}

// A changed point forecast belongs to Core's recovery, not the model whose
// raw prediction was rejected. Risk adjustments keep the recovered source.
func recoveredForecastSources(base []mpc.Slot, selected []forecasting.Point) []forecasting.Point {
// A load Core recovered belongs to that recovery, not the model whose raw
// prediction was rejected. Risk adjustments keep the recovered source.
func recoveredForecastSources(selected []forecasting.Point, recoveredLoad []bool) []forecasting.Point {
out := append([]forecasting.Point(nil), selected...)
for i, slot := range base {
if i >= len(out) {
break
}
if slot.LoadW != out[i].LoadW {
for i, recovered := range recoveredLoad {
if recovered && i < len(out) {
out[i].LoadSource, out[i].LoadQuality = "core_recovery", "fallback"
out[i].ModelLoad = nil
}
if -slot.PVW != out[i].PVW {
out[i].PVSource, out[i].PVQuality = "core_recovery", "fallback"
out[i].ModelPV = nil
}
}
return out
}
Expand Down
Loading
Loading