Repository navigation
Expand file tree
/
Copy pathmpc.go
More file actions
1387 lines (1303 loc) · 54.4 KB
/
Copy pathmpc.go
File metadata and controls
1387 lines (1303 loc) · 54.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Package mpc is a receding-horizon energy scheduler. It turns forecast
// prices + forecast PV + current SoC into an optimal battery power
// schedule by running dynamic programming over a discretized SoC grid.
//
// We deliberately avoid an LP/QP solver here:
// - DP is exact over the quantization grid
// - No dependencies, one file of pure Go
// - Easy to explain + audit
// - Fast enough for horizons up to ~100 slots × 50 SoC × 50 actions
// (~250k state evaluations — under 50ms on any modern CPU)
//
// Site sign convention (same as the rest of the codebase):
//
// grid_w > 0 → importing (paying)
// grid_w < 0 → exporting
// pv_w < 0 → PV generating into site
// battery > 0 → charging (load on site)
// battery < 0 → discharging (source on site)
//
// Power balance per slot (from the grid meter's point of view). EV
// charge is a site load; the identity lives in loadpoint.GridW:
//
// grid_w = load_w + pv_w + battery_w + ev_w
//
// Battery efficiency: the `battery_w` we command is measured at the AC
// terminals (site-facing). Due to conversion losses, only a fraction
// actually lands in (or comes out of) the cells:
//
// charging (battery_w > 0): ΔSoC_kWh = +battery_w × dt × charge_eff
// discharging(battery_w < 0): ΔSoC_kWh = +battery_w × dt / discharge_eff
//
// So a 1000W charge command with 95% efficiency adds 950Wh/h to SoC. A
// 1000W discharge command with 95% efficiency drains ~1053Wh/h from SoC.
// Round-trip = charge_eff × discharge_eff (typically ~0.90).
package mpc
import (
"context"
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"time"
"github.com/srcfl/ftw/go/internal/gridcost"
"github.com/srcfl/ftw/go/internal/loadpoint"
)
// Mode selects how aggressively the planner uses the battery.
type Mode string
const (
// ModeSelfConsumption: only use the battery to cover local load or
// absorb PV surplus. Never import to charge; never export to discharge.
// Matches the behavior of the base control loop (no planning needed).
ModeSelfConsumption Mode = "self_consumption"
// ModeCheapCharge: allow importing to charge when prices are low
// (the DP decides based on forecast). Still never export battery to
// grid — discharge stays ≤ local load.
//
// Superseded by ModePassiveArbitrage as of v0.82 — the merged mode
// covers the same use case (charge from cheapest available source,
// never export battery) and removes the operator-facing choice
// between "smart self-consumption" and "cheap charge" that the
// planner is already capable of making on its own. Kept here for
// existing-config compatibility.
ModeCheapCharge Mode = "cheap_charge"
// ModePassiveArbitrage: charge the battery from the cheapest
// available energy source — PV surplus when there's sun, grid
// during cheap night hours when not — for use BY THE HOUSE.
// Battery never exports to grid. Subsumes both self_consumption
// (summer behavior, PV always wins) and cheap_charge (winter
// behavior, grid wins during off-peak). The DP picks per slot.
//
// Uses the strict-self-consumption bias (battery prefers covering
// house load over letting the grid do it) and the mean-import
// terminal price (every stored kWh is worth a typical retail
// import that it'll later displace).
ModePassiveArbitrage Mode = "passive_arbitrage"
// ModeArbitrage: unrestricted. Charge from grid, discharge to grid —
// whatever minimizes total cost over the horizon, subject to SoC and
// power limits.
//
// "Active arbitrage" in the v0.82 UI rename. Same DP behavior;
// difference vs ModePassiveArbitrage is solely battery-export
// permission. Operator opts in here when they want full timing
// arbitrage on both directions.
ModeArbitrage Mode = "arbitrage"
)
// IdleGateThresholdW is the per-slot average battery power below which the
// plan is treated as "idle this slot" by the planner_self control branch.
// Mirrors the chargeThresh used by reasonFor — a slot averaging less than
// this much battery action is interpreted as the DP declining to
// participate (e.g. saving surplus for a later slot with a richer price/PV
// mix). The control package duplicates this constant (same import-avoidance
// trick as SlotDirective) — keep the two in sync.
const IdleGateThresholdW = 100.0
// inputProvenanceSchemaVersion identifies the diagnostic meaning of the
// source and availability fields carried by Slot.
const inputProvenanceSchemaVersion = 1
// Slot is one input time slot for the optimizer.
type Slot struct {
// ExecutionStartMs trims the first price slot to the time still available.
// Zero means the full interval; StartMs/LenMin keep the price identity.
ExecutionStartMs int64 `json:",omitempty"`
StartMs int64
LenMin int
PriceOre float64 // total consumer öre/kWh (incl. grid + VAT) — used for IMPORT cost
SpotOre float64 // raw spot öre/kWh — used for EXPORT revenue (before bonus/fee)
PVW float64 // negative (site sign). 0 if no forecast.
LoadW float64 // positive (site sign). Defaults to a flat baseline.
// Input provenance is diagnostic metadata; it does not change optimizer
// math. AvailableAtMs is when Core fetched or created the row, not a
// provider issue time or model revision. WeatherRow identifies only the
// cached row consulted while constructing PVW; PVW may also include a
// learned twin, defaults, residual correction, forecast/twin blending, and
// a Go-DP downside adjustment.
InputProvenanceSchema int
PriceInputSource string
PriceInputAvailableAtMs int64
WeatherRowSource string
WeatherRowAvailableAtMs int64
// Limits caps grid flow for this slot. Zero value = unlimited.
// See power_limits.go for use cases (peak-tariff capacity, DSO
// curtailment, service-entrance current limit).
Limits PowerLimits
}
// Params bounds the optimization. All fields are required.
type Params struct {
PVCurtailment PVCurtailment
Mode Mode
// SoC grid
SoCLevels int // e.g. 41 (2.5% steps)
CapacityWh float64 // aggregate battery capacity
SoCMin float64 // hardware/chemistry floor — DP feasibility check refuses to land below this
SoCMax float64 // e.g. 95
InitialSoC float64
// InitialSoCUnclamped records the SoC the site actually reported when it
// had drifted outside SoCMin…SoCMax and InitialSoC was pulled onto the
// band edge so Core could plan at all. Zero when no clamp was needed. It
// is a record, not an input: nothing solves from it.
InitialSoCUnclamped float64
// Forecast-risk reserve is handled outside the DP cost now: the planner
// optimises against downside PV (forecast − k·σ) via
// service.go:applyPVDownside, replacing the former SoCSafetyFloorPct /
// SafetyFloorPenaltyOreKwhHour soft floor. See the Alt 2 design spec.
// PVChargeBonusOreKwh credits each kWh of battery charge that
// comes from live PV surplus. Operator preference: prefer using
// solar NOW (certain) over exporting cheap and refilling from
// grid LATER (forecast). Without this bias the DP is economically
// indifferent between the two when terminal credit is roughly
// equal — it would happily export 27 kWh at 10 öre/kWh and refill
// from a 30 öre/kWh cheap-night hour, losing ~10 % round-trip and
// dragging itself through forecast risk for no gain.
//
// Magnitude: ~30 öre/kWh by default. Large enough to dominate the
// typical PV-export spot price (5-30 öre/kWh) and the round-trip
// efficiency cost; small enough to leave the door open for
// genuine arbitrage (active_arbitrage paths still see spreads
// >100 öre/kWh).
//
// Applies in every mode, matching the MILP (#1020). 0 disables.
// The cost reduction is bounded by the live PV surplus this slot —
// DP can never claim a bonus larger than the surplus available, so
// the bias never converts to grid-charge incentive.
PVChargeBonusOreKwh float64
// Action grid (+charge, −discharge; site sign)
ActionLevels int // odd number preferred so 0 is represented (e.g. 21)
MaxChargeW float64 // ≥ 0
MaxDischargeW float64 // ≥ 0 (magnitude)
// Efficiency (0..1). Default 0.95 each → ~90% round-trip.
ChargeEfficiency float64
DischargeEfficiency float64
// Terminal valuation. If > 0, we credit the plan with
// TerminalSoCPrice × remaining_kwh at the final slot. Prevents the
// planner from always ending at SoCMin to minimize cost. A good
// default is the mean price over the horizon.
TerminalSoCPrice float64
// Export revenue. Two modes:
// - If ExportOrePerKWh > 0, every slot earns this flat rate on
// export. Useful for operators with a fixed feed-in tariff.
// - If ExportOrePerKWh == 0, each slot earns
// slot.SpotOre + ExportBonusOreKwh − ExportFeeOreKwh
// i.e. raw spot pricing. The value can go negative when spot
// is negative (Sweden + Nordic markets, increasingly common
// during midday solar peaks). The DP treats a negative export
// ore as a positive cost — exactly right when most retailers
// pass the negative spot through to the customer.
//
// ExportFloorOreKwh, if non-nil, clamps the per-slot export
// ore at the given floor. Set to a pointer-to-zero for
// retailers that cap export at 0 öre (no negative-spot
// billing). nil = no clamp (default; matches the physics).
ExportOrePerKWh float64
ExportBonusOreKwh float64
ExportFeeOreKwh float64
ExportFloorOreKwh *float64
// MinArbitrageSpreadOreKwh is a virtual cost in öre per kWh of battery
// discharge throughput, applied ONLY in the arbitrage modes
// (ModeArbitrage / ModePassiveArbitrage). It is the operator's
// "minimum arbitrage spread": the planner won't cycle the battery for
// grid arbitrage unless the price gain beats this many öre/kWh on top
// of the round-trip efficiency loss. It biases the DP's *decision*
// only — the reported Plan.CostOre is recomputed from raw grid flow
// (SlotGridCostOre), so savings accounting is unaffected. 0 disables.
MinArbitrageSpreadOreKwh float64
// Loadpoint extends the DP state space with one EV charge point.
// Nil (default) keeps the battery-only optimization path. See
// LoadpointSpec for the state/action shape.
Loadpoint *LoadpointSpec
// Loadpoints is the unbounded mathematical-planner representation. The Go
// DP fallback consumes Loadpoint (the first active entry) only.
Loadpoints []*LoadpointSpec
// Storages preserves the physical battery fleet for the mathematical
// optimizer. Dispatch still consumes the aggregate BatteryW target, while
// the optimizer and validator enforce each online battery's own energy and
// power bounds. The Go DP fallback continues to use the aggregate fields.
Storages []StorageAssetSpec
// PV uncertainty is the one snapshot Service used to move the planning
// slots to the PV downside, kept here for diagnostics and replay.
// PVRelativeUncertainty (0..1) sizes the spread per slot against that
// slot's own generation; 0 keeps the flat PVUncertaintyW watt figure.
PVUncertaintyW float64
PVRelativeUncertainty float64
PVForecastSafetyK float64
// DemandCharges are peak-power tariffs Core expanded to clock hours.
// Empty leaves Energyplan on energy prices only.
DemandCharges []DemandCharge
// ImportHours carry the owner's hourly import ceiling: each clock hour
// the plan spans may import ImportMeanW over its length, less what it had
// used. ValidatePlan holds every plan to them, Core's own planners spread
// each hour over its slots, and a worker that announces
// hourly_import_caps gets them on its wire.
ImportMeanW float64
ImportHours []ImportHour
}
// StorageAssetSpec is one independently constrained home battery in the
// mathematical planner. Power follows the site convention: positive charges
// the battery and negative discharges it.
type StorageAssetSpec struct {
ID string
CapacityWh float64
InitialEnergyWh float64
MinEnergyWh float64
MaxEnergyWh float64
MaxChargeW float64
MaxDischargeW float64
ChargeEfficiency float64
DischargeEfficiency float64
}
func (p Params) activeLoadpoints() []*LoadpointSpec {
if len(p.Loadpoints) > 0 {
out := make([]*LoadpointSpec, 0, len(p.Loadpoints))
for _, lp := range p.Loadpoints {
if lp.active() {
out = append(out, lp)
}
}
if len(out) > 0 {
return out
}
}
if p.Loadpoint.active() {
return []*LoadpointSpec{p.Loadpoint}
}
return nil
}
// Action is one scheduled battery target.
type Action struct {
ExecutionStartMs int64 `json:"execution_start_ms,omitempty"`
SlotStartMs int64 `json:"slot_start_ms"`
SlotLenMin int `json:"slot_len_min"`
PriceOre float64 `json:"price_ore"`
// SpotOre is the raw wholesale spot price (öre/kWh, ex grid tariff
// and VAT). Surfaced so the UI can break the price bar into
// components (spot + grid tariff + VAT) — pedagogical view of
// where the kr/kWh actually goes. Mirrors Slot.SpotOre.
SpotOre float64 `json:"spot_ore"`
PVW float64 `json:"pv_w"`
ForecastPVW *float64 `json:"forecast_pv_w,omitempty"`
ForecastLoadW *float64 `json:"forecast_load_w,omitempty"`
LoadW float64 `json:"load_w"`
BatteryW float64 `json:"battery_w"` // decision (site sign, AC terminals)
GridW float64 `json:"grid_w"` // resulting grid power
SoC float64 `json:"soc"` // 0–1 at END of slot
CostOre float64 `json:"cost_ore"` // this slot's cost (öre). Negative = revenue.
Reason string `json:"reason"` // short human-readable explanation
EMSMode string `json:"ems_mode"` // effective EMS mode for this slot (set by SlotAt post-processing)
// PVLimitW is the recommended cap on PV inverter output (W, positive).
// When PVCurtailActive is false, 0 means no cap (a dispatch hint may
// still use a positive PVLimitW without rewriting GridW). When
// PVCurtailActive is true, 0 is a real zero cap already applied to
// GridW. Includes house load + battery charge + any planned EV
// loadpoint charge so that curtailment does not starve loads the
// plan itself scheduled. Consumed by the control loop only when
// the driver advertises `supports_pv_curtail`.
PVLimitW float64 `json:"pv_limit_w,omitempty"`
PVCurtailActive bool `json:"pv_curtail_active,omitempty"`
// LoadpointW is the EV charger power (W, positive = charging) the
// DP picked for this slot. Zero when no loadpoint was in Params
// or the DP chose "don't charge" this slot. Per-loadpoint in a
// multi-LP future; single-value for now.
LoadpointW float64 `json:"loadpoint_w,omitempty"`
// LoadpointSoC is the EV SoC at END of slot, following the
// same convention as SoC for the home battery.
LoadpointSoC float64 `json:"loadpoint_soc,omitempty"`
LoadpointPowerW map[string]float64 `json:"loadpoint_power_w,omitempty"`
// Core reserve plans may spend an exact Wh budget at this legal on-power
// for part of a slot. It is an instantaneous ceiling, not average demand.
LoadpointMaxPowerW map[string]float64 `json:"loadpoint_max_power_w,omitempty"`
LoadpointSoCByID map[string]float64 `json:"loadpoint_soc_by_id,omitempty"`
// Per-storage values make a multi-battery solve auditable. BatteryW and
// 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,
// included alongside the plan so the UI can show "savings vs X" numbers
// without re-deriving the cost model client-side. All values in öre.
//
// NoBatteryOre is the cost if the battery didn't exist at all (grid =
// load + pv each slot, priced with the same import/export model the DP
// uses). SelfConsumptionOre comes from re-running Optimize with
// ModeSelfConsumption — so it uses the real efficiency, power, and SoC
// constraints, not a simplified simulation. FlatAvgOre re-prices the
// no-battery flows at horizon-mean prices (import and export each at
// their own mean) — shows the value of *timing* (shifting load to
// cheap hours), separate from the value of having a battery.
// AvgPriceOre is the time-weighted mean import price over the horizon;
// NetKWh is import minus export volume.
type Baselines struct {
NoBatteryOre float64 `json:"no_battery_ore"`
SelfConsumptionOre float64 `json:"self_consumption_ore"`
FlatAvgOre float64 `json:"flat_avg_ore"`
AvgPriceOre float64 `json:"avg_price_ore"`
NetKWh float64 `json:"net_kwh"`
}
// Plan is the output.
type Plan struct {
// DecisionID identifies one plan accepted by the Core service. It is assigned
// after the latest-generation gate, so solver attempts that never become
// active do not get an execution-facing identity. Together with an
// Action's SlotStartMs it forms the stable key for one planned decision.
// Direct Optimize callers leave it empty; the service fills it on publish.
DecisionID string `json:"decision_id,omitempty"`
GeneratedAtMs int64 `json:"generated_at_ms"`
Mode Mode `json:"mode"`
HorizonSlots int `json:"horizon_slots"`
CapacityWh float64 `json:"capacity_wh"`
InitialSoC float64 `json:"initial_soc"`
TotalCostOre float64 `json:"total_cost_ore"`
Actions []Action `json:"actions"`
Baselines *Baselines `json:"baselines,omitempty"`
Solver *SolverInfo `json:"solver,omitempty"`
DPShadow *ShadowPlan `json:"dp_shadow,omitempty"`
DPEvaluationShadow *ShadowPlan `json:"dp_evaluation_shadow,omitempty"`
// OptimizerInput is the exact versioned request used by an external
// optimizer. It is omitted from the live plan API and copied into the
// persisted Diagnostic for deterministic replay.
OptimizerInput json.RawMessage `json:"-"`
LoadpointShortfallWh map[string]float64 `json:"loadpoint_shortfall_wh,omitempty"`
// CautionOff is set when the planning style asks for forecast caution and
// this plan uses the point forecast instead: the caution inputs were
// invalid, or a caution plan could not keep the point plan's limits and
// departure goals. The planning warnings say why.
CautionOff bool `json:"caution_off,omitempty"`
}
// ShadowPlan is a challenger candidate calculated alongside the active plan.
// It is diagnostic only and is never read by dispatch. ActiveMinusShadowOre is
// negative when the challenger has lower raw cost.
type ShadowPlan struct {
TotalCostOre float64 `json:"total_cost_ore"`
ActiveMinusShadowOre float64 `json:"active_minus_shadow_ore"`
ForecastBasis string `json:"forecast_basis"`
MeanAbsBatteryDeltaW float64 `json:"mean_abs_battery_delta_w"`
MaxAbsBatteryDeltaW float64 `json:"max_abs_battery_delta_w"`
DirectionDisagreements int `json:"direction_disagreements"`
ComparedSlots int `json:"compared_slots"`
FirstAction *Action `json:"first_action,omitempty"`
Solver *SolverInfo `json:"solver,omitempty"`
// Terminal-corrected costs net out the terminal-SoC credit both solvers
// optimize with but neither reports. Two plans that park the horizon at
// different SoC are not comparable on raw cost: the fuller one is storing
// value, not overspending, and on a 9.6 kWh pack that artifact is worth up
// to ~21 SEK. Filled by the Core DP shadow; zero on blocks written before
// it existed.
ActiveTerminalCorrectedOre float64 `json:"active_terminal_corrected_ore,omitempty"`
TerminalCorrectedOre float64 `json:"terminal_corrected_ore,omitempty"`
ActiveMinusShadowTerminalCorrectedOre float64 `json:"active_minus_shadow_terminal_corrected_ore,omitempty"`
}
// terminalCorrectedOre nets the terminal-SoC credit out of a raw grid cost so
// two plans ending at different SoC compare honestly. Same formula as the DP's
// terminal value (Optimize).
func terminalCorrectedOre(rawCostOre, endSoC float64, p Params) float64 {
return rawCostOre - p.TerminalSoCPrice*(endSoC*p.CapacityWh)/1000.0
}
// planEndSoC is the SoC the plan parks the horizon at.
func planEndSoC(plan *Plan) float64 {
if plan == nil || len(plan.Actions) == 0 {
return 0
}
return plan.Actions[len(plan.Actions)-1].SoC
}
// SolverInfo identifies the engine that produced a plan and records enough
// detail to audit which planner ran. Engine "core" / Backend "dp" is the
// in-process Go DP; Fallback separates "core is the configured planner" from
// "the external planner failed and the DP caught it".
type SolverInfo struct {
Engine string `json:"engine"`
Backend string `json:"backend,omitempty"`
Status string `json:"status"`
Formulation string `json:"formulation,omitempty"`
// SoCLevels and ActionLevels are the DP's discretization grid — the
// dimension that bounds how close it lands to a continuous optimum, and
// the one derated when a loadpoint joins the state space. Empty for
// solvers that do not discretize.
SoCLevels int `json:"soc_levels,omitempty"`
ActionLevels int `json:"action_levels,omitempty"`
ObjectiveOre float64 `json:"objective_ore,omitempty"`
LowerBoundOre *float64 `json:"lower_bound_ore,omitempty"`
AbsoluteGapOre *float64 `json:"absolute_gap_ore,omitempty"`
SearchNodes int64 `json:"search_nodes,omitempty"`
ServiceSlack float64 `json:"service_slack,omitempty"`
SolveMs float64 `json:"solve_ms,omitempty"`
PrepareMs float64 `json:"prepare_ms,omitempty"`
BuildMs float64 `json:"build_ms,omitempty"`
SolverMs float64 `json:"solver_ms,omitempty"`
CacheHit bool `json:"cache_hit,omitempty"`
DPP bool `json:"dpp,omitempty"`
MIPGap *float64 `json:"mip_gap,omitempty"`
ScenarioCount int `json:"scenario_count,omitempty"`
ScenarioOriginalCount int `json:"scenario_original_count,omitempty"`
ScenarioReductionError float64 `json:"scenario_reduction_error,omitempty"`
ScenarioPolicy string `json:"scenario_policy,omitempty"`
PolicyVersion string `json:"policy_version,omitempty"`
PolicyConfig string `json:"policy_config,omitempty"`
NonAnticipativeSlots int `json:"non_anticipative_slots,omitempty"`
TreeNodes int `json:"tree_nodes,omitempty"`
MoveBlocks int `json:"move_blocks,omitempty"`
Decomposition string `json:"decomposition,omitempty"`
RiskModel string `json:"risk_model,omitempty"`
CVaRWeight float64 `json:"cvar_weight,omitempty"`
CVaRAlpha float64 `json:"cvar_alpha,omitempty"`
ServiceCVaRWeight float64 `json:"service_cvar_weight,omitempty"`
ServiceCVaRAlpha float64 `json:"service_cvar_alpha,omitempty"`
EconomicCVaRWeight float64 `json:"economic_cvar_weight,omitempty"`
EconomicCVaRAlpha float64 `json:"economic_cvar_alpha,omitempty"`
PHIterations int `json:"ph_iterations,omitempty"`
PHResidualW float64 `json:"ph_residual_w,omitempty"`
PHRho float64 `json:"ph_rho,omitempty"`
ModelVariables int `json:"model_variables,omitempty"`
ModelConstraints int `json:"model_constraints,omitempty"`
Fallback bool `json:"fallback,omitempty"`
FallbackReason string `json:"fallback_reason,omitempty"`
}
// SlotGridCostOre returns the raw öre cost of flowing gridKWh across the
// meter during a slot. Positive gridKWh = importing at consumer price;
// negative = exporting, revenue = spot + bonus − fee (or a flat rate if
// p.ExportOrePerKWh > 0). Export revenue is allowed to go negative unless
// p.ExportFloorOreKwh explicitly floors it.
//
// Shared by plan reporting, baselines, diagnostics reconstruction, and
// curtailment. The DP decides with this same export-price model.
func SlotGridCostOre(slot Slot, gridKWh float64, p Params) float64 {
return gridcost.GridCostOre(slot.PriceOre, slot.SpotOre, gridKWh, exportPricingFromParams(p))
}
// SlotExportPriceOre returns the öre/kWh a slot's export earns, using the
// same model SlotGridCostOre uses on the export side: a flat
// ExportOrePerKWh wins if set, otherwise spot + bonus − fee, optionally
// floored by ExportFloorOreKwh. Negative values are intentional: under
// pass-through retail agreements, exporting during negative spot is a cost.
func SlotExportPriceOre(slot Slot, p Params) float64 {
return gridcost.ExportPriceOre(slot.SpotOre, exportPricingFromParams(p))
}
func exportPricingFromParams(p Params) gridcost.ExportPricing {
return gridcost.ExportPricing{
BonusOreKwh: p.ExportBonusOreKwh,
FeeOreKwh: p.ExportFeeOreKwh,
FlatOreKwh: p.ExportOrePerKWh,
FloorOreKwh: p.ExportFloorOreKwh,
}
}
func finite(v float64) bool {
return !math.IsNaN(v) && !math.IsInf(v, 0)
}
func gridIndex(value, min, step float64, n int) int {
if n <= 1 || step <= 0 {
return 0
}
i := int(math.Round((value - min) / step))
if i < 0 {
return 0
}
if i >= n {
return n - 1
}
return i
}
func operatingBoundWorsens(from, to, min, max float64) bool {
const eps = 1e-9
return math.Max(0, min-to) > math.Max(0, min-from)+eps ||
math.Max(0, to-max) > math.Max(0, from-max)+eps
}
// clipBatteryPowerToBand reduces a DP action so continuous SoC does not
// worsen operating-bound recovery. Policy is looked up on the grid; energy
// is not, so a charge that lands on max from the nearest grid point can
// overshoot from the real SoC.
func clipBatteryPowerToBand(soc, powerW, dtH, capacityWh, etaC, etaD, min, max float64) float64 {
if capacityWh <= 0 || dtH <= 0 {
return 0
}
delta := loadpoint.BatteryEnergyDeltaWh(powerW, dtH, etaC, etaD) / capacityWh
if !operatingBoundWorsens(soc, soc+delta, min, max) {
return powerW
}
if powerW > 0 {
headroom := max - soc
if headroom <= 0 || etaC <= 0 {
return 0
}
return headroom * capacityWh / (dtH * etaC)
}
headroom := soc - min
if headroom <= 0 || etaD <= 0 {
return 0
}
return -headroom * capacityWh * etaD / dtH
}
func sanitizeOptimizeSlots(slots []Slot) []Slot {
out := make([]Slot, 0, len(slots))
for _, s := range slots {
if s.LenMin <= 0 {
s.LenMin = 60
}
if !finite(s.PriceOre) || !finite(s.SpotOre) {
continue
}
if !finite(s.PVW) {
s.PVW = 0
}
if !finite(s.LoadW) || s.LoadW < 0 {
s.LoadW = 0
}
if !finite(s.Limits.MaxImportW) {
s.Limits.MaxImportW = 0
}
if !finite(s.Limits.MaxExportW) {
s.Limits.MaxExportW = 0
}
out = append(out, s)
}
return out
}
// Optimize runs DP and returns the cost-minimizing plan.
//
// Complexity: O(N × S × A) where N = len(slots), S = SoCLevels, A = ActionLevels.
// For a 96-slot (24h × 15m) horizon with 41 SoC × 21 action levels, that's
// ~82k evaluations — well under 10ms.
func Optimize(slots []Slot, p Params) Plan {
plan, _ := OptimizeContext(context.Background(), slots, p)
return plan
}
// OptimizeContext bounds background DP work and discards a cancelled solve.
func OptimizeContext(ctx context.Context, slots []Slot, p Params) (Plan, error) {
if err := ctx.Err(); err != nil {
return Plan{}, err
}
if err := coreDPModelError(p); err != nil {
return Plan{}, err
}
now := time.Now().UnixMilli()
if err := validatePartialSlots(slots); err != nil {
return Plan{}, err
}
slots = sanitizeOptimizeSlots(slots)
if len(slots) == 0 || p.CapacityWh <= 0 {
return Plan{GeneratedAtMs: now, Mode: p.Mode}, nil
}
if p.Mode == "" {
p.Mode = ModeSelfConsumption
}
if p.ChargeEfficiency <= 0 {
p.ChargeEfficiency = 0.95
}
if p.DischargeEfficiency <= 0 {
p.DischargeEfficiency = 0.95
}
N := len(slots)
S := p.SoCLevels
if S < 3 {
S = 3
}
A := p.ActionLevels
if A < 3 {
A = 3
}
// Bound allocation before constructing any grid. The two value/policy
// tables use 16 bytes per state plus slice headers. Cancellation cannot
// stop make from exhausting the box or panicking on an integer overflow.
evLevels := 1
if p.Loadpoint.active() {
evLevels = max(2, p.Loadpoint.Levels)
}
workingBytes := (float64(N)+1)*float64(S)*(16*float64(evLevels)+48) + (float64(A)+1)*8
const maxDPWorkingBytes = 64 * 1024 * 1024
if workingBytes > maxDPWorkingBytes {
return Plan{}, fmt.Errorf("Core DP exceeds its 64 MiB working memory budget")
}
socStep := (p.SoCMax - p.SoCMin) / float64(S-1)
socAt := func(i int) float64 { return p.SoCMin + float64(i)*socStep }
// The mean price scales the EV deadline penalty and labels reasons.
meanPrice := horizonMeanPrice(slots)
importCaps, carImportCaps := importHourCapsW(slots, p)
// Action grid spans −MaxDischargeW … +MaxChargeW and always contains
// 0 W. With asymmetric charge/discharge limits, an odd number of evenly
// spaced points does not put zero at the midpoint, so inject it
// explicitly and remember the true idle index for infeasible fallbacks.
actionGrid := make([]float64, 0, A+1)
hasZero := false
for j := 0; j < A; j++ {
frac := float64(j) / float64(A-1) // 0..1
w := -p.MaxDischargeW + frac*(p.MaxChargeW+p.MaxDischargeW)
if math.Abs(w) < 1e-9 {
w = 0
hasZero = true
}
actionGrid = append(actionGrid, w)
}
if !hasZero {
actionGrid = append(actionGrid, 0)
}
sort.Float64s(actionGrid)
A = len(actionGrid)
idleActionIdx := 0
idleAbs := math.Inf(1)
for i, w := range actionGrid {
if aw := math.Abs(w); aw < idleAbs {
idleAbs = aw
idleActionIdx = i
}
}
actionAt := func(j int) float64 { return actionGrid[j] }
// EV dimensions. When no loadpoint is active, EL=EA=1 and the
// EV loops degenerate to a single pass that adds zero power /
// zero SoC — functionally identical to the legacy battery-only
// DP. Keeps one code path.
lp := p.Loadpoint
evActive := lp.active()
EL := 1
EA := 1
var evSteps []float64
var evSocStep float64
var evChargeEff float64 = 0.9
if evActive {
EL = lp.Levels
evSteps = lp.normalizedSteps()
EA = len(evSteps)
if lp.SoCMax <= lp.SoCMin {
lp.SoCMax = 1.0
lp.SoCMin = 0
}
evSocStep = (lp.SoCMax - lp.SoCMin) / float64(EL-1)
if lp.ChargeEfficiency > 0 {
evChargeEff = lp.ChargeEfficiency
}
}
evSocAt := func(e int) float64 {
if !evActive {
return 0
}
return lp.SoCMin + float64(e)*evSocStep
}
evActionW := func(ea int) float64 {
if !evActive {
return 0
}
return evSteps[ea]
}
// V[t][s][e] = minimum expected cost from slot t onward, starting
// from battery SoC index s and EV SoC index e. Backward-filled.
V := make([][][]float64, N+1)
Policy := make([][][]int, N) // encodes (battActionIdx * EA + evActionIdx)
for t := 0; t <= N; t++ {
V[t] = make([][]float64, S)
for si := 0; si < S; si++ {
V[t][si] = make([]float64, EL)
}
if t < N {
Policy[t] = make([][]int, S)
for si := 0; si < S; si++ {
Policy[t][si] = make([]int, EL)
}
}
}
// Deadline slot for the EV target penalty; -1 means opportunistic
// charging only. Energyplan receives the same slot.
deadlineSlot := -1
if evActive {
deadlineSlot = lp.deadlineSlot(N)
}
// Terminal values. Battery SoC credits stored energy. EV SoC
// carries no terminal cost — the deadline-slot penalty below
// handles target enforcement and avoids double-counting.
for si := 0; si < S; si++ {
battKwh := p.CapacityWh * socAt(si) / 1000.0
battCredit := -p.TerminalSoCPrice * battKwh
for ei := 0; ei < EL; ei++ {
V[N][si][ei] = battCredit
}
}
// Backwards induction.
for t := N - 1; t >= 0; t-- {
if err := ctx.Err(); err != nil {
return Plan{}, err
}
slot := slots[t]
dtH := slot.DurationHours()
for si := 0; si < S; si++ {
soc := socAt(si)
for ei := 0; ei < EL; ei++ {
evSoc := evSocAt(ei)
bestV := math.Inf(1)
bestPolicy := 0
for ba := 0; ba < A; ba++ {
battW := actionAt(ba)
// Battery SoC transition (independent of EV).
dBattWh := loadpoint.BatteryEnergyDeltaWh(battW, dtH, p.ChargeEfficiency, p.DischargeEfficiency)
battSoc2 := soc + dBattWh/p.CapacityWh
if battSoc2 < p.SoCMin-1e-9 || battSoc2 > p.SoCMax+1e-9 {
continue
}
for ea := 0; ea < EA; ea++ {
evW := evActionW(ea)
// EV SoC transition: only charging, so non-
// negative by construction. Skip actions
// that would overshoot max (realistic
// chargers taper, but we approximate).
var evSoc2 float64
if evActive {
dEvWh := evW * dtH * evChargeEff
evSoc2 = evSoc + dEvWh/lp.CapacityWh
if evSoc2 > lp.SoCMax+1e-9 {
continue
}
}
// EV appears as a site load (+ site-signed).
gridW := loadpoint.GridW(slot.LoadW, slot.PVW, battW, evW)
// Surplus-only EV: take at most leftover PV
// after house load. Site import caused by a
// simultaneous home-battery grid-charge is not
// the car importing — forbidding gridW > 0
// whenever evW > 0 forced the DP to idle the
// car on every cheap slot the battery wanted
// to buy, which is how "EV takes the PV, Pixii
// never grid-charges" and the reverse
// "battery buys, car sits in the sun" both
// appear on the same site. evW = 0 is always
// feasible, so a no-PV day degrades to "miss
// the deadline" rather than an infeasible
// plan. Epsilon is loadpoint.SitePowerEpsW.
if evActive && lp.SurplusOnly && surplusOnlyExceedsHousePV(evW, slot.LoadW, slot.PVW) {
continue
}
// Surplus-only must not also ban home-battery
// grid charge. The leftover-PV rule above
// keeps the car off grid, and blocksBatteryToEV()
// below already rejects battery→EV, so the
// "launder cheap grid through the battery into
// the car" path is closed without forbidding
// Pixii/house-battery arbitrage while the car
// is taking real PV. Active arbitrage with a
// surplus-only EV is a real operator setup.
// Don't simultaneously discharge the home battery
// for grid export AND charge the EV. Either the
// battery is being sold for arbitrage profit (and
// EV charging would erode the export), or it's a
// roundtrip-laundering loop (battery → AC → EV at
// 2× round-trip loss vs. just charging the EV
// from grid in a different slot). Letting both
// happen at once is strictly worse than picking a
// slot that does one or the other. The deadline
// shortfall penalty handles "but the EV needs to
// charge eventually" — the DP will move EV
// charging to a slot where battery isn't
// exporting. 50 W epsilon mirrors the surplus-
// only constraints above.
if evActive && evW > 0 && battW < 0 && gridW < -50 {
continue
}
// Battery-to-EV block: operator has BatteryCoversEV=false
// (the default), or this loadpoint is surplus-only. Same
// conservation check as ValidatePlan and the runtime
// clamp in control/dispatch.go (search CANONICAL
// "battery may not feed EV"): loadpoint.BatteryDischargeFeedsEV.
if evActive && lp.blocksBatteryToEV() && loadpoint.BatteryDischargeFeedsEV(battW, evW, slot.LoadW, slot.PVW) {
continue
}
// Mode-based feasibility. Baseline includes
// EV so the mode check asks "is the extra
// battery action pulling the grid further
// into import/export than baseline?".
baseGridW := loadpoint.GridW(slot.LoadW, slot.PVW, 0, evW)
if !modeAllows(p.Mode, baseGridW, gridW, battW) {
continue
}
// Per-slot power limits, and the slot's share of
// its clock hour's import budget.
if !slot.Limits.allowsImport(gridW) || !slot.Limits.allowsExport(gridW) {
continue
}
if importCaps != nil && (gridW > importCaps[t] || evW > 0 && gridW > carImportCaps[t]) {
continue
}
gridKWh := gridW * dtH / 1000.0
var cost float64
if gridKWh > 0 {
cost = slot.PriceOre * gridKWh
} else {
cost = -SlotExportPriceOre(slot, p) * (-gridKWh)
}
// Strict self-consumption bias. When the mode
// is self_consumption we add a penalty equal
// to 2× effPrice per kWh of HOUSE-driven grid
// import — so the total house-import cost the
// DP sees is tripled. That makes discharge
// strictly cheaper than idle whenever the
// battery can physically supply house load.
//
// The penalty applies only to the house
// portion of the import (gridW minus EV). EV
// charging has its own deadline-shortfall
// penalty (below, 4× meanPrice per kWh of
// shortfall); applying the SC bias on top
// would let the DP prefer missing an EV
// deadline over charging it whenever the
// slot price exceeded ~4/3 of the horizon
// mean — Codex P1 on PR #122.
//
// Rationale: operator picked self_consumption
// because they want "use my battery before
// grid." Pure cost-minimisation over a long
// horizon with a high horizon-mean will
// sometimes prefer importing today to
// preserve SoC for tomorrow's peak — that's
// arbitrage behaviour, not self-consumption.
// The bias extends all the way down to
// SoCMin: the operator's configured floor
// IS the reserve, no implicit extra buffer on
// top of it (#157). The hard floor is still
// enforced by the SoC-transition feasibility
// check above (line 357), so the bias can only
// push discharge *toward* the floor, never
// past it.
if p.Mode == ModeSelfConsumption || p.Mode == ModePassiveArbitrage {
houseGridW := slot.LoadW + slot.PVW + battW
if houseGridW > 0 {
houseKWh := houseGridW * dtH / 1000.0
cost += strictSCBiasOre(slot.PriceOre, houseKWh)
}
}
// Forecast-risk reserve is no longer a SoC/energy floor.
// The planner instead optimises against DOWNSIDE PV
// (forecast − k·σ, applied in service.go:applyPVDownside),
// so a reserve emerges from the live forecast uncertainty
// itself — sized to the real risk, zero when PV is certain
// (clear day) or absent (winter). Keep this reserve in
// sync with the downside-PV tests.
// PV-first bias: when this slot has PV surplus
// AND the action charges the battery, credit a
// bonus for the kWh that came from the surplus.
// Operator preference: certain PV now beats
// forecast grid later, even if the DP sees them
// as economically equivalent. The bonus is
// bounded by the live surplus so it never
// incentivises grid-charging.
// Applied in every mode, matching the MILP
// (parity fix, #1020 — the DP used to gate
// this to passive_arbitrage; the bound to
// live surplus is what keeps it safe, not
// the mode).
if p.PVChargeBonusOreKwh > 0 && battW > 0 {
pvSurplusW := -slot.PVW - slot.LoadW
if pvSurplusW > 0 {
chargeFromPVW := battW
if chargeFromPVW > pvSurplusW {
chargeFromPVW = pvSurplusW
}
chargeFromPVkWh := chargeFromPVW * dtH / 1000.0
cost -= p.PVChargeBonusOreKwh * chargeFromPVkWh
}
}
// Minimum-arbitrage-spread deadband. A virtual cost
// per kWh of battery discharge throughput, applied only
// in the arbitrage modes. It makes the DP refuse a
// discharge whose revenue doesn't beat the stored
// energy's opportunity cost by at least this margin —
// i.e. "don't cycle for a gain smaller than S". Taxing
// discharge alone suppresses the whole round-trip (no
// point charging for a discharge that won't clear the
// threshold). Self-consumption / cover-load discharge
// is never reached here: their spread is retail+VAT,
// orders of magnitude above any öre-level S. The cost
// is virtual — Plan.CostOre is re-derived from raw grid
// flow below, so savings accounting is untouched.
if p.MinArbitrageSpreadOreKwh > 0 && battW < 0 &&
(p.Mode == ModeArbitrage || p.Mode == ModePassiveArbitrage) {
dischargeKWh := -battW * dtH / 1000.0
cost += p.MinArbitrageSpreadOreKwh * dischargeKWh
}
// Deadline slot: if this slot is the EV's
// deadline AND target isn't met with this
// action's evSoc2, add a shortfall penalty.
// Penalty factor 4×meanPrice makes missing
// the target more expensive than the
// aggressive-slot charge cost a DP might
// otherwise prefer, so it commits when
// feasible. Lexicographic behaviour:
// infeasible targets degrade gracefully —
// DP maximizes delivered energy since less
// shortfall = less penalty.
if deadlineSlot == t {
short := lp.TargetSoC - evSoc2