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/forecast-planner-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Keep price planning when a load forecast cannot fit the site. Retry with the point forecast and then the configured load prior, keeping hardware limits, current measurements and departure goals. Retain the rejected inputs for support and report when the plan uses a simpler forecast.
5 changes: 5 additions & 0 deletions .changeset/optional-forecast-caution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Keep a validated point plan when optional forecast caution fails, shortens the plan or worsens a car's departure goal. Try the point forecast before a load prior in Core fallback, and keep raw rejected risk inputs for support.
5 changes: 5 additions & 0 deletions .changeset/reserve-charging-prices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Use available prices in reserve charging plans. Move EV charging to cheaper legal slots while retaining the same energy, departure time, battery trajectory and site limits.
23 changes: 23 additions & 0 deletions go/cmd/ftw/forecast_primary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,26 @@ func trainNativeLoad(t *testing.T, r *rustForecast, target time.Time) {
}
}
}

func TestForecastRecoveryArchiveDoesNotClaimRejectedModel(t *testing.T) {
at := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)
f := primaryFixture(at, primaryReply)
in := f.Snapshot(at, trackerWeather(at, at))
base := in.Resolve(context.Background(), trackerSlots(at, 4))
rawLoad := base[0].LoadW
for i := range base {
base[i].LoadW = 800
}
in.Record(base, base, "recovered-decision", at.UnixMilli())
issue := (<-f.queue).issue
champion := primarySeries(t, issue, "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 raw := primarySeries(t, issue, "energyplan").Points[0]; raw.LoadW != rawLoad || raw.LoadSource == "core_recovery" {
t.Fatal("raw model prediction was overwritten")
}
if err := issue.Validate(); err != nil {
t.Fatal(err)
}
}
23 changes: 23 additions & 0 deletions go/cmd/ftw/forecast_tracking.go
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,9 @@ func (f *forecastTracker) Snapshot(_ time.Time, weather []state.ForecastPoint) m
}
version := forecastModelRevision(issue.Models)
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)
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 @@ -718,6 +721,26 @@ 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 {
out := append([]forecasting.Point(nil), selected...)
for i, slot := range base {
if i >= len(out) {
break
}
if slot.LoadW != out[i].LoadW {
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
}

// Preserve the source and quality used for each signal, while recording the
// actual capped or risk-adjusted watts passed through MPC.
func primaryForecastPoints(slots []mpc.Slot, fallback, selected []forecasting.Point) []forecasting.Point {
Expand Down
17 changes: 11 additions & 6 deletions go/internal/mpc/core_recovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ func TestCoreRecoveryRejectsUnsafeCurrentSlotAndInvalidInputs(t *testing.T) {
}
}

func TestRecoveryPublishesPersistsAndReturnsToFullHorizon(t *testing.T) {
func TestRecoveryPublishesFullPriorAndReturnsToModel(t *testing.T) {
for _, externalFailure := range []bool{false, true} {
t.Run(map[bool]string{false: "core", true: "external failure"}[externalFailure], func(t *testing.T) {
svc := shadowTestService(t)
Expand Down Expand Up @@ -193,16 +193,21 @@ func TestRecoveryPublishesPersistsAndReturnsToFullHorizon(t *testing.T) {
}
plan := svc.Replan(context.Background())
snap := svc.PlanSnapshot()
if plan == nil || len(plan.Actions) != 2 || snap.Outdated || snap.Error != "" || len(snap.Warnings) == 0 {
t.Fatalf("safe prefix not published: %+v", snap)
if plan == nil || len(plan.Actions) != 4 || snap.Outdated || snap.Error != "" || len(snap.Warnings) == 0 {
t.Fatalf("full prior plan not published: %+v", snap)
}
if _, ok := svc.SlotDirectiveAt(svc.now()); !ok {
t.Fatal("safe current plan cannot dispatch")
}
if _, ok := svc.SlotDirectiveAt(start.Add(2 * time.Hour)); ok {
t.Fatal("plan dispatches beyond its validated prefix")
if _, ok := svc.SlotDirectiveAt(start.Add(2 * time.Hour)); !ok {
t.Fatal("valid prior did not retain the full price horizon")
}
if saved == nil || len(saved.Slots) != 2 || saved.Horizon != 2 || recorded != 2 {
for _, slot := range svc.lastSlots {
if slot.LoadW != 500 {
t.Fatal("rejected forecast reached accepted plan")
}
}
if saved == nil || len(saved.Slots) != 4 || saved.Horizon != 4 || recorded != 4 {
t.Fatalf("diagnostic or forecast archive lost accepted horizon: saved=%+v recorded=%d", saved, recorded)
}
if err := ValidatePlan(svc.lastSlots, svc.lastParams, plan); err != nil {
Expand Down
6 changes: 3 additions & 3 deletions go/internal/mpc/energyplan.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"time"
)

// EnergyplanOptimizer runs the compiled worker on Core's downside forecast.
// EnergyplanOptimizer runs the compiled worker on the forecast Core selects.
// Source and builds belong to the private Energyplan repository.
type EnergyplanOptimizer struct {
*ExternalOptimizer
Expand Down Expand Up @@ -65,8 +65,8 @@ func energyplanTimeBudget(slots []Slot, p Params) time.Duration {
return budget
}

// Optimize solves the slots Service has already moved to the PV downside, so
// the worker never adds a second risk margin.
// Optimize solves the slots Service selects, including point and caution
// attempts. The worker never adds a second risk margin.
func (o *EnergyplanOptimizer) Optimize(ctx context.Context, slots []Slot, p Params) (Plan, error) {
budget := energyplanTimeBudget(slots, p)
if budget <= 0 {
Expand Down
2 changes: 1 addition & 1 deletion go/internal/mpc/energyplan_fault_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,10 @@ func TestNativeEnergyplanEVRecoveryFallbackFaults(t *testing.T) {
if previous == nil || previous.Solver == nil || previous.Solver.Fallback || previous.InitialSoC != measuredSoC {
t.Fatalf("native baseline did not publish from measured low SoC: %+v", previous)
}
svc.shadowWG.Wait()
if err := ValidatePlan(svc.lastSlots, svc.lastParams, previous); err != nil {
t.Fatal(err)
}
svc.shadowWG.Wait()
previous = svc.Latest()
previousBytes, _ = json.Marshal(previous)
}
Expand Down
104 changes: 104 additions & 0 deletions go/internal/mpc/ev_goal_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package mpc

import (
"context"
"encoding/json"
"math"
"testing"
"time"
)

// An unreachable departure changes the reported shortfall, never whether the
// rest of the site receives a plan. These expectations use direct Wh arithmetic.
func TestEVGoalPlanAndPriceContract(t *testing.T) {
for _, backend := range []string{"native", "reserve"} {
t.Run(backend, func(t *testing.T) {
solve := coreReservePlan
if backend == "native" {
worker := nativeWorker(t, time.Second)
t.Cleanup(func() { _ = worker.Close() })
solve = worker.Optimize
}
for _, tc := range []struct {
name string
due, cheap int
houseW float64
partial bool
wantShortfall float64
wantCostlyWh float64
}{
{"enough cheaper hours", 15, 8, 1800, false, 0, 0},
{"expensive energy needed for goal", 15, 12, 1800, false, 0, 10380.0/.9 - 10350},
{"unreachable departure", 0, 8, 1800, false, 10380 - 10350*.25*.9, 10350 * .25},
{"forty-five seconds to departure", 0, 8, 1800, true, 10380 - 10350*.0125*.9, 10350 * .0125},
{"no legal charger step fits", 15, 8, 14000, false, 10380, 0},
} {
t.Run(tc.name, func(t *testing.T) {
start := time.Date(2026, 10, 15, 17, 0, 0, 0, time.UTC)
slots := make([]Slot, 16)
for i := range slots {
price := 450.0
if i >= tc.cheap {
price = 50
}
slots[i] = Slot{StartMs: start.Add(time.Duration(i) * 15 * time.Minute).UnixMilli(), LenMin: 15,
LoadW: tc.houseW, PriceOre: price, SpotOre: price,
Limits: PowerLimits{MaxImportW: 17250, MaxExportW: 17250}}
}
if tc.partial {
slots[0].ExecutionStartMs = slots[0].StartMs + (14*time.Minute + 15*time.Second).Milliseconds()
}
p := Params{Mode: ModeArbitrage, CapacityWh: 15000, InitialSoC: .5,
SoCMin: .1, SoCMax: .9, SoCLevels: 101, ActionLevels: 201,
ChargeEfficiency: .95, DischargeEfficiency: .95,
Loadpoint: &LoadpointSpec{ID: "car", CapacityWh: 86500, InitialSoC: .68,
SoCMax: 1, Levels: 101, PluggedIn: true, TargetSoC: .8, TargetSlotIdx: tc.due,
MinChargeW: 4140, MaxChargeW: 11000, AllowedStepsW: chargerSteps(690),
ChargeEfficiency: .9, NoBatteryToEV: true}}
// Use three-phase steps from 6 to 15 A, up to 10,350 W.
p.Loadpoint.AllowedStepsW = p.Loadpoint.AllowedStepsW[:len(p.Loadpoint.AllowedStepsW)-1]
before, err := json.Marshal(p)
if err != nil {
t.Fatal(err)
}
plan, err := solve(context.Background(), slots, p)
if err != nil {
t.Fatalf("EV goal disabled site planning: %v", err)
}
if len(plan.Actions) != len(slots) || plan.HorizonSlots != len(slots) {
t.Fatalf("EV goal shortened the site plan: %d actions, horizon %d", len(plan.Actions), plan.HorizonSlots)
}
if err := ValidatePlan(slots, p, &plan); err != nil {
t.Fatal(err)
}
if got := plan.LoadpointShortfallWh["car"]; math.Abs(got-tc.wantShortfall) > 1 {
t.Fatalf("shortfall %.6f Wh, want %.6f", got, tc.wantShortfall)
}
var deliveredWh, costlyWh float64
for i, a := range plan.Actions {
deliveredWh += a.LoadpointW * slots[i].DurationHours()
if i < tc.cheap {
costlyWh += a.LoadpointW * slots[i].DurationHours()
}
if i > tc.due && a.LoadpointW > 1e-6 {
t.Fatalf("slot %d charges after departure: %.6f W", i, a.LoadpointW)
}
if math.Abs(a.SoC-p.InitialSoC) > 1e-6 || math.Abs(a.BatteryW) > 1e-6 {
t.Fatal("EV goal changed the fixed battery trajectory")
}
}
if math.Abs(deliveredWh*.9+tc.wantShortfall-10380) > 1 || math.Abs(costlyWh-tc.wantCostlyWh) > 1 {
t.Fatalf("delivered %.6f Wh, expensive %.6f Wh; want %.6f and %.6f", deliveredWh, costlyWh, (10380-tc.wantShortfall)/.9, tc.wantCostlyWh)
}
if got := plan.Actions[tc.due].LoadpointSoC*86500 + tc.wantShortfall; math.Abs(got-.8*86500) > 1 {
t.Fatalf("departure energy plus missing energy %.6f Wh, want 69200", got)
}
after, err := json.Marshal(p)
if err != nil || string(before) != string(after) {
t.Fatal("planner changed the requested goal or physical settings")
}
})
}
})
}
}
6 changes: 3 additions & 3 deletions go/internal/mpc/ev_reserve.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import (
"github.com/srcfl/ftw/go/internal/loadpoint"
)

// coreReservePlan makes prompt progress toward an EV deadline when the worker
// is unavailable. It uses exact energy, legal on-power and idle/charge pulses;
// coreReservePlan retains an EV departure schedule when the worker is
// unavailable. It uses exact energy, legal on-power and idle/charge pulses;
// optional home-battery trading must not consume the car's available headroom.
// The service still validates the complete plan before publication.
func coreReservePlan(ctx context.Context, slots []Slot, p Params) (Plan, error) {
Expand Down Expand Up @@ -117,7 +117,7 @@ func coreReservePlan(ctx context.Context, slots []Slot, p Params) (Plan, error)
if lp == nil {
plan.Solver.Backend = "site_reserve"
}
return plan, nil
return priceReserveCharging(ctx, slots, p, plan)
}

type reserveEnergyBand struct{ min, max float64 }
Expand Down
6 changes: 5 additions & 1 deletion go/internal/mpc/external_optimizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ type externalError struct {
Message string `json:"message"`
}

func (e *externalError) Error() string {
return fmt.Sprintf("optimizer %s: %s", e.Code, e.Message)
}

type externalPlan struct {
FlexShortfallWh map[string]float64 `json:"flex_shortfall_wh,omitempty"`
Mode Mode `json:"mode"`
Expand Down Expand Up @@ -240,7 +244,7 @@ func (o *ExternalOptimizer) Optimize(ctx context.Context, slots []Slot, p Params
if response.Error == nil {
return Plan{}, errors.New("optimizer returned an unspecified error")
}
return Plan{}, fmt.Errorf("optimizer %s: %s", response.Error.Code, response.Error.Message)
return Plan{}, response.Error
}
if err := validateExternalAssets(request, response.Plan); err != nil {
return Plan{}, fmt.Errorf("optimizer contract rejected: %w", err)
Expand Down
21 changes: 14 additions & 7 deletions go/internal/mpc/failure_diagnostic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,19 @@ func TestFailureEvidenceKeepsRejectedDownsideInputs(t *testing.T) {
svc.PV = func(time.Time, float64) float64 { return 1000 }
svc.PVForecastSafetyK = 1
svc.PVUncertaintyW = func() float64 { return math.NaN() }
if svc.Replan(context.Background()) != nil {
t.Fatal("invalid current downside forecast became a plan")
plan := svc.Replan(context.Background())
if plan == nil || len(plan.Actions) != 4 {
t.Fatal("invalid optional downside forecast blocked a valid point plan")
}
failure := svc.PlanningFailureSnapshot()
if failure == nil || failure.Stage != "risk_inputs" {
t.Fatalf("rejected downside evidence was overwritten: %+v", failure)
}
var inputs struct {
Slots []map[string]any `json:"slots"`
Params map[string]any `json:"params"`
}
if err := json.Unmarshal(failure.Inputs, &inputs); err != nil || len(inputs.Slots) == 0 || inputs.Slots[0]["PVW"] != "NaN" {
t.Fatalf("failure reports the valid base forecast instead of rejected risk inputs: %s %v", failure.Inputs, err)
if err := json.Unmarshal(failure.Inputs, &inputs); err != nil || inputs.Params["PVUncertaintyW"] != "NaN" {
t.Fatalf("failure lost the raw uncertainty before caution was disabled: %s %v", failure.Inputs, err)
}
restored := New(svc.Store, nil, svc.Zone, svc.Defaults).PlanningFailureSnapshot()
if restored == nil || !bytes.Equal(restored.Inputs, failure.Inputs) || restored.Stage != "risk_inputs" {
Expand Down Expand Up @@ -343,13 +344,19 @@ func TestNativeSupportFailureEvidenceMatchesRejectedWorkerRequest(t *testing.T)
svc.Optimizer = &EnergyplanOptimizer{ExternalOptimizer: worker}
plan := svc.Replan(context.Background())
failure := svc.PlanningFailureSnapshot()
if plan == nil || len(plan.Actions) != 2 || failure == nil || failure.OptimizerRequest == nil || failure.OptimizerResponse == nil {
if plan == nil || len(plan.Actions) != 4 || failure == nil || failure.OptimizerRequest == nil || failure.OptimizerResponse == nil {
t.Fatalf("native failure or safe recovery missing: plan=%v failure=%+v", plan != nil, failure)
}
var request externalRequest
var response externalResponse
if json.Unmarshal(failure.OptimizerRequest.JSON, &request) != nil || json.Unmarshal(failure.OptimizerResponse.JSON, &response) != nil ||
request.RequestID != response.RequestID || response.OK || len(request.Slots) != 4 || !strings.Contains(failure.Error, "infeasible") {
request.RequestID != response.RequestID || response.OK || len(request.Slots) != 4 || request.Slots[2].LoadW != 40000 || !strings.Contains(failure.Error, "infeasible") {
t.Fatal("captured exchange does not reproduce the rejected native request")
}
if plan.Solver.Engine != "native" || plan.Solver.Fallback || failure.RecoveredDecisionID != plan.DecisionID {
t.Fatal("simpler forecast did not recover native price planning")
}
if err := ValidatePlan(svc.lastSlots, svc.lastParams, plan); err != nil {
t.Fatal(err)
}
}
Loading
Loading