Skip to content
Open
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/optimizer-grid-limit-residue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Accept sub-watt solver residue at a slot's grid limit so an optimizer plan at the configured fuse ceiling does not trigger Go planner fallback. Larger import and export violations still fail validation.
5 changes: 5 additions & 0 deletions .changeset/planning-physics-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Go fallback plans are replayed against the same site-power and battery-energy identities as the mathematical optimizer before they can become the live plan. A true zero PV cap is now a distinct `pv_curtail_active` flag, so full curtailment is no longer serialized as “no cap”. A trajectory that cannot be reconstructed from the request is kept off dispatch.
24 changes: 24 additions & 0 deletions go/internal/loadpoint/site_power.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,27 @@ func BatteryDischargeFeedsEV(batteryW, evW, loadW, pvW float64) bool {
func PlannedSurplusForEVW(loadW, pvW, batteryW, gridW float64) float64 {
return -pvW - loadW - PlannedPVSoakW(batteryW, gridW)
}

// BatteryEnergyDeltaWh is the cell-side energy change for a site-signed
// AC battery power over dtH hours. Charge (powerW > 0) lands ηc of the
// AC energy in the cells. Discharge (powerW < 0) draws AC / ηd from the
// cells, so a 1000 W discharge at 0.95 efficiency removes ~1053 Wh/h.
func BatteryEnergyDeltaWh(powerW, dtH, chargeEff, dischargeEff float64) float64 {
if powerW >= 0 {
return powerW * dtH * chargeEff
}
return powerW * dtH / dischargeEff
}

// EffectivePVW is the site-signed PV used in grid replay after an
// optional curtailment cap. Inactive means the forecast stands.
// Active with pvLimitW = 0 is a true zero cap (no generation this slot).
func EffectivePVW(pvW, pvLimitW float64, curtailActive bool) float64 {
if !curtailActive {
return pvW
}
if pvLimitW < 0 {
pvLimitW = 0
}
return -pvLimitW
}
78 changes: 77 additions & 1 deletion go/internal/loadpoint/site_power_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package loadpoint

import "testing"
import (
"encoding/json"
"math"
"os"
"testing"
)

func TestGridWIncludesEV(t *testing.T) {
// 500 house, 8 kW PV, 10 kW battery charge, 4.14 kW EV → import.
Expand Down Expand Up @@ -51,6 +56,18 @@ func TestBatteryDischargeFeedsEV(t *testing.T) {
}
}

func TestEffectivePVWActiveZeroIsTrueCap(t *testing.T) {
if got := EffectivePVW(-5000, 0, false); got != -5000 {
t.Errorf("inactive = %.0f, want -5000", got)
}
if got := EffectivePVW(-5000, 0, true); got != 0 {
t.Errorf("active zero = %.0f, want 0", got)
}
if got := EffectivePVW(-5000, 2000, true); got != -2000 {
t.Errorf("partial cap = %.0f, want -2000", got)
}
}

func TestPlannedSurplusForEVWSkipsGridFundedCharge(t *testing.T) {
// leftover 7500, battery soaking 2000 of it.
if got := PlannedSurplusForEVW(500, -8000, 2000, 0); got != 5500 {
Expand All @@ -65,3 +82,62 @@ func TestPlannedSurplusForEVWSkipsGridFundedCharge(t *testing.T) {
t.Errorf("soak+EV: got %.0f, want 3500", got)
}
}

type sitePhysicsFixture struct {
Flows []struct {
Name string `json:"name"`
LoadW float64 `json:"load_w"`
PVW float64 `json:"pv_w"`
BatteryW float64 `json:"battery_w"`
EVW float64 `json:"ev_w"`
GridW float64 `json:"grid_w"`
LeftoverW float64 `json:"leftover_w"`
HouseResidualW float64 `json:"house_residual_w"`
FeedsEV bool `json:"feeds_ev"`
} `json:"flows"`
EnergySteps []struct {
Name string `json:"name"`
PowerW float64 `json:"power_w"`
DtH float64 `json:"dt_h"`
ChargeEff float64 `json:"charge_eff"`
DischargeEff float64 `json:"discharge_eff"`
DeltaWh float64 `json:"delta_wh"`
} `json:"energy_steps"`
}

func loadSitePhysicsFixture(t *testing.T) sitePhysicsFixture {
t.Helper()
raw, err := os.ReadFile("testdata/site_physics.json")
if err != nil {
t.Fatal(err)
}
var fixture sitePhysicsFixture
if err := json.Unmarshal(raw, &fixture); err != nil {
t.Fatal(err)
}
return fixture
}

func TestSitePhysicsTable(t *testing.T) {
fixture := loadSitePhysicsFixture(t)
for _, row := range fixture.Flows {
if got := GridW(row.LoadW, row.PVW, row.BatteryW, row.EVW); got != row.GridW {
t.Errorf("%s: GridW = %.6g, want %.6g", row.Name, got, row.GridW)
}
if got := PVLeftoverAfterHouseW(row.LoadW, row.PVW); got != row.LeftoverW {
t.Errorf("%s: leftover = %.6g, want %.6g", row.Name, got, row.LeftoverW)
}
if got := HouseResidualW(row.LoadW, row.PVW); got != row.HouseResidualW {
t.Errorf("%s: residual = %.6g, want %.6g", row.Name, got, row.HouseResidualW)
}
if got := BatteryDischargeFeedsEV(row.BatteryW, row.EVW, row.LoadW, row.PVW); got != row.FeedsEV {
t.Errorf("%s: feeds EV = %v, want %v", row.Name, got, row.FeedsEV)
}
}
for _, row := range fixture.EnergySteps {
got := BatteryEnergyDeltaWh(row.PowerW, row.DtH, row.ChargeEff, row.DischargeEff)
if math.Abs(got-row.DeltaWh) > 1e-9 {
t.Errorf("%s: delta = %.12g, want %.12g", row.Name, got, row.DeltaWh)
}
}
}
85 changes: 85 additions & 0 deletions go/internal/loadpoint/testdata/site_physics.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
{
"flows": [
{
"name": "pv leftover after house",
"load_w": 500,
"pv_w": -8000,
"battery_w": 0,
"ev_w": 0,
"grid_w": -7500,
"leftover_w": 7500,
"house_residual_w": 0,
"feeds_ev": false
},
{
"name": "house residual after weak pv",
"load_w": 2000,
"pv_w": -500,
"battery_w": 0,
"ev_w": 0,
"grid_w": 1500,
"leftover_w": 0,
"house_residual_w": 1500,
"feeds_ev": false
},
{
"name": "leftover pv into ev and grid into battery",
"load_w": 500,
"pv_w": -8000,
"battery_w": 10000,
"ev_w": 4140,
"grid_w": 6640,
"leftover_w": 7500,
"house_residual_w": 0,
"feeds_ev": false
},
{
"name": "battery discharge covers house only",
"load_w": 500,
"pv_w": 0,
"battery_w": -400,
"ev_w": 4000,
"grid_w": 4100,
"leftover_w": 0,
"house_residual_w": 500,
"feeds_ev": false
},
{
"name": "battery discharge feeds ev",
"load_w": 500,
"pv_w": 0,
"battery_w": -4000,
"ev_w": 4000,
"grid_w": 500,
"leftover_w": 0,
"house_residual_w": 500,
"feeds_ev": true
}
],
"energy_steps": [
{
"name": "1 kW charge for 1 h at 0.95",
"power_w": 1000,
"dt_h": 1,
"charge_eff": 0.95,
"discharge_eff": 0.95,
"delta_wh": 950
},
{
"name": "1 kW discharge for 1 h at 0.95",
"power_w": -1000,
"dt_h": 1,
"charge_eff": 0.95,
"discharge_eff": 0.95,
"delta_wh": -1052.6315789473683
},
{
"name": "idle",
"power_w": 0,
"dt_h": 0.25,
"charge_eff": 0.95,
"discharge_eff": 0.95,
"delta_wh": 0
}
]
}
85 changes: 44 additions & 41 deletions go/internal/mpc/diagnose.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,14 @@ type DiagnosticSlot struct {
WeatherRowAvailableAtMs int64 `json:"weather_row_available_at_ms,omitempty"`

// Outputs
BatteryW float64 `json:"battery_w"`
GridW float64 `json:"grid_w"`
SoC float64 `json:"soc"` // SoC at END of slot
CostOre float64 `json:"cost_ore"` // raw (un-blended) slot cost
Reason string `json:"reason"`
EMSMode string `json:"ems_mode"`
PVLimitW float64 `json:"pv_limit_w,omitempty"`
BatteryW float64 `json:"battery_w"`
GridW float64 `json:"grid_w"`
SoC float64 `json:"soc"` // 0–1 at END of slot
CostOre float64 `json:"cost_ore"`
Reason string `json:"reason"`
EMSMode string `json:"ems_mode"`
PVLimitW float64 `json:"pv_limit_w,omitempty"`
PVCurtailActive bool `json:"pv_curtail_active,omitempty"`

// EV outputs — present only when the plan included a loadpoint.
// `omitempty` + the web renderer's `lpActive` gate mean plans
Expand All @@ -53,22 +54,22 @@ type DiagnosticSlot struct {
// against `LOAD 1.6 kW` and reasonably assumed the battery was
// exporting — reality was `LOAD 1.6 + EV 4.0 = 5.6 kW covered`,
// grid ≈ 0. See issue #174.
LoadpointW float64 `json:"loadpoint_w,omitempty"`
LoadpointW float64 `json:"loadpoint_w,omitempty"`
LoadpointSoC float64 `json:"loadpoint_soc,omitempty"`
LoadpointPowerW map[string]float64 `json:"loadpoint_power_w,omitempty"`
LoadpointPowerW map[string]float64 `json:"loadpoint_power_w,omitempty"`
LoadpointSoCByID map[string]float64 `json:"loadpoint_soc_by_id,omitempty"`
StoragePowerW map[string]float64 `json:"storage_power_w,omitempty"`
StorageEnergyWh map[string]float64 `json:"storage_energy_wh,omitempty"`
StoragePowerW map[string]float64 `json:"storage_power_w,omitempty"`
StorageEnergyWh map[string]float64 `json:"storage_energy_wh,omitempty"`
}

// DiagnosticParams is a JSON-friendly subset of the Params struct —
// enough for operators to verify the DP was parameterized correctly
// without pulling the whole internal struct.
type DiagnosticParams struct {
Mode Mode `json:"mode"`
InitialSoC float64 `json:"initial_soc"`
SoCMin float64 `json:"soc_min"`
SoCMax float64 `json:"soc_max"`
InitialSoC float64 `json:"initial_soc"`
SoCMin float64 `json:"soc_min"`
SoCMax float64 `json:"soc_max"`
PVChargeBonusOreKwh float64 `json:"pv_charge_bonus_ore_kwh,omitempty"`
SoCLevels int `json:"soc_levels"`
ActionLevels int `json:"action_levels"`
Expand Down Expand Up @@ -170,15 +171,16 @@ func buildDiagnostic(plan *Plan, slots []Slot, p Params, zone string,
WeatherRowAvailableAtMs: slot.WeatherRowAvailableAtMs,
BatteryW: action.BatteryW,
GridW: action.GridW,
SoC: action.SoC,
SoC: action.SoC,
CostOre: action.CostOre,
Reason: action.Reason,
EMSMode: action.EMSMode,
PVLimitW: action.PVLimitW,
PVCurtailActive: action.PVCurtailActive,
LoadpointW: action.LoadpointW,
LoadpointSoC: action.LoadpointSoC,
LoadpointSoC: action.LoadpointSoC,
LoadpointPowerW: action.LoadpointPowerW,
LoadpointSoCByID: action.LoadpointSoCByID,
LoadpointSoCByID: action.LoadpointSoCByID,
StoragePowerW: action.StoragePowerW,
StorageEnergyWh: action.StorageEnergyWh,
}
Expand All @@ -202,9 +204,9 @@ func buildDiagnostic(plan *Plan, slots []Slot, p Params, zone string,
OptimizerInput: append(json.RawMessage(nil), plan.OptimizerInput...),
Params: DiagnosticParams{
Mode: p.Mode,
InitialSoC: p.InitialSoC,
SoCMin: p.SoCMin,
SoCMax: p.SoCMax,
InitialSoC: p.InitialSoC,
SoCMin: p.SoCMin,
SoCMax: p.SoCMax,
PVChargeBonusOreKwh: p.PVChargeBonusOreKwh,
SoCLevels: p.SoCLevels,
ActionLevels: p.ActionLevels,
Expand Down Expand Up @@ -324,9 +326,9 @@ func planFromDiagnostic(d *Diagnostic) (*Plan, []Slot, Params, time.Time, bool)
}
params := Params{
Mode: d.Params.Mode,
InitialSoC: d.Params.InitialSoC,
SoCMin: d.Params.SoCMin,
SoCMax: d.Params.SoCMax,
InitialSoC: d.Params.InitialSoC,
SoCMin: d.Params.SoCMin,
SoCMax: d.Params.SoCMax,
PVChargeBonusOreKwh: d.Params.PVChargeBonusOreKwh,
SoCLevels: d.Params.SoCLevels,
ActionLevels: d.Params.ActionLevels,
Expand Down Expand Up @@ -377,26 +379,27 @@ func planFromDiagnostic(d *Diagnostic) (*Plan, []Slot, Params, time.Time, bool)
WeatherRowAvailableAtMs: ds.WeatherRowAvailableAtMs,
})
action := Action{
SlotStartMs: ds.SlotStartMs,
SlotLenMin: lenMin,
PriceOre: ds.PriceOre,
SpotOre: ds.SpotOre,
PVW: ds.PVW,
LoadW: ds.LoadW,
BatteryW: ds.BatteryW,
GridW: ds.GridW,
SlotStartMs: ds.SlotStartMs,
SlotLenMin: lenMin,
PriceOre: ds.PriceOre,
SpotOre: ds.SpotOre,
PVW: ds.PVW,
LoadW: ds.LoadW,
BatteryW: ds.BatteryW,
GridW: ds.GridW,
SoC: ds.SoC,
CostOre: ds.CostOre,
Confidence: ds.Confidence,
Reason: ds.Reason,
EMSMode: ds.EMSMode,
PVLimitW: ds.PVLimitW,
LoadpointW: ds.LoadpointW,
CostOre: ds.CostOre,
Confidence: ds.Confidence,
Reason: ds.Reason,
EMSMode: ds.EMSMode,
PVLimitW: ds.PVLimitW,
PVCurtailActive: ds.PVCurtailActive,
LoadpointW: ds.LoadpointW,
LoadpointSoC: ds.LoadpointSoC,
LoadpointPowerW: ds.LoadpointPowerW,
LoadpointPowerW: ds.LoadpointPowerW,
LoadpointSoCByID: ds.LoadpointSoCByID,
StoragePowerW: ds.StoragePowerW,
StorageEnergyWh: ds.StorageEnergyWh,
StoragePowerW: ds.StoragePowerW,
StorageEnergyWh: ds.StorageEnergyWh,
}
if identified && ds.SlotEndMs > 0 {
slotEndMs, err := checkedSlotEndMs(action.SlotStartMs, action.SlotLenMin)
Expand Down Expand Up @@ -424,7 +427,7 @@ func planFromDiagnostic(d *Diagnostic) (*Plan, []Slot, Params, time.Time, bool)
Mode: params.Mode,
HorizonSlots: horizon,
CapacityWh: params.CapacityWh,
InitialSoC: params.InitialSoC,
InitialSoC: params.InitialSoC,
TotalCostOre: d.TotalCostOre,
Actions: actions,
Solver: d.Solver,
Expand Down
Loading