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/plan-live-solar-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

The plan now includes, for each slot, how far live solar beyond the plan may charge the battery.
50 changes: 50 additions & 0 deletions go/internal/api/api_mpc_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package api
import (
"encoding/json"
"math"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/mpc"
)
Expand Down Expand Up @@ -41,3 +43,51 @@ func TestWriteJSONPlanKeepsNumericSoC(t *testing.T) {
t.Fatalf("encoded plan = %s", rr.Body.String())
}
}

// The Plan card reads live_pv_surplus_soc_cap from /api/mpc/plan to say when
// live solar beyond the plan may charge the battery. It must be the number
// dispatch reads for the same slot.
func TestMPCPlanCarriesLivePVSurplusSoCCap(t *testing.T) {
const key = "live_pv_surplus_soc_cap"
now := time.Now()
start := now.Add(-time.Minute)
svc := &mpc.Service{}
svc.InstallPlan(mpc.Plan{
GeneratedAtMs: now.UnixMilli(),
Actions: []mpc.Action{
// Idle now; the next slot buys 2 kW from the grid at 120 öre.
{SlotStartMs: start.UnixMilli(), SlotLenMin: 15, SpotOre: 40, SoC: 0.5},
{SlotStartMs: start.Add(15 * time.Minute).UnixMilli(), SlotLenMin: 15,
PriceOre: 120, BatteryW: 2000, GridW: 2500, SoC: 0.55},
},
}, mpc.Params{Mode: mpc.ModeArbitrage, CapacityWh: 10000, ChargeEfficiency: 1}, "")
dir, ok := svc.SlotDirectiveAt(now)
if !ok || math.Abs(dir.LivePVSurplusSoCCap-0.55) > 1e-9 {
t.Fatalf("directive cap = %v (ok=%t), want 0.55", dir.LivePVSurplusSoCCap, ok)
}

rr := httptest.NewRecorder()
New(&Deps{MPC: svc}).Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/mpc/plan", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d: %s", rr.Code, rr.Body.String())
}
var body struct {
Plan *struct {
Actions []map[string]json.RawMessage `json:"actions"`
} `json:"plan"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("%v: %s", err, rr.Body.String())
}
if body.Plan == nil || len(body.Plan.Actions) != 2 {
t.Fatalf("plan = %s", rr.Body.String())
}
var got float64
if err := json.Unmarshal(body.Plan.Actions[0][key], &got); err != nil || got != dir.LivePVSurplusSoCCap {
t.Fatalf("%s = %s, want %v", key, body.Plan.Actions[0][key], dir.LivePVSurplusSoCCap)
}
// No grid charge follows the last slot, so it grants nothing, and says so.
if raw, ok := body.Plan.Actions[1][key]; !ok || string(raw) != "0" {
t.Fatalf("last slot %s = %s (present %v), want 0", key, raw, ok)
}
}
3 changes: 3 additions & 0 deletions go/internal/mpc/diagnose.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,9 @@ func (s *Service) RestoreDiagnostic(d *Diagnostic, now time.Time, reason string)
if params.PVChargeBonusOreKwh == 0 && s.Defaults.PVChargeBonusOreKwh > 0 {
params.PVChargeBonusOreKwh = s.Defaults.PVChargeBonusOreKwh
}
// The snapshot does not store the caps. Derive them from the restored
// params, as dispatch did before the caps lived on the plan.
s.setLivePVSurplusSoCCapsLocked(plan.Actions, params)
s.last = plan
s.lastSlots = slots
s.lastParams = params
Expand Down
9 changes: 9 additions & 0 deletions go/internal/mpc/mpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,15 @@ type Action struct {
// SoC remain the stable aggregate dispatch/API contract.
StoragePowerW map[string]float64 `json:"storage_power_w,omitempty"`
StorageEnergyWh map[string]float64 `json:"storage_energy_wh,omitempty"`

// LivePVSurplusSoCCap is a 0–1 SoC fraction that Core sets when it
// publishes the plan; solver output leaves it zero. Zero means the
// planner gives no permission to move live surplus into the battery in
// this slot. Above zero, live export beyond plan may charge the battery
// up to this SoC. Dispatch still applies its own limits, and an operator
// PVSurplusAbsorbSoCCap overrides it. Always sent, so a client can tell
// zero from a box that predates the field.
LivePVSurplusSoCCap float64 `json:"live_pv_surplus_soc_cap"`
}

// Baselines are counter-factual dispatch costs over the same horizon,
Expand Down
41 changes: 32 additions & 9 deletions go/internal/mpc/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log/slog"
"maps"
"math"
"slices"
"sort"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -494,6 +495,9 @@ func (s *Service) InstallPlan(plan Plan, params Params, loadpointID string) {
s.mu.Lock()
defer s.mu.Unlock()
copied := plan
// Own the actions, so storing the caps never writes into the caller's slice.
copied.Actions = slices.Clone(plan.Actions)
s.setLivePVSurplusSoCCapsLocked(copied.Actions, params)
s.last = &copied
s.executionPlan = s.last
s.lastParams = params
Expand Down Expand Up @@ -556,7 +560,8 @@ type SlotDirective struct {
// this slot's effective export revenue and minimum spread. Runtime may
// opportunistically move that future charge into live PV now, but only up
// to this SoC and only while the meter exports beyond plan. Zero means
// preserve the slot exactly.
// preserve the slot exactly. The value comes from the published Action,
// so the plan API shows the number dispatch uses.
LivePVSurplusSoCCap float64

// LoadpointEnergyWh carries per-loadpoint EV energy budgets for
Expand Down Expand Up @@ -588,12 +593,7 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) {
currentContract := s.executionPlan == p
failedReplacement := s.failedReplanGeneration > s.publishedReplanGeneration
lpID := s.lastLoadpointID
params := s.lastParams
if params.Mode == "" {
// Tests and the short startup window before the first replan have no
// effective-params snapshot yet. Keep the historical fallback there.
params = s.Defaults
}
params := s.directiveParamsLocked(s.lastParams)
s.mu.RUnlock()
if p == nil || failedReplacement || !s.planExecutionAllowed(p, params.PVCurtailment, currentContract) {
return SlotDirective{}, false
Expand All @@ -602,7 +602,7 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) {
return SlotDirective{}, false
}
nowMs := now.UnixMilli()
for i, a := range p.Actions {
for _, a := range p.Actions {
slotLenMs := int64(a.SlotLenMin) * 60 * 1000
endMs := a.SlotStartMs + slotLenMs
if nowMs < a.ExecutionStart() || nowMs >= endMs {
Expand All @@ -622,7 +622,7 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) {
PVCurtailActive: a.PVCurtailActive,
PVCurtailment: params.PVCurtailment,
GridW: a.GridW,
LivePVSurplusSoCCap: livePVSurplusSoCCap(p.Actions, i, params),
LivePVSurplusSoCCap: a.LivePVSurplusSoCCap,
}
if len(params.Storages) > 0 && len(a.StoragePowerW) > 0 {
d.StorageEnergyWh = make(map[string]float64, len(a.StoragePowerW))
Expand Down Expand Up @@ -721,6 +721,26 @@ func (snapshot PlanSnapshot) LoadpointPlanWindows(id string, now time.Time, max
return windows, totalWh
}

// directiveParamsLocked returns the params dispatch pairs with the active
// plan. Params published without a mode, as some tests do, fall back to
// Defaults. Callers hold s.mu.
func (s *Service) directiveParamsLocked(p Params) Params {
if p.Mode == "" {
return s.Defaults
}
return p
}

// setLivePVSurplusSoCCapsLocked stores each slot's live-surplus ceiling on a
// plan before it becomes active, so dispatch and the plan API read one number.
// Callers hold s.mu and pass actions no reader can see yet.
func (s *Service) setLivePVSurplusSoCCapsLocked(actions []Action, p Params) {
p = s.directiveParamsLocked(p)
for i := range actions {
actions[i].LivePVSurplusSoCCap = livePVSurplusSoCCap(actions, i, p)
}
}

// livePVSurplusSoCCap returns a quantified ceiling for moving later
// grid-funded charging into live PV in the current slot. This is deliberately
// derived from decisions already present in the plan rather than a blanket
Expand Down Expand Up @@ -1907,6 +1927,9 @@ func (s *Service) runReplan(request replanRequest) *Plan {
}

sanitizePlanLoads(&plan)
// Every solver path, fallback included, publishes here. No reader has the
// actions yet, and nothing edits them after this point.
s.setLivePVSurplusSoCCapsLocked(plan.Actions, p)
plan.DecisionID = s.nextDecisionIDLocked()
s.last = &plan
s.executionPlan = s.last
Expand Down
Loading
Loading