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
7 changes: 7 additions & 0 deletions .changeset/remaining-slot-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": patch
---

Score remaining slot time in the load rain-check and cap the Energyplan
worker budget so a late-in-slot replan cannot burn a full solve that
cannot be published.
30 changes: 28 additions & 2 deletions go/internal/mpc/energyplan.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mpc

import (
"context"
"fmt"
"path/filepath"
"time"
)
Expand All @@ -25,6 +26,13 @@ func NewEnergyplanOptimizer(binary string) (*EnergyplanOptimizer, error) {
return &EnergyplanOptimizer{ExternalOptimizer: external}, nil
}

const (
energyplanSmallBudget = 500 * time.Millisecond
energyplanFleetBudget = 5 * time.Second
// Leave time for ValidatePlan and publication before firstSlotExpired.
energyplanPublishMargin = 50 * time.Millisecond
)

func energyplanTimeBudget(slots []Slot, p Params) time.Duration {
batteries := len(p.Storages)
if batteries == 0 && p.CapacityWh > 0 {
Expand All @@ -33,16 +41,34 @@ func energyplanTimeBudget(slots []Slot, p Params) time.Duration {
assets := 3*batteries + 2*len(p.activeLoadpoints())
// Core already adjusts PV to one downside horizon. That margin does not
// add worker scenarios or change this model's size.
budget := energyplanSmallBudget
if len(slots)*assets >= 193*6 || p.PVCurtailment.MinW > 0 {
return 5 * time.Second
budget = energyplanFleetBudget
}
remaining := remainingFirstSlot(slots)
if remaining < energyplanSmallBudget {
return 0
}
return 500 * time.Millisecond
available := remaining - energyplanPublishMargin
if available < budget {
return available
}
return budget
}

func (o *EnergyplanOptimizer) Optimize(ctx context.Context, slots []Slot, p Params) (Plan, error) {
// Service has already applied the risk margin to these slots. Do not
// construct a second scenario model for this deterministic solver.
p.PVUncertaintyW, p.PVRelativeUncertainty, p.PVForecastSafetyK = 0, 0, 0
budget := energyplanTimeBudget(slots, p)
if budget <= 0 {
return Plan{}, fmt.Errorf("remaining first-slot time %s is below the Energyplan budget", remainingFirstSlot(slots))
}
if wait := remainingFirstSlot(slots) - energyplanPublishMargin; o.cfg.Timeout > 0 && wait > 0 && wait < o.cfg.Timeout {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, wait)
defer cancel()
}
return o.ExternalOptimizer.Optimize(ctx, slots, p)
}

Expand Down
165 changes: 165 additions & 0 deletions go/internal/mpc/energyplan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package mpc
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
Expand All @@ -11,6 +12,7 @@ import (
"time"

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

func TestValidatePlanRejectsEVOverCapacity(t *testing.T) {
Expand Down Expand Up @@ -287,3 +289,166 @@ func TestBatterylessEVBudgetDoesNotInventStorage(t *testing.T) {
t.Fatalf("real aggregate battery budget=%v", got)
}
}

func energyplanFleetHorizon() ([]Slot, Params) {
slots, params := topologyFixture(2, 2)
first := slots[0]
horizon := make([]Slot, 193)
for i := range horizon {
horizon[i] = first
horizon[i].StartMs = first.StartMs + int64(i*first.LenMin)*60000
}
return horizon, params
}

func trimFirstSlotRemaining(slots []Slot, remaining time.Duration) {
end := slots[0].StartMs + int64(slots[0].LenMin)*60000
slots[0].ExecutionStartMs = end - remaining.Milliseconds()
}

type countingTransport struct {
n int
last []byte
deadlineWait time.Duration
hasDeadline bool
}

func (c *countingTransport) RoundTrip(ctx context.Context, payload []byte) ([]byte, error) {
c.n++
c.last = append([]byte(nil), payload...)
if d, ok := ctx.Deadline(); ok {
c.hasDeadline = true
c.deadlineWait = time.Until(d)
}
return nil, errors.New("stop")
}
func (c *countingTransport) Health(context.Context) (OptimizerRuntimeInfo, error) {
return OptimizerRuntimeInfo{}, nil
}
func (c *countingTransport) Close() error { return nil }

func TestEnergyplanTimeBudgetRespectsRemainingSlot(t *testing.T) {
t.Parallel()
slots, p := energyplanFleetHorizon()
if got := energyplanTimeBudget(slots, p); got != energyplanFleetBudget {
t.Fatalf("full-slot fleet budget=%v, want %v", got, energyplanFleetBudget)
}
trimFirstSlotRemaining(slots, 200*time.Millisecond)
if got := energyplanTimeBudget(slots, p); got != 0 {
t.Fatalf("200ms remaining fleet budget=%v, want fail-fast", got)
}
trimFirstSlotRemaining(slots, 2*time.Second)
got := energyplanTimeBudget(slots, p)
remaining := remainingFirstSlot(slots)
if got <= 0 || got > remaining || remaining-got != energyplanPublishMargin {
t.Fatalf("2s remaining fleet budget=%v remaining=%v", got, remaining)
}
small, smallParams := topologyFixture(1, 0)
trimFirstSlotRemaining(small, 2*time.Second)
if got := energyplanTimeBudget(small, smallParams); got != energyplanSmallBudget {
t.Fatalf("2s remaining small budget=%v, want %v", got, energyplanSmallBudget)
}
}

func TestEnergyplanFailsFastWhenRemainingBelowSmallBudget(t *testing.T) {
t.Parallel()
capture := &countingTransport{}
engine := &EnergyplanOptimizer{ExternalOptimizer: &ExternalOptimizer{
cfg: ExternalOptimizerConfig{Timeout: 7 * time.Second},
transport: capture,
timeBudget: energyplanTimeBudget,
}}
slots, p := energyplanFleetHorizon()
trimFirstSlotRemaining(slots, 200*time.Millisecond)
_, err := engine.Optimize(context.Background(), slots, p)
if err == nil || !strings.Contains(err.Error(), "below the Energyplan budget") {
t.Fatalf("expected fail-fast, got %v", err)
}
if capture.n != 0 {
t.Fatalf("doomed worker started %d times", capture.n)
}
}

func TestEnergyplanCapsWorkerBudgetAndWaitToRemaining(t *testing.T) {
t.Parallel()
capture := &countingTransport{}
engine := &EnergyplanOptimizer{ExternalOptimizer: &ExternalOptimizer{
cfg: ExternalOptimizerConfig{Timeout: 7 * time.Second},
transport: capture,
timeBudget: energyplanTimeBudget,
}}
slots, p := energyplanFleetHorizon()
trimFirstSlotRemaining(slots, 2*time.Second)
_, _ = engine.Optimize(context.Background(), slots, p)
if capture.n != 1 {
t.Fatalf("worker trips=%d", capture.n)
}
var request externalRequest
if err := json.Unmarshal(capture.last, &request); err != nil {
t.Fatal(err)
}
want := energyplanTimeBudget(slots, p).Seconds()
if request.Settings.TimeLimitS != want || request.Settings.TimeLimitS > 2 {
t.Fatalf("TimeLimitS=%g, want %g (≤ remaining)", request.Settings.TimeLimitS, want)
}
if !capture.hasDeadline || capture.deadlineWait > 2*time.Second || capture.deadlineWait < time.Second {
t.Fatalf("process wait %v, want ≤ remaining 2s", capture.deadlineWait)
}
}

func TestEnergyplanLateFleetReplanDoesNotStartDoomedSolve(t *testing.T) {
capture := &countingTransport{}
engine := &EnergyplanOptimizer{ExternalOptimizer: &ExternalOptimizer{
cfg: ExternalOptimizerConfig{Timeout: 7 * time.Second},
transport: capture,
timeBudget: energyplanTimeBudget,
}}
start := time.Date(2026, 9, 8, 4, 45, 0, 0, time.UTC)
now := start.Add(15*time.Minute - 200*time.Millisecond)
st, err := state.Open(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
for i := 0; i < 16; i++ {
slot := start.Add(time.Duration(i) * 15 * time.Minute)
if err := st.SavePrices([]state.PricePoint{{
Zone: "SE3", SlotTsMs: slot.UnixMilli(), SlotLenMin: 15,
SpotOreKwh: 50, TotalOreKwh: 100, Source: "test", FetchedAtMs: start.UnixMilli(),
}}); err != nil {
t.Fatal(err)
}
}
tele := telemetry.NewStore()
soc := 0.5
svc := New(st, tele, "SE3", Params{
Mode: ModeArbitrage, SoCLevels: 11, ActionLevels: 5,
CapacityWh: 10000, InitialSoC: 0.5, SoCMin: 0.1, SoCMax: 0.95,
MaxChargeW: 4000, MaxDischargeW: 4000,
ChargeEfficiency: 0.95, DischargeEfficiency: 0.95,
})
svc.now = func() time.Time { return now }
svc.Optimizer = engine
svc.Horizon = 4 * time.Hour
svc.BaseLoad = 500
for _, id := range []string{"battery-0", "battery-1"} {
tele.Update(id, telemetry.DerBattery, 0, &soc, nil)
tele.DriverHealthMut(id).RecordSuccess()
}
svc.UpdateBatteryFleet([]BatteryFleetMember{
{Driver: "battery-0", CapacityWh: 5000, MaxChargeW: 2000, MaxDischargeW: 2000},
{Driver: "battery-1", CapacityWh: 5000, MaxChargeW: 2000, MaxDischargeW: 2000},
}, 10000, 4000, 4000)
previous := Plan{DecisionID: "keep-me", GeneratedAtMs: now.UnixMilli(), Actions: []Action{{SlotStartMs: start.UnixMilli(), SlotLenMin: 15}}}
svc.InstallPlan(previous, svc.Defaults, "")
plan := svc.Replan(context.Background())
if capture.n != 0 {
t.Fatalf("doomed worker started %d times", capture.n)
}
if svc.PlanSnapshot().Reason == "slot_elapsed" {
t.Fatal("queued slot_elapsed from a plan that could not land")
}
if plan == nil || plan.DecisionID != "keep-me" {
t.Fatalf("wanted previous plan, got %+v", plan)
}
}
11 changes: 11 additions & 0 deletions go/internal/mpc/execution_time.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ func (s Slot) DurationHours() float64 {
return float64(s.StartMs+int64(s.LenMin)*60000-s.ExecutionStart()) / 3600000
}

func remainingFirstSlot(slots []Slot) time.Duration {
if len(slots) == 0 {
return 0
}
rem := slots[0].StartMs + int64(slots[0].LenMin)*60000 - slots[0].ExecutionStart()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Base the solve deadline on the current wall clock

When input construction or a scheduler pause takes more than the 50 ms margin after trimFirstExecutionSlot captures ExecutionStartMs, this computes the original modeled duration rather than the time remaining when the worker actually starts. Consequently, both the fail-fast check and context.WithTimeout are stale by that delay, so a late fleet solve can run past the slot boundary, be discarded by firstSlotExpired, and trigger the retry this change is intended to avoid. Subtract the current time from the slot end at admission/deadline creation rather than subtracting the captured execution start.

Useful? React with 👍 / 👎.

if rem <= 0 {
return 0
}
return time.Duration(rem) * time.Millisecond
}

func (a Action) ExecutionStart() int64 {
if a.ExecutionStartMs != 0 {
return a.ExecutionStartMs
Expand Down
14 changes: 5 additions & 9 deletions go/internal/mpc/load_bounds.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import (
// what the house actually used on recent days. Days can differ a lot, so
// this only lifts a collapsed forecast — it never force-fits the shape.
const (
loadRainCheckDays = 3
loadRainCheckMinFraction = 0.2 // lift only when forecast Wh < 20% of recent mean
loadRainCheckMaxScale = 1.5
loadRecentMeanFloorFrac = 0.2 // no slot below 20% of recent mean watts
loadRainCheckDays = 3
loadRainCheckMinFraction = 0.2 // lift only when forecast Wh < 20% of recent mean
loadRainCheckMaxScale = 1.5
loadRecentMeanFloorFrac = 0.2 // no slot below 20% of recent mean watts
loadRecentDayMinIntervals = 50
loadRecentDayMinWh = 500
)
Expand Down Expand Up @@ -57,11 +57,7 @@ func capPlanLoad(plan *Plan, minW, maxW float64) {
func forecastLoadWh(slots []Slot) float64 {
var wh float64
for _, s := range slots {
min := s.LenMin
if min <= 0 {
min = 60
}
wh += math.Max(0, s.LoadW) * float64(min) / 60.0
wh += math.Max(0, s.LoadW) * math.Max(0, s.DurationHours())
}
return wh
}
Expand Down
17 changes: 17 additions & 0 deletions go/internal/mpc/load_bounds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ func TestRainCheckLoadNoHistoryNoOp(t *testing.T) {
}
}

func TestForecastLoadWhUsesRemainingHours(t *testing.T) {
t.Parallel()
start := time.Date(2026, 9, 8, 4, 45, 0, 0, time.UTC)
slots := []Slot{{
StartMs: start.UnixMilli(), LenMin: 15,
ExecutionStartMs: start.Add(10 * time.Minute).UnixMilli(),
LoadW: 1200,
}}
if got := forecastLoadWh(slots); math.Abs(got-100) > 1e-9 {
t.Fatalf("rain-check energy=%g Wh, want 100 (5 min of 1200 W), not 300", got)
}
slots[0].ExecutionStartMs = 0
if got := forecastLoadWh(slots); math.Abs(got-300) > 1e-9 {
t.Fatalf("full slot energy=%g Wh, want 300", got)
}
}

func TestRecentDailyLoadWhSkipsEmptyDays(t *testing.T) {
st, err := state.Open(t.TempDir() + "/t.db")
if err != nil {
Expand Down