diff --git a/.changeset/plan-live-solar-capture.md b/.changeset/plan-live-solar-capture.md new file mode 100644 index 00000000..69721644 --- /dev/null +++ b/.changeset/plan-live-solar-capture.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The plan now includes, for each slot, how far live solar beyond the plan may charge the battery. diff --git a/go/internal/api/api_mpc_plan_test.go b/go/internal/api/api_mpc_plan_test.go index bf6a2f6b..e5ff3287 100644 --- a/go/internal/api/api_mpc_plan_test.go +++ b/go/internal/api/api_mpc_plan_test.go @@ -3,8 +3,10 @@ package api import ( "encoding/json" "math" + "net/http" "net/http/httptest" "testing" + "time" "github.com/srcfl/ftw/go/internal/mpc" ) @@ -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) + } +} diff --git a/go/internal/mpc/diagnose.go b/go/internal/mpc/diagnose.go index aaefe43b..2e0d1561 100644 --- a/go/internal/mpc/diagnose.go +++ b/go/internal/mpc/diagnose.go @@ -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 diff --git a/go/internal/mpc/mpc.go b/go/internal/mpc/mpc.go index c30eefa9..9aa7e4b6 100644 --- a/go/internal/mpc/mpc.go +++ b/go/internal/mpc/mpc.go @@ -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, diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index 7cff325e..d3730c59 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -5,6 +5,7 @@ import ( "log/slog" "maps" "math" + "slices" "sort" "sync" "sync/atomic" @@ -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 @@ -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 @@ -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 @@ -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 { @@ -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)) @@ -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 @@ -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 diff --git a/go/internal/mpc/service_live_pv_surplus_test.go b/go/internal/mpc/service_live_pv_surplus_test.go new file mode 100644 index 00000000..bb905c3a --- /dev/null +++ b/go/internal/mpc/service_live_pv_surplus_test.go @@ -0,0 +1,275 @@ +package mpc + +import ( + "context" + "encoding/json" + "math" + "path/filepath" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/state" +) + +const liveSurplusCapKey = "live_pv_surplus_soc_cap" + +// scheduledPlanOptimizer turns a fixed battery schedule into a plan that +// passes ValidatePlan, so a test decides which later slots buy grid energy. +type scheduledPlanOptimizer struct{ batteryW []float64 } + +func (o scheduledPlanOptimizer) Optimize(_ context.Context, slots []Slot, p Params) (Plan, error) { + plan := Plan{ + GeneratedAtMs: time.Now().UnixMilli(), Mode: p.Mode, HorizonSlots: len(slots), + CapacityWh: p.CapacityWh, InitialSoC: p.InitialSoC, + Solver: &SolverInfo{Engine: "test", Status: "optimal"}, + } + soc := p.InitialSoC + for i, slot := range slots { + batteryW := 0.0 + if i < len(o.batteryW) { + batteryW = o.batteryW[i] + } + soc += loadpoint.BatteryEnergyDeltaWh(batteryW, slot.DurationHours(), + p.ChargeEfficiency, p.DischargeEfficiency) / p.CapacityWh + a := Action{ + SlotStartMs: slot.StartMs, SlotLenMin: slot.LenMin, ExecutionStartMs: slot.ExecutionStartMs, + PriceOre: slot.PriceOre, SpotOre: slot.SpotOre, PVW: slot.PVW, LoadW: slot.LoadW, + BatteryW: batteryW, GridW: loadpoint.GridW(slot.LoadW, slot.PVW, batteryW, 0), SoC: soc, + } + a.CostOre = reserveSlotCost(slot, p, a) + plan.TotalCostOre += a.CostOre + plan.Actions = append(plan.Actions, a) + } + return plan, nil +} + +func (scheduledPlanOptimizer) Close() error { return nil } + +// newLiveSurplusService plans four 15-minute slots that import at 100 öre +// and export at 50 öre, with a 500 W house load and no sun. Later grid +// charge at 100 öre clears the 50 öre export plus the 20 öre minimum spread. +func newLiveSurplusService(t *testing.T, optimizer PlanOptimizer) *Service { + t.Helper() + st, err := state.Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + start := time.Now().UTC().Truncate(time.Minute).Add(-5 * time.Minute) + for i := 0; i < 4; i++ { + if err := st.SavePrices([]state.PricePoint{{ + Zone: "SE3", SlotTsMs: start.Add(time.Duration(i) * 15 * time.Minute).UnixMilli(), + SlotLenMin: 15, SpotOreKwh: 50, TotalOreKwh: 100, + Source: "test", FetchedAtMs: start.UnixMilli(), + }}); err != nil { + t.Fatal(err) + } + } + svc := New(st, nil, "SE3", Params{ + Mode: ModeArbitrage, SoCLevels: 101, ActionLevels: 41, + CapacityWh: 10000, SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, + MaxChargeW: 2000, MaxDischargeW: 2000, + ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, + // Stored energy is worth more than it costs to buy, so the Core + // planner charges from the grid in every slot. + TerminalSoCPrice: 300, + }) + svc.BaseLoad = 500 + svc.MinArbitrageSpreadOreKwh = 20 + svc.Optimizer = optimizer + return svc +} + +// actionJSON returns each published action as raw JSON fields, the shape the +// plan API serves. +func actionJSON(t *testing.T, plan *Plan) []map[string]json.RawMessage { + t.Helper() + body, err := json.Marshal(plan) + if err != nil { + t.Fatal(err) + } + var wire struct { + Actions []map[string]json.RawMessage `json:"actions"` + } + if err := json.Unmarshal(body, &wire); err != nil { + t.Fatal(err) + } + if len(wire.Actions) != len(plan.Actions) { + t.Fatalf("JSON has %d actions, plan has %d", len(wire.Actions), len(plan.Actions)) + } + return wire.Actions +} + +// assertDispatchReadsStoredCaps checks that dispatch gets the stored cap for +// every slot, and that the stored cap is what the published params give. +func assertDispatchReadsStoredCaps(t *testing.T, svc *Service, plan *Plan) { + t.Helper() + svc.mu.RLock() + params := svc.directiveParamsLocked(svc.lastParams) + svc.mu.RUnlock() + for i, a := range plan.Actions { + if want := livePVSurplusSoCCap(plan.Actions, i, params); a.LivePVSurplusSoCCap != want { + t.Errorf("slot %d stored cap = %v, want %v from the published params", i, a.LivePVSurplusSoCCap, want) + } + d, ok := svc.SlotDirectiveAt(time.UnixMilli(a.ExecutionStart()).Add(time.Second)) + if !ok { + t.Fatalf("slot %d: no directive", i) + } + if d.LivePVSurplusSoCCap != a.LivePVSurplusSoCCap { + t.Errorf("slot %d directive cap = %v, plan API cap = %v", i, d.LivePVSurplusSoCCap, a.LivePVSurplusSoCCap) + } + } +} + +// Every solver path publishes through one gate. A plan that buys grid energy +// later, at an import price above this slot's export price plus the minimum +// spread, must tell the plan API and dispatch the same SoC ceiling. +func TestReplanStoresLivePVSurplusSoCCap(t *testing.T) { + cases := []struct { + name string + optimizer PlanOptimizer + fallback bool + want float64 // 0: any value above zero + }{ + // Idle now, then 2 kW of grid charge in each of the next two slots: + // 0.5 + 2 × 2000 W × 0.25 h × 0.95 / 10 kWh. + {name: "external optimizer", optimizer: scheduledPlanOptimizer{batteryW: []float64{0, 2000, 2000, 0}}, want: 0.595}, + {name: "core planner"}, + {name: "core fallback after optimizer failure", optimizer: failingPrimaryOptimizer{}, fallback: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := newLiveSurplusService(t, tc.optimizer) + plan := svc.Replan(context.Background()) + if plan == nil || len(plan.Actions) != 4 { + t.Fatalf("plan = %+v", plan) + } + if got := plan.Solver != nil && plan.Solver.Fallback; got != tc.fallback { + t.Fatalf("fallback = %v, want %v", got, tc.fallback) + } + if svc.Latest() != plan { + t.Fatal("Replan returned a plan it did not publish") + } + got := plan.Actions[0].LivePVSurplusSoCCap + if got <= 0 || got > 1 { + t.Fatalf("current slot cap = %v, want a SoC above zero", got) + } + if tc.want > 0 && math.Abs(got-tc.want) > 1e-9 { + t.Fatalf("current slot cap = %v, want %v", got, tc.want) + } + assertDispatchReadsStoredCaps(t, svc, plan) + raw, ok := actionJSON(t, plan)[0][liveSurplusCapKey] + if !ok { + t.Fatalf("plan JSON omits %s", liveSurplusCapKey) + } + var wire float64 + if err := json.Unmarshal(raw, &wire); err != nil || wire != got { + t.Fatalf("plan JSON %s = %s, want %v", liveSurplusCapKey, raw, got) + } + }) + } +} + +// Without later grid-funded charge the planner gives no permission. The plan +// JSON still sends the zero, so a client can tell it from a box without the +// field. +func TestReplanWithoutLaterGridChargeSendsZeroLivePVSurplusSoCCap(t *testing.T) { + svc := newLiveSurplusService(t, scheduledPlanOptimizer{batteryW: []float64{0, 0, -1000, -1000}}) + plan := svc.Replan(context.Background()) + if plan == nil || len(plan.Actions) != 4 { + t.Fatalf("plan = %+v", plan) + } + for i, a := range plan.Actions { + if a.LivePVSurplusSoCCap != 0 { + t.Errorf("slot %d cap = %v, want 0", i, a.LivePVSurplusSoCCap) + } + } + assertDispatchReadsStoredCaps(t, svc, plan) + for i, fields := range actionJSON(t, plan) { + if raw, ok := fields[liveSurplusCapKey]; !ok || string(raw) != "0" { + t.Errorf("slot %d JSON has %s = %s (present %v), want 0", i, liveSurplusCapKey, raw, ok) + } + } +} + +// A diagnostic snapshot does not store the caps. After a restart, the +// restored plan must still give dispatch and the plan API the same ceiling +// the live plan had. +func TestRestoredDiagnosticKeepsLivePVSurplusSoCCap(t *testing.T) { + svc := newLiveSurplusService(t, scheduledPlanOptimizer{batteryW: []float64{0, 2000, 2000, 0}}) + var saved []byte + svc.SaveDiag = func(d *Diagnostic, _ string) error { + var err error + saved, err = json.Marshal(d) + return err + } + plan := svc.Replan(context.Background()) + if plan == nil || saved == nil { + t.Fatalf("plan = %+v, diagnostic saved = %t", plan, saved != nil) + } + var d Diagnostic + if err := json.Unmarshal(saved, &d); err != nil { + t.Fatal(err) + } + + restored := &Service{Defaults: Params{Mode: ModeArbitrage}} + if !restored.RestoreDiagnostic(&d, time.Now(), "") { + t.Fatal("RestoreDiagnostic returned false") + } + latest := restored.Latest() + if latest == nil || len(latest.Actions) != len(plan.Actions) { + t.Fatalf("restored plan = %+v", latest) + } + for i := range plan.Actions { + if got, want := latest.Actions[i].LivePVSurplusSoCCap, plan.Actions[i].LivePVSurplusSoCCap; math.Abs(got-want) > 1e-9 { + t.Errorf("slot %d restored cap = %v, live plan had %v", i, got, want) + } + } + if latest.Actions[0].LivePVSurplusSoCCap <= 0 { + t.Fatalf("restored current slot cap = %v, want above zero", latest.Actions[0].LivePVSurplusSoCCap) + } + assertDispatchReadsStoredCaps(t, restored, latest) +} + +// InstallPlan stores the caps on its own copy and leaves the caller's +// actions as they were. +func TestInstallPlanDoesNotWriteCallerActions(t *testing.T) { + now := time.Now() + start := now.Add(-time.Minute) + actions := []Action{ + {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}, + } + svc := &Service{} + svc.InstallPlan(Plan{GeneratedAtMs: now.UnixMilli(), Actions: actions}, + Params{Mode: ModeArbitrage, CapacityWh: 10000, ChargeEfficiency: 1}, "") + if actions[0].LivePVSurplusSoCCap != 0 { + t.Fatalf("caller action cap = %v, want it untouched", actions[0].LivePVSurplusSoCCap) + } + if got := svc.Latest().Actions[0].LivePVSurplusSoCCap; math.Abs(got-0.55) > 1e-9 { + t.Fatalf("published cap = %v, want 0.55", got) + } +} + +// The Core DP shadow swaps in a copy of the active plan with its comparison +// data. The copy must keep the stored caps. +func TestCoreDPShadowKeepsLivePVSurplusSoCCap(t *testing.T) { + now := time.Now() + start := now.Add(-time.Minute) + svc := &Service{} + svc.InstallPlan(Plan{DecisionID: testDecisionID1, GeneratedAtMs: now.UnixMilli(), Actions: []Action{ + {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}, + }}, Params{Mode: ModeArbitrage, CapacityWh: 10000, ChargeEfficiency: 1}, "") + before := svc.Latest() + svc.recordCoreDPShadow(*before, nil, Params{}, "test", now.UnixMilli(), &ShadowPlan{}) + after := svc.Latest() + if after == before || after.DPShadow == nil { + t.Fatal("shadow result did not replace the active plan") + } + if got := after.Actions[0].LivePVSurplusSoCCap; got != before.Actions[0].LivePVSurplusSoCCap || got <= 0 { + t.Fatalf("cap after shadow = %v, before = %v", got, before.Actions[0].LivePVSurplusSoCCap) + } + assertDispatchReadsStoredCaps(t, svc, after) +} diff --git a/go/internal/mpc/service_test.go b/go/internal/mpc/service_test.go index 241aa1cf..8cec0718 100644 --- a/go/internal/mpc/service_test.go +++ b/go/internal/mpc/service_test.go @@ -886,34 +886,31 @@ func TestSlotDirectiveAt(t *testing.T) { slotStart := now.Add(-3 * time.Minute) // we're 3 min into a 15-min slot slotLenMin := 15 - s := &Service{ - Defaults: Params{Mode: ModeArbitrage}, - lastParams: Params{ - Mode: ModeArbitrage, CapacityWh: 10000, ChargeEfficiency: 1, - }, - last: &Plan{ - DecisionID: testDecisionID1, - GeneratedAtMs: now.Add(-time.Minute).UnixMilli(), - Actions: []Action{ - { - SlotStartMs: slotStart.UnixMilli(), - SlotLenMin: slotLenMin, - SpotOre: 40, - BatteryW: 800, // 800 W × 15/60 h = 200 Wh for the slot - SoC: 0.455, - GridW: -150, // plan expects 150 W export - }, - { - SlotStartMs: slotStart.Add(15 * time.Minute).UnixMilli(), - SlotLenMin: slotLenMin, - PriceOre: 120, // later grid charge costs more than export earns now - BatteryW: 2000, - GridW: 1500, - SoC: 0.8, - }, + s := &Service{Defaults: Params{Mode: ModeArbitrage}} + // Publish through InstallPlan: Core stores the live-surplus cap on the + // plan when it publishes it, and dispatch only reads it. + s.InstallPlan(Plan{ + DecisionID: testDecisionID1, + GeneratedAtMs: now.Add(-time.Minute).UnixMilli(), + Actions: []Action{ + { + SlotStartMs: slotStart.UnixMilli(), + SlotLenMin: slotLenMin, + SpotOre: 40, + BatteryW: 800, // 800 W × 15/60 h = 200 Wh for the slot + SoC: 0.455, + GridW: -150, // plan expects 150 W export + }, + { + SlotStartMs: slotStart.Add(15 * time.Minute).UnixMilli(), + SlotLenMin: slotLenMin, + PriceOre: 120, // later grid charge costs more than export earns now + BatteryW: 2000, + GridW: 1500, + SoC: 0.8, }, }, - } + }, Params{Mode: ModeArbitrage, CapacityWh: 10000, ChargeEfficiency: 1}, "") d, ok := s.SlotDirectiveAt(now) if !ok {