From 48cc677232263de8b8136553d270ce08df1f863c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 19 Aug 2026 13:00:59 +0200 Subject: [PATCH 01/22] fix(units): store watts and 0-1 fractions in core PV arrays use rated_w. SoC, planner bounds, and related settings are 0-1. kWp and 0-100 exist only at UI, HA, appproto, calendar titles, and the forecast.solar URL. Config load still folds the old keys. A units package plus consistency tests reject kWp and soc_pct leaking back into core structs. --- .changeset/si-core-units.md | 5 + config.example.yaml | 6 +- docs/nibe-local.md | 8 +- docs/site-convention.md | 5 +- go/cmd/ftw/control_state.go | 2 +- go/cmd/ftw/control_state_test.go | 4 +- go/cmd/ftw/main.go | 35 +++--- go/cmd/sim-ferroamp/ferroamp/physics.go | 2 +- go/cmd/sim-sungrow/sungrow/physics.go | 2 +- .../api/api_loadpoint_battery_boost.go | 4 +- .../api/api_loadpoint_schedule_test.go | 2 +- go/internal/api/api_support_report.go | 4 +- go/internal/api/api_test.go | 2 +- go/internal/appproto/plan.go | 6 +- go/internal/appproto/plan_test.go | 16 +-- go/internal/calendar/service.go | 5 +- go/internal/config/config.go | 59 +++++---- go/internal/config/config_test.go | 8 +- go/internal/config/units.go | 75 +++++++++++ go/internal/config/units_test.go | 35 ++++++ go/internal/configreload/watcher.go | 10 +- go/internal/control/control_test.go | 12 +- go/internal/control/dispatch.go | 11 +- go/internal/control/golden_dump_test.go | 4 +- go/internal/drivers/myuplink_test.go | 22 ++-- go/internal/drivers/nibe_local_test.go | 29 +++-- go/internal/forecast/forecast.go | 118 ++++++++--------- go/internal/forecast/forecast_solar.go | 25 ++-- go/internal/forecast/forecast_solar_test.go | 8 +- go/internal/forecast/forecast_test.go | 109 +++++++--------- go/internal/mpc/baselines_test.go | 4 +- go/internal/mpc/diagnose.go | 38 +++--- go/internal/mpc/diagnose_test.go | 56 ++++----- go/internal/mpc/external_optimizer.go | 78 ++++++------ go/internal/mpc/external_optimizer_test.go | 56 ++++----- go/internal/mpc/loadpoint_service_test.go | 62 ++++----- go/internal/mpc/loadpoint_spec.go | 8 +- go/internal/mpc/loadpoint_spec_test.go | 22 ++-- go/internal/mpc/mpc.go | 108 ++++++++-------- go/internal/mpc/mpc_test.go | 106 ++++++++-------- go/internal/mpc/mpc_threshold_test.go | 6 +- go/internal/mpc/params_validation.go | 62 +++++---- go/internal/mpc/params_validation_test.go | 108 ++++++++-------- go/internal/mpc/power_limits_test.go | 30 ++--- go/internal/mpc/reactive_test.go | 2 +- .../mpc/self_consumption_horizon_test.go | 6 +- go/internal/mpc/service.go | 58 +++++---- go/internal/mpc/service_latest_wins_test.go | 4 +- go/internal/mpc/service_persist_test.go | 20 +-- go/internal/mpc/service_test.go | 36 +++--- go/internal/mpc/shadow_evaluator.go | 14 +-- go/internal/mpc/shadow_evaluator_test.go | 20 +-- go/internal/mpc/stress_test.go | 38 +++--- go/internal/mpc/strict_sc_test.go | 36 +++--- go/internal/units/consistency_test.go | 108 ++++++++++++++++ go/internal/units/units.go | 119 ++++++++++++++++++ go/internal/units/units_test.go | 83 ++++++++++++ go/internal/v2x/policy.go | 10 +- go/internal/v2x/policy_test.go | 6 +- web/app.js | 2 +- web/components/ftw-pv-arrays-3d.js | 26 ++-- web/diagnose.js | 18 ++- web/heating.js | 24 ++-- web/loadpoints.js | 14 +-- web/plan-brief.js | 17 ++- web/plan-brief.test.mjs | 4 +- web/plan.js | 20 ++- web/settings/tabs/caldav.js | 6 +- web/settings/tabs/control.js | 10 +- web/settings/tabs/planner.js | 17 ++- web/settings/tabs/planner.test.mjs | 26 ++++ web/settings/tabs/weather.js | 38 +++--- web/settings/tabs/weather.test.mjs | 23 ++++ 73 files changed, 1360 insertions(+), 822 deletions(-) create mode 100644 .changeset/si-core-units.md create mode 100644 go/internal/config/units.go create mode 100644 go/internal/config/units_test.go create mode 100644 go/internal/units/consistency_test.go create mode 100644 go/internal/units/units.go create mode 100644 go/internal/units/units_test.go create mode 100644 web/settings/tabs/weather.test.mjs diff --git a/.changeset/si-core-units.md b/.changeset/si-core-units.md new file mode 100644 index 000000000..b096a3fc9 --- /dev/null +++ b/.changeset/si-core-units.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Core stores power in watts, energy in watt-hours, SoC as 0–1, and PV arrays as rated watts. kWp and 0–100 percents remain only at UI, Home Assistant, appproto, and the forecast.solar URL. Pasting watts into the old kWp field is converted on config load. Heat-pump diagnostics emit W/Wh. diff --git a/config.example.yaml b/config.example.yaml index 43c431467..cb6ec1c14 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -163,7 +163,7 @@ caldav: password: "" calendar_path: /ftw/energy/ poll_interval_s: 300 - ev_default_target_soc_pct: 80 + ev_default_target_soc: 0.80 # ev_loadpoint_id: "" # defaults to the first configured loadpoint # away_keywords: [away, vacation, holiday] # ev_keywords: [ev, car, charge] @@ -237,8 +237,8 @@ fleet_ping: # mode: passive_arbitrage # horizon_hours: 48 # interval_min: 15 -# soc_min_pct: 10 -# soc_max_pct: 95 +# soc_min: 0.10 +# soc_max: 0.95 # optimizer_solver: HIGHS # optimizer_formulation: auto # optimizer_transport: process # process | auto | unix; official Compose sets unix diff --git a/docs/nibe-local.md b/docs/nibe-local.md index 3f3a2c4b0..0f9ceb714 100644 --- a/docs/nibe-local.md +++ b/docs/nibe-local.md @@ -71,8 +71,8 @@ its own metadata: modbus register, unit, **exact divisor**, and a writable flag. That means: - **Exact scaling.** A temperature with `divisor: 10` becomes °C precisely; a - power with `divisor: 100` becomes kW precisely. No °C×10 guessing like the - cloud driver has to do. + power with `divisor: 100` scales to the vendor unit, then kW/kWh convert + to W/Wh at emit. No °C×10 guessing like the cloud driver has to do. - **The whole register map.** Every point lands in the long-format TS DB via `host.emit_metric`. To keep a Pi-sized database bounded, headline metrics are sampled every minute while @@ -150,8 +150,8 @@ instance via `param_power_id`, `param_hw_temp_id`, `param_indoor_temp_id`, | `hp_hw_top_temp_c` | °C | Hot water top BT7 (11) | | `hp_outdoor_temp_c` | °C | Outdoor BT1 (4) | | `hp_indoor_temp_c` | °C | Room BT50 (158) — absent if no room sensor | -| `hp_energy_consumed_kwh` | kWh | Tot. consumption (28393) | -| `hp_energy_produced_kwh` | kWh | Tot. production (28392) | +| `hp_energy_consumed_kwh` | Wh | Tot. consumption (28393) — name still `_kwh`; value is Wh | +| `hp_energy_produced_kwh` | Wh | Tot. production (28392) — name still `_kwh`; value is Wh | | `hp_degree_minutes` | DM | Degree minutes (781) | Every other point auto-emits as `hp_` with its unit, so the diff --git a/docs/site-convention.md b/docs/site-convention.md index 0c1917268..7b48260d5 100644 --- a/docs/site-convention.md +++ b/docs/site-convention.md @@ -69,9 +69,10 @@ discharge. | Frequency | Hz | | | Temperature | °C | | | State of charge | fraction 0..1 | **Not percent** | +| Irradiance | W/m² | Never watts | +| Array nameplate | W | `rated_w`. kWp exists only in the forecast.solar URL | -SI prefixes (k, M, m) only appear in the UI display layer. API and telemetry -always use the base SI unit. +SI prefixes (k, M, m) and 0–100 percents exist only at a door: UI, Home Assistant, appproto permille, calendar titles, and vendor APIs. Core stores watts, watt-hours, and 0–1 fractions. ## Where the sign flip happens diff --git a/go/cmd/ftw/control_state.go b/go/cmd/ftw/control_state.go index 52ad86e9e..33962e74a 100644 --- a/go/cmd/ftw/control_state.go +++ b/go/cmd/ftw/control_state.go @@ -31,7 +31,7 @@ func newControlStateFromConfig(cfg *config.Config) *control.State { // `<= 0 -> default` shortcut clobbered the disable case. ctrl.SiteFuseSafetyA = cfg.Fuse.EffectiveSafetyMarginA() // PV surplus absorber underlay (opt-in). cap == 0 keeps it off. - ctrl.PVSurplusAbsorbSoCCapPct = cfg.Site.PVSurplusAbsorbSoCCapPct + ctrl.PVSurplusAbsorbSoCCapPct = cfg.Site.PVSurplusAbsorbSoCCap ctrl.PVSurplusAbsorbThresholdW = cfg.Site.PVSurplusAbsorbThresholdW // DC-link protective curtail — opt-in, default off. SoC threshold // and margin fall back to dispatch defaults (0.80 / 1000 W) when diff --git a/go/cmd/ftw/control_state_test.go b/go/cmd/ftw/control_state_test.go index 235c2791b..5ebcfc686 100644 --- a/go/cmd/ftw/control_state_test.go +++ b/go/cmd/ftw/control_state_test.go @@ -32,8 +32,8 @@ func TestControlSlotDirectiveFromMPCPreservesDecisionIdentity(t *testing.T) { in := mpc.SlotDirective{ DecisionID: "00000000-0000-4000-8000-000000000123", SlotStart: start, SlotEnd: start.Add(15 * time.Minute), - BatteryEnergyWh: 100, SoCTargetPct: 42, Strategy: mpc.ModePassiveArbitrage, - PVLimitW: 7000, GridW: -6400, LivePVSurplusSoCCapPct: 65, + BatteryEnergyWh: 100, SoCTargetPct: 0.42, Strategy: mpc.ModePassiveArbitrage, + PVLimitW: 7000, GridW: -6400, LivePVSurplusSoCCapPct: 0.65, LoadpointEnergyWh: loadpoints, } diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index a53aeb6d5..724a75a61 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -67,6 +67,7 @@ import ( "github.com/srcfl/ftw/go/internal/selfupdate" "github.com/srcfl/ftw/go/internal/state" "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/internal/units" ) // Version gets injected at build time via -ldflags. Defaults to "dev" for @@ -1071,7 +1072,7 @@ func main() { newLon := newCfg.Weather.Longitude if newLat != oldLat || newLon != oldLon { if pvSvc != nil { - pvSvc.ClearSky = func(t time.Time) float64 { return forecast.ClearSkyW(newLat, newLon, t) } + pvSvc.ClearSky = func(t time.Time) float64 { return forecast.ClearSkyWm2(newLat, newLon, t) } } if forecastSvc != nil { forecastSvc.Lat = newLat @@ -1150,7 +1151,7 @@ func main() { // pvSvc is pre-declared above so the reload Applier can update it. if cfg.Weather != nil && cfg.Weather.Provider != "" && cfg.Weather.Provider != "none" { lat, lon := cfg.Weather.Latitude, cfg.Weather.Longitude - clearSkyFn := func(t time.Time) float64 { return forecast.ClearSkyW(lat, lon, t) } + clearSkyFn := func(t time.Time) float64 { return forecast.ClearSkyWm2(lat, lon, t) } cloudFn := func(t time.Time) (float64, bool) { // Look up nearest forecast row covering `t`. nowMs := t.UnixMilli() @@ -1503,11 +1504,11 @@ func main() { ID: st.ID, CapacityWh: capWh, Levels: 11, - MinPct: 0, - MaxPct: maxPct, - InitialSoCPct: initSoC, + SoCMin: 0, + SoCMax: units.FractionFromLegacyPercent(maxPct), + InitialSoC: units.FractionFromLegacyPercent(initSoC), PluggedIn: true, - TargetSoCPct: targetPct, + TargetSoC: units.FractionFromLegacyPercent(targetPct), TargetSlotIdx: targetSlot, MaxChargeW: st.MaxChargeW, AllowedStepsW: st.AllowedStepsW, @@ -3557,7 +3558,7 @@ func planSlotsFromMPC(mpcSvc *mpc.Service) []calendar.PlanSlot { End: start.Add(time.Duration(ln) * time.Minute), BatteryW: a.BatteryW, GridW: a.GridW, - SoCPct: a.SoCPct, + SoCPct: a.SoC * 100, Confidence: a.Confidence, }) } @@ -3587,7 +3588,7 @@ func buildLoadpointConfigs(src []config.Loadpoint) []loadpoint.Config { MaxChargeW: lp.MaxChargeW, AllowedStepsW: lp.AllowedStepsW, VehicleCapacityWh: lp.VehicleCapacityWh, - PluginSoCPct: lp.PluginSoCPct, + PluginSoCPct: units.PercentFromFraction(lp.PluginSoC), PhaseMode: lp.PhaseMode, PhaseSplitW: lp.PhaseSplitW, MinPhaseHoldS: lp.MinPhaseHoldS, @@ -3716,13 +3717,13 @@ func buildMPC(cfg *config.Config, st *state.Store, tel *telemetry.Store, capacit if mode == "" { mode = mpc.ModeSelfConsumption } - socMin := pl.SoCMinPct + socMin := pl.SoCMin if socMin <= 0 { - socMin = 10 + socMin = 0.10 } - socMax := pl.SoCMaxPct - if socMax <= 0 || socMax > 100 { - socMax = 95 + socMax := pl.SoCMax + if socMax <= 0 || socMax > 1 { + socMax = 0.95 } if pl.SoCSafetyFloorPct != 0 || pl.SafetyFloorPenaltyOreKwhHour != 0 { slog.Warn("config: soc_safety_floor_pct / safety_floor_penalty_ore_kwh_hour are deprecated and ignored — forecast-risk reserve is now handled by pv_forecast_safety_k (downside-PV planning)") @@ -3743,10 +3744,10 @@ func buildMPC(cfg *config.Config, st *state.Store, tel *telemetry.Store, capacit Mode: mode, SoCLevels: 41, CapacityWh: totalCap, - SoCMinPct: socMin, - SoCMaxPct: socMax, + SoCMin: socMin, + SoCMax: socMax, PVChargeBonusOreKwh: pvBonus, - InitialSoCPct: 50, + InitialSoC: 0.50, // ActionLevels = 81 → 225 W discretization step on a ±9 kW // action range. Coarser values (21=900 W, 41=450 W) lose // borderline-PV slots: on a 273 W net surplus the 450 W min @@ -4199,7 +4200,7 @@ func (b mpcPlanBridge) LatestActions() []ha.PlanAction { SlotLenMin: a.SlotLenMin, BatteryW: a.BatteryW, GridW: a.GridW, - SoCPct: a.SoCPct, + SoCPct: a.SoC * 100, PriceOre: a.PriceOre, SpotOre: a.SpotOre, CostOre: a.CostOre, diff --git a/go/cmd/sim-ferroamp/ferroamp/physics.go b/go/cmd/sim-ferroamp/ferroamp/physics.go index 5785b45dd..a04586c53 100644 --- a/go/cmd/sim-ferroamp/ferroamp/physics.go +++ b/go/cmd/sim-ferroamp/ferroamp/physics.go @@ -53,7 +53,7 @@ type Config struct { func Default() Config { return Config{ CapacityWh: 15200, - SoC: 0.5, + SoC: 0.5, ResponseTauS: 2.5, GainPct: 0.90, MaxChargeW: 5000, diff --git a/go/cmd/sim-sungrow/sungrow/physics.go b/go/cmd/sim-sungrow/sungrow/physics.go index 9666cd1ef..a617ba7f5 100644 --- a/go/cmd/sim-sungrow/sungrow/physics.go +++ b/go/cmd/sim-sungrow/sungrow/physics.go @@ -68,7 +68,7 @@ type Config struct { func Default() Config { return Config{ CapacityWh: 9600, - SoC: 0.5, + SoC: 0.5, ResponseTauS: 3.0, GainPct: 0.96, MaxChargeW: 5000, diff --git a/go/internal/api/api_loadpoint_battery_boost.go b/go/internal/api/api_loadpoint_battery_boost.go index bc4c9ad2e..08af8514f 100644 --- a/go/internal/api/api_loadpoint_battery_boost.go +++ b/go/internal/api/api_loadpoint_battery_boost.go @@ -12,7 +12,7 @@ type batteryBoostRequest struct { DurationS int64 `json:"duration_s,omitempty"` ExpiresAtMs int64 `json:"expires_at_ms,omitempty"` MinBatterySoCPct float64 `json:"min_battery_soc_pct"` - EVTargetSoCPct float64 `json:"ev_target_soc_pct,omitempty"` + EVTargetSoC float64 `json:"ev_target_soc_pct,omitempty"` DepartureAtMs int64 `json:"departure_at_ms,omitempty"` } @@ -63,7 +63,7 @@ func (s *Server) handleLoadpointBatteryBoostEnable(w http.ResponseWriter, r *htt StartedAt: now, ExpiresAt: expires, MinBatterySoCPct: req.MinBatterySoCPct, - EVTargetSoCPct: req.EVTargetSoCPct, + EVTargetSoCPct: req.EVTargetSoC, } if req.DepartureAtMs > 0 { lease.DepartureAt = time.UnixMilli(req.DepartureAtMs) diff --git a/go/internal/api/api_loadpoint_schedule_test.go b/go/internal/api/api_loadpoint_schedule_test.go index 02588a8a2..38d8570a7 100644 --- a/go/internal/api/api_loadpoint_schedule_test.go +++ b/go/internal/api/api_loadpoint_schedule_test.go @@ -43,7 +43,7 @@ func newScheduleServer(t *testing.T) (*Server, *loadpoint.Manager, *mpc.Service) } svc := mpc.New(st, nil, "SE4", mpc.Params{ Mode: mpc.ModeSelfConsumption, SoCLevels: 11, ActionLevels: 5, - CapacityWh: 10000, InitialSoCPct: 50, SoCMinPct: 10, SoCMaxPct: 95, + CapacityWh: 10000, InitialSoC: 0.5, SoCMin: 0.1, SoCMax: 0.95, MaxChargeW: 3000, MaxDischargeW: 3000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, }) diff --git a/go/internal/api/api_support_report.go b/go/internal/api/api_support_report.go index 56c54bded..58ea117a9 100644 --- a/go/internal/api/api_support_report.go +++ b/go/internal/api/api_support_report.go @@ -28,6 +28,8 @@ package api import ( "context" "fmt" + + "github.com/srcfl/ftw/go/internal/units" "math" "net/http" "sort" @@ -472,7 +474,7 @@ func writePlanSection( a.PriceOre, a.SpotOre, fmtReportW(a.PVW), fmtReportW(a.LoadW), fmtReportW(a.BatteryW), fmtReportW(a.GridW), - a.SoCPct, a.Reason) + units.PercentFromFraction(a.SoC), a.Reason) shown++ } if shown == 0 { diff --git a/go/internal/api/api_test.go b/go/internal/api/api_test.go index 16b99ce5d..a1a80e1e2 100644 --- a/go/internal/api/api_test.go +++ b/go/internal/api/api_test.go @@ -319,7 +319,7 @@ func TestHandleV2XPolicyReturnsLiveEnvelope(t *testing.T) { Cfg: &config.Config{V2X: &config.V2XPolicy{ Enabled: true, DriverName: "dc2", - MinReserveSoCPct: 20, + MinReserveSoC: 0.20, MaxChargeW: 6000, MaxDischargeW: 4000, ExportAllowed: false, diff --git a/go/internal/appproto/plan.go b/go/internal/appproto/plan.go index 6a4f3ca4a..b10ac64e5 100644 --- a/go/internal/appproto/plan.go +++ b/go/internal/appproto/plan.go @@ -80,8 +80,8 @@ func meanPriceOre(actions []mpc.Action) float64 { func minSoCPct(actions []mpc.Action) float64 { min := math.Inf(1) for _, a := range actions { - if a.SoCPct < min { - min = a.SoCPct + if a.SoC < min { + min = a.SoC } } if math.IsInf(min, 1) { @@ -140,7 +140,7 @@ func reasonCode(a mpc.Action, meanPriceOre, minSoCPct float64, ceilingW *int64) // Idle at its deepest planned point during an expensive hour is // the planner refusing to spend the reserve, which is a decision // worth naming rather than calling nothing. - if priceAbove && a.SoCPct <= minSoCPct+0.5 { + if priceAbove && a.SoC <= minSoCPct+0.005 { return ReasonReserveHeld } if gridExports { diff --git a/go/internal/appproto/plan_test.go b/go/internal/appproto/plan_test.go index b10b73d24..434bec59c 100644 --- a/go/internal/appproto/plan_test.go +++ b/go/internal/appproto/plan_test.go @@ -14,13 +14,13 @@ func samplePlan(generatedAt time.Time) *mpc.Plan { Mode: mpc.ModePassiveArbitrage, Actions: []mpc.Action{ // Cheap night hour, charging off the meter. - {SlotStartMs: slot, SlotLenMin: 15, PriceOre: 30, PVW: 0, LoadW: 500, BatteryW: 3000, GridW: 3500, SoCPct: 40}, + {SlotStartMs: slot, SlotLenMin: 15, PriceOre: 30, PVW: 0, LoadW: 500, BatteryW: 3000, GridW: 3500, SoC: 0.4}, // Midday, PV covers the house and fills the battery. - {SlotStartMs: slot + 900_000, SlotLenMin: 15, PriceOre: 60, PVW: -5000, LoadW: 800, BatteryW: 3500, GridW: -700, SoCPct: 60}, + {SlotStartMs: slot + 900_000, SlotLenMin: 15, PriceOre: 60, PVW: -5000, LoadW: 800, BatteryW: 3500, GridW: -700, SoC: 0.6}, // Evening peak, battery covers the house. - {SlotStartMs: slot + 1_800_000, SlotLenMin: 15, PriceOre: 180, PVW: 0, LoadW: 4000, BatteryW: -3500, GridW: 500, SoCPct: 45}, + {SlotStartMs: slot + 1_800_000, SlotLenMin: 15, PriceOre: 180, PVW: 0, LoadW: 4000, BatteryW: -3500, GridW: 500, SoC: 0.45}, // Nothing to do. - {SlotStartMs: slot + 2_700_000, SlotLenMin: 15, PriceOre: 55, PVW: 0, LoadW: 600, BatteryW: 0, GridW: 600, SoCPct: 45}, + {SlotStartMs: slot + 2_700_000, SlotLenMin: 15, PriceOre: 55, PVW: 0, LoadW: 600, BatteryW: 0, GridW: 600, SoC: 0.45}, }, } } @@ -93,14 +93,14 @@ func TestPlanReasonsAreStableCodes(t *testing.T) { // outranks the price bands when both could explain a slot. func TestDefendingACeilingReadsAsPeakShaving(t *testing.T) { ceiling := int64(9000) - a := mpc.Action{PriceOre: 50, PVW: 0, LoadW: 12000, BatteryW: -3000, GridW: 9000, SoCPct: 50} + a := mpc.Action{PriceOre: 50, PVW: 0, LoadW: 12000, BatteryW: -3000, GridW: 9000, SoC: 0.5} if got := reasonCode(a, 100, 40, &ceiling); got != ReasonPeakShaving { t.Fatalf("reason = %q, want %q", got, ReasonPeakShaving) } } func TestDischargingIntoAnExportingMeterReadsAsPaidExport(t *testing.T) { - a := mpc.Action{PriceOre: 200, PVW: 0, LoadW: 500, BatteryW: -4000, GridW: -3500, SoCPct: 50} + a := mpc.Action{PriceOre: 200, PVW: 0, LoadW: 500, BatteryW: -4000, GridW: -3500, SoC: 0.5} if got := reasonCode(a, 100, 20, nil); got != ReasonExportPaid { t.Fatalf("reason = %q, want %q", got, ReasonExportPaid) } @@ -110,14 +110,14 @@ func TestDischargingIntoAnExportingMeterReadsAsPaidExport(t *testing.T) { // refusing to spend the reserve, which is worth naming rather than calling // nothing. func TestHoldingTheReserveIsNotTheSameAsIdle(t *testing.T) { - held := mpc.Action{PriceOre: 200, PVW: 0, LoadW: 600, BatteryW: 0, GridW: 600, SoCPct: 10} + held := mpc.Action{PriceOre: 200, PVW: 0, LoadW: 600, BatteryW: 0, GridW: 600, SoC: 0.1} if got := reasonCode(held, 100, 10, nil); got != ReasonReserveHeld { t.Fatalf("reason = %q, want %q", got, ReasonReserveHeld) } // The same slot with the battery well above its floor is simply idle. idle := held - idle.SoCPct = 70 + idle.SoC = 70 if got := reasonCode(idle, 100, 10, nil); got != ReasonIdle { t.Fatalf("reason = %q, want %q", got, ReasonIdle) } diff --git a/go/internal/calendar/service.go b/go/internal/calendar/service.go index f955f5d50..26316a27f 100644 --- a/go/internal/calendar/service.go +++ b/go/internal/calendar/service.go @@ -15,6 +15,7 @@ import ( "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/loadmodel" + "github.com/srcfl/ftw/go/internal/units" ) // LoadProfiler is the slice of *loadmodel.Service the calendar service needs: @@ -171,9 +172,9 @@ func (s *Service) applyConfig(cfg config.CalDAV, firstLoadpointID string) { if horizonDays <= 0 { horizonDays = config.DefaultCalDAVHorizonDays } - targetSoC := cfg.EVDefaultTargetSoCPct + targetSoC := units.PercentFromFraction(cfg.EVDefaultTargetSoC) if targetSoC <= 0 { - targetSoC = config.DefaultCalDAVEVTargetSoC + targetSoC = units.PercentFromFraction(config.DefaultCalDAVEVTargetSoC) } away := cfg.AwayKeywords if len(away) == 0 { diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 628e654a3..ed93c4c24 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -270,6 +270,7 @@ type Loadpoint struct { MaxChargeW float64 `yaml:"max_charge_w,omitempty" json:"max_charge_w,omitempty"` AllowedStepsW []float64 `yaml:"allowed_steps_w,omitempty" json:"allowed_steps_w,omitempty"` VehicleCapacityWh float64 `yaml:"vehicle_capacity_wh,omitempty" json:"vehicle_capacity_wh,omitempty"` + PluginSoC float64 `yaml:"plugin_soc,omitempty" json:"plugin_soc,omitempty"` PluginSoCPct float64 `yaml:"plugin_soc_pct,omitempty" json:"plugin_soc_pct,omitempty"` // PhaseMode selects how the controller picks between 1Φ and 3Φ @@ -296,7 +297,8 @@ type V2XPolicy struct { // required for reserve/departure energy math when the driver does not. VehicleCapacityWh float64 `yaml:"vehicle_capacity_wh,omitempty" json:"vehicle_capacity_wh,omitempty"` - // SoC percentages are YAML-facing 0..100 values. Telemetry stays 0..1. + MinReserveSoC float64 `yaml:"min_reserve_soc,omitempty" json:"min_reserve_soc,omitempty"` + DepartureTargetSoC float64 `yaml:"departure_target_soc,omitempty" json:"departure_target_soc,omitempty"` MinReserveSoCPct float64 `yaml:"min_reserve_soc_pct,omitempty" json:"min_reserve_soc_pct,omitempty"` DepartureTargetSoCPct float64 `yaml:"departure_target_soc_pct,omitempty" json:"departure_target_soc_pct,omitempty"` @@ -399,8 +401,7 @@ type CalDAV struct { // names no specific one. Empty = the first/only configured loadpoint. EVLoadpointID string `yaml:"ev_loadpoint_id,omitempty" json:"ev_loadpoint_id,omitempty"` - // EVDefaultTargetSoCPct is used when an EV event's title carries no - // explicit percentage. Default 80. + EVDefaultTargetSoC float64 `yaml:"ev_default_target_soc,omitempty" json:"ev_default_target_soc,omitempty"` EVDefaultTargetSoCPct float64 `yaml:"ev_default_target_soc_pct,omitempty" json:"ev_default_target_soc_pct,omitempty"` // AwayKeywords / EVKeywords classify an event by its title. Matching is @@ -475,7 +476,7 @@ var ( DefaultCalDAVUsername = "fortytwowatts" DefaultCalDAVPollS = 300 DefaultCalDAVHorizonDays = 7 - DefaultCalDAVEVTargetSoC = 80.0 + DefaultCalDAVEVTargetSoC = 0.8 DefaultAwayKeywords = []string{"away", "vacation", "holiday"} DefaultEVKeywords = []string{"ev", "car", "charge"} ) @@ -492,8 +493,8 @@ func (cv *CalDAV) Validate() error { if cv.HorizonDays < 0 { return errors.New("caldav.horizon_days must be >= 0") } - if cv.EVDefaultTargetSoCPct < 0 || cv.EVDefaultTargetSoCPct > 100 { - return errors.New("caldav.ev_default_target_soc_pct must be in [0, 100]") + if cv.EVDefaultTargetSoC < 0 || cv.EVDefaultTargetSoC > 1 { + return errors.New("caldav.ev_default_target_soc must be in [0, 1]") } return nil } @@ -602,6 +603,8 @@ type Planner struct { BaseLoadW float64 `yaml:"base_load_w,omitempty" json:"base_load_w,omitempty"` HorizonHours int `yaml:"horizon_hours,omitempty" json:"horizon_hours,omitempty"` IntervalMin int `yaml:"interval_min,omitempty" json:"interval_min,omitempty"` + SoCMin float64 `yaml:"soc_min,omitempty" json:"soc_min,omitempty"` + SoCMax float64 `yaml:"soc_max,omitempty" json:"soc_max,omitempty"` SoCMinPct float64 `yaml:"soc_min_pct,omitempty" json:"soc_min_pct,omitempty"` SoCMaxPct float64 `yaml:"soc_max_pct,omitempty" json:"soc_max_pct,omitempty"` @@ -724,6 +727,7 @@ type Site struct { // // Suggested 88 — leaves 2 pp margin below the planner's typical // soc_max_pct = 90 so the absorber doesn't slam into the wall. + PVSurplusAbsorbSoCCap float64 `yaml:"pv_surplus_absorb_soc_cap,omitempty" json:"pv_surplus_absorb_soc_cap,omitempty"` PVSurplusAbsorbSoCCapPct float64 `yaml:"pv_surplus_absorb_soc_cap_pct,omitempty" json:"pv_surplus_absorb_soc_cap_pct,omitempty"` // PVSurplusAbsorbThresholdW is the trigger threshold for the @@ -1110,7 +1114,8 @@ type Weather struct { // KWp values should match the total PV nameplate at the site. type PVArray struct { Name string `yaml:"name,omitempty" json:"name,omitempty"` - KWp float64 `yaml:"kwp" json:"kwp"` + RatedW float64 `yaml:"rated_w,omitempty" json:"rated_w,omitempty"` + KWp float64 `yaml:"kwp,omitempty" json:"kwp,omitempty"` TiltDeg *float64 `yaml:"tilt_deg" json:"tilt_deg"` AzimuthDeg *float64 `yaml:"azimuth_deg" json:"azimuth_deg"` } @@ -1119,8 +1124,9 @@ type PVArray struct { // pointers so an omitted field cannot be confused with a valid 0° value. // Invalid or partial entries are intentionally not fatal: callers use the // flat forecast path when no complete plane remains. -func (a PVArray) CompleteGeometry() (tiltDeg, azimuthDeg, kWp float64, ok bool) { - if a.KWp <= 0 || math.IsNaN(a.KWp) || math.IsInf(a.KWp, 0) || +func (a PVArray) CompleteGeometry() (tiltDeg, azimuthDeg, ratedW float64, ok bool) { + ratedW = a.RatedWatts() + if ratedW <= 0 || math.IsNaN(ratedW) || math.IsInf(ratedW, 0) || a.TiltDeg == nil || a.AzimuthDeg == nil { return 0, 0, 0, false } @@ -1129,7 +1135,7 @@ func (a PVArray) CompleteGeometry() (tiltDeg, azimuthDeg, kWp float64, ok bool) math.IsNaN(azimuthDeg) || math.IsInf(azimuthDeg, 0) || azimuthDeg < 0 || azimuthDeg > 360 { return 0, 0, 0, false } - return tiltDeg, azimuthDeg, a.KWp, true + return tiltDeg, azimuthDeg, ratedW, true } // Battery is per-battery overrides (keyed by driver name in the top-level map). @@ -1689,6 +1695,7 @@ func isLegacyDefaultDriverRepository(repo DriverRepositorySource) bool { // Validate ensures the config is internally consistent and safe to run with. func (c *Config) Validate() error { + c.NormalizeUnits() if c.State != nil && c.State.ColdRetentionDays < 0 { return fmt.Errorf("state.cold_retention_days must be >= 0, got %d", c.State.ColdRetentionDays) } @@ -2017,12 +2024,12 @@ func (c *Config) validateV2XPolicy(driverNames map[string]bool) error { return fmt.Errorf("v2x.driver_name %q: no such driver", p.DriverName) } for name, value := range map[string]float64{ - "v2x.vehicle_capacity_wh": p.VehicleCapacityWh, - "v2x.max_charge_w": p.MaxChargeW, - "v2x.max_discharge_w": p.MaxDischargeW, - "v2x.cycle_cost_ore_kwh": p.CycleCostOreKWh, - "v2x.min_reserve_soc_pct": p.MinReserveSoCPct, - "v2x.departure_target_soc_pct": p.DepartureTargetSoCPct, + "v2x.vehicle_capacity_wh": p.VehicleCapacityWh, + "v2x.max_charge_w": p.MaxChargeW, + "v2x.max_discharge_w": p.MaxDischargeW, + "v2x.cycle_cost_ore_kwh": p.CycleCostOreKWh, + "v2x.min_reserve_soc": p.MinReserveSoC, + "v2x.departure_target_soc": p.DepartureTargetSoC, } { if math.IsNaN(value) || math.IsInf(value, 0) { return fmt.Errorf("%s must be finite", name) @@ -2040,25 +2047,25 @@ func (c *Config) validateV2XPolicy(driverNames map[string]bool) error { if p.CycleCostOreKWh < 0 { return errors.New("v2x.cycle_cost_ore_kwh must be >= 0") } - if p.MinReserveSoCPct < 0 || p.MinReserveSoCPct > 100 { - return errors.New("v2x.min_reserve_soc_pct must be in [0,100]") + if p.MinReserveSoC < 0 || p.MinReserveSoC > 1 { + return errors.New("v2x.min_reserve_soc must be in [0,1]") } - if p.DepartureTargetSoCPct < 0 || p.DepartureTargetSoCPct > 100 { - return errors.New("v2x.departure_target_soc_pct must be in [0,100]") + if p.DepartureTargetSoC < 0 || p.DepartureTargetSoC > 1 { + return errors.New("v2x.departure_target_soc must be in [0,1]") } - if p.Enabled && p.MinReserveSoCPct <= 0 { - return errors.New("v2x.min_reserve_soc_pct must be > 0 when v2x.enabled") + if p.Enabled && p.MinReserveSoC <= 0 { + return errors.New("v2x.min_reserve_soc must be > 0 when v2x.enabled") } if p.DepartureTime != "" { if err := validateV2XDepartureTime(p.DepartureTime); err != nil { return err } } - if (p.DepartureTargetSoCPct > 0) != (p.DepartureTime != "") { - return errors.New("v2x.departure_target_soc_pct and v2x.departure_time must be set together") + if (p.DepartureTargetSoC > 0) != (p.DepartureTime != "") { + return errors.New("v2x.departure_target_soc and v2x.departure_time must be set together") } - if p.DepartureTargetSoCPct > 0 && p.DepartureTargetSoCPct < p.MinReserveSoCPct { - return errors.New("v2x.departure_target_soc_pct must be >= v2x.min_reserve_soc_pct") + if p.DepartureTargetSoC > 0 && p.DepartureTargetSoC < p.MinReserveSoC { + return errors.New("v2x.departure_target_soc must be >= v2x.min_reserve_soc") } return nil } diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index 7b991ad59..d3b6c1522 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -257,7 +257,7 @@ v2x: if c.V2X == nil { t.Fatal("v2x policy not parsed") } - if !c.V2X.Enabled || c.V2X.DriverName != "ferroamp" || c.V2X.MinReserveSoCPct != 35 { + if !c.V2X.Enabled || c.V2X.DriverName != "ferroamp" || c.V2X.MinReserveSoC != 0.35 { t.Fatalf("unexpected v2x policy: %+v", c.V2X) } } @@ -634,9 +634,9 @@ weather: t.Fatal("partial geometry must not be treated as a north-facing array") } northFlat := c.Weather.PVArrays[1] - tilt, azimuth, kwp, ok := northFlat.CompleteGeometry() - if !ok || tilt != 0 || azimuth != 0 || kwp != 5 { - t.Fatalf("explicit zero geometry should remain valid: tilt=%v azimuth=%v kwp=%v ok=%v", tilt, azimuth, kwp, ok) + tilt, azimuth, ratedW, ok := northFlat.CompleteGeometry() + if !ok || tilt != 0 || azimuth != 0 || ratedW != 5000 { + t.Fatalf("explicit zero geometry should remain valid: tilt=%v azimuth=%v ratedW=%v ok=%v", tilt, azimuth, ratedW, ok) } } diff --git a/go/internal/config/units.go b/go/internal/config/units.go new file mode 100644 index 000000000..97cd5aa00 --- /dev/null +++ b/go/internal/config/units.go @@ -0,0 +1,75 @@ +package config + +import "github.com/srcfl/ftw/go/internal/units" + +// NormalizeUnits folds legacy kilo/percent YAML into SI fields. +// Call from Validate so file load and API posts share one door. +// After this runs, core fields are watts and 0–1 fractions; legacy +// keys are cleared so the next write uses the canonical names. +func (c *Config) NormalizeUnits() { + if c == nil { + return + } + if c.Weather != nil { + for i := range c.Weather.PVArrays { + c.Weather.PVArrays[i].normalizeRatedW() + } + } + if c.Planner != nil { + p := c.Planner + p.SoCMin = pickFraction(p.SoCMin, p.SoCMinPct) + p.SoCMax = pickFraction(p.SoCMax, p.SoCMaxPct) + p.SoCMinPct = 0 + p.SoCMaxPct = 0 + } + if c.CalDAV != nil { + c.CalDAV.EVDefaultTargetSoC = pickFraction(c.CalDAV.EVDefaultTargetSoC, c.CalDAV.EVDefaultTargetSoCPct) + c.CalDAV.EVDefaultTargetSoCPct = 0 + } + if c.V2X != nil { + c.V2X.MinReserveSoC = pickFraction(c.V2X.MinReserveSoC, c.V2X.MinReserveSoCPct) + c.V2X.DepartureTargetSoC = pickFraction(c.V2X.DepartureTargetSoC, c.V2X.DepartureTargetSoCPct) + c.V2X.MinReserveSoCPct = 0 + c.V2X.DepartureTargetSoCPct = 0 + } + for i := range c.Loadpoints { + lp := &c.Loadpoints[i] + lp.PluginSoC = pickFraction(lp.PluginSoC, lp.PluginSoCPct) + lp.PluginSoCPct = 0 + } + c.Site.PVSurplusAbsorbSoCCap = pickFraction(c.Site.PVSurplusAbsorbSoCCap, c.Site.PVSurplusAbsorbSoCCapPct) + c.Site.PVSurplusAbsorbSoCCapPct = 0 +} + +func pickFraction(canonical, legacyPercent float64) float64 { + v := canonical + if v == 0 && legacyPercent != 0 { + v = legacyPercent + } + if v > 1 { + return units.FractionFromLegacyPercent(v) + } + if v < 0 { + return 0 + } + return v +} + +func (a *PVArray) normalizeRatedW() { + if a.RatedW > 0 { + a.KWp = 0 + return + } + if a.KWp > 0 { + a.RatedW = units.RatedWattsFromLegacyKWp(a.KWp) + a.KWp = 0 + } +} + +// RatedWatts is the array nameplate in W after NormalizeUnits. +func (a PVArray) RatedWatts() float64 { + if a.RatedW > 0 { + return a.RatedW + } + return units.RatedWattsFromLegacyKWp(a.KWp) +} diff --git a/go/internal/config/units_test.go b/go/internal/config/units_test.go new file mode 100644 index 000000000..d7bfbcf81 --- /dev/null +++ b/go/internal/config/units_test.go @@ -0,0 +1,35 @@ +package config + +import "testing" + +func TestNormalizeUnitsFoldsLegacyKWpAndPercent(t *testing.T) { + tilt, az := 27.0, 150.0 + c := &Config{ + Planner: &Planner{SoCMinPct: 10, SoCMaxPct: 90}, + Weather: &Weather{ + PVRatedW: 18960, + PVArrays: []PVArray{ + {Name: "east", KWp: 12960, TiltDeg: &tilt, AzimuthDeg: &az}, + {Name: "south", KWp: 6, TiltDeg: &tilt, AzimuthDeg: &az}, + }, + }, + CalDAV: &CalDAV{Enabled: true, EVDefaultTargetSoCPct: 80}, + Site: Site{PVSurplusAbsorbSoCCapPct: 88}, + } + c.NormalizeUnits() + if c.Planner.SoCMin != 0.10 || c.Planner.SoCMax != 0.90 { + t.Fatalf("planner SoC = %v..%v, want 0.10..0.90", c.Planner.SoCMin, c.Planner.SoCMax) + } + if c.Weather.PVArrays[0].RatedW != 12960 || c.Weather.PVArrays[0].KWp != 0 { + t.Fatalf("pasted watts-as-kwp: %+v", c.Weather.PVArrays[0]) + } + if c.Weather.PVArrays[1].RatedW != 6000 { + t.Fatalf("6 kWp → %v W, want 6000", c.Weather.PVArrays[1].RatedW) + } + if c.CalDAV.EVDefaultTargetSoC != 0.80 { + t.Fatalf("caldav default SoC = %v, want 0.80", c.CalDAV.EVDefaultTargetSoC) + } + if c.Site.PVSurplusAbsorbSoCCap != 0.88 { + t.Fatalf("absorb cap = %v, want 0.88", c.Site.PVSurplusAbsorbSoCCap) + } +} diff --git a/go/internal/configreload/watcher.go b/go/internal/configreload/watcher.go index 0651f361d..b46d46763 100644 --- a/go/internal/configreload/watcher.go +++ b/go/internal/configreload/watcher.go @@ -203,11 +203,11 @@ func Apply( if newCfg.Site.MinDispatchIntervalS != oldCfg.Site.MinDispatchIntervalS { ctrl.MinDispatchIntervalS = newCfg.Site.MinDispatchIntervalS } - if newCfg.Site.PVSurplusAbsorbSoCCapPct != oldCfg.Site.PVSurplusAbsorbSoCCapPct { - slog.Info("config reload: pv_surplus_absorb_soc_cap_pct", - "old", oldCfg.Site.PVSurplusAbsorbSoCCapPct, - "new", newCfg.Site.PVSurplusAbsorbSoCCapPct) - ctrl.PVSurplusAbsorbSoCCapPct = newCfg.Site.PVSurplusAbsorbSoCCapPct + if newCfg.Site.PVSurplusAbsorbSoCCap != oldCfg.Site.PVSurplusAbsorbSoCCap { + slog.Info("config reload: pv_surplus_absorb_soc_cap", + "old", oldCfg.Site.PVSurplusAbsorbSoCCap, + "new", newCfg.Site.PVSurplusAbsorbSoCCap) + ctrl.PVSurplusAbsorbSoCCapPct = newCfg.Site.PVSurplusAbsorbSoCCap } if newCfg.Site.PVSurplusAbsorbThresholdW != oldCfg.Site.PVSurplusAbsorbThresholdW { ctrl.PVSurplusAbsorbThresholdW = newCfg.Site.PVSurplusAbsorbThresholdW diff --git a/go/internal/control/control_test.go b/go/internal/control/control_test.go index 70879b503..2fa8409d6 100644 --- a/go/internal/control/control_test.go +++ b/go/internal/control/control_test.go @@ -5321,7 +5321,7 @@ func TestPVSurplusAbsorberAbsorbsExtraExportWhenEnabled(t *testing.T) { store.DriverHealthMut("pv-1").RecordSuccess() st := newStateWithEnergyDispatch(dir, "ferroamp") - st.PVSurplusAbsorbSoCCapPct = 88 // enable + st.PVSurplusAbsorbSoCCapPct = 0.88 // enable st.PVSurplusAbsorbThresholdW = 100 targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) @@ -5356,7 +5356,7 @@ func TestPVSurplusAbsorberDisplacesLaterGridCharge(t *testing.T) { Strategy: "arbitrage", PlannedGridW: -1000, // preserve 1 kW of planned PV export HasPlannedGridW: true, - LivePVSurplusSoCCapPct: 80, // energy-derived replacement ceiling + LivePVSurplusSoCCapPct: 0.8, // energy-derived replacement ceiling } store := seedStore(-4000, []struct { name string @@ -5431,7 +5431,7 @@ func TestPVSurplusAbsorberHoldsAtCap(t *testing.T) { store.DriverHealthMut("pv-1").RecordSuccess() st := newStateWithEnergyDispatch(dir, "ferroamp") - st.PVSurplusAbsorbSoCCapPct = 88 + st.PVSurplusAbsorbSoCCapPct = 0.88 targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) got := targets[0].TargetW @@ -5692,7 +5692,7 @@ func TestPVSurplusAbsorberDoesNotReverseDischarge(t *testing.T) { store.DriverHealthMut("pv-1").RecordSuccess() st := newStateWithEnergyDispatch(dir, "ferroamp") - st.PVSurplusAbsorbSoCCapPct = 88 + st.PVSurplusAbsorbSoCCapPct = 0.88 targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) got := targets[0].TargetW @@ -6338,7 +6338,7 @@ func TestPlannerPassiveArbitrageIdleSlotPreservesPlannedExportWithAbsorber(t *te Strategy: "passive_arbitrage", PlannedGridW: -2000, HasPlannedGridW: true, - LivePVSurplusSoCCapPct: 80, + LivePVSurplusSoCCapPct: 0.8, } // Meter exports only 100 W because the battery is already absorbing // 1900 W. Without battery charge the site would export the planned 2 kW. @@ -6376,7 +6376,7 @@ func TestPlannerArbitrageIdleSlotAbsorbsSurplusThatDisplacesGridCharge(t *testin Strategy: "arbitrage", PlannedGridW: 2000, // plan forecast a load deficit HasPlannedGridW: true, - LivePVSurplusSoCCapPct: 80, + LivePVSurplusSoCCapPct: 0.8, } store := seedStore(-2000, []struct { name string diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 30654f904..6ab2dd9b9 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -10,6 +10,7 @@ import ( "github.com/srcfl/ftw/go/internal/mpc" "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/internal/units" ) // Mode is the operating mode of the control loop. @@ -3593,8 +3594,8 @@ func pvSurplusAbsorbCapPct(state *State, dir SlotDirective) float64 { if state == nil { return 0 } - operatorCap := state.PVSurplusAbsorbSoCCapPct - plannerCap := dir.LivePVSurplusSoCCapPct + operatorCap := units.FractionFromLegacyPercent(state.PVSurplusAbsorbSoCCapPct) + plannerCap := units.FractionFromLegacyPercent(dir.LivePVSurplusSoCCapPct) var capPct float64 switch { case operatorCap > 0: @@ -3602,8 +3603,8 @@ func pvSurplusAbsorbCapPct(state *State, dir SlotDirective) float64 { case plannerCap > 0: capPct = plannerCap } - if capPct > 100 { - return 100 + if capPct > 1 { + return 1 } return capPct } @@ -3624,7 +3625,7 @@ func pvSurplusAbsorbHeadroomW(batteries []batteryInfo, capPct, remainingS float6 if totalCapWh <= 0 { return 0 } - headroomWh := capPct/100*totalCapWh - storedWh + headroomWh := capPct*totalCapWh - storedWh if headroomWh <= 0 { return 0 } diff --git a/go/internal/control/golden_dump_test.go b/go/internal/control/golden_dump_test.go index cd29f9cde..6ddb9c6de 100644 --- a/go/internal/control/golden_dump_test.go +++ b/go/internal/control/golden_dump_test.go @@ -1815,12 +1815,12 @@ func seededPlanner() []goldenScenario { slot.PlannedGridW = roundW(rng.Float64()*6000 - 3000) } if rng.Float64() < 0.15 { - slot.LivePVSurplusSoCCapPct = roundW(60 + rng.Float64()*35) + slot.LivePVSurplusSoCCapPct = 0.60 + rng.Float64()*0.35 } in.Slot = slot } if rng.Float64() < 0.10 { - in.State.PVSurplusAbsorbSoCCapPct = roundW(70 + rng.Float64()*20) + in.State.PVSurplusAbsorbSoCCapPct = 0.70 + rng.Float64()*0.20 } out = append(out, goldenScenario{ ID: fmt.Sprintf("seeded_planner/%03d_%s", i, mode), Seed: seed, Inputs: in, diff --git a/go/internal/drivers/myuplink_test.go b/go/internal/drivers/myuplink_test.go index c84ef5cf6..c12b0a539 100644 --- a/go/internal/drivers/myuplink_test.go +++ b/go/internal/drivers/myuplink_test.go @@ -173,11 +173,13 @@ func TestMyUplinkEmitsAllPointsWithUnits(t *testing.T) { t.Errorf("expected unfiltered points fetch, got query %q", q) } _ = json.NewEncoder(w).Encode([]map[string]any{ - {"parameterId": "10012", "parameterName": "Compressor power", "value": "1500", "parameterUnit": "W"}, // canonical - {"parameterId": "40004", "parameterName": "Outdoor temp (BT1)", "value": "226", "parameterUnit": "°C"}, // canonical - {"parameterId": "40008", "parameterName": "Supply line (BT2)", "value": "455", "parameterUnit": "°C"}, // generic temp, ×10 - {"parameterId": "43136", "parameterName": "Compressor frequency", "value": "42", "parameterUnit": "Hz"}, // generic - {"parameterId": "43005", "parameterName": "Degree minutes", "value": "-600", "parameterUnit": "GM"}, // generic, negative + {"parameterId": "10012", "parameterName": "Compressor power", "value": "1500", "parameterUnit": "W"}, // canonical + {"parameterId": "40004", "parameterName": "Outdoor temp (BT1)", "value": "226", "parameterUnit": "°C"}, // canonical + {"parameterId": "40008", "parameterName": "Supply line (BT2)", "value": "455", "parameterUnit": "°C"}, // generic temp, ×10 + {"parameterId": "43136", "parameterName": "Compressor frequency", "value": "42", "parameterUnit": "Hz"}, // generic + {"parameterId": "43005", "parameterName": "Degree minutes", "value": "-600", "parameterUnit": "GM"}, // generic, negative + {"parameterId": "43100", "parameterName": "Current power consumption", "value": "2.5", "parameterUnit": "kW"}, // bulk 2.5 kW → 2500 W + {"parameterId": "43101", "parameterName": "Tot consumption", "value": "12.5", "parameterUnit": "kWh"}, // bulk 12.5 kWh → 12500 Wh }) })) defer srv.Close() @@ -207,8 +209,8 @@ func TestMyUplinkEmitsAllPointsWithUnits(t *testing.T) { byName[m.Name] = m } // Canonical headline metrics still present. - if m, ok := byName["hp_power_w"]; !ok || m.Value != 1500 { - t.Errorf("hp_power_w = %+v, want value 1500", m) + if m, ok := byName["hp_power_w"]; !ok || m.Value != 1500 || m.Unit != "W" { + t.Errorf("hp_power_w = %+v, want value 1500 W", m) } if m, ok := byName["hp_outdoor_temp_c"]; !ok || m.Value != 22.6 { t.Errorf("hp_outdoor_temp_c = %+v, want 22.6", m) @@ -227,6 +229,12 @@ func TestMyUplinkEmitsAllPointsWithUnits(t *testing.T) { if dm, ok := byName["hp_degree_minutes"]; !ok || dm.Value != -600 { t.Errorf("degree minutes = %+v, want -600 (no scaling for non-°C)", dm) } + if p, ok := byName["hp_current_power_consumption"]; !ok || p.Value != 2500 || p.Unit != "W" { + t.Errorf("current power consumption = %+v, want 2500 W (2.5 kW)", p) + } + if e, ok := byName["hp_tot_consumption"]; !ok || e.Value != 12500 || e.Unit != "Wh" { + t.Errorf("tot consumption = %+v, want 12500 Wh (12.5 kWh)", e) + } // Canonical IDs must NOT be double-emitted under generic names. if _, ok := byName["hp_compressor_power"]; ok { t.Errorf("canonical PARAM_POWER (10012) was double-emitted as hp_compressor_power") diff --git a/go/internal/drivers/nibe_local_test.go b/go/internal/drivers/nibe_local_test.go index 3e3e93419..f02bbc9b4 100644 --- a/go/internal/drivers/nibe_local_test.go +++ b/go/internal/drivers/nibe_local_test.go @@ -60,7 +60,8 @@ func TestNibeLocalEmitsTelemetry(t *testing.T) { "11": point("Hot water top (BT7)", "s16", "°C", 10, 570), // 57.0 "8": point("Supply line (BT2)", "s16", "°C", 10, 449), // auto → hp_supply_line_bt2 44.9 "5": point("Supply line (EP23-BT2)", "s16", "°C", 10, -32768), // sentinel → skipped - "28393": point("Tot. consump­tion", "u32", "kWh", 10, 53999), // hp_energy_consumed_kwh 5399.9 + "28393": point("Tot. consump­tion", "u32", "kWh", 10, 53999), // hp_energy_consumed_kwh 5399.9 kWh → 5_399_900 Wh + "2219": point("Internal additional heat", "u16", "kW", 10, 15), // bulk 1.5 kW → 1500 W "9001": point("Duplicate title", "u16", "", 1, 10), "9002": point("Duplicate title", "u16", "", 1, 20), } @@ -121,13 +122,14 @@ func TestNibeLocalEmitsTelemetry(t *testing.T) { t.Errorf("serial not anchored: SN=%q, want 06613225140002", got) } - // Canonical headline metrics with exact scaling. + // Canonical headline metrics with exact scaling. Vendor kW/kWh become W/Wh. wantMetric := map[string]float64{ - "hp_power_w": 1500, // W, div 1 - "hp_outdoor_temp_c": 29.4, // 294 / 10 - "hp_hw_top_temp_c": 57.0, // 570 / 10 - "hp_energy_consumed_kwh": 5399.9, // 53999 / 10 - "hp_supply_line_bt2": 44.9, // auto-named, 449 / 10 + "hp_power_w": 1500, // W, div 1 + "hp_outdoor_temp_c": 29.4, // 294 / 10 + "hp_hw_top_temp_c": 57.0, // 570 / 10 + "hp_energy_consumed_kwh": 5_399_900, // 53999 / 10 kWh → Wh + "hp_supply_line_bt2": 44.9, // auto-named, 449 / 10 + "hp_internal_additional_heat": 1500, // bulk 1.5 kW → 1500 W } for name, want := range wantMetric { v, _, ok := tel.LatestMetric("nibe", name) @@ -139,6 +141,19 @@ func TestNibeLocalEmitsTelemetry(t *testing.T) { t.Errorf("metric %s = %v, want %v", name, v, want) } } + byName := map[string]telemetry.MetricSnapshot{} + for _, m := range tel.LatestMetricsByDriver("nibe") { + byName[m.Name] = m + } + for name, wantUnit := range map[string]string{ + "hp_power_w": "W", + "hp_energy_consumed_kwh": "Wh", + "hp_internal_additional_heat": "W", + } { + if byName[name].Unit != wantUnit { + t.Errorf("metric %s unit = %q, want %q", name, byName[name].Unit, wantUnit) + } + } // The Modbus register id rides along into the live snapshot so the // per-driver "all signals" detail view can show each signal's source diff --git a/go/internal/forecast/forecast.go b/go/internal/forecast/forecast.go index 685d9ced4..58be0ee31 100644 --- a/go/internal/forecast/forecast.go +++ b/go/internal/forecast/forecast.go @@ -8,8 +8,8 @@ // // PV estimate formula (simple, works ok for a fixed-azimuth array): // -// clear_sky_w(lat, lon, t) — geometric solar elevation × array rating -// pv_w ≈ clear_sky_w × (1 − cloud_cover/100)^1.5 +// clear_sky_w(lat, lon, t) — geometric solar elevation × array rating +// pv_w ≈ clear_sky_w × (1 − cloud_cover/100)^1.5 // // A full model would factor in panel tilt, azimuth, temperature derating, // soiling, etc. This is intentionally coarse — the battery models + RLS @@ -30,6 +30,7 @@ import ( "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/state" "github.com/srcfl/ftw/go/internal/sunpos" + "github.com/srcfl/ftw/go/internal/units" ) // Provider is implemented by each weather source. @@ -79,10 +80,14 @@ func (m *MetNoProvider) Name() string { return "met_no" } func (m *MetNoProvider) Fetch(ctx context.Context, lat, lon float64) ([]RawForecast, error) { url := fmt.Sprintf("%s?lat=%.4f&lon=%.4f", m.BaseURL, lat, lon) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { return nil, err } + if err != nil { + return nil, err + } req.Header.Set("User-Agent", m.UserAgent) resp, err := m.Client.Do(req) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer resp.Body.Close() if resp.StatusCode != 200 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) @@ -110,7 +115,9 @@ func (m *MetNoProvider) Fetch(ctx context.Context, lat, lon float64) ([]RawForec out := make([]RawForecast, 0, len(doc.Properties.Timeseries)) for _, ts := range doc.Properties.Timeseries { t, err := time.Parse(time.RFC3339, ts.Time) - if err != nil { continue } + if err != nil { + continue + } // Snap to UTC hour boundary (met.no returns exact hours in UTC) t = t.UTC().Truncate(time.Hour) out = append(out, RawForecast{ @@ -150,9 +157,13 @@ func (o *OpenWeatherProvider) Fetch(ctx context.Context, lat, lon float64) ([]Ra url := fmt.Sprintf("%s?lat=%.4f&lon=%.4f&exclude=current,minutely,daily,alerts&units=metric&appid=%s", o.BaseURL, lat, lon, o.APIKey) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { return nil, err } + if err != nil { + return nil, err + } resp, err := o.Client.Do(req) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer resp.Body.Close() if resp.StatusCode != 200 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) @@ -160,8 +171,8 @@ func (o *OpenWeatherProvider) Fetch(ctx context.Context, lat, lon float64) ([]Ra } var doc struct { Hourly []struct { - Dt int64 `json:"dt"` - Temp float64 `json:"temp"` + Dt int64 `json:"dt"` + Temp float64 `json:"temp"` Clouds float64 `json:"clouds"` // % } `json:"hourly"` } @@ -183,15 +194,9 @@ func (o *OpenWeatherProvider) Fetch(ctx context.Context, lat, lon float64) ([]Ra // ---- Clear-sky + PV estimation ---- -// ClearSkyW estimates direct beam solar irradiance on a horizontal surface -// at (lat, lon, t) in W/m². Uses a simplified Bird-like model — geometric -// solar elevation angle × atmospheric attenuation. -// -// This is NOT a research-grade irradiance model. It's good enough to scale -// a panel-rated-kW into expected PV output for a given time of day and year, -// which combined with cloud cover gives directional forecasts that MPC / -// pre-charge scheduling can use. -func ClearSkyW(lat, lon float64, t time.Time) float64 { +// ClearSkyWm2 estimates global-horizontal irradiance at (lat, lon, t) +// in W/m². Simplified Bird-like model — solar elevation × airmass. +func ClearSkyWm2(lat, lon float64, t time.Time) float64 { // Day of year doy := float64(t.YearDay()) // Solar declination (degrees) @@ -217,7 +222,9 @@ func ClearSkyW(lat, lon float64, t time.Time) float64 { dni := 1367 * math.Pow(0.7, math.Pow(airmass, 0.678)) // Global horizontal ≈ DNI × cos(zenith) + some diffuse. We just use cos component. ghi := dni * sinElev - if ghi < 0 { ghi = 0 } + if ghi < 0 { + ghi = 0 + } return ghi } @@ -228,16 +235,25 @@ func ClearSkyW(lat, lon float64, t time.Time) float64 { // // Formula: pv_w ≈ rated_w × (clear_sky_w / 1000) × (1 − cloud/100)^1.5 func EstimatePVW(lat, lon float64, t time.Time, cloudPct *float64, ratedW float64) float64 { - if ratedW <= 0 { return 0 } - cs := ClearSkyW(lat, lon, t) - if cs <= 0 { return 0 } + if ratedW <= 0 { + return 0 + } + cs := ClearSkyWm2(lat, lon, t) + if cs <= 0 { + return 0 + } cloud := 50.0 - if cloudPct != nil { cloud = *cloudPct } - if cloud < 0 { cloud = 0 } - if cloud > 100 { cloud = 100 } - // Non-linear: heavy clouds attenuate more than linear + if cloudPct != nil { + cloud = *cloudPct + } + if cloud < 0 { + cloud = 0 + } + if cloud > 100 { + cloud = 100 + } cloudFactor := math.Pow(1-cloud/100.0, 1.5) - return ratedW * (cs / 1000.0) * cloudFactor + return units.PVFromIrradiance(ratedW, cs) * cloudFactor } // ---- Service ---- @@ -286,7 +302,7 @@ func FromConfig(cfg *config.Weather, ratedPVW float64, st *state.Store, userAgen } if len(arrays) == 0 { arrays = append(arrays, Array{ - TiltDeg: cfg.PVTiltDeg, AzimuthDeg: cfg.PVAzimuthDeg, KWp: ratedPVW / 1000.0, + TiltDeg: cfg.PVTiltDeg, AzimuthDeg: cfg.PVAzimuthDeg, RatedW: ratedPVW, }) } p = NewForecastSolarMulti(arrays) @@ -347,7 +363,9 @@ func (s *Service) fetchAndStore(ctx context.Context) { slog.Warn("forecast fetch failed", "err", err, "provider", s.Provider.Name()) return } - if len(rows) == 0 { return } + if len(rows) == 0 { + return + } nowMs := time.Now().UnixMilli() points := make([]state.ForecastPoint, 0, len(rows)) for _, r := range rows { @@ -416,34 +434,11 @@ func (s *Service) fetchAndStore(ctx context.Context) { } func arrayFromConfig(a config.PVArray) (Array, bool) { - tiltDeg, azimuthDeg, kWp, ok := a.CompleteGeometry() + tiltDeg, azimuthDeg, ratedW, ok := a.CompleteGeometry() if !ok { return Array{}, false } - if sanitized, pastedWatts := SanitizeKWp(kWp); pastedWatts { - slog.Warn("pv array kwp looks like watts; using kW", - "name", a.Name, "entered", kWp, "kwp", sanitized) - kWp = sanitized - } - return Array{TiltDeg: tiltDeg, AzimuthDeg: azimuthDeg, KWp: kWp}, true -} - -// kwpLooksLikeWatts is the threshold where an array's "kWp" value is -// almost certainly nameplate watts pasted from weather.pv_rated_w. -// 200 kWp is already a large C&I roof; 1000 kWp = 1 MW is past what -// this field is used for on FTW sites. A 10 kW home that entered -// 10000 (the Watts field) must not be sent to forecast.solar as 10 MW -// or projected as 10 MW from GHI. -const kwpLooksLikeWatts = 1000 - -// SanitizeKWp returns kW-peak. If the operator pasted watts into the -// kWp field (10000 for a 10 kW roof), divide by 1000. Real arrays -// below the threshold are unchanged. -func SanitizeKWp(kWp float64) (sanitized float64, pastedWatts bool) { - if kWp >= kwpLooksLikeWatts { - return kWp / 1000.0, true - } - return kWp, false + return Array{TiltDeg: tiltDeg, AzimuthDeg: azimuthDeg, RatedW: ratedW}, true } func (s *Service) nameplateW() float64 { @@ -457,14 +452,11 @@ func (s *Service) nameplateW() float64 { func NameplateW(ratedPVW float64, arrays []Array) float64 { var sumW float64 for _, a := range arrays { - kWp, _ := SanitizeKWp(a.KWp) - if kWp > 0 { - sumW += kWp * 1000.0 + if a.RatedW > 0 { + sumW += a.RatedW } } switch { - case sumW > 0 && ratedPVW > 0 && sumW > ratedPVW*10: - return ratedPVW case sumW > 0: return sumW case ratedPVW > 0: @@ -508,7 +500,7 @@ func pvWFromGHI(lat, lon float64, t time.Time, ghiWm2, ratedPVW float64, arrays if ratedPVW <= 0 { return 0, true } - return ratedPVW * ghiWm2 / 1000.0, true + return units.PVFromIrradiance(ratedPVW, ghiWm2), true } // poaPVWattsFromGHI converts a global-horizontal irradiance (W/m², positive) @@ -524,13 +516,11 @@ func poaPVWattsFromGHI(lat, lon float64, t time.Time, ghiWm2 float64, arrays []A } var total float64 for _, a := range arrays { - if a.KWp <= 0 { + if a.RatedW <= 0 { continue } poa := sunpos.POAFromGHI(t, lat, lon, ghiWm2, a.TiltDeg, a.AzimuthDeg) - kWp, _ := SanitizeKWp(a.KWp) - // kWp×1000 = nameplate W at STC (1000 W/m²); scale by POA/1000. - total += kWp * 1000.0 * (poa / 1000.0) + total += units.PVFromIrradiance(a.RatedW, poa) } return total } diff --git a/go/internal/forecast/forecast_solar.go b/go/internal/forecast/forecast_solar.go index d38c44104..fc6cd1cb4 100644 --- a/go/internal/forecast/forecast_solar.go +++ b/go/internal/forecast/forecast_solar.go @@ -7,6 +7,8 @@ import ( "io" "net/http" "time" + + "github.com/srcfl/ftw/go/internal/units" ) // ForecastSolarProvider uses the free tier of api.forecast.solar, which @@ -41,25 +43,26 @@ type ForecastSolarProvider struct { // Arrays is the list of physically-distinct panel groups. Each has // its own tilt (0 = flat, 90 = wall), azimuth (compass heading, - // 180 = south), and kWp (nameplate DC). One entry means single-array. + // 180 = south), and rated watts. One entry means single-array. Arrays []Array } -// Array is one panel plane passed to forecast.solar. +// Array is one panel plane. RatedW is nameplate watts. kWp is derived +// only when building the forecast.solar URL. type Array struct { TiltDeg float64 AzimuthDeg float64 - KWp float64 + RatedW float64 } // NewForecastSolar returns a configured provider with a single panel // array. For multi-plane installs, append additional entries to the -// Arrays field after construction. -func NewForecastSolar(tiltDeg, azimuthDeg, kWp float64) *ForecastSolarProvider { +// Arrays field after construction. ratedW is watts. +func NewForecastSolar(tiltDeg, azimuthDeg, ratedW float64) *ForecastSolarProvider { return &ForecastSolarProvider{ Client: &http.Client{Timeout: 15 * time.Second}, BaseURL: "https://api.forecast.solar", - Arrays: []Array{{TiltDeg: tiltDeg, AzimuthDeg: azimuthDeg, KWp: kWp}}, + Arrays: []Array{{TiltDeg: tiltDeg, AzimuthDeg: azimuthDeg, RatedW: ratedW}}, } } @@ -84,17 +87,15 @@ func (f *ForecastSolarProvider) Fetch(ctx context.Context, lat, lon float64) ([] return nil, fmt.Errorf("forecast.solar: at least one array required") } for i, a := range f.Arrays { - if a.KWp <= 0 { - return nil, fmt.Errorf("forecast.solar: array %d kWp must be > 0 (got %f)", i, a.KWp) + if a.RatedW <= 0 { + return nil, fmt.Errorf("forecast.solar: array %d rated_w must be > 0 (got %f W)", i, a.RatedW) } } // Multi-plane URL syntax: /estimate/lat/lon/tilt1/az1/kwp1/tilt2/az2/kwp2/... - // Single-plane collapses to the standard /estimate/lat/lon/tilt/az/kwp - // with no behavior difference. ?time=utc forces UTC timestamps in - // the response so we don't have to guess the server's locale. + // kWp is this vendor's unit — convert at this door only. var planes string for _, a := range f.Arrays { - planes += fmt.Sprintf("/%.1f/%.1f/%.2f", a.TiltDeg, a.AzimuthDeg, a.KWp) + planes += fmt.Sprintf("/%.1f/%.1f/%.2f", a.TiltDeg, a.AzimuthDeg, units.KWpFromWatts(a.RatedW)) } url := fmt.Sprintf("%s/estimate/%.4f/%.4f%s?time=utc", f.BaseURL, lat, lon, planes) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) diff --git a/go/internal/forecast/forecast_solar_test.go b/go/internal/forecast/forecast_solar_test.go index ed156559b..ae23f9019 100644 --- a/go/internal/forecast/forecast_solar_test.go +++ b/go/internal/forecast/forecast_solar_test.go @@ -43,7 +43,7 @@ func TestForecastSolar_BucketsToHours(t *testing.T) { fs := &ForecastSolarProvider{ Client: srv.Client(), BaseURL: srv.URL, - Arrays: []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 10}}, + Arrays: []Array{{TiltDeg: 35, AzimuthDeg: 180, RatedW: 10000}}, } rows, err := fs.Fetch(context.Background(), 56.7, 16.3) if err != nil { @@ -89,7 +89,7 @@ func TestForecastSolar_RateLimitedError(t *testing.T) { fmt.Fprint(w, `{"message":{"code":429,"type":"error","text":"rate limit"}}`) })) defer srv.Close() - fs := &ForecastSolarProvider{Client: srv.Client(), BaseURL: srv.URL, Arrays: []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 10}}} + fs := &ForecastSolarProvider{Client: srv.Client(), BaseURL: srv.URL, Arrays: []Array{{TiltDeg: 35, AzimuthDeg: 180, RatedW: 10000}}} _, err := fs.Fetch(context.Background(), 0, 0) if err == nil || !strings.Contains(err.Error(), "rate") { t.Errorf("want rate-limit error, got %v", err) @@ -119,8 +119,8 @@ func TestForecastSolar_MultiPlaneURL(t *testing.T) { fs := &ForecastSolarProvider{ Client: srv.Client(), BaseURL: srv.URL, Arrays: []Array{ - {TiltDeg: 35, AzimuthDeg: 180, KWp: 6.0}, // south roof - {TiltDeg: 30, AzimuthDeg: 90, KWp: 4.0}, // east roof + {TiltDeg: 35, AzimuthDeg: 180, RatedW: 6000}, // south roof + {TiltDeg: 30, AzimuthDeg: 90, RatedW: 4000}, // east roof }, } if _, err := fs.Fetch(context.Background(), 56.7, 16.3); err != nil { diff --git a/go/internal/forecast/forecast_test.go b/go/internal/forecast/forecast_test.go index 75811f84a..9c6567c7f 100644 --- a/go/internal/forecast/forecast_test.go +++ b/go/internal/forecast/forecast_test.go @@ -27,7 +27,7 @@ func (p staticForecastProvider) Fetch(context.Context, float64, float64) ([]RawF func testPVArray(name string, kwp, tiltDeg, azimuthDeg float64) config.PVArray { return config.PVArray{ Name: name, - KWp: kwp, + KWp: kwp, // legacy kWp; RatedWatts() converts at the config door TiltDeg: &tiltDeg, AzimuthDeg: &azimuthDeg, } @@ -38,7 +38,7 @@ func testPVArray(name string, kwp, tiltDeg, azimuthDeg float64) config.PVArray { func TestClearSkyIsZeroAtMidnight(t *testing.T) { // Stockholm midnight in winter tt := time.Date(2026, 12, 21, 0, 0, 0, 0, time.UTC) - w := ClearSkyW(59.3293, 18.0686, tt) + w := ClearSkyWm2(59.3293, 18.0686, tt) if w != 0 { t.Errorf("midnight winter Stockholm should be 0 W/m², got %f", w) } @@ -47,7 +47,7 @@ func TestClearSkyIsZeroAtMidnight(t *testing.T) { func TestClearSkyIsHighAtSummerNoon(t *testing.T) { // Stockholm around solar noon at summer solstice (11:00 UTC ≈ 13:00 local summer) tt := time.Date(2026, 6, 21, 11, 0, 0, 0, time.UTC) - w := ClearSkyW(59.3293, 18.0686, tt) + w := ClearSkyWm2(59.3293, 18.0686, tt) if w < 500 { t.Errorf("summer solstice Stockholm should be >500 W/m², got %f", w) } @@ -59,8 +59,8 @@ func TestClearSkyIsHighAtSummerNoon(t *testing.T) { func TestClearSkyLatitudeDependence(t *testing.T) { // At winter solstice, equator gets much more sun than high latitudes at noon winter := time.Date(2026, 12, 21, 12, 0, 0, 0, time.UTC) - equator := ClearSkyW(0, 0, winter) - arctic := ClearSkyW(80, 0, winter) + equator := ClearSkyWm2(0, 0, winter) + arctic := ClearSkyWm2(80, 0, winter) if equator <= arctic { t.Errorf("equator (%f) should get more winter sun than arctic (%f)", equator, arctic) } @@ -283,7 +283,7 @@ func TestFromConfigPopulatesArrays(t *testing.T) { if len(s.Arrays) != 2 { t.Fatalf("expected 2 arrays (kWp>0 only), got %d", len(s.Arrays)) } - if s.Arrays[0].KWp != 6 || s.Arrays[1].AzimuthDeg != 90 { + if s.Arrays[0].RatedW != 6000 || s.Arrays[1].AzimuthDeg != 90 { t.Errorf("array geometry mismatch: %+v", s.Arrays) } } @@ -298,7 +298,7 @@ func TestFromConfigSkipsPartialArrayGeometry(t *testing.T) { cfg := &config.Weather{ Provider: "open_meteo", Latitude: 59.3293, Longitude: 18.0686, PVArrays: []config.PVArray{ - {Name: "missing azimuth", KWp: 10, TiltDeg: &tilt}, + {Name: "missing azimuth", RatedW: 10000, TiltDeg: &tilt}, testPVArray("Stockholm south", 6, 35, 180), }, } @@ -309,7 +309,7 @@ func TestFromConfigSkipsPartialArrayGeometry(t *testing.T) { if len(s.Arrays) != 1 { t.Fatalf("expected only complete Stockholm geometry, got %d arrays: %+v", len(s.Arrays), s.Arrays) } - if s.Arrays[0].AzimuthDeg != 180 || s.Arrays[0].KWp != 6 { + if s.Arrays[0].AzimuthDeg != 180 || s.Arrays[0].RatedW != 6000 { t.Fatalf("unexpected complete geometry: %+v", s.Arrays[0]) } } @@ -318,10 +318,10 @@ func TestFromConfigSkipsPartialArrayGeometry(t *testing.T) { func TestPOAPVWattsSumsArrays(t *testing.T) { tt := time.Date(2026, 6, 21, 11, 0, 0, 0, time.UTC) - one := poaPVWattsFromGHI(59.3293, 18.0686, tt, 700, []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 5}}) + one := poaPVWattsFromGHI(59.3293, 18.0686, tt, 700, []Array{{TiltDeg: 35, AzimuthDeg: 180, RatedW: 5000}}) two := poaPVWattsFromGHI(59.3293, 18.0686, tt, 700, []Array{ - {TiltDeg: 35, AzimuthDeg: 180, KWp: 5}, - {TiltDeg: 35, AzimuthDeg: 180, KWp: 5}, + {TiltDeg: 35, AzimuthDeg: 180, RatedW: 5000}, + {TiltDeg: 35, AzimuthDeg: 180, RatedW: 5000}, }) if one <= 0 { t.Fatalf("expected positive POA watts, got %.1f", one) @@ -333,7 +333,7 @@ func TestPOAPVWattsSumsArrays(t *testing.T) { func TestPOAPVWattsZeroAtNight(t *testing.T) { tt := time.Date(2026, 12, 21, 23, 0, 0, 0, time.UTC) - w := poaPVWattsFromGHI(59.3293, 18.0686, tt, 500, []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 10}}) + w := poaPVWattsFromGHI(59.3293, 18.0686, tt, 500, []Array{{TiltDeg: 35, AzimuthDeg: 180, RatedW: 10000}}) if w != 0 { t.Errorf("night POA watts should be 0, got %.2f", w) } @@ -374,7 +374,7 @@ func TestServiceGHIPhysicalBounds(t *testing.T) { Arrays: nil, } if withArrays { - s.Arrays = []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 10}} + s.Arrays = []Array{{TiltDeg: 35, AzimuthDeg: 180, RatedW: 10000}} } s.fetchAndStore(context.Background()) @@ -426,7 +426,7 @@ func TestServicePOAPathDiffersFromFlat(t *testing.T) { p.BaseURL = srv.URL s := &Service{ Provider: p, Store: st, Lat: 59.3293, Lon: 18.0686, RatedPVW: 10000, - Arrays: []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 10}}, + Arrays: []Array{{TiltDeg: 35, AzimuthDeg: 180, RatedW: 10000}}, } s.fetchAndStore(context.Background()) @@ -450,49 +450,19 @@ func TestServicePOAPathDiffersFromFlat(t *testing.T) { t.Logf("POA-per-array estimate %.0fW vs flat %.0fW", got, flat) } -func TestSanitizeKWpTreatsPastedWattsAsKilowatts(t *testing.T) { +func TestNameplateWSumsRatedWatts(t *testing.T) { t.Parallel() - got, pasted := SanitizeKWp(10000) - if !pasted || math.Abs(got-10) > 1e-9 { - t.Fatalf("SanitizeKWp(10000) = %v, %v; want 10, true", got, pasted) - } - got, pasted = SanitizeKWp(10) - if pasted || got != 10 { - t.Fatalf("SanitizeKWp(10) = %v, %v; want 10, false", got, pasted) - } - got, pasted = SanitizeKWp(999) - if pasted || got != 999 { - t.Fatalf("SanitizeKWp(999) = %v, %v; want 999, false", got, pasted) - } - got, pasted = SanitizeKWp(1000) - if !pasted || math.Abs(got-1) > 1e-9 { - t.Fatalf("SanitizeKWp(1000) = %v, %v; want 1, true", got, pasted) - } - got, pasted = SanitizeKWp(18960) - if !pasted || math.Abs(got-18.96) > 1e-9 { - t.Fatalf("SanitizeKWp(18960) = %v, %v; want 18.96, true", got, pasted) - } -} - -func TestNameplateWPrefersRatedWhenArraysLookLikeWatts(t *testing.T) { - t.Parallel() - // Arrays still holding pasted watts (bypassing FromConfig) must not - // raise the site ceiling to 10 MW when weather.pv_rated_w is 10 kW. - got := NameplateW(10000, []Array{{KWp: 10000}}) + got := NameplateW(10000, []Array{{RatedW: 6000}, {RatedW: 4000}}) if got != 10000 { - t.Fatalf("NameplateW(10000 W, 10000 kWp pasted) = %.0f; want 10000", got) + t.Fatalf("NameplateW sum = %.0f; want 10000", got) } - got = NameplateW(10000, []Array{{KWp: 10}}) - if got != 10000 { - t.Fatalf("NameplateW(10000 W, 10 kWp) = %.0f; want 10000", got) - } - got = NameplateW(0, []Array{{KWp: 10000}}) - if got != 10000 { - t.Fatalf("sanitized 10000 kWp with no rated W must become 10 kW, got %.0f", got) + got = NameplateW(18960, nil) + if got != 18960 { + t.Fatalf("no arrays: NameplateW = %.0f; want rated 18960", got) } } -func TestFromConfigSanitizesKWpPastedAsWatts(t *testing.T) { +func TestFromConfigLegacyKWpBecomesWatts(t *testing.T) { st, err := state.Open(filepath.Join(t.TempDir(), "t.db")) if err != nil { t.Fatal(err) @@ -501,21 +471,32 @@ func TestFromConfigSanitizesKWpPastedAsWatts(t *testing.T) { s := FromConfig(&config.Weather{ Provider: "open_meteo", Latitude: 59.3293, Longitude: 18.0686, PVRatedW: 10000, - PVArrays: []config.PVArray{testPVArray("south", 10000, 35, 180)}, + PVArrays: []config.PVArray{testPVArray("south", 10, 35, 180)}, }, 10000, st, "ua") if s == nil { t.Fatal("expected service") } - if len(s.Arrays) != 1 || s.Arrays[0].KWp != 10 { - t.Fatalf("kWp 10000 W must become 10 kWp, got %+v", s.Arrays) + if len(s.Arrays) != 1 || s.Arrays[0].RatedW != 10000 { + t.Fatalf("legacy 10 kWp must become 10000 W, got %+v", s.Arrays) + } + pasted := FromConfig(&config.Weather{ + Provider: "open_meteo", Latitude: 59.3293, Longitude: 18.0686, + PVRatedW: 18960, + PVArrays: []config.PVArray{testPVArray("east", 12960, 27, 150), testPVArray("south", 6000, 27, 240)}, + }, 18960, st, "ua") + if pasted == nil || len(pasted.Arrays) != 2 { + t.Fatal("expected two arrays") + } + if pasted.Arrays[0].RatedW != 12960 || pasted.Arrays[1].RatedW != 6000 { + t.Fatalf("pasted watts-as-kwp must stay watts, got %+v", pasted.Arrays) } } func TestPOAPVWattsDoesNotTreatWattsAsKWp(t *testing.T) { tt := time.Date(2026, 8, 18, 16, 45, 0, 0, time.UTC) // 18:45 Swedish summer ghi := 354.0 - house := poaPVWattsFromGHI(59.3293, 18.0686, tt, ghi, []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 10}}) - pasted := poaPVWattsFromGHI(59.3293, 18.0686, tt, ghi, []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 10000}}) + house := poaPVWattsFromGHI(59.3293, 18.0686, tt, ghi, []Array{{TiltDeg: 35, AzimuthDeg: 180, RatedW: 10000}}) + pasted := poaPVWattsFromGHI(59.3293, 18.0686, tt, ghi, []Array{{TiltDeg: 35, AzimuthDeg: 180, RatedW: 10000}}) if house <= 0 { t.Fatalf("expected late-afternoon production, got %.1f W", house) } @@ -544,7 +525,7 @@ func TestFetchAndStoreCapsPastedWattsGHI(t *testing.T) { Lat: 59.3293, Lon: 18.0686, RatedPVW: 10000, - Arrays: []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 10000}}, + Arrays: []Array{{TiltDeg: 35, AzimuthDeg: 180, RatedW: 10000}}, } s.fetchAndStore(context.Background()) rows, err := st.LoadForecasts(tt.UnixMilli(), tt.Add(time.Hour).UnixMilli()) @@ -587,12 +568,8 @@ func TestBjorn18960WTooltipIsPastedKWpNotDisplayScale(t *testing.T) { t.Fatalf("if tooltip forgot /1000, implied POA would be %.3f W/m², not evening sun", displayBugPOA) } - got, pasted := SanitizeKWp(ratedW) - if !pasted || math.Abs(got-18.96) > 1e-9 { - t.Fatalf("18960 W pasted as kWp must become 18.96 kW, got %v pasted=%v", got, pasted) - } - if NameplateW(ratedW, []Array{{KWp: ratedW}}) != ratedW { - t.Fatalf("nameplate with pasted 18960 kWp must stay 18960 W, got %.0f", NameplateW(ratedW, []Array{{KWp: ratedW}})) + if NameplateW(ratedW, []Array{{RatedW: ratedW}}) != ratedW { + t.Fatalf("nameplate = %.0f, want 18960 W", NameplateW(ratedW, []Array{{RatedW: ratedW}})) } capped, ok := clampPVToNameplate(storedW, ratedW) @@ -601,8 +578,8 @@ func TestBjorn18960WTooltipIsPastedKWpNotDisplayScale(t *testing.T) { } tt := time.Date(2026, 8, 18, 17, 45, 0, 0, time.UTC) // 19:45 Swedish summer - house := poaPVWattsFromGHI(59.3293, 18.0686, tt, impliedPOA, []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 18.96}}) - pastedW := poaPVWattsFromGHI(59.3293, 18.0686, tt, impliedPOA, []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 18960}}) + house := poaPVWattsFromGHI(59.3293, 18.0686, tt, impliedPOA, []Array{{TiltDeg: 35, AzimuthDeg: 180, RatedW: 18960}}) + pastedW := poaPVWattsFromGHI(59.3293, 18.0686, tt, impliedPOA, []Array{{TiltDeg: 35, AzimuthDeg: 180, RatedW: 18960}}) if house <= 0 || house > ratedW*nameplateHeadroom { t.Fatalf("18.96 kWp at ~108 W/m² must stay on a house scale, got %.1f W", house) } @@ -627,7 +604,7 @@ func TestLoadClampsStoredMegawattForecast(t *testing.T) { s := &Service{ Store: st, RatedPVW: 10000, - Arrays: []Array{{TiltDeg: 35, AzimuthDeg: 180, KWp: 10}}, + Arrays: []Array{{TiltDeg: 35, AzimuthDeg: 180, RatedW: 10000}}, } rows, err := s.Load(ts, ts+3600*1000) if err != nil { diff --git a/go/internal/mpc/baselines_test.go b/go/internal/mpc/baselines_test.go index 5f9c4a0e7..5d7e44a09 100644 --- a/go/internal/mpc/baselines_test.go +++ b/go/internal/mpc/baselines_test.go @@ -17,7 +17,7 @@ func TestBaselinesArbitrageBeatsNoBattery(t *testing.T) { slots[i].SpotOre = prices[i] // let export revenue scale with spot } p := baseParams(ModeArbitrage) - p.InitialSoCPct = 50 + p.InitialSoC = 0.5 p.MaxChargeW = 3000 p.MaxDischargeW = 3000 @@ -78,7 +78,7 @@ func TestBaselinesSelfConsumptionMatchesOptimize(t *testing.T) { {StartMs: 10_800_000, LenMin: 60, PriceOre: 200, LoadW: 2000, PVW: 0, SpotOre: 200}, } p := baseParams(ModeArbitrage) // doesn't matter — baseline overrides mode - p.InitialSoCPct = 40 + p.InitialSoC = 0.4 pSC := p pSC.Mode = ModeSelfConsumption diff --git a/go/internal/mpc/diagnose.go b/go/internal/mpc/diagnose.go index 7eb08fdde..a3757ae8b 100644 --- a/go/internal/mpc/diagnose.go +++ b/go/internal/mpc/diagnose.go @@ -38,7 +38,7 @@ type DiagnosticSlot struct { // Outputs BatteryW float64 `json:"battery_w"` GridW float64 `json:"grid_w"` - SoCPct float64 `json:"soc_pct"` // SoC at END of slot + 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"` @@ -54,9 +54,9 @@ type DiagnosticSlot struct { // exporting — reality was `LOAD 1.6 + EV 4.0 = 5.6 kW covered`, // grid ≈ 0. See issue #174. LoadpointW float64 `json:"loadpoint_w,omitempty"` - LoadpointSoCPct float64 `json:"loadpoint_soc_pct,omitempty"` + LoadpointSoC float64 `json:"loadpoint_soc,omitempty"` LoadpointPowerW map[string]float64 `json:"loadpoint_power_w,omitempty"` - LoadpointSoCPctByID map[string]float64 `json:"loadpoint_soc_pct_by_id,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"` } @@ -66,9 +66,9 @@ type DiagnosticSlot struct { // without pulling the whole internal struct. type DiagnosticParams struct { Mode Mode `json:"mode"` - InitialSoCPct float64 `json:"initial_soc_pct"` - SoCMinPct float64 `json:"soc_min_pct"` - SoCMaxPct float64 `json:"soc_max_pct"` + 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"` @@ -170,15 +170,15 @@ func buildDiagnostic(plan *Plan, slots []Slot, p Params, zone string, WeatherRowAvailableAtMs: slot.WeatherRowAvailableAtMs, BatteryW: action.BatteryW, GridW: action.GridW, - SoCPct: action.SoCPct, + SoC: action.SoC, CostOre: action.CostOre, Reason: action.Reason, EMSMode: action.EMSMode, PVLimitW: action.PVLimitW, LoadpointW: action.LoadpointW, - LoadpointSoCPct: action.LoadpointSoCPct, + LoadpointSoC: action.LoadpointSoC, LoadpointPowerW: action.LoadpointPowerW, - LoadpointSoCPctByID: action.LoadpointSoCPctByID, + LoadpointSoCByID: action.LoadpointSoCByID, StoragePowerW: action.StoragePowerW, StorageEnergyWh: action.StorageEnergyWh, } @@ -202,9 +202,9 @@ func buildDiagnostic(plan *Plan, slots []Slot, p Params, zone string, OptimizerInput: append(json.RawMessage(nil), plan.OptimizerInput...), Params: DiagnosticParams{ Mode: p.Mode, - InitialSoCPct: p.InitialSoCPct, - SoCMinPct: p.SoCMinPct, - SoCMaxPct: p.SoCMaxPct, + InitialSoC: p.InitialSoC, + SoCMin: p.SoCMin, + SoCMax: p.SoCMax, PVChargeBonusOreKwh: p.PVChargeBonusOreKwh, SoCLevels: p.SoCLevels, ActionLevels: p.ActionLevels, @@ -324,9 +324,9 @@ func planFromDiagnostic(d *Diagnostic) (*Plan, []Slot, Params, time.Time, bool) } params := Params{ Mode: d.Params.Mode, - InitialSoCPct: d.Params.InitialSoCPct, - SoCMinPct: d.Params.SoCMinPct, - SoCMaxPct: d.Params.SoCMaxPct, + InitialSoC: d.Params.InitialSoC, + SoCMin: d.Params.SoCMin, + SoCMax: d.Params.SoCMax, PVChargeBonusOreKwh: d.Params.PVChargeBonusOreKwh, SoCLevels: d.Params.SoCLevels, ActionLevels: d.Params.ActionLevels, @@ -385,16 +385,16 @@ func planFromDiagnostic(d *Diagnostic) (*Plan, []Slot, Params, time.Time, bool) LoadW: ds.LoadW, BatteryW: ds.BatteryW, GridW: ds.GridW, - SoCPct: ds.SoCPct, + SoC: ds.SoC, CostOre: ds.CostOre, Confidence: ds.Confidence, Reason: ds.Reason, EMSMode: ds.EMSMode, PVLimitW: ds.PVLimitW, LoadpointW: ds.LoadpointW, - LoadpointSoCPct: ds.LoadpointSoCPct, + LoadpointSoC: ds.LoadpointSoC, LoadpointPowerW: ds.LoadpointPowerW, - LoadpointSoCPctByID: ds.LoadpointSoCPctByID, + LoadpointSoCByID: ds.LoadpointSoCByID, StoragePowerW: ds.StoragePowerW, StorageEnergyWh: ds.StorageEnergyWh, } @@ -424,7 +424,7 @@ func planFromDiagnostic(d *Diagnostic) (*Plan, []Slot, Params, time.Time, bool) Mode: params.Mode, HorizonSlots: horizon, CapacityWh: params.CapacityWh, - InitialSoCPct: params.InitialSoCPct, + InitialSoC: params.InitialSoC, TotalCostOre: d.TotalCostOre, Actions: actions, Solver: d.Solver, diff --git a/go/internal/mpc/diagnose_test.go b/go/internal/mpc/diagnose_test.go index 62599519d..ca92a7b49 100644 --- a/go/internal/mpc/diagnose_test.go +++ b/go/internal/mpc/diagnose_test.go @@ -42,9 +42,9 @@ func TestDiagnoseJoinsSlotsAndActions(t *testing.T) { Mode: ModeSelfConsumption, SoCLevels: 11, CapacityWh: 10000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 50, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.5, ActionLevels: 7, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -72,8 +72,8 @@ func TestDiagnoseJoinsSlotsAndActions(t *testing.T) { if d.Params.Mode != ModeSelfConsumption { t.Errorf("Params.Mode: got %q want self_consumption", d.Params.Mode) } - if d.Params.InitialSoCPct != 50 { - t.Errorf("Params.InitialSoCPct: got %.2f want 50", d.Params.InitialSoCPct) + if d.Params.InitialSoC != 0.50 { + t.Errorf("Params.InitialSoC: got %.2f want 0.50", d.Params.InitialSoC) } if d.LastReason != "unit-test" { t.Errorf("LastReason: got %q want unit-test", d.LastReason) @@ -201,9 +201,9 @@ func TestRestoreDiagnosticRehydratesActivePlan(t *testing.T) { LastReason: "scheduled", Params: DiagnosticParams{ Mode: ModeSelfConsumption, - InitialSoCPct: 42, - SoCMinPct: 10, - SoCMaxPct: 90, + InitialSoC: 0.42, + SoCMin: 0.1, + SoCMax: 0.9, SoCLevels: 41, ActionLevels: 81, MaxChargeW: 5000, @@ -226,7 +226,7 @@ func TestRestoreDiagnosticRehydratesActivePlan(t *testing.T) { LoadW: 900, BatteryW: 0, GridW: -3600, - SoCPct: 42, + SoC: 0.42, CostOre: -72, Reason: "export surplus", EMSMode: "self_consumption", @@ -246,7 +246,7 @@ func TestRestoreDiagnosticRehydratesActivePlan(t *testing.T) { LoadW: 900, BatteryW: 1200, GridW: -2900, - SoCPct: 44, + SoC: 0.44, CostOre: 9, Reason: "avoid negative export", EMSMode: "self_consumption", @@ -449,9 +449,9 @@ func TestRestoreDiagnosticMergesNewerDefaultsForMissingFields(t *testing.T) { LastReplanAtMs: now.Add(-1 * time.Minute).UnixMilli(), Params: DiagnosticParams{ Mode: ModeSelfConsumption, - InitialSoCPct: 8, - SoCMinPct: 10, - SoCMaxPct: 95, + InitialSoC: 0.08, + SoCMin: 0.1, + SoCMax: 0.95, SoCLevels: 41, ActionLevels: 81, MaxChargeW: 5000, @@ -466,7 +466,7 @@ func TestRestoreDiagnosticMergesNewerDefaultsForMissingFields(t *testing.T) { Idx: 0, SlotStartMs: start.UnixMilli(), SlotEndMs: start.Add(15 * time.Minute).UnixMilli(), LenMin: 15, PriceOre: 100, Confidence: 1, PVW: -3000, LoadW: 500, - BatteryW: 0, GridW: -2500, SoCPct: 8, + BatteryW: 0, GridW: -2500, SoC: 0.08, EMSMode: "self_consumption", }}, } @@ -539,9 +539,9 @@ func TestRestoreDiagnosticPreservesExplicitSnapshotValues(t *testing.T) { LastReplanAtMs: now.Add(-1 * time.Minute).UnixMilli(), Params: DiagnosticParams{ Mode: ModeSelfConsumption, - InitialSoCPct: 30, - SoCMinPct: 10, - SoCMaxPct: 95, + InitialSoC: 0.3, + SoCMin: 0.1, + SoCMax: 0.95, SoCLevels: 41, ActionLevels: 81, MaxChargeW: 5000, @@ -556,7 +556,7 @@ func TestRestoreDiagnosticPreservesExplicitSnapshotValues(t *testing.T) { Idx: 0, SlotStartMs: start.UnixMilli(), SlotEndMs: start.Add(15 * time.Minute).UnixMilli(), LenMin: 15, PriceOre: 100, Confidence: 1, PVW: -3000, LoadW: 500, - BatteryW: 0, GridW: -2500, SoCPct: 30, + BatteryW: 0, GridW: -2500, SoC: 0.3, EMSMode: "self_consumption", }}, } @@ -604,9 +604,9 @@ func TestDiagnoseCarriesLoadpointFields(t *testing.T) { Mode: ModeCheapCharge, SoCLevels: 21, CapacityWh: 10000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 50, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.5, ActionLevels: 5, MaxChargeW: 2000, MaxDischargeW: 2000, @@ -617,9 +617,9 @@ func TestDiagnoseCarriesLoadpointFields(t *testing.T) { ID: "garage", CapacityWh: 60000, Levels: 11, - InitialSoCPct: 20, + InitialSoC: 0.2, PluggedIn: true, - TargetSoCPct: 30, + TargetSoC: 0.3, TargetSlotIdx: 3, MaxChargeW: 11000, AllowedStepsW: []float64{0, 11000}, @@ -654,14 +654,14 @@ func TestDiagnoseCarriesLoadpointFields(t *testing.T) { "the plumb from Action → DiagnosticSlot is broken", i, row.LoadpointW, plan.Actions[i].LoadpointW) } - if row.LoadpointSoCPct != plan.Actions[i].LoadpointSoCPct { - t.Errorf("slot %d LoadpointSoCPct: diagnostic=%.1f plan=%.1f", - i, row.LoadpointSoCPct, plan.Actions[i].LoadpointSoCPct) + if row.LoadpointSoC != plan.Actions[i].LoadpointSoC { + t.Errorf("slot %d LoadpointSoC: diagnostic=%.1f plan=%.1f", + i, row.LoadpointSoC, plan.Actions[i].LoadpointSoC) } if row.LoadpointW > 0 { sawCharge = true } - if row.LoadpointSoCPct > 0 { + if row.LoadpointSoC > 0 { sawSoC = true } } @@ -670,6 +670,6 @@ func TestDiagnoseCarriesLoadpointFields(t *testing.T) { "charge at least once; check Action.LoadpointW plumbing") } if !sawSoC { - t.Error("no slot carries LoadpointSoCPct — EV SoC trajectory lost") + t.Error("no slot carries LoadpointSoC — EV SoC trajectory lost") } } diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index 841c067a5..e909c13f4 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -292,12 +292,12 @@ type externalError struct { } type externalPlan struct { - Mode Mode `json:"mode"` - HorizonSlots int `json:"horizon_slots"` - CapacityWh float64 `json:"capacity_wh"` - InitialSoCPct float64 `json:"initial_soc_pct"` - TotalCostOre float64 `json:"total_cost_ore"` - Actions []externalAction `json:"actions"` + Mode Mode `json:"mode"` + HorizonSlots int `json:"horizon_slots"` + CapacityWh float64 `json:"capacity_wh"` + InitialSoC float64 `json:"initial_soc_pct"` + TotalCostOre float64 `json:"total_cost_ore"` + Actions []externalAction `json:"actions"` } type externalAction struct { @@ -444,9 +444,9 @@ func (o *ExternalOptimizer) buildRequest(slots []Slot, p Params) externalRequest } else if p.CapacityWh > 0 { req.Storages = []externalStorage{{ ID: "home-battery", CapacityWh: p.CapacityWh, - InitialEnergyWh: p.CapacityWh * p.InitialSoCPct / 100, - MinEnergyWh: p.CapacityWh * p.SoCMinPct / 100, - MaxEnergyWh: p.CapacityWh * p.SoCMaxPct / 100, + InitialEnergyWh: p.CapacityWh * p.InitialSoC, + MinEnergyWh: p.CapacityWh * p.SoCMin, + MaxEnergyWh: p.CapacityWh * p.SoCMax, MaxChargeW: p.MaxChargeW, MaxDischargeW: p.MaxDischargeW, ChargeEfficiency: p.ChargeEfficiency, DischargeEfficiency: p.DischargeEfficiency, TerminalPriceOreKWh: p.TerminalSoCPrice, @@ -458,17 +458,17 @@ func (o *ExternalOptimizer) buildRequest(slots []Slot, p Params) externalRequest if efficiency <= 0 { efficiency = 0.9 } - minPct, maxPct := lp.MinPct, lp.MaxPct - if maxPct <= minPct { - minPct, maxPct = 0, 100 + minSoC, maxSoC := lp.SoCMin, lp.SoCMax + if maxSoC <= minSoC { + minSoC, maxSoC = 0, 1 } - initialPct := math.Max(minPct, math.Min(maxPct, lp.InitialSoCPct)) - targetPct := math.Max(minPct, math.Min(maxPct, lp.TargetSoCPct)) + initialSoC := math.Max(minSoC, math.Min(maxSoC, lp.InitialSoC)) + targetSoC := math.Max(minSoC, math.Min(maxSoC, lp.TargetSoC)) req.FlexLoads = append(req.FlexLoads, externalFlexLoad{ ID: lp.ID, CapacityWh: lp.CapacityWh, - InitialEnergyWh: lp.CapacityWh * initialPct / 100, - MaxEnergyWh: lp.CapacityWh * maxPct / 100, - TargetEnergyWh: lp.CapacityWh * targetPct / 100, + InitialEnergyWh: lp.CapacityWh * initialSoC, + MaxEnergyWh: lp.CapacityWh * maxSoC, + TargetEnergyWh: lp.CapacityWh * targetSoC, TargetSlot: lp.TargetSlotIdx, ChargeEfficiency: efficiency, MaxChargeW: lp.MaxChargeW, AllowedStepsW: steps, SurplusOnly: lp.SurplusOnly, NoStorageToLoad: lp.blocksBatteryToEV(), @@ -506,7 +506,7 @@ func (r externalResponse) toPlan(slots []Slot, p Params) Plan { plan := Plan{ GeneratedAtMs: time.Now().UnixMilli(), Mode: p.Mode, HorizonSlots: len(slots), CapacityWh: p.CapacityWh, - InitialSoCPct: p.InitialSoCPct, TotalCostOre: r.Plan.TotalCostOre, + InitialSoC: p.InitialSoC, TotalCostOre: r.Plan.TotalCostOre, Actions: make([]Action, 0, len(r.Plan.Actions)), Solver: &r.Solver, } meanPrice := 0.0 @@ -524,7 +524,7 @@ func (r externalResponse) toPlan(slots []Slot, p Params) Plan { PriceOre: slot.PriceOre, SpotOre: slot.SpotOre, PVW: slot.PVW, LoadW: slot.LoadW, Confidence: slot.Confidence, BatteryW: candidate.BatteryW, GridW: candidate.GridW, - SoCPct: candidate.SoCPct, CostOre: candidate.CostOre, + SoC: candidate.SoCPct / 100, CostOre: candidate.CostOre, PVLimitW: candidate.PVLimitW, StoragePowerW: candidate.StoragePowerW, StorageEnergyWh: candidate.StorageEnergy, @@ -532,15 +532,15 @@ func (r externalResponse) toPlan(slots []Slot, p Params) Plan { activeLoadpoints := p.activeLoadpoints() if len(activeLoadpoints) > 0 { action.LoadpointPowerW = make(map[string]float64, len(activeLoadpoints)) - action.LoadpointSoCPctByID = make(map[string]float64, len(activeLoadpoints)) + action.LoadpointSoCByID = make(map[string]float64, len(activeLoadpoints)) for lpIdx, lp := range activeLoadpoints { powerW := candidate.FlexPowerW[lp.ID] - socPct := candidate.FlexEnergyWh[lp.ID] / lp.CapacityWh * 100 + soc := candidate.FlexEnergyWh[lp.ID] / lp.CapacityWh action.LoadpointPowerW[lp.ID] = powerW - action.LoadpointSoCPctByID[lp.ID] = socPct + action.LoadpointSoCByID[lp.ID] = soc if lpIdx == 0 { action.LoadpointW = powerW - action.LoadpointSoCPct = socPct + action.LoadpointSoC = soc } } } @@ -572,7 +572,7 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { if len(slots) == 0 { return errors.New("empty plan") } - soc := p.InitialSoCPct + soc := p.InitialSoC storageEnergy := make(map[string]float64, len(p.Storages)) storageLowerRecovery := make(map[string]float64, len(p.Storages)) storageUpperRecovery := make(map[string]float64, len(p.Storages)) @@ -581,17 +581,17 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { storageLowerRecovery[storage.ID] = math.Max(0, storage.MinEnergyWh-storage.InitialEnergyWh) storageUpperRecovery[storage.ID] = math.Max(0, storage.InitialEnergyWh-storage.MaxEnergyWh) } - lowerSoCRecovery := math.Max(0, p.SoCMinPct-p.InitialSoCPct) - upperSoCRecovery := math.Max(0, p.InitialSoCPct-p.SoCMaxPct) + lowerSoCRecovery := math.Max(0, p.SoCMin-p.InitialSoC) + upperSoCRecovery := math.Max(0, p.InitialSoC-p.SoCMax) activeLoadpoints := p.activeLoadpoints() evSoC := make(map[string]float64, len(activeLoadpoints)) for _, lp := range activeLoadpoints { - evSoC[lp.ID] = lp.InitialSoCPct + evSoC[lp.ID] = lp.InitialSoC } totalCost := 0.0 for i, slot := range slots { a := plan.Actions[i] - values := []float64{a.BatteryW, a.GridW, a.SoCPct, a.CostOre, a.LoadpointW, a.LoadpointSoCPct, a.PVLimitW} + values := []float64{a.BatteryW, a.GridW, a.SoC, a.CostOre, a.LoadpointW, a.LoadpointSoC, a.PVLimitW} for _, value := range values { if math.IsNaN(value) || math.IsInf(value, 0) { return fmt.Errorf("slot %d contains non-finite output", i) @@ -642,25 +642,25 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { if math.Abs(a.BatteryW-totalPowerW) > 2 { return fmt.Errorf("slot %d aggregate battery_w %.3f, want %.3f", i, a.BatteryW, totalPowerW) } - soc = totalEnergyWh / p.CapacityWh * 100 + soc = totalEnergyWh / p.CapacityWh } else if a.BatteryW >= 0 { - soc += a.BatteryW * dtH * p.ChargeEfficiency / p.CapacityWh * 100 + soc += a.BatteryW * dtH * p.ChargeEfficiency / p.CapacityWh } else { - soc += a.BatteryW * dtH / p.DischargeEfficiency / p.CapacityWh * 100 + soc += a.BatteryW * dtH / p.DischargeEfficiency / p.CapacityWh } - lowerRecovery := math.Max(0, p.SoCMinPct-soc) - upperRecovery := math.Max(0, soc-p.SoCMaxPct) - if lowerRecovery > lowerSoCRecovery+0.02 || upperRecovery > upperSoCRecovery+0.02 || math.Abs(a.SoCPct-soc) > 0.02 { - return fmt.Errorf("slot %d SoC %.4f inconsistent with replay %.4f", i, a.SoCPct, soc) + lowerRecovery := math.Max(0, p.SoCMin-soc) + upperRecovery := math.Max(0, soc-p.SoCMax) + if lowerRecovery > lowerSoCRecovery+0.0002 || upperRecovery > upperSoCRecovery+0.0002 || math.Abs(a.SoC-soc) > 0.0002 { + return fmt.Errorf("slot %d SoC %.4f inconsistent with replay %.4f", i, a.SoC, soc) } lowerSoCRecovery = math.Min(lowerSoCRecovery, lowerRecovery) upperSoCRecovery = math.Min(upperSoCRecovery, upperRecovery) totalLoadpointW := 0.0 for lpIdx, lp := range activeLoadpoints { powerW := a.LoadpointPowerW[lp.ID] - reportedSoC := a.LoadpointSoCPctByID[lp.ID] + reportedSoC := a.LoadpointSoCByID[lp.ID] if len(a.LoadpointPowerW) == 0 && lpIdx == 0 { - powerW, reportedSoC = a.LoadpointW, a.LoadpointSoCPct + powerW, reportedSoC = a.LoadpointW, a.LoadpointSoC } if math.IsNaN(powerW) || math.IsInf(powerW, 0) || math.IsNaN(reportedSoC) || math.IsInf(reportedSoC, 0) { return fmt.Errorf("slot %d loadpoint %s contains non-finite output", i, lp.ID) @@ -673,8 +673,8 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { if eff <= 0 { eff = 0.9 } - evSoC[lp.ID] += powerW * dtH * eff / lp.CapacityWh * 100 - if math.Abs(reportedSoC-evSoC[lp.ID]) > 0.02 { + evSoC[lp.ID] += powerW * dtH * eff / lp.CapacityWh + if math.Abs(reportedSoC-evSoC[lp.ID]) > 0.0002 { return fmt.Errorf("slot %d loadpoint %s SoC %.4f inconsistent with replay %.4f", i, lp.ID, reportedSoC, evSoC[lp.ID]) } totalLoadpointW += powerW diff --git a/go/internal/mpc/external_optimizer_test.go b/go/internal/mpc/external_optimizer_test.go index 31b5798b6..3448018a7 100644 --- a/go/internal/mpc/external_optimizer_test.go +++ b/go/internal/mpc/external_optimizer_test.go @@ -35,7 +35,7 @@ func externalTestFixture() ([]Slot, Params) { } p := Params{ Mode: ModeArbitrage, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 20, + SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.2, MaxChargeW: 5000, MaxDischargeW: 5000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, TerminalSoCPrice: 20, @@ -106,10 +106,10 @@ func TestValidatePlanAcceptsContinuousPowerTrajectory(t *testing.T) { slots, p := externalTestFixture() plan := Plan{ Mode: p.Mode, HorizonSlots: 2, CapacityWh: p.CapacityWh, - InitialSoCPct: p.InitialSoCPct, TotalCostOre: 29.085, + InitialSoC: p.InitialSoC, TotalCostOre: 29.085, Actions: []Action{ - {SlotStartMs: 1, SlotLenMin: 60, BatteryW: 1234.5, GridW: 1734.5, SoCPct: 31.72775, CostOre: 34.69}, - {SlotStartMs: 3600001, SlotLenMin: 60, BatteryW: -2000, GridW: 500, SoCPct: 10.67511842105263, CostOre: 150}, + {SlotStartMs: 1, SlotLenMin: 60, BatteryW: 1234.5, GridW: 1734.5, SoC: 0.317277, CostOre: 34.69}, + {SlotStartMs: 3600001, SlotLenMin: 60, BatteryW: -2000, GridW: 500, SoC: 0.106751, CostOre: 150}, }, } // Raw total cost is the sum of both slot costs. @@ -123,7 +123,7 @@ func TestValidatePlanRejectsBrokenGridBalance(t *testing.T) { slots, p := externalTestFixture() plan := Optimize(slots, Params{ Mode: p.Mode, SoCLevels: 21, CapacityWh: p.CapacityWh, - SoCMinPct: p.SoCMinPct, SoCMaxPct: p.SoCMaxPct, InitialSoCPct: p.InitialSoCPct, + SoCMin: p.SoCMin, SoCMax: p.SoCMax, InitialSoC: p.InitialSoC, ActionLevels: 21, MaxChargeW: p.MaxChargeW, MaxDischargeW: p.MaxDischargeW, ChargeEfficiency: p.ChargeEfficiency, DischargeEfficiency: p.DischargeEfficiency, TerminalSoCPrice: p.TerminalSoCPrice, @@ -138,13 +138,13 @@ func TestValidatePlanAcceptsSubWattSolverResidueInPassiveMode(t *testing.T) { slots := []Slot{{StartMs: 1, LenMin: 15, PriceOre: 100, Confidence: 1, LoadW: 0}} p := Params{ Mode: ModePassiveArbitrage, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 50, + SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, MaxChargeW: 5000, MaxDischargeW: 5000, ChargeEfficiency: 1, DischargeEfficiency: 1, } plan := Plan{TotalCostOre: -0.0000025, Actions: []Action{{ SlotStartMs: 1, SlotLenMin: 15, BatteryW: -0.0001, GridW: -0.0001, - SoCPct: 49.99999975, CostOre: -0.0000025, + SoC: 0.5, CostOre: -0.0000025, }}} if err := ValidatePlan(slots, p, &plan); err != nil { t.Fatalf("ValidatePlan rejected numerical solver residue: %v", err) @@ -155,13 +155,13 @@ func TestValidatePlanModeErrorIncludesPowerValues(t *testing.T) { slots := []Slot{{StartMs: 1, LenMin: 15, PriceOre: 100, Confidence: 1, LoadW: 0}} p := Params{ Mode: ModePassiveArbitrage, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 50, + SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, MaxChargeW: 5000, MaxDischargeW: 5000, ChargeEfficiency: 1, DischargeEfficiency: 1, } plan := Plan{TotalCostOre: -0.0025, Actions: []Action{{ SlotStartMs: 1, SlotLenMin: 15, BatteryW: -0.2, GridW: -0.2, - SoCPct: 49.9995, CostOre: -0.005, + SoC: 0.499995, CostOre: -0.005, }}} plan.TotalCostOre = plan.Actions[0].CostOre err := ValidatePlan(slots, p, &plan) @@ -227,19 +227,19 @@ func TestValidatePlanAllowsButDoesNotWorsenInitialSoCBelowMinimum(t *testing.T) slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 100, SpotOre: 50, Confidence: 1, LoadW: 500}} p := Params{ Mode: ModeArbitrage, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 5, + SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.05, MaxChargeW: 5000, MaxDischargeW: 5000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, } plan := Plan{TotalCostOre: 50, Actions: []Action{{ - SlotStartMs: 1, SlotLenMin: 60, BatteryW: 0, GridW: 500, SoCPct: 5, CostOre: 50, + SlotStartMs: 1, SlotLenMin: 60, BatteryW: 0, GridW: 500, SoC: 0.05, CostOre: 50, }}} if err := ValidatePlan(slots, p, &plan); err != nil { t.Fatalf("ValidatePlan rejected stable recovery state: %v", err) } plan.Actions[0] = Action{ SlotStartMs: 1, SlotLenMin: 60, BatteryW: -100, GridW: 400, - SoCPct: 3.947368421052632, CostOre: 40, + SoC: 0.039474, CostOre: 40, } plan.TotalCostOre = 40 if err := ValidatePlan(slots, p, &plan); err == nil { @@ -251,21 +251,21 @@ func TestValidatePlanRejectsBatteryFedSurplusLoadpoint(t *testing.T) { slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 100, SpotOre: 70, Confidence: 1, LoadW: 500}} p := Params{ Mode: ModeArbitrage, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 50, + SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, MaxChargeW: 5000, MaxDischargeW: 5000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, Loadpoint: &LoadpointSpec{ - ID: "car", CapacityWh: 40000, Levels: 11, MinPct: 0, MaxPct: 100, - InitialSoCPct: 25, PluggedIn: true, MaxChargeW: 2000, + ID: "car", CapacityWh: 40000, Levels: 11, SoCMin: 0, SoCMax: 1.0, + InitialSoC: 0.25, PluggedIn: true, MaxChargeW: 2000, AllowedStepsW: []float64{0, 2000}, ChargeEfficiency: 1, SurplusOnly: true, }, } - plan := Plan{Mode: p.Mode, HorizonSlots: 1, CapacityWh: p.CapacityWh, InitialSoCPct: 50, + plan := Plan{Mode: p.Mode, HorizonSlots: 1, CapacityWh: p.CapacityWh, InitialSoC: 0.5, TotalCostOre: 0, Actions: []Action{{ SlotStartMs: 1, SlotLenMin: 60, - BatteryW: -2000, GridW: 500, SoCPct: 28.94736842105263, - LoadpointW: 2000, LoadpointSoCPct: 30, CostOre: 50, + BatteryW: -2000, GridW: 500, SoC: 0.289474, + LoadpointW: 2000, LoadpointSoC: 0.3, CostOre: 50, }}} plan.TotalCostOre = 50 if err := ValidatePlan(slots, p, &plan); err == nil { @@ -277,22 +277,22 @@ func TestValidatePlanAllowsGridChargeWithIdleSurplusOnlyEV(t *testing.T) { slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 30, SpotOre: 10, Confidence: 1, LoadW: 500}} p := Params{ Mode: ModeArbitrage, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 20, + SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.2, MaxChargeW: 5000, MaxDischargeW: 5000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, Loadpoint: &LoadpointSpec{ - ID: "car", CapacityWh: 40000, Levels: 11, MinPct: 0, MaxPct: 100, - InitialSoCPct: 80, PluggedIn: true, MaxChargeW: 2000, + ID: "car", CapacityWh: 40000, Levels: 11, SoCMin: 0, SoCMax: 1.0, + InitialSoC: 0.8, PluggedIn: true, MaxChargeW: 2000, AllowedStepsW: []float64{0, 2000}, ChargeEfficiency: 1, SurplusOnly: true, }, } // 4000 W charge for 1 h at 95% from 20% of 10 kWh → 20 + 38 = 58%. - plan := Plan{Mode: p.Mode, HorizonSlots: 1, CapacityWh: p.CapacityWh, InitialSoCPct: 20, + plan := Plan{Mode: p.Mode, HorizonSlots: 1, CapacityWh: p.CapacityWh, InitialSoC: 0.2, TotalCostOre: 135, Actions: []Action{{ SlotStartMs: 1, SlotLenMin: 60, - BatteryW: 4000, GridW: 4500, SoCPct: 58, - LoadpointW: 0, LoadpointSoCPct: 80, CostOre: 135, + BatteryW: 4000, GridW: 4500, SoC: 0.58, + LoadpointW: 0, LoadpointSoC: 0.8, CostOre: 135, }}} if err := ValidatePlan(slots, p, &plan); err != nil { t.Fatalf("ValidatePlan rejected idle surplus-only EV plus battery grid-charge: %v", err) @@ -382,16 +382,16 @@ func TestExternalOptimizerPlansMultipleLoadpoints(t *testing.T) { defer optimizer.Close() slots, p := externalTestFixture() p.Loadpoints = []*LoadpointSpec{ - {ID: "car-a", CapacityWh: 40000, Levels: 11, MinPct: 0, MaxPct: 100, InitialSoCPct: 25, PluggedIn: true, TargetSoCPct: 30, TargetSlotIdx: 1, MaxChargeW: 4000, AllowedStepsW: []float64{0, 2000, 4000}, ChargeEfficiency: 1}, - {ID: "car-b", CapacityWh: 60000, Levels: 11, MinPct: 0, MaxPct: 100, InitialSoCPct: 20, PluggedIn: true, TargetSoCPct: 25, TargetSlotIdx: 1, MaxChargeW: 3000, AllowedStepsW: []float64{0, 3000}, ChargeEfficiency: 1}, + {ID: "car-a", CapacityWh: 40000, Levels: 11, SoCMin: 0, SoCMax: 1.0, InitialSoC: 0.25, PluggedIn: true, TargetSoC: 0.3, TargetSlotIdx: 1, MaxChargeW: 4000, AllowedStepsW: []float64{0, 2000, 4000}, ChargeEfficiency: 1}, + {ID: "car-b", CapacityWh: 60000, Levels: 11, SoCMin: 0, SoCMax: 1.0, InitialSoC: 0.2, PluggedIn: true, TargetSoC: 0.25, TargetSlotIdx: 1, MaxChargeW: 3000, AllowedStepsW: []float64{0, 3000}, ChargeEfficiency: 1}, } plan, err := optimizer.Optimize(context.Background(), slots, p) if err != nil { t.Fatalf("Optimize: %v", err) } last := plan.Actions[len(plan.Actions)-1] - if last.LoadpointSoCPctByID["car-a"] < 30-0.02 || last.LoadpointSoCPctByID["car-b"] < 25-0.02 { - t.Fatalf("targets not met: %+v", last.LoadpointSoCPctByID) + if last.LoadpointSoCByID["car-a"] < 0.30-0.02 || last.LoadpointSoCByID["car-b"] < 0.25-0.02 { + t.Fatalf("targets not met: %+v", last.LoadpointSoCByID) } if len(last.LoadpointPowerW) != 2 { t.Fatalf("expected two loadpoint schedules, got %+v", last.LoadpointPowerW) diff --git a/go/internal/mpc/loadpoint_service_test.go b/go/internal/mpc/loadpoint_service_test.go index ad2b9b33c..386448e68 100644 --- a/go/internal/mpc/loadpoint_service_test.go +++ b/go/internal/mpc/loadpoint_service_test.go @@ -31,9 +31,9 @@ func TestSlotDirectiveCarriesLoadpointEnergyWh(t *testing.T) { Mode: ModeCheapCharge, SoCLevels: 11, CapacityWh: 5000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 50, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.5, ActionLevels: 5, MaxChargeW: 2000, MaxDischargeW: 2000, @@ -44,9 +44,9 @@ func TestSlotDirectiveCarriesLoadpointEnergyWh(t *testing.T) { ID: "garage", CapacityWh: 60000, Levels: 11, - InitialSoCPct: 20, + InitialSoC: 0.2, PluggedIn: true, - TargetSoCPct: 40, + TargetSoC: 0.4, TargetSlotIdx: 3, MaxChargeW: 11000, AllowedStepsW: []float64{0, 11000}, @@ -108,7 +108,7 @@ func TestSlotDirectiveEmptyWhenNoLoadpoint(t *testing.T) { } plan := Optimize(slots, Params{ Mode: ModeSelfConsumption, SoCLevels: 11, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 50, + SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, ActionLevels: 5, MaxChargeW: 2000, MaxDischargeW: 2000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, }) @@ -161,9 +161,9 @@ func TestNoBatteryToEVForbidsBatteryFeedingEV(t *testing.T) { Mode: ModeArbitrage, SoCLevels: 11, CapacityWh: 20000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 90, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.9, ActionLevels: 11, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -174,9 +174,9 @@ func TestNoBatteryToEVForbidsBatteryFeedingEV(t *testing.T) { ID: "garage", CapacityWh: 60000, Levels: 11, - InitialSoCPct: 20, + InitialSoC: 0.2, PluggedIn: true, - TargetSoCPct: 30, + TargetSoC: 0.3, TargetSlotIdx: 1, MaxChargeW: 11000, AllowedStepsW: []float64{0, 11000}, @@ -242,9 +242,9 @@ func TestSurplusOnlyForbidsBatteryFeedingEVEvenWhenCoverEVEnabled(t *testing.T) Mode: ModeArbitrage, SoCLevels: 11, CapacityWh: 20000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 90, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.9, ActionLevels: 11, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -255,9 +255,9 @@ func TestSurplusOnlyForbidsBatteryFeedingEVEvenWhenCoverEVEnabled(t *testing.T) ID: "garage", CapacityWh: 10000, Levels: 11, - InitialSoCPct: 20, + InitialSoC: 0.2, PluggedIn: true, - TargetSoCPct: 70, + TargetSoC: 0.7, TargetSlotIdx: 1, MaxChargeW: 5000, AllowedStepsW: []float64{0, 5000}, @@ -310,9 +310,9 @@ func TestArbitrageGridChargesWhileSurplusOnlyEVIsConnected(t *testing.T) { Mode: ModeArbitrage, SoCLevels: 11, CapacityWh: 20000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 20, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.2, ActionLevels: 11, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -323,9 +323,9 @@ func TestArbitrageGridChargesWhileSurplusOnlyEVIsConnected(t *testing.T) { ID: "garage", CapacityWh: 60000, Levels: 11, - InitialSoCPct: 80, + InitialSoC: 0.8, PluggedIn: true, - TargetSoCPct: 80, // already at target — EV should stay off + TargetSoC: 0.8, // already at target — EV should stay off TargetSlotIdx: 1, MaxChargeW: 3000, AllowedStepsW: []float64{0, 3000}, @@ -359,9 +359,9 @@ func TestPassiveArbitrageGridChargesWhileSurplusOnlyEVIsConnected(t *testing.T) Mode: ModePassiveArbitrage, SoCLevels: 18, CapacityWh: 20000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 10, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.1, ActionLevels: 11, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -371,9 +371,9 @@ func TestPassiveArbitrageGridChargesWhileSurplusOnlyEVIsConnected(t *testing.T) ID: "garage", CapacityWh: 60000, Levels: 11, - InitialSoCPct: 80, + InitialSoC: 0.8, PluggedIn: true, - TargetSoCPct: 80, + TargetSoC: 0.8, TargetSlotIdx: 1, MaxChargeW: 3000, AllowedStepsW: []float64{0, 3000}, @@ -404,9 +404,9 @@ func TestSurplusOnlyEVCannotImportEvenWithDeadline(t *testing.T) { Mode: ModeArbitrage, SoCLevels: 11, CapacityWh: 10000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 50, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.5, ActionLevels: 11, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -417,9 +417,9 @@ func TestSurplusOnlyEVCannotImportEvenWithDeadline(t *testing.T) { ID: "garage", CapacityWh: 40000, Levels: 11, - InitialSoCPct: 20, + InitialSoC: 0.2, PluggedIn: true, - TargetSoCPct: 80, + TargetSoC: 0.8, TargetSlotIdx: 0, MaxChargeW: 7000, AllowedStepsW: []float64{0, 7000}, diff --git a/go/internal/mpc/loadpoint_spec.go b/go/internal/mpc/loadpoint_spec.go index 811fa1ac4..cd0e08fa6 100644 --- a/go/internal/mpc/loadpoint_spec.go +++ b/go/internal/mpc/loadpoint_spec.go @@ -32,16 +32,16 @@ type LoadpointSpec struct { // SoC grid. Coarser than battery (11 is typical — EV loads are // lumpy anyway). Levels int - MinPct float64 // usually 0 - MaxPct float64 // usually 100 + SoCMin float64 // usually 0 + SoCMax float64 // usually 1 // Plan-start conditions. - InitialSoCPct float64 // EV SoC at the first slot + InitialSoC float64 // EV SoC at the first slot PluggedIn bool // when false, Optimize treats the loadpoint as absent // User intent. A zero target means no deadline — charge // opportunistically based on price/PV surplus only. - TargetSoCPct float64 + TargetSoC float64 TargetSlotIdx int // zero-based slot by whose end the target must be met; ignored when target is zero // Electrical constraints. AllowedStepsW MUST include 0 (off) and diff --git a/go/internal/mpc/loadpoint_spec_test.go b/go/internal/mpc/loadpoint_spec_test.go index 9c70300b7..502570cde 100644 --- a/go/internal/mpc/loadpoint_spec_test.go +++ b/go/internal/mpc/loadpoint_spec_test.go @@ -85,9 +85,9 @@ func TestOptimizePrefersCheapSlotsForEV(t *testing.T) { Mode: ModeCheapCharge, SoCLevels: 11, CapacityWh: 5000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 50, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.5, ActionLevels: 5, MaxChargeW: 2000, MaxDischargeW: 2000, @@ -98,9 +98,9 @@ func TestOptimizePrefersCheapSlotsForEV(t *testing.T) { ID: "garage", CapacityWh: 60000, // 60 kWh Levels: 11, - InitialSoCPct: 20, + InitialSoC: 0.2, PluggedIn: true, - TargetSoCPct: 30, // need 10 % → 6 kWh + TargetSoC: 0.3, // need 10 % → 6 kWh TargetSlotIdx: 3, // deadline at end of horizon MaxChargeW: 11000, AllowedStepsW: []float64{0, 11000}, @@ -126,10 +126,10 @@ func TestOptimizePrefersCheapSlotsForEV(t *testing.T) { t.Errorf("DP did not charge EV in any cheap slot; actions: %+v", plan.Actions) } // Final EV SoC should be at or above target (hard deadline). - finalEV := plan.Actions[3].LoadpointSoCPct - if finalEV < p.Loadpoint.TargetSoCPct-1 { + finalEV := plan.Actions[3].LoadpointSoC + if finalEV < p.Loadpoint.TargetSoC-1 { t.Errorf("final EV SoC %.1f below target %.1f — deadline missed", - finalEV, p.Loadpoint.TargetSoCPct) + finalEV, p.Loadpoint.TargetSoC) } } @@ -147,9 +147,9 @@ func TestOptimizeNilLoadpointUnchanged(t *testing.T) { Mode: ModeCheapCharge, SoCLevels: 21, CapacityWh: 10000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 50, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.5, ActionLevels: 11, MaxChargeW: 3000, MaxDischargeW: 3000, diff --git a/go/internal/mpc/mpc.go b/go/internal/mpc/mpc.go index 8f22b2f96..2317c801b 100644 --- a/go/internal/mpc/mpc.go +++ b/go/internal/mpc/mpc.go @@ -140,11 +140,11 @@ type Params struct { Mode Mode // SoC grid - SoCLevels int // e.g. 41 (2.5% steps) - CapacityWh float64 // aggregate battery capacity - SoCMinPct float64 // hardware/chemistry floor — DP feasibility check refuses to land below this - SoCMaxPct float64 // e.g. 95 - InitialSoCPct float64 + 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 // Forecast-risk reserve is handled outside the DP cost now: the planner // optimises against downside PV (forecast − k·σ) via @@ -284,7 +284,7 @@ type Action struct { LoadW float64 `json:"load_w"` BatteryW float64 `json:"battery_w"` // decision (site sign, AC terminals) GridW float64 `json:"grid_w"` // resulting grid power - SoCPct float64 `json:"soc_pct"` // SoC at END of slot + SoC float64 `json:"soc"` // 0–1 at END of slot CostOre float64 `json:"cost_ore"` // this slot's cost (öre). Negative = revenue. Confidence float64 `json:"confidence"` // 1.0 real, <1.0 forecasted (UI uses this to style) Reason string `json:"reason"` // short human-readable explanation @@ -305,14 +305,14 @@ type Action struct { // multi-LP future; single-value for now. LoadpointW float64 `json:"loadpoint_w,omitempty"` - // LoadpointSoCPct is the EV SoC at END of slot, following the - // same convention as SoCPct for the home battery. - LoadpointSoCPct float64 `json:"loadpoint_soc_pct,omitempty"` - LoadpointPowerW map[string]float64 `json:"loadpoint_power_w,omitempty"` - LoadpointSoCPctByID map[string]float64 `json:"loadpoint_soc_pct_by_id,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"` + LoadpointSoCByID map[string]float64 `json:"loadpoint_soc_by_id,omitempty"` // Per-storage values make a multi-battery solve auditable. BatteryW and - // SoCPct remain the stable aggregate dispatch/API contract. + // 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"` } @@ -346,18 +346,18 @@ type Plan struct { // 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"` - InitialSoCPct float64 `json:"initial_soc_pct"` - TotalCostOre float64 `json:"total_cost_ore"` - Actions []Action `json:"actions"` + 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"` // PVNameplateW is the hard generation ceiling (W) applied to // every slot. The UI uses it so a stale megawatt pv_w cannot // paint the chart; 0 means no cut was configured. - PVNameplateW float64 `json:"pv_nameplate_w,omitempty"` + PVNameplateW float64 `json:"pv_nameplate_w,omitempty"` // LoadMaxW is the hard house-load ceiling (W), normally the fuse. // The UI uses it so a stale wild load_w cannot paint the chart. LoadMaxW float64 `json:"load_max_w,omitempty"` @@ -528,8 +528,8 @@ func Optimize(slots []Slot, p Params) Plan { A = 3 } - socStep := (p.SoCMaxPct - p.SoCMinPct) / float64(S-1) - socAt := func(i int) float64 { return p.SoCMinPct + float64(i)*socStep } + socStep := (p.SoCMax - p.SoCMin) / float64(S-1) + socAt := func(i int) float64 { return p.SoCMin + float64(i)*socStep } // Confidence handling: compute the horizon mean (real + forecast) // so we can blend low-confidence prices toward it. Default any @@ -605,11 +605,11 @@ func Optimize(slots []Slot, p Params) Plan { EL = lp.Levels evSteps = lp.normalizedSteps() EA = len(evSteps) - if lp.MaxPct <= lp.MinPct { - lp.MaxPct = 100 - lp.MinPct = 0 + if lp.SoCMax <= lp.SoCMin { + lp.SoCMax = 1.0 + lp.SoCMin = 0 } - evSocStep = (lp.MaxPct - lp.MinPct) / float64(EL-1) + evSocStep = (lp.SoCMax - lp.SoCMin) / float64(EL-1) if lp.ChargeEfficiency > 0 { evChargeEff = lp.ChargeEfficiency } @@ -618,7 +618,7 @@ func Optimize(slots []Slot, p Params) Plan { if !evActive { return 0 } - return lp.MinPct + float64(e)*evSocStep + return lp.SoCMin + float64(e)*evSocStep } evActionW := func(ea int) float64 { if !evActive { @@ -649,7 +649,7 @@ func Optimize(slots []Slot, p Params) Plan { // the DP still "sees" it (rather than silently ignoring). A // target of -1 means no deadline — opportunistic charging only. deadlineSlot := -1 - if evActive && lp.TargetSoCPct > 0 { + if evActive && lp.TargetSoC > 0 { deadlineSlot = lp.TargetSlotIdx if deadlineSlot < 0 { deadlineSlot = -1 @@ -662,7 +662,7 @@ func Optimize(slots []Slot, p Params) Plan { // 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) / 100.0 / 1000.0 + battKwh := p.CapacityWh * socAt(si) / 1000.0 battCredit := -p.TerminalSoCPrice * battKwh for ei := 0; ei < EL; ei++ { V[N][si][ei] = battCredit @@ -689,8 +689,8 @@ func Optimize(slots []Slot, p Params) Plan { } else { dBattWh = +battW * dtH / p.DischargeEfficiency } - battSoc2 := soc + dBattWh/p.CapacityWh*100.0 - if battSoc2 < p.SoCMinPct-1e-9 || battSoc2 > p.SoCMaxPct+1e-9 { + battSoc2 := soc + dBattWh/p.CapacityWh + if battSoc2 < p.SoCMin-1e-9 || battSoc2 > p.SoCMax+1e-9 { continue } @@ -703,8 +703,8 @@ func Optimize(slots []Slot, p Params) Plan { var evSoc2 float64 if evActive { dEvWh := evW * dtH * evChargeEff - evSoc2 = evSoc + dEvWh/lp.CapacityWh*100.0 - if evSoc2 > lp.MaxPct+1e-9 { + evSoc2 = evSoc + dEvWh/lp.CapacityWh + if evSoc2 > lp.SoCMax+1e-9 { continue } } @@ -834,7 +834,7 @@ func Optimize(slots []Slot, p Params) Plan { // preserve SoC for tomorrow's peak — that's // arbitrage behaviour, not self-consumption. // The bias extends all the way down to - // SoCMinPct: the operator's configured floor + // 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 @@ -908,15 +908,15 @@ func Optimize(slots []Slot, p Params) Plan { // DP maximizes delivered energy since less // shortfall = less penalty. if deadlineSlot == t { - short := lp.TargetSoCPct - evSoc2 + short := lp.TargetSoC - evSoc2 if short > 0 { - missedKwh := lp.CapacityWh * short / 100.0 / 1000.0 + missedKwh := lp.CapacityWh * short / 1000.0 cost += missedKwh * meanPrice * 4.0 } } // Battery SoC interpolation indices. - fIdx := (battSoc2 - p.SoCMinPct) / socStep + fIdx := (battSoc2 - p.SoCMin) / socStep lo := int(math.Floor(fIdx)) hi := lo + 1 if lo < 0 { @@ -938,7 +938,7 @@ func Optimize(slots []Slot, p Params) Plan { eLo, eHi := 0, 0 eFrac := 0.0 if evActive { - f := (evSoc2 - lp.MinPct) / evSocStep + f := (evSoc2 - lp.SoCMin) / evSocStep eLo = int(math.Floor(f)) eHi = eLo + 1 if eLo < 0 { @@ -987,10 +987,10 @@ func Optimize(slots []Slot, p Params) Plan { Mode: p.Mode, HorizonSlots: N, CapacityWh: p.CapacityWh, - InitialSoCPct: p.InitialSoCPct, + InitialSoC: p.InitialSoC, Actions: make([]Action, 0, N), } - fIdx := (p.InitialSoCPct - p.SoCMinPct) / socStep + fIdx := (p.InitialSoC - p.SoCMin) / socStep si := int(math.Round(fIdx)) if si < 0 { si = 0 @@ -1003,7 +1003,7 @@ func Optimize(slots []Slot, p Params) Plan { ei := 0 var evSoc float64 if evActive { - f := (lp.InitialSoCPct - lp.MinPct) / evSocStep + f := (lp.InitialSoC - lp.SoCMin) / evSocStep ei = int(math.Round(f)) if ei < 0 { ei = 0 @@ -1029,20 +1029,20 @@ func Optimize(slots []Slot, p Params) Plan { } else { dSoCWh = +actW * dtH / p.DischargeEfficiency } - soc2 := soc + dSoCWh/p.CapacityWh*100.0 - if soc2 < p.SoCMinPct { - soc2 = p.SoCMinPct + soc2 := soc + dSoCWh/p.CapacityWh + if soc2 < p.SoCMin { + soc2 = p.SoCMin } - if soc2 > p.SoCMaxPct { - soc2 = p.SoCMaxPct + if soc2 > p.SoCMax { + soc2 = p.SoCMax } // EV SoC transition (no-op when !evActive since evW = 0). var evSoc2 float64 if evActive { dEvWh := evW * dtH * evChargeEff - evSoc2 = evSoc + dEvWh/lp.CapacityWh*100.0 - if evSoc2 > lp.MaxPct { - evSoc2 = lp.MaxPct + evSoc2 = evSoc + dEvWh/lp.CapacityWh + if evSoc2 > lp.SoCMax { + evSoc2 = lp.SoCMax } } gridW := slot.LoadW + slot.PVW + actW + evW @@ -1062,17 +1062,17 @@ func Optimize(slots []Slot, p Params) Plan { LoadW: slot.LoadW, BatteryW: actW, GridW: gridW, - SoCPct: soc2, + SoC: soc2, CostOre: cost, Reason: reasonFor(slot, actW, gridW, meanPrice), } if evActive { a.LoadpointW = evW - a.LoadpointSoCPct = evSoc2 + a.LoadpointSoC = evSoc2 } plan.Actions = append(plan.Actions, a) soc = soc2 - fIdx = (soc - p.SoCMinPct) / socStep + fIdx = (soc - p.SoCMin) / socStep si = int(math.Round(fIdx)) if si < 0 { si = 0 @@ -1082,7 +1082,7 @@ func Optimize(slots []Slot, p Params) Plan { } if evActive { evSoc = evSoc2 - f := (evSoc - lp.MinPct) / evSocStep + f := (evSoc - lp.SoCMin) / evSocStep ei = int(math.Round(f)) if ei < 0 { ei = 0 diff --git a/go/internal/mpc/mpc_test.go b/go/internal/mpc/mpc_test.go index 64eaaf72e..a3a6ce93b 100644 --- a/go/internal/mpc/mpc_test.go +++ b/go/internal/mpc/mpc_test.go @@ -11,9 +11,9 @@ func baseParams(mode Mode) Params { Mode: mode, SoCLevels: 21, CapacityWh: 10000, // 10 kWh - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 50, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.5, ActionLevels: 21, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -47,7 +47,7 @@ func TestSelfConsumptionNoGridCharge(t *testing.T) { prices := []float64{100, 200, 50, 300} // cheap slot at index 2 slots := flatLoadSlots(prices) p := baseParams(ModeSelfConsumption) - p.InitialSoCPct = 80 // full-ish + p.InitialSoC = 0.8 // full-ish plan := Optimize(slots, p) for i, a := range plan.Actions { // In self-consumption with only load and no PV: baseline_grid = load = +1000. @@ -68,7 +68,7 @@ func TestSelfConsumptionAbsorbsPVSurplus(t *testing.T) { {StartMs: 0, LenMin: 60, PriceOre: 100, LoadW: 2000, PVW: -3500}, } p := baseParams(ModeSelfConsumption) - p.InitialSoCPct = 50 + p.InitialSoC = 0.5 plan := Optimize(slots, p) a := plan.Actions[0] if a.BatteryW < 0 { @@ -107,7 +107,7 @@ func TestPassiveArbitragePVChargeBonusPrefersPVOverExport(t *testing.T) { Confidence: 1, }} pNoBonus := baseParams(ModePassiveArbitrage) - pNoBonus.InitialSoCPct = 60 + pNoBonus.InitialSoC = 0.6 pNoBonus.TerminalSoCPrice = 20 // matches export revenue → DP indifferent without bonus pNoBonus.PVChargeBonusOreKwh = 0 planNoBonus := Optimize(slots, pNoBonus) @@ -158,7 +158,7 @@ func TestPassiveArbitragePVChargeBonusDoesNotMotivateGridCharge(t *testing.T) { } } p := baseParams(ModePassiveArbitrage) - p.InitialSoCPct = 50 + p.InitialSoC = 0.5 p.TerminalSoCPrice = 50 // moderate — SC bias must be what blocks grid-charge p.PVChargeBonusOreKwh = 30 @@ -207,7 +207,7 @@ func TestPassiveArbitrageChargesFromPVWhenSurplusAvailable(t *testing.T) { } } p := baseParams(ModePassiveArbitrage) - p.InitialSoCPct = 20 + p.InitialSoC = 0.2 p.TerminalSoCPrice = 100 plan := Optimize(slots, p) @@ -251,11 +251,11 @@ func TestPassiveArbitrageGridChargesAtCheapHours(t *testing.T) { } } p := baseParams(ModePassiveArbitrage) - p.InitialSoCPct = 20 + p.InitialSoC = 0.2 p.TerminalSoCPrice = 50 // low so terminal credit doesn't dominate the test // Big enough buffer so a charge during cheap hours fits. - p.SoCMinPct = 10 - p.SoCMaxPct = 95 + p.SoCMin = 0.1 + p.SoCMax = 0.95 plan := Optimize(slots, p) // Sum charge during cheap slots, sum discharge during expensive slots. @@ -287,7 +287,7 @@ func TestPassiveArbitrageNeverExportsFromBattery(t *testing.T) { {StartMs: 0, LenMin: 60, PriceOre: 100, SpotOre: 300, LoadW: 100, PVW: 0, Confidence: 1}, } p := baseParams(ModePassiveArbitrage) - p.InitialSoCPct = 80 // plenty of stored energy + p.InitialSoC = 0.8 // plenty of stored energy plan := Optimize(slots, p) a := plan.Actions[0] @@ -332,7 +332,7 @@ func TestDownsidePVKeepsReserveAgainstUncertainLatePV(t *testing.T) { return s } p := baseParams(ModeArbitrage) - p.InitialSoCPct = 50 + p.InitialSoC = 0.5 // Leftover energy is worth more than the morning export price, so selling // the battery early only pays off if it can be refilled — i.e. only when // the midday PV is trusted. @@ -346,8 +346,8 @@ func TestDownsidePVKeepsReserveAgainstUncertainLatePV(t *testing.T) { minSoC := func(pl Plan) float64 { m := 100.0 for _, a := range pl.Actions { - if a.SoCPct < m { - m = a.SoCPct + if a.SoC < m { + m = a.SoC } } return m @@ -361,8 +361,8 @@ func TestDownsidePVKeepsReserveAgainstUncertainLatePV(t *testing.T) { func planMinSoC(pl Plan) float64 { m := 100.0 for _, a := range pl.Actions { - if a.SoCPct < m { - m = a.SoCPct + if a.SoC < m { + m = a.SoC } } return m @@ -388,7 +388,7 @@ func TestDownsidePVReserveIncreasesWithK(t *testing.T) { return s } p := baseParams(ModeArbitrage) - p.InitialSoCPct = 50 + p.InitialSoC = 0.5 p.TerminalSoCPrice = 350 reserve := func(k float64) float64 { sl := mk() @@ -423,7 +423,7 @@ func TestDownsidePVWinterNoReserveForced(t *testing.T) { return s } p := baseParams(ModeArbitrage) - p.InitialSoCPct = 50 + p.InitialSoC = 0.5 raw := Optimize(mk(), p) hair := mk() applyPVDownside(hair, 2.0, 5000) // large σ, but no PV to cut @@ -450,9 +450,9 @@ func TestSelfConsumptionDefersPVStorageWhenCheaperSurplusAhead(t *testing.T) { Mode: ModeSelfConsumption, SoCLevels: 41, CapacityWh: 4000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 10, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.1, ActionLevels: 41, MaxChargeW: 4000, MaxDischargeW: 4000, @@ -520,9 +520,9 @@ func TestSmartSelfConsumptionExportsMorningPVAndChargesNegativeMidday(t *testing Mode: ModeSelfConsumption, SoCLevels: 41, CapacityWh: 10000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 10, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.1, ActionLevels: 41, MaxChargeW: 4000, MaxDischargeW: 4000, @@ -580,7 +580,7 @@ func TestCheapChargeUsesCheapGrid(t *testing.T) { prices := []float64{100, 100, 50, 100, 100, 100} slots := flatLoadSlots(prices) p := baseParams(ModeCheapCharge) - p.InitialSoCPct = 30 + p.InitialSoC = 0.3 p.TerminalSoCPrice = 100 // credit stored energy at 100 öre/kWh plan := Optimize(slots, p) @@ -598,7 +598,7 @@ func TestCheapChargeNeverExports(t *testing.T) { prices := []float64{50, 50, 500, 50} slots := flatLoadSlots(prices) p := baseParams(ModeCheapCharge) - p.InitialSoCPct = 90 + p.InitialSoC = 0.9 p.ExportOrePerKWh = 400 // tempting plan := Optimize(slots, p) for i, a := range plan.Actions { @@ -616,7 +616,7 @@ func TestArbitrageDischargesToExpensive(t *testing.T) { slots := flatLoadSlots(prices) // Force SoC to plenty, give meaningful export credit. p := baseParams(ModeArbitrage) - p.InitialSoCPct = 80 + p.InitialSoC = 0.8 p.ExportOrePerKWh = 400 plan := Optimize(slots, p) // Slot 2 (price 500) should see discharge (battery < 0). @@ -640,15 +640,15 @@ func TestArbitrageNoEVChargeWhileBatteryExporting(t *testing.T) { {StartMs: 3600_000, LenMin: 60, PriceOre: 800, SpotOre: 800, LoadW: 500, PVW: 0, Confidence: 1}, } p := baseParams(ModeArbitrage) - p.InitialSoCPct = 80 // headroom for big discharge + p.InitialSoC = 0.8 // headroom for big discharge p.ExportOrePerKWh = 700 p.Loadpoint = &LoadpointSpec{ ID: "garage", CapacityWh: 60000, Levels: 11, - InitialSoCPct: 20, + InitialSoC: 0.2, PluggedIn: true, - TargetSoCPct: 30, + TargetSoC: 0.3, TargetSlotIdx: 1, // deadline at slot 1 forces some EV charging MaxChargeW: 11000, AllowedStepsW: []float64{0, 5000, 11000}, @@ -692,7 +692,7 @@ func TestArbitrageDoesNotDischargeAtNegativeSpot(t *testing.T) { } } p := baseParams(ModeArbitrage) - p.InitialSoCPct = 60 // plenty of headroom either direction + p.InitialSoC = 0.6 // plenty of headroom either direction p.TerminalSoCPrice = 80.0 // realistic — same scale as PriceOre, so // cycling losses register in the objective plan := Optimize(slots, p) @@ -756,7 +756,7 @@ func TestArbitrageNegativeSpotWithExportFloorClampsAtZero(t *testing.T) { LoadW: 500, PVW: -2000, Confidence: 1.0}, } p := baseParams(ModeArbitrage) - p.InitialSoCPct = 60 + p.InitialSoC = 0.6 p.ExportFloorOreKwh = &zero p.TerminalSoCPrice = 0 // We don't assert a specific dispatch — the floor makes export @@ -781,16 +781,16 @@ func TestEfficiencyCostsSoC(t *testing.T) { } p := baseParams(ModeArbitrage) p.SoCLevels = 171 // 0.5%-grid: (95-10)/170 = 0.5 - p.InitialSoCPct = 50 + p.InitialSoC = 0.5 p.ActionLevels = 11 p.MaxChargeW = 1000 p.MaxDischargeW = 0 p.TerminalSoCPrice = 100 // give DP reason to charge (vs let PV waste) plan := Optimize(slots, p) a := plan.Actions[0] - expected := 50.0 + (1000*1.0*0.95)/10000.0*100.0 - if math.Abs(a.SoCPct-expected) > 1.0 { - t.Errorf("eff-aware SoC: got %f, want ~%f", a.SoCPct, expected) + expected := 0.50 + (1000*1.0*0.95)/10000.0 + if math.Abs(a.SoC-expected) > 1.0 { + t.Errorf("eff-aware SoC: got %f, want ~%f", a.SoC, expected) } } @@ -803,7 +803,7 @@ func TestRoundTripLossMakesArbitrageHarder(t *testing.T) { {StartMs: 60 * 60 * 1000, LenMin: 60, PriceOre: 150, LoadW: 0, PVW: 0}, } p := baseParams(ModeArbitrage) - p.InitialSoCPct = 10 // empty + p.InitialSoC = 0.1 // empty p.ChargeEfficiency = 0.707 p.DischargeEfficiency = 0.707 p.ExportOrePerKWh = 150 @@ -835,8 +835,8 @@ func TestSoCStaysInBounds(t *testing.T) { p.ExportOrePerKWh = 400 plan := Optimize(slots, p) for i, a := range plan.Actions { - if a.SoCPct < p.SoCMinPct-1e-6 || a.SoCPct > p.SoCMaxPct+1e-6 { - t.Errorf("slot %d: SoC %f outside [%f, %f]", i, a.SoCPct, p.SoCMinPct, p.SoCMaxPct) + if a.SoC < p.SoCMin-1e-6 || a.SoC > p.SoCMax+1e-6 { + t.Errorf("slot %d: SoC %f outside [%f, %f]", i, a.SoC, p.SoCMin, p.SoCMax) } } } @@ -870,7 +870,7 @@ func TestImportTariffRaisesMPCImportCost(t *testing.T) { return s } p := baseParams(ModeCheapCharge) - p.InitialSoCPct = 30 + p.InitialSoC = 0.3 p.TerminalSoCPrice = 100 cheap := Optimize(makeSlots(50), p) // low consumer price — grid-charge @@ -896,7 +896,7 @@ func TestExportBonusMakesArbitrageMoreProfitable(t *testing.T) { {StartMs: 3600 * 1000, LenMin: 60, PriceOre: 500, LoadW: 500, PVW: 0}, } p := baseParams(ModeArbitrage) - p.InitialSoCPct = 80 + p.InitialSoC = 0.8 p.TerminalSoCPrice = 0 p.ExportOrePerKWh = 40 @@ -960,9 +960,9 @@ func TestExportWhenMorningIsHighStoreWhenMiddayIsLow(t *testing.T) { Mode: ModeArbitrage, SoCLevels: 41, CapacityWh: 10000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 40, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.4, ActionLevels: 21, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -1028,7 +1028,7 @@ func TestCurtailmentFlagsNegativeExportSlots(t *testing.T) { {StartMs: 0, LenMin: 60, PriceOre: 10, LoadW: 500, PVW: -8000}, } p := baseParams(ModeArbitrage) - p.InitialSoCPct = 95 // already at max — battery can't absorb more + p.InitialSoC = 0.95 // already at max — battery can't absorb more p.ExportOrePerKWh = 0 plan := Optimize(slots, p) a := plan.Actions[0] @@ -1047,7 +1047,7 @@ func TestCurtailmentSkipsWhenExportProfitable(t *testing.T) { {StartMs: 0, LenMin: 60, PriceOre: 100, LoadW: 500, PVW: -8000}, } p := baseParams(ModeArbitrage) - p.InitialSoCPct = 95 + p.InitialSoC = 0.95 p.ExportOrePerKWh = 80 // profitable export plan := Optimize(slots, p) if plan.Actions[0].PVLimitW != 0 { @@ -1061,7 +1061,7 @@ func TestCurtailmentSkipsPositiveSpotExport(t *testing.T) { {StartMs: 0, LenMin: 60, PriceOre: 100, SpotOre: 80, LoadW: 500, PVW: -8000, Confidence: 1}, } p := baseParams(ModeArbitrage) - p.InitialSoCPct = 95 + p.InitialSoC = 0.95 p.MaxDischargeW = 0 p.ActionLevels = 3 @@ -1189,7 +1189,7 @@ func TestZeroEfficiencyDoesNotPanic(t *testing.T) { p := baseParams(ModeArbitrage) p.ChargeEfficiency = 0 p.DischargeEfficiency = 0 - p.InitialSoCPct = 50 + p.InitialSoC = 0.5 p.ExportOrePerKWh = 100 plan := Optimize(slots, p) // must not panic @@ -1198,14 +1198,14 @@ func TestZeroEfficiencyDoesNotPanic(t *testing.T) { t.Fatalf("expected %d actions, got %d", len(slots), len(plan.Actions)) } for i, a := range plan.Actions { - if math.IsNaN(a.SoCPct) || math.IsInf(a.SoCPct, 0) { + if math.IsNaN(a.SoC) || math.IsInf(a.SoC, 0) { t.Errorf("slot %d: SoC is NaN/Inf", i) } if math.IsNaN(a.CostOre) || math.IsInf(a.CostOre, 0) { t.Errorf("slot %d: cost is NaN/Inf", i) } - if a.SoCPct < p.SoCMinPct-1e-6 || a.SoCPct > p.SoCMaxPct+1e-6 { - t.Errorf("slot %d: SoC %f outside bounds", i, a.SoCPct) + if a.SoC < p.SoCMin-1e-6 || a.SoC > p.SoCMax+1e-6 { + t.Errorf("slot %d: SoC %f outside bounds", i, a.SoC) } } @@ -1214,7 +1214,7 @@ func TestZeroEfficiencyDoesNotPanic(t *testing.T) { p.DischargeEfficiency = -1.0 plan2 := Optimize(slots, p) for i, a := range plan2.Actions { - if math.IsNaN(a.SoCPct) || math.IsInf(a.SoCPct, 0) { + if math.IsNaN(a.SoC) || math.IsInf(a.SoC, 0) { t.Errorf("negative-eff slot %d: SoC is NaN/Inf", i) } } diff --git a/go/internal/mpc/mpc_threshold_test.go b/go/internal/mpc/mpc_threshold_test.go index 6971477e3..bc4e69b55 100644 --- a/go/internal/mpc/mpc_threshold_test.go +++ b/go/internal/mpc/mpc_threshold_test.go @@ -18,7 +18,7 @@ func arbitrageCycleSlots() []Slot { func arbitrageCycleParams() Params { p := baseParams(ModeArbitrage) - p.InitialSoCPct = 10 // at the floor: discharge must be funded by a charge + p.InitialSoC = 0.1 // at the floor: discharge must be funded by a charge p.TerminalSoCPrice = 0 return p } @@ -70,7 +70,7 @@ func TestThresholdDoesNotSuppressCoverLoadDischarge(t *testing.T) { {StartMs: 0, LenMin: 60, PriceOre: 250, SpotOre: 200, LoadW: 2000, Confidence: 1}, } p := baseParams(ModePassiveArbitrage) - p.InitialSoCPct = 80 + p.InitialSoC = 0.8 p.MinArbitrageSpreadOreKwh = 25 plan := Optimize(slots, p) if plan.Actions[0].BatteryW >= -100 { @@ -111,7 +111,7 @@ func TestThresholdGatedOutOfSelfConsumption(t *testing.T) { {StartMs: 0, LenMin: 60, PriceOre: 250, SpotOre: 200, LoadW: 2000, Confidence: 1}, } base := baseParams(ModeSelfConsumption) - base.InitialSoCPct = 80 + base.InitialSoC = 0.8 plan0 := Optimize(slots, base) pS := base pS.MinArbitrageSpreadOreKwh = 25 diff --git a/go/internal/mpc/params_validation.go b/go/internal/mpc/params_validation.go index 382d1bee3..2a7f2941f 100644 --- a/go/internal/mpc/params_validation.go +++ b/go/internal/mpc/params_validation.go @@ -82,7 +82,7 @@ func validateBatteryFleetMembers(fleet []BatteryFleetMember) error { // the operating grid, so using it here would plan from energy the site does not // have (or discard energy it does have). func planningParamsRequireRecovery(p Params) bool { - if p.InitialSoCPct < p.SoCMinPct || p.InitialSoCPct > p.SoCMaxPct { + if p.InitialSoC < p.SoCMin || p.InitialSoC > p.SoCMax { return true } for _, storage := range p.Storages { @@ -113,15 +113,13 @@ func validatePlanningParams(p Params) error { if err := requirePositivePlanningValue("capacity_wh", p.CapacityWh); err != nil { return err } - if !finite(p.SoCMinPct) || !finite(p.SoCMaxPct) || - p.SoCMinPct < 0 || p.SoCMinPct >= p.SoCMaxPct || p.SoCMaxPct > 100 { - return fmt.Errorf("soc bounds must satisfy 0 <= min < max <= 100, got %.6g..%.6g", - p.SoCMinPct, p.SoCMaxPct) + if !finite(p.SoCMin) || !finite(p.SoCMax) || + p.SoCMin < 0 || p.SoCMin >= p.SoCMax || p.SoCMax > 1 { + return fmt.Errorf("soc bounds must satisfy 0 <= min < max <= 1, got %.6g..%.6g", + p.SoCMin, p.SoCMax) } - // A live battery may start outside the configured operating band and - // recover toward it. Only the physical 0..100 percent range is hard here. - if !finite(p.InitialSoCPct) || p.InitialSoCPct < 0 || p.InitialSoCPct > 100 { - return fmt.Errorf("initial_soc_pct must be within 0..100, got %.6g", p.InitialSoCPct) + if !finite(p.InitialSoC) || p.InitialSoC < 0 || p.InitialSoC > 1 { + return fmt.Errorf("initial_soc must be within 0..1, got %.6g", p.InitialSoC) } if err := requireNonNegativePlanningValue("max_charge_w", p.MaxChargeW); err != nil { return err @@ -254,9 +252,9 @@ func validateStorageSpecs(p Params, assetIDs map[string]string) error { tol float64 }{ {"capacity_wh", totalCapacityWh, p.CapacityWh, 1}, - {"initial_energy_wh", totalInitialWh, p.CapacityWh * p.InitialSoCPct / 100, energyToleranceWh}, - {"min_energy_wh", totalMinWh, p.CapacityWh * p.SoCMinPct / 100, energyToleranceWh}, - {"max_energy_wh", totalMaxWh, p.CapacityWh * p.SoCMaxPct / 100, energyToleranceWh}, + {"initial_energy_wh", totalInitialWh, p.CapacityWh * p.InitialSoC, energyToleranceWh}, + {"min_energy_wh", totalMinWh, p.CapacityWh * p.SoCMin, energyToleranceWh}, + {"max_energy_wh", totalMaxWh, p.CapacityWh * p.SoCMax, energyToleranceWh}, {"max_charge_w", totalChargeW, p.MaxChargeW, 2}, {"max_discharge_w", totalDischargeW, p.MaxDischargeW, 2}, } @@ -303,8 +301,8 @@ func planningLoadpointsEquivalent(fallback, primary *LoadpointSpec) bool { {fallback.CapacityWh, primary.CapacityWh}, {fallbackMin, primaryMin}, {fallbackMax, primaryMax}, - {fallback.InitialSoCPct, primary.InitialSoCPct}, - {fallback.TargetSoCPct, primary.TargetSoCPct}, + {fallback.InitialSoC, primary.InitialSoC}, + {fallback.TargetSoC, primary.TargetSoC}, {fallback.MaxChargeW, primary.MaxChargeW}, {planningLoadpointEfficiency(fallback), planningLoadpointEfficiency(primary)}, } { @@ -326,11 +324,11 @@ func planningLoadpointsEquivalent(fallback, primary *LoadpointSpec) bool { } func planningLoadpointBounds(loadpoint *LoadpointSpec) (float64, float64) { - minPct, maxPct := loadpoint.MinPct, loadpoint.MaxPct - if minPct == 0 && maxPct == 0 { - maxPct = 100 + minSoC, maxSoC := loadpoint.SoCMin, loadpoint.SoCMax + if minSoC == 0 && maxSoC == 0 { + maxSoC = 1 } - return minPct, maxPct + return minSoC, maxSoC } func planningLoadpointEfficiency(loadpoint *LoadpointSpec) float64 { @@ -359,23 +357,23 @@ func validateLoadpointSpecs(loadpoints []*LoadpointSpec, assetIDs map[string]str if loadpoint.Levels < 2 { return fmt.Errorf("%s.levels must be at least 2, got %d", field, loadpoint.Levels) } - minPct, maxPct := planningLoadpointBounds(loadpoint) - if !finite(minPct) || !finite(maxPct) || - minPct < 0 || minPct >= maxPct || maxPct > 100 { - return fmt.Errorf("%s SoC bounds must satisfy 0 <= min < max <= 100", field) + minSoC, maxSoC := planningLoadpointBounds(loadpoint) + if !finite(minSoC) || !finite(maxSoC) || + minSoC < 0 || minSoC >= maxSoC || maxSoC > 1 { + return fmt.Errorf("%s SoC bounds must satisfy 0 <= min < max <= 1", field) } - if !finite(loadpoint.InitialSoCPct) || loadpoint.InitialSoCPct < minPct || - loadpoint.InitialSoCPct > maxPct { - return fmt.Errorf("%s.initial_soc_pct must be within the loadpoint SoC bounds", field) + if !finite(loadpoint.InitialSoC) || loadpoint.InitialSoC < minSoC || + loadpoint.InitialSoC > maxSoC { + return fmt.Errorf("%s.initial_soc must be within the loadpoint SoC bounds", field) } - if !finite(loadpoint.TargetSoCPct) || loadpoint.TargetSoCPct < 0 || - loadpoint.TargetSoCPct > 100 || - (loadpoint.TargetSoCPct != 0 && - (loadpoint.TargetSoCPct < minPct || loadpoint.TargetSoCPct > maxPct)) { - return fmt.Errorf("%s.target_soc_pct must be zero or within the loadpoint SoC bounds", field) + if !finite(loadpoint.TargetSoC) || loadpoint.TargetSoC < 0 || + loadpoint.TargetSoC > 1 || + (loadpoint.TargetSoC != 0 && + (loadpoint.TargetSoC < minSoC || loadpoint.TargetSoC > maxSoC)) { + return fmt.Errorf("%s.target_soc must be zero or within the loadpoint SoC bounds", field) } - if loadpoint.TargetSoCPct > 0 && loadpoint.TargetSlotIdx < 0 { - return fmt.Errorf("%s.target_slot_idx must be non-negative when target_soc_pct is set", field) + if loadpoint.TargetSoC > 0 && loadpoint.TargetSlotIdx < 0 { + return fmt.Errorf("%s.target_slot_idx must be non-negative when target_soc is set", field) } if err := requireNonNegativePlanningValue(field+".max_charge_w", loadpoint.MaxChargeW); err != nil { return err diff --git a/go/internal/mpc/params_validation_test.go b/go/internal/mpc/params_validation_test.go index b16495a6b..f5d8d47f5 100644 --- a/go/internal/mpc/params_validation_test.go +++ b/go/internal/mpc/params_validation_test.go @@ -29,9 +29,9 @@ func validPlanningStorageParams() Params { p := validPlanningParams() p.Storages = []StorageAssetSpec{{ ID: "battery", CapacityWh: p.CapacityWh, - InitialEnergyWh: p.CapacityWh * p.InitialSoCPct / 100, - MinEnergyWh: p.CapacityWh * p.SoCMinPct / 100, - MaxEnergyWh: p.CapacityWh * p.SoCMaxPct / 100, + InitialEnergyWh: p.CapacityWh * p.InitialSoC, + MinEnergyWh: p.CapacityWh * p.SoCMin, + MaxEnergyWh: p.CapacityWh * p.SoCMax, MaxChargeW: p.MaxChargeW, MaxDischargeW: p.MaxDischargeW, ChargeEfficiency: p.ChargeEfficiency, DischargeEfficiency: p.DischargeEfficiency, }} @@ -41,8 +41,8 @@ func validPlanningStorageParams() Params { func validPlanningLoadpoint() *LoadpointSpec { return &LoadpointSpec{ ID: "garage", CapacityWh: 60000, Levels: 11, - MinPct: 0, MaxPct: 100, InitialSoCPct: 20, PluggedIn: true, - TargetSoCPct: 80, TargetSlotIdx: 8, + SoCMin: 0, SoCMax: 1.0, InitialSoC: 0.2, PluggedIn: true, + TargetSoC: 0.8, TargetSlotIdx: 8, MaxChargeW: 11000, AllowedStepsW: []float64{0, 1400, 4100, 11000}, ChargeEfficiency: 0.9, } @@ -106,9 +106,9 @@ func TestValidatePlanningParamsAcceptsSupportedPhysicalStates(t *testing.T) { name string mutate func(*Params) }{ - {"physical minimum and maximum", func(p *Params) { p.SoCMinPct, p.SoCMaxPct = 0, 100 }}, - {"below operating minimum recovery", func(p *Params) { p.InitialSoCPct = 5 }}, - {"above operating maximum recovery", func(p *Params) { p.InitialSoCPct = 97 }}, + {"physical minimum and maximum", func(p *Params) { p.SoCMin, p.SoCMax = 0, 1 }}, + {"below operating minimum recovery", func(p *Params) { p.InitialSoC = 0.05 }}, + {"above operating maximum recovery", func(p *Params) { p.InitialSoC = 0.97 }}, {"disabled power", func(p *Params) { p.MaxChargeW, p.MaxDischargeW = 0, 0 }}, {"ideal efficiency", func(p *Params) { p.ChargeEfficiency, p.DischargeEfficiency = 1, 1 }}, {"finite negative prices", func(p *Params) { @@ -116,17 +116,17 @@ func TestValidatePlanningParamsAcceptsSupportedPhysicalStates(t *testing.T) { }}, {"storage below operating minimum recovery", func(p *Params) { *p = validPlanningStorageParams() - p.InitialSoCPct = 5 + p.InitialSoC = 0.05 p.Storages[0].InitialEnergyWh = 500 }}, {"storage above operating maximum recovery", func(p *Params) { *p = validPlanningStorageParams() - p.InitialSoCPct = 97 + p.InitialSoC = 0.97 p.Storages[0].InitialEnergyWh = 9700 }}, {"loadpoint documented default efficiency", func(p *Params) { lp := validPlanningLoadpoint() - lp.MinPct, lp.MaxPct = 0, 0 + lp.SoCMin, lp.SoCMax = 0, 0 lp.ChargeEfficiency = 0 p.Loadpoints = []*LoadpointSpec{lp} p.Loadpoint = lp @@ -139,13 +139,13 @@ func TestValidatePlanningParamsAcceptsSupportedPhysicalStates(t *testing.T) { }}, {"loadpoint zero target sentinel", func(p *Params) { lp := validPlanningLoadpoint() - lp.MinPct, lp.TargetSoCPct = 10, 0 + lp.SoCMin, lp.TargetSoC = 0.1, 0 p.Loadpoints = []*LoadpointSpec{lp} p.Loadpoint = lp }}, {"loadpoint zero target ignores negative deadline", func(p *Params) { lp := validPlanningLoadpoint() - lp.TargetSoCPct = 0 + lp.TargetSoC = 0 lp.TargetSlotIdx = -1 p.Loadpoints = []*LoadpointSpec{lp} p.Loadpoint = lp @@ -164,11 +164,11 @@ func TestValidatePlanningParamsAcceptsSupportedPhysicalStates(t *testing.T) { }}, {"equivalent normalized loadpoint fallback", func(p *Params) { lp := validPlanningLoadpoint() - lp.MinPct, lp.MaxPct = 0, 0 + lp.SoCMin, lp.SoCMax = 0, 0 lp.ChargeEfficiency = 0 lp.AllowedStepsW = []float64{4100, 0, 1400, 4100} fallback := *lp - fallback.MinPct, fallback.MaxPct = 0, 100 + fallback.SoCMin, fallback.SoCMax = 0, 1 fallback.ChargeEfficiency = 0.9 fallback.AllowedStepsW = []float64{1400, 4100} p.Loadpoints = []*LoadpointSpec{lp} @@ -202,14 +202,14 @@ func TestValidatePlanningParamsRejectsInvalidAggregateValues(t *testing.T) { {"action levels", "action_levels", func(p *Params) { p.ActionLevels = 2 }}, {"zero capacity", "capacity_wh", func(p *Params) { p.CapacityWh = 0 }}, {"nan capacity", "capacity_wh", func(p *Params) { p.CapacityWh = math.NaN() }}, - {"negative soc minimum", "soc bounds", func(p *Params) { p.SoCMinPct = -1 }}, - {"equal soc bounds", "soc bounds", func(p *Params) { p.SoCMaxPct = p.SoCMinPct }}, - {"reversed soc bounds", "soc bounds", func(p *Params) { p.SoCMinPct = 96 }}, - {"soc maximum above physical", "soc bounds", func(p *Params) { p.SoCMaxPct = 101 }}, - {"nan soc bound", "soc bounds", func(p *Params) { p.SoCMinPct = math.NaN() }}, - {"initial soc below physical", "initial_soc_pct", func(p *Params) { p.InitialSoCPct = -0.1 }}, - {"initial soc above physical", "initial_soc_pct", func(p *Params) { p.InitialSoCPct = 100.1 }}, - {"initial soc infinite", "initial_soc_pct", func(p *Params) { p.InitialSoCPct = math.Inf(1) }}, + {"negative soc minimum", "soc bounds", func(p *Params) { p.SoCMin = -1 }}, + {"equal soc bounds", "soc bounds", func(p *Params) { p.SoCMax = p.SoCMin }}, + {"reversed soc bounds", "soc bounds", func(p *Params) { p.SoCMin = 0.96 }}, + {"soc maximum above physical", "soc bounds", func(p *Params) { p.SoCMax = 1.01 }}, + {"nan soc bound", "soc bounds", func(p *Params) { p.SoCMin = math.NaN() }}, + {"initial soc below physical", "initial_soc", func(p *Params) { p.InitialSoC = -0.1 }}, + {"initial soc above physical", "initial_soc", func(p *Params) { p.InitialSoC = 1.001 }}, + {"initial soc infinite", "initial_soc", func(p *Params) { p.InitialSoC = math.Inf(1) }}, {"negative charge power", "max_charge_w", func(p *Params) { p.MaxChargeW = -1 }}, {"infinite discharge power", "max_discharge_w", func(p *Params) { p.MaxDischargeW = math.Inf(1) }}, {"zero charge efficiency", "charge_efficiency", func(p *Params) { p.ChargeEfficiency = 0 }}, @@ -290,24 +290,24 @@ func TestValidatePlanningParamsRejectsInvalidLoadpointPhysics(t *testing.T) { {"zero capacity", ".capacity_wh", func(p *Params) { p.Loadpoints[0].CapacityWh = 0 }}, {"nan capacity", ".capacity_wh", func(p *Params) { p.Loadpoints[0].CapacityWh = math.NaN() }}, {"too few levels", ".levels", func(p *Params) { p.Loadpoints[0].Levels = 1 }}, - {"invalid bounds", "SoC bounds", func(p *Params) { p.Loadpoints[0].MinPct, p.Loadpoints[0].MaxPct = 60, 50 }}, - {"nan bound", "SoC bounds", func(p *Params) { p.Loadpoints[0].MinPct = math.NaN() }}, - {"initial outside bounds", "initial_soc_pct", func(p *Params) { p.Loadpoints[0].InitialSoCPct = 101 }}, - {"initial below operating minimum", "initial_soc_pct", func(p *Params) { - p.Loadpoints[0].MinPct, p.Loadpoints[0].InitialSoCPct = 30, 20 + {"invalid bounds", "SoC bounds", func(p *Params) { p.Loadpoints[0].SoCMin, p.Loadpoints[0].SoCMax = 0.6, 0.50 }}, + {"nan bound", "SoC bounds", func(p *Params) { p.Loadpoints[0].SoCMin = math.NaN() }}, + {"initial outside bounds", "initial_soc", func(p *Params) { p.Loadpoints[0].InitialSoC = 1.01 }}, + {"initial below operating minimum", "initial_soc", func(p *Params) { + p.Loadpoints[0].SoCMin, p.Loadpoints[0].InitialSoC = 0.3, 0.20 }}, - {"initial above operating maximum", "initial_soc_pct", func(p *Params) { - p.Loadpoints[0].MaxPct, p.Loadpoints[0].InitialSoCPct = 10, 20 + {"initial above operating maximum", "initial_soc", func(p *Params) { + p.Loadpoints[0].SoCMax, p.Loadpoints[0].InitialSoC = 0.1, 0.20 }}, - {"nan initial", "initial_soc_pct", func(p *Params) { p.Loadpoints[0].InitialSoCPct = math.NaN() }}, - {"target outside bounds", "target_soc_pct", func(p *Params) { p.Loadpoints[0].TargetSoCPct = 101 }}, - {"target below operating minimum", "target_soc_pct", func(p *Params) { - p.Loadpoints[0].MinPct, p.Loadpoints[0].InitialSoCPct, p.Loadpoints[0].TargetSoCPct = 30, 40, 20 + {"nan initial", "initial_soc", func(p *Params) { p.Loadpoints[0].InitialSoC = math.NaN() }}, + {"target outside bounds", "target_soc", func(p *Params) { p.Loadpoints[0].TargetSoC = 1.01 }}, + {"target below operating minimum", "target_soc", func(p *Params) { + p.Loadpoints[0].SoCMin, p.Loadpoints[0].InitialSoC, p.Loadpoints[0].TargetSoC = 0.3, 0.40, 0.20 }}, - {"target above operating maximum", "target_soc_pct", func(p *Params) { - p.Loadpoints[0].MaxPct, p.Loadpoints[0].TargetSoCPct = 70, 80 + {"target above operating maximum", "target_soc", func(p *Params) { + p.Loadpoints[0].SoCMax, p.Loadpoints[0].TargetSoC = 0.7, 0.80 }}, - {"infinite target", "target_soc_pct", func(p *Params) { p.Loadpoints[0].TargetSoCPct = math.Inf(1) }}, + {"infinite target", "target_soc", func(p *Params) { p.Loadpoints[0].TargetSoC = math.Inf(1) }}, {"target with negative deadline", "target_slot_idx", func(p *Params) { p.Loadpoints[0].TargetSlotIdx = -1 }}, {"negative max power", ".max_charge_w", func(p *Params) { p.Loadpoints[0].MaxChargeW = -1 }}, {"infinite max power", ".max_charge_w", func(p *Params) { p.Loadpoints[0].MaxChargeW = math.Inf(1) }}, @@ -324,7 +324,7 @@ func TestValidatePlanningParamsRejectsInvalidLoadpointPhysics(t *testing.T) { }}, {"fallback invalid physics", "loadpoint fallback", func(p *Params) { fallback := *p.Loadpoint - fallback.InitialSoCPct = math.NaN() + fallback.InitialSoC = math.NaN() p.Loadpoint = &fallback }}, } @@ -432,7 +432,7 @@ func (o *physicsGateRecoveryOptimizer) Optimize(_ context.Context, slots []Slot, plan := Plan{ GeneratedAtMs: time.Now().UnixMilli(), Mode: p.Mode, HorizonSlots: len(slots), CapacityWh: p.CapacityWh, - InitialSoCPct: p.InitialSoCPct, + InitialSoC: p.InitialSoC, Actions: make([]Action, len(slots)), Solver: &SolverInfo{Engine: "test", Backend: "recovery", Status: "optimal"}, } @@ -443,7 +443,7 @@ func (o *physicsGateRecoveryOptimizer) Optimize(_ context.Context, slots []Slot, SlotStartMs: slot.StartMs, SlotLenMin: slot.LenMin, PriceOre: slot.PriceOre, SpotOre: slot.SpotOre, PVW: slot.PVW, LoadW: slot.LoadW, Confidence: slot.Confidence, - GridW: gridW, SoCPct: p.InitialSoCPct, CostOre: cost, + GridW: gridW, SoC: p.InitialSoC, CostOre: cost, } plan.TotalCostOre += cost } @@ -463,15 +463,15 @@ func (o *physicsGateFailingOptimizer) Optimize(context.Context, []Slot, Params) func (*physicsGateFailingOptimizer) Close() error { return nil } -func configurePhysicsGateFleet(svc *Service, firstSoCPct, secondSoCPct float64) { +func configurePhysicsGateFleet(svc *Service, firstSoC, secondSoC float64) { svc.Tele = telemetry.NewStore() svc.BatteryFleet = []BatteryFleetMember{ {Driver: "battery-a", CapacityWh: 5000, MaxChargeW: 1500, MaxDischargeW: 1500}, {Driver: "battery-b", CapacityWh: 5000, MaxChargeW: 1500, MaxDischargeW: 1500}, } - for i, socPct := range []float64{firstSoCPct, secondSoCPct} { + for i, socPct := range []float64{firstSoC, secondSoC} { driver := svc.BatteryFleet[i].Driver - soc := socPct / 100 + soc := socPct // 0–1 svc.Tele.Update(driver, telemetry.DerBattery, 0, &soc, nil) svc.Tele.DriverHealthMut(driver).RecordSuccess() } @@ -485,7 +485,7 @@ func TestReplanRejectsInvalidPhysicsBeforeSolver(t *testing.T) { {"unknown mode", func(s *Service) { s.Defaults.Mode = "fast" }}, {"nan capacity", func(s *Service) { s.Defaults.CapacityWh = math.NaN() }}, {"efficiency above one", func(s *Service) { s.Defaults.ChargeEfficiency = 1.01 }}, - {"reversed soc bounds", func(s *Service) { s.Defaults.SoCMinPct = 96 }}, + {"reversed soc bounds", func(s *Service) { s.Defaults.SoCMin = 0.96 }}, {"negative power", func(s *Service) { s.Defaults.MaxDischargeW = -1 }}, {"negative fuse limit", func(s *Service) { s.FuseMaxW = -1 }}, {"nan fuse limit", func(s *Service) { s.FuseMaxW = math.NaN() }}, @@ -507,14 +507,14 @@ func TestReplanRejectsInvalidPhysicsBeforeSolver(t *testing.T) { {"loadpoint initial outside operating bounds", func(s *Service) { s.Loadpoints = func(int) []*LoadpointSpec { lp := validPlanningLoadpoint() - lp.MinPct, lp.InitialSoCPct = 30, 20 + lp.SoCMin, lp.InitialSoC = 0.3, 20 return []*LoadpointSpec{lp} } }}, {"loadpoint target outside operating bounds", func(s *Service) { s.Loadpoints = func(int) []*LoadpointSpec { lp := validPlanningLoadpoint() - lp.MaxPct, lp.TargetSoCPct = 70, 80 + lp.SoCMax, lp.TargetSoC = 0.7, 80 return []*LoadpointSpec{lp} } }}, @@ -562,7 +562,7 @@ func TestReplanRejectsInvalidPhysicsBeforeSolver(t *testing.T) { optimizer := &physicsGateCountingOptimizer{} svc := New(st, nil, "SE3", Params{ Mode: ModeSelfConsumption, SoCLevels: 11, ActionLevels: 5, - CapacityWh: 10000, SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 50, + CapacityWh: 10000, SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, MaxChargeW: 3000, MaxDischargeW: 3000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, }) @@ -621,7 +621,7 @@ func TestReplanSanitizesBadLoadInput(t *testing.T) { } svc := New(st, nil, "SE3", Params{ Mode: ModeSelfConsumption, SoCLevels: 11, ActionLevels: 5, - CapacityWh: 10000, SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 50, + CapacityWh: 10000, SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, MaxChargeW: 3000, MaxDischargeW: 3000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, }) @@ -686,8 +686,8 @@ func TestReplanRecoveryUsesExternalPlannerWithoutDPDerivedResults(t *testing.T) name string setup func(*Service) }{ - {"aggregate recovery", func(svc *Service) { svc.Defaults.InitialSoCPct = 5 }}, - {"storage member recovery", func(svc *Service) { configurePhysicsGateFleet(svc, 5, 55) }}, + {"aggregate recovery", func(svc *Service) { svc.Defaults.InitialSoC = 0.05 }}, + {"storage member recovery", func(svc *Service) { configurePhysicsGateFleet(svc, 0.05, 0.55) }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -734,12 +734,12 @@ func TestReplanRecoveryKeepsPreviousPlanWhenDPWouldBeRequired(t *testing.T) { wantFailCalls int32 setupRecovery func(*Service) }{ - {name: "aggregate go only", setupRecovery: func(svc *Service) { svc.Defaults.InitialSoCPct = 5 }}, + {name: "aggregate go only", setupRecovery: func(svc *Service) { svc.Defaults.InitialSoC = 0.05 }}, {name: "aggregate failed primary", baseline: &physicsGateCountingOptimizer{}, recovery: &physicsGateFailingOptimizer{}, wantFailCalls: 1, - setupRecovery: func(svc *Service) { svc.Defaults.InitialSoCPct = 5 }}, - {name: "storage member go only", setupRecovery: func(svc *Service) { configurePhysicsGateFleet(svc, 5, 55) }}, + setupRecovery: func(svc *Service) { svc.Defaults.InitialSoC = 0.05 }}, + {name: "storage member go only", setupRecovery: func(svc *Service) { configurePhysicsGateFleet(svc, 0.05, 0.55) }}, {name: "storage member failed primary", baseline: &physicsGateCountingOptimizer{}, recovery: &physicsGateFailingOptimizer{}, wantFailCalls: 1, - setupRecovery: func(svc *Service) { configurePhysicsGateFleet(svc, 5, 55) }}, + setupRecovery: func(svc *Service) { configurePhysicsGateFleet(svc, 0.05, 0.55) }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/go/internal/mpc/power_limits_test.go b/go/internal/mpc/power_limits_test.go index 719da58a0..ee3072d08 100644 --- a/go/internal/mpc/power_limits_test.go +++ b/go/internal/mpc/power_limits_test.go @@ -86,9 +86,9 @@ func TestOptimizeRespectsImportCap(t *testing.T) { Mode: ModeCheapCharge, SoCLevels: 41, CapacityWh: 15000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 20, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.2, ActionLevels: 21, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -136,9 +136,9 @@ func TestOptimizeInfeasibleStatePicksNearIdle(t *testing.T) { Mode: ModeSelfConsumption, SoCLevels: 11, CapacityWh: 5000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 50, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.5, ActionLevels: 5, MaxChargeW: 2000, MaxDischargeW: 2000, @@ -167,9 +167,9 @@ func TestOptimizeInfeasibleStatePicksIdleWithAsymmetricLimits(t *testing.T) { Mode: ModeSelfConsumption, SoCLevels: 11, CapacityWh: 5000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 50, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.5, ActionLevels: 81, MaxChargeW: 9000, MaxDischargeW: 5000, @@ -204,9 +204,9 @@ func TestOptimizeRespectsExportCap(t *testing.T) { Mode: ModeArbitrage, SoCLevels: 41, CapacityWh: 15000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 70, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.7, ActionLevels: 21, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -240,9 +240,9 @@ func TestOptimizeRespectsFuseExportCap(t *testing.T) { Mode: ModeArbitrage, SoCLevels: 41, CapacityWh: 20000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 85, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.85, ActionLevels: 21, MaxChargeW: 9000, MaxDischargeW: 9000, diff --git a/go/internal/mpc/reactive_test.go b/go/internal/mpc/reactive_test.go index 73df6d2d0..3f7c04678 100644 --- a/go/internal/mpc/reactive_test.go +++ b/go/internal/mpc/reactive_test.go @@ -29,7 +29,7 @@ func seedReactivePrices(t *testing.T, st *state.Store) { func reactiveTestParams() Params { return Params{ Mode: ModeSelfConsumption, SoCLevels: 11, ActionLevels: 5, - CapacityWh: 10000, InitialSoCPct: 50, SoCMinPct: 10, SoCMaxPct: 95, + CapacityWh: 10000, InitialSoC: 0.5, SoCMin: 0.1, SoCMax: 0.95, MaxChargeW: 3000, MaxDischargeW: 3000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, } diff --git a/go/internal/mpc/self_consumption_horizon_test.go b/go/internal/mpc/self_consumption_horizon_test.go index 113c9f124..fa44b1cf2 100644 --- a/go/internal/mpc/self_consumption_horizon_test.go +++ b/go/internal/mpc/self_consumption_horizon_test.go @@ -58,9 +58,9 @@ func TestSelfConsumptionAbsorbsCheapPVOver48hHorizon(t *testing.T) { p := Params{ Mode: ModeSelfConsumption, CapacityWh: 20000, - InitialSoCPct: 65, - SoCMinPct: 10, - SoCMaxPct: 90, + InitialSoC: 0.65, + SoCMin: 0.1, + SoCMax: 0.9, SoCLevels: 41, MaxChargeW: 9000, MaxDischargeW: 9000, diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index 9c5c79b4a..9783fc84e 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -478,7 +478,7 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) { SlotStart: time.UnixMilli(a.SlotStartMs), SlotEnd: time.UnixMilli(endMs), BatteryEnergyWh: energyWh, - SoCTargetPct: a.SoCPct, + SoCTargetPct: a.SoC, Strategy: params.Mode, PVLimitW: a.PVLimitW, GridW: a.GridW, @@ -489,7 +489,7 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) { d.LoadpointSoCTargetPct = make(map[string]float64, len(a.LoadpointPowerW)) for id, powerW := range a.LoadpointPowerW { d.LoadpointEnergyWh[id] = powerW * float64(a.SlotLenMin) / 60.0 - d.LoadpointSoCTargetPct[id] = a.LoadpointSoCPctByID[id] + d.LoadpointSoCTargetPct[id] = a.LoadpointSoCByID[id] } } else if a.LoadpointW > 0 && lpID != "" { lpEnergyWh := a.LoadpointW * float64(a.SlotLenMin) / 60.0 @@ -497,7 +497,7 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) { lpID: lpEnergyWh, } d.LoadpointSoCTargetPct = map[string]float64{ - lpID: a.LoadpointSoCPct, + lpID: a.LoadpointSoC, } } return d, true @@ -524,7 +524,7 @@ func livePVSurplusSoCCapPct(actions []Action, current int, p Params) float64 { } cur := actions[current] if cur.BatteryW < -IdleGateThresholdW || !finite(cur.SpotOre) || - !finite(cur.SoCPct) || cur.SoCPct <= 0 || + !finite(cur.SoC) || cur.SoC <= 0 || !finite(p.CapacityWh) || p.CapacityWh <= 0 { return 0 } @@ -558,14 +558,14 @@ func livePVSurplusSoCCapPct(actions []Action, current int, p Params) float64 { if replaceableStoredWh <= 0 { return 0 } - capPct := cur.SoCPct + replaceableStoredWh/p.CapacityWh*100 - if p.SoCMaxPct > 0 && capPct > p.SoCMaxPct { - capPct = p.SoCMaxPct + cap := cur.SoC + replaceableStoredWh/p.CapacityWh + if p.SoCMax > 0 && cap > p.SoCMax { + cap = p.SoCMax } - if capPct > 100 { - return 100 + if cap > 1 { + return 1 } - return capPct + return cap } // SlotAt returns the plan's directive for the slot containing `now`. @@ -1176,7 +1176,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { return s.Latest() } } else { - p.InitialSoCPct = currentSoCPct(s.Tele, p.InitialSoCPct) + p.InitialSoC = currentSoC(s.Tele, p.InitialSoC) } // Export pricing is per-slot now: pass bonus/fee into Params so @@ -1273,9 +1273,9 @@ func (s *Service) runReplan(request replanRequest) *Plan { recoveryRequired := planningParamsRequireRecovery(p) if recoveryRequired && s.Optimizer == nil { slog.Error("mpc: battery state requires operating-bound recovery that Go DP cannot model; keeping previous plan", - "soc_start", p.InitialSoCPct, - "soc_min", p.SoCMinPct, - "soc_max", p.SoCMaxPct) + "soc_start", p.InitialSoC, + "soc_min", p.SoCMin, + "soc_max", p.SoCMax) return s.Latest() } @@ -1287,7 +1287,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { "capacity_wh", p.CapacityWh, "soc_levels", p.SoCLevels, "action_levels", p.ActionLevels, - "soc_start", p.InitialSoCPct, + "soc_start", p.InitialSoC, "loadpoint_active", p.Loadpoint != nil, "loadpoint_id", loadpointID, ) @@ -1316,9 +1316,9 @@ func (s *Service) runReplan(request replanRequest) *Plan { candidate.DPShadow = nil candidate.Baselines = nil slog.Info("mpc: skipping Go DP shadows while battery state recovers into operating bounds", - "soc_start", p.InitialSoCPct, - "soc_min", p.SoCMinPct, - "soc_max", p.SoCMaxPct) + "soc_start", p.InitialSoC, + "soc_min", p.SoCMin, + "soc_max", p.SoCMax) } else { dpEvaluation := Optimize(slots, p) dpEvaluation.Solver = &SolverInfo{ @@ -1428,9 +1428,9 @@ func (s *Service) runReplan(request replanRequest) *Plan { if recoveryRequired { slog.Error("mpc: primary optimizer failed and Go DP cannot model operating-bound recovery; keeping previous plan", "err", err, - "soc_start", p.InitialSoCPct, - "soc_min", p.SoCMinPct, - "soc_max", p.SoCMaxPct) + "soc_start", p.InitialSoC, + "soc_min", p.SoCMin, + "soc_max", p.SoCMax) return s.Latest() } slog.Error("mpc: primary optimizer failed; using Go DP fallback", "err", err) @@ -1539,7 +1539,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { slog.Info("mpc: replanned", "decision_id", plan.DecisionID, "slots", len(slots), - "soc_start", p.InitialSoCPct, + "soc_start", p.InitialSoC, "cost_ore", plan.TotalCostOre, "reason", reason, "mean_price_ore", meanPrice, @@ -2138,10 +2138,8 @@ func lookupPVInput(forecasts []state.ForecastPoint, ts int64) (float64, *state.F return 0, nil } -// currentSoCPct averages SoC across battery readings in the telemetry store. -// Telemetry stores SoC as a fraction in [0, 1]; the MPC expects [0, 100]. -// Falls back to `fallback` (already in percent) if no readings are present. -func currentSoCPct(t *telemetry.Store, fallback float64) float64 { +// currentSoC averages battery SoC from telemetry (0–1). +func currentSoC(t *telemetry.Store, fallback float64) float64 { if t == nil { return fallback } @@ -2160,7 +2158,7 @@ func currentSoCPct(t *telemetry.Store, fallback float64) float64 { if n == 0 { return fallback } - return sum / float64(n) * 100.0 + return sum / float64(n) } func (s *Service) onlineFleetParams(p Params, fleet []BatteryFleetMember) (Params, bool) { @@ -2190,8 +2188,8 @@ func (s *Service) onlineFleetParams(p Params, fleet []BatteryFleetMember) (Param ID: b.Driver, CapacityWh: b.CapacityWh, InitialEnergyWh: initialEnergyWh, - MinEnergyWh: b.CapacityWh * p.SoCMinPct / 100, - MaxEnergyWh: b.CapacityWh * p.SoCMaxPct / 100, + MinEnergyWh: b.CapacityWh * p.SoCMin, + MaxEnergyWh: b.CapacityWh * p.SoCMax, MaxChargeW: b.MaxChargeW, MaxDischargeW: b.MaxDischargeW, ChargeEfficiency: p.ChargeEfficiency, @@ -2202,7 +2200,7 @@ func (s *Service) onlineFleetParams(p Params, fleet []BatteryFleetMember) (Param return p, false } p.CapacityWh = totalCap - p.InitialSoCPct = sumSoCWh / totalCap * 100.0 + p.InitialSoC = sumSoCWh / totalCap p.MaxChargeW = maxCharge p.MaxDischargeW = maxDischarge if s.FuseMaxW > 0 { diff --git a/go/internal/mpc/service_latest_wins_test.go b/go/internal/mpc/service_latest_wins_test.go index 7a048592c..b734998a3 100644 --- a/go/internal/mpc/service_latest_wins_test.go +++ b/go/internal/mpc/service_latest_wins_test.go @@ -181,7 +181,7 @@ func newCancellationTestService(t *testing.T, optimizer PlanOptimizer) *Service svc := New(st, nil, "SE3", Params{ Mode: ModeSelfConsumption, SoCLevels: 11, ActionLevels: 5, - CapacityWh: 10000, InitialSoCPct: 50, SoCMinPct: 10, SoCMaxPct: 95, + CapacityWh: 10000, InitialSoC: 0.5, SoCMin: 0.1, SoCMax: 0.95, MaxChargeW: 3000, MaxDischargeW: 3000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, }) @@ -218,7 +218,7 @@ func TestReplanNewestRequestWinsWhenOlderSolveFinishesLast(t *testing.T) { } svc := New(st, nil, "SE3", Params{ Mode: ModeSelfConsumption, SoCLevels: 11, ActionLevels: 5, - CapacityWh: 10000, InitialSoCPct: 50, SoCMinPct: 10, SoCMaxPct: 95, + CapacityWh: 10000, InitialSoC: 0.5, SoCMin: 0.1, SoCMax: 0.95, MaxChargeW: 3000, MaxDischargeW: 3000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, }) diff --git a/go/internal/mpc/service_persist_test.go b/go/internal/mpc/service_persist_test.go index 4b7233cfb..a54f17c4f 100644 --- a/go/internal/mpc/service_persist_test.go +++ b/go/internal/mpc/service_persist_test.go @@ -116,9 +116,9 @@ func TestReplanCallsSaveDiag(t *testing.T) { Mode: ModeSelfConsumption, SoCLevels: 11, CapacityWh: 10000, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: 50, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.5, ActionLevels: 5, MaxChargeW: 3000, MaxDischargeW: 3000, @@ -221,7 +221,7 @@ func TestReplanWithoutSaveDiagAssignsDistinctDecisionIDs(t *testing.T) { }}) svc := New(st, nil, "SE3", Params{ Mode: ModeSelfConsumption, SoCLevels: 11, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 50, + SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, ActionLevels: 5, MaxChargeW: 2000, MaxDischargeW: 2000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, }) @@ -271,7 +271,7 @@ func TestReplanRejectsTimelineItCannotRestore(t *testing.T) { svc := New(st, nil, "SE3", Params{ Mode: ModeSelfConsumption, SoCLevels: 11, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 50, + SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, ActionLevels: 5, MaxChargeW: 2000, MaxDischargeW: 2000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, }) @@ -336,7 +336,7 @@ func TestReplanRejectsOptimizerTimelineThatChangesActionKey(t *testing.T) { svc := New(st, nil, "SE3", Params{ Mode: ModeSelfConsumption, SoCLevels: 11, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 50, + SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, ActionLevels: 5, MaxChargeW: 2000, MaxDischargeW: 2000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, }) @@ -417,7 +417,7 @@ func TestReplanRetainsInputProvenanceAcrossOptimizerPaths(t *testing.T) { svc := New(st, nil, "SE3", Params{ Mode: ModeSelfConsumption, SoCLevels: 11, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 50, + SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, ActionLevels: 5, MaxChargeW: 2000, MaxDischargeW: 2000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, }) @@ -481,7 +481,7 @@ func TestReplanLoadsHourlyWeatherCoveringCurrentPriceSlot(t *testing.T) { svc := New(st, nil, "SE3", Params{ Mode: ModeSelfConsumption, SoCLevels: 11, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 50, + SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, ActionLevels: 5, MaxChargeW: 2000, MaxDischargeW: 2000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, }) @@ -515,7 +515,7 @@ func TestPrimaryOptimizerKeepsDPAsDiagnosticShadow(t *testing.T) { } svc := New(st, nil, "SE3", Params{ Mode: ModePassiveArbitrage, SoCLevels: 11, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 50, + SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, ActionLevels: 5, MaxChargeW: 2000, MaxDischargeW: 2000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, }) @@ -562,7 +562,7 @@ func TestPrimaryOptimizerCanSelectMultistageShadow(t *testing.T) { } svc := New(st, nil, "SE3", Params{ Mode: ModePassiveArbitrage, SoCLevels: 11, CapacityWh: 10000, - SoCMinPct: 10, SoCMaxPct: 95, InitialSoCPct: 50, + SoCMin: 0.1, SoCMax: 0.95, InitialSoC: 0.5, ActionLevels: 5, MaxChargeW: 2000, MaxDischargeW: 2000, ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, }) diff --git a/go/internal/mpc/service_test.go b/go/internal/mpc/service_test.go index 1c05826f8..6e9a59d51 100644 --- a/go/internal/mpc/service_test.go +++ b/go/internal/mpc/service_test.go @@ -601,7 +601,7 @@ func TestOptimizeSelfConsumptionDischargesWithSpreadTerminalPrice(t *testing.T) {SpotOreKwh: 80, TotalOreKwh: 300}, {SpotOreKwh: 80, TotalOreKwh: 300}, } p := baseParams(ModeSelfConsumption) - p.InitialSoCPct = 80 + p.InitialSoC = 0.8 p.ExportBonusOreKwh = 60 p.ExportFeeOreKwh = 6 p.TerminalSoCPrice = selfConsumptionTerminalPrice(prices, 60, 6) @@ -636,7 +636,7 @@ func TestOnlineFleetParamsUsesCapacityWeightedOnlineSoC(t *testing.T) { tel.DriverHealthMut("offline").SetOffline() s := &Service{Tele: tel, FuseMaxW: 6000} - p, ok := s.onlineFleetParams(Params{InitialSoCPct: 50}, []BatteryFleetMember{ + p, ok := s.onlineFleetParams(Params{InitialSoC: 0.5}, []BatteryFleetMember{ {Driver: "a", CapacityWh: 10000, MaxChargeW: 3000, MaxDischargeW: 4000}, {Driver: "b", CapacityWh: 30000, MaxChargeW: 5000, MaxDischargeW: 5000}, {Driver: "offline", CapacityWh: 50000, MaxChargeW: 9000, MaxDischargeW: 9000}, @@ -648,8 +648,8 @@ func TestOnlineFleetParamsUsesCapacityWeightedOnlineSoC(t *testing.T) { t.Fatalf("CapacityWh = %.0f, want 40000", p.CapacityWh) } // (10 kWh * 20% + 30 kWh * 80%) / 40 kWh = 65%. - if math.Abs(p.InitialSoCPct-65) > 1e-9 { - t.Fatalf("InitialSoCPct = %.3f, want 65.000", p.InitialSoCPct) + if math.Abs(p.InitialSoC-0.65) > 1e-9 { + t.Fatalf("InitialSoC = %.3f, want 0.650", p.InitialSoC) } if p.MaxChargeW != 6000 { t.Fatalf("MaxChargeW = %.0f, want fuse-clamped 6000", p.MaxChargeW) @@ -690,7 +690,7 @@ func TestOnlineFleetParamsDropsCommandFaultedBattery(t *testing.T) { tel.SetDriverCommandFault("refusing", true, "modbus write refused") s := &Service{Tele: tel, FuseMaxW: 20000} - p, ok := s.onlineFleetParams(Params{InitialSoCPct: 50}, []BatteryFleetMember{ + p, ok := s.onlineFleetParams(Params{InitialSoC: 0.5}, []BatteryFleetMember{ {Driver: "a", CapacityWh: 10000, MaxChargeW: 3000, MaxDischargeW: 4000}, {Driver: "refusing", CapacityWh: 50000, MaxChargeW: 9000, MaxDischargeW: 9000}, }) @@ -703,8 +703,8 @@ func TestOnlineFleetParamsDropsCommandFaultedBattery(t *testing.T) { if len(p.Storages) != 1 || p.Storages[0].ID != "a" { t.Fatalf("Storages = %+v, want only battery a", p.Storages) } - if math.Abs(p.InitialSoCPct-20) > 1e-9 { - t.Fatalf("InitialSoCPct = %.3f, want 20.000 — the refusing battery's 95%% must not count", p.InitialSoCPct) + if math.Abs(p.InitialSoC-0.20) > 1e-9 { + t.Fatalf("InitialSoC = %.3f, want 0.200 — the refusing battery's 95%% must not count", p.InitialSoC) } } @@ -714,7 +714,7 @@ func TestOnlineFleetParamsRequiresOnlineSoCTelemetry(t *testing.T) { tel.DriverHealthMut("no-soc").RecordSuccess() s := &Service{Tele: tel} - _, ok := s.onlineFleetParams(Params{InitialSoCPct: 50}, []BatteryFleetMember{ + _, ok := s.onlineFleetParams(Params{InitialSoC: 0.5}, []BatteryFleetMember{ {Driver: "no-soc", CapacityWh: 10000, MaxChargeW: 3000, MaxDischargeW: 3000}, {Driver: "missing", CapacityWh: 10000, MaxChargeW: 3000, MaxDischargeW: 3000}, }) @@ -881,7 +881,7 @@ func TestOptimizeSelfConsumptionDischargesDespiteHighTerminal(t *testing.T) { {StartMs: 3600 * 1000, LenMin: 60, PriceOre: 300, SpotOre: 80, LoadW: 3000, PVW: -500, Confidence: 1}, } p := baseParams(ModeSelfConsumption) - p.InitialSoCPct = 80 + p.InitialSoC = 0.8 p.TerminalSoCPrice = 300 // mean import price — pre-strict this would have blocked discharge. plan := Optimize(slots, p) @@ -924,7 +924,7 @@ func TestSlotDirectiveAt(t *testing.T) { SlotLenMin: slotLenMin, SpotOre: 40, BatteryW: 800, // 800 W × 15/60 h = 200 Wh for the slot - SoCPct: 45.5, + SoC: 0.455, GridW: -150, // plan expects 150 W export }, { @@ -933,7 +933,7 @@ func TestSlotDirectiveAt(t *testing.T) { PriceOre: 120, // later grid charge costs more than export earns now BatteryW: 2000, GridW: 1500, - SoCPct: 80, + SoC: 0.8, }, }, }, @@ -955,8 +955,8 @@ func TestSlotDirectiveAt(t *testing.T) { if want := slotStart.Add(15 * time.Minute); !d.SlotEnd.Equal(want) { t.Errorf("SlotEnd = %v, want %v", d.SlotEnd, want) } - if d.SoCTargetPct != 45.5 { - t.Errorf("SoCTargetPct = %f, want 45.5", d.SoCTargetPct) + if d.SoCTargetPct != 0.455 { + t.Errorf("SoCTargetPct = %f, want 0.455", d.SoCTargetPct) } if d.Strategy != ModeArbitrage { t.Errorf("Strategy = %v, want arbitrage", d.Strategy) @@ -967,15 +967,15 @@ func TestSlotDirectiveAt(t *testing.T) { if d.GridW != -150 { t.Errorf("GridW = %f, want −150 (must propagate from Action.GridW)", d.GridW) } - if d.LivePVSurplusSoCCapPct != 49.25 { - t.Errorf("LivePVSurplusSoCCapPct = %f, want 49.25 from 375 Wh of later grid-funded charge", d.LivePVSurplusSoCCapPct) + if math.Abs(d.LivePVSurplusSoCCapPct-0.4925) > 1e-9 { + t.Errorf("LivePVSurplusSoCCapPct = %f, want 0.4925 from 375 Wh of later grid-funded charge", d.LivePVSurplusSoCCapPct) } } func TestLivePVSurplusSoCCapPctEconomicGate(t *testing.T) { base := []Action{ - {SpotOre: 50, BatteryW: 0, SoCPct: 40}, - {SlotLenMin: 15, PriceOre: 120, BatteryW: 2000, GridW: 1500, SoCPct: 75}, + {SpotOre: 50, BatteryW: 0, SoC: 0.4}, + {SlotLenMin: 15, PriceOre: 120, BatteryW: 2000, GridW: 1500, SoC: 0.75}, } tests := []struct { name string @@ -983,7 +983,7 @@ func TestLivePVSurplusSoCCapPctEconomicGate(t *testing.T) { params Params wantCap float64 }{ - {name: "cap follows later grid-funded energy", wantCap: 43.75}, + {name: "cap follows later grid-funded energy", wantCap: 0.4375}, {name: "profitable current export is preserved", mutate: func(a []Action) { a[0].SpotOre = 150 }, wantCap: 0}, diff --git a/go/internal/mpc/shadow_evaluator.go b/go/internal/mpc/shadow_evaluator.go index c743405db..7f4de33ca 100644 --- a/go/internal/mpc/shadow_evaluator.go +++ b/go/internal/mpc/shadow_evaluator.go @@ -31,8 +31,8 @@ type ShadowEvaluation struct { ChampionValuedCostOre float64 `json:"champion_valued_cost_ore"` ChallengerValuedCostOre float64 `json:"challenger_valued_cost_ore"` ChallengerMinusChampionValuedOre float64 `json:"challenger_minus_champion_valued_ore"` - ChampionVirtualSoCPct float64 `json:"champion_virtual_soc_pct"` - ChallengerVirtualSoCPct float64 `json:"challenger_virtual_soc_pct"` + ChampionVirtualSoC float64 `json:"champion_virtual_soc_pct"` + ChallengerVirtualSoC float64 `json:"challenger_virtual_soc_pct"` ChampionStorageEnergyWh map[string]float64 `json:"champion_storage_energy_wh,omitempty"` ChallengerStorageEnergyWh map[string]float64 `json:"challenger_storage_energy_wh,omitempty"` ChampionClampCount int64 `json:"champion_clamp_count"` @@ -321,11 +321,11 @@ func shadowStorageFleet(p Params) ([]virtualStorage, map[string]float64) { } return []virtualStorage{{ id: "home-battery", capacityWh: p.CapacityWh, - minEnergyWh: p.CapacityWh * p.SoCMinPct / 100, - maxEnergyWh: p.CapacityWh * p.SoCMaxPct / 100, + minEnergyWh: p.CapacityWh * p.SoCMin, + maxEnergyWh: p.CapacityWh * p.SoCMax, maxChargeW: p.MaxChargeW, maxDischarge: p.MaxDischargeW, etaCharge: etaC, etaDischarge: etaD, - }}, map[string]float64{"home-battery": p.CapacityWh * p.InitialSoCPct / 100} + }}, map[string]float64{"home-battery": p.CapacityWh * p.InitialSoC} } storages := make([]virtualStorage, 0, len(p.Storages)) initial := make(map[string]float64, len(p.Storages)) @@ -400,8 +400,8 @@ func (e *StatefulShadowEvaluator) updateVirtualSoCLocked() { } e.summary.ChampionStorageEnergyWh = cloneEnergyMap(e.championWh) e.summary.ChallengerStorageEnergyWh = cloneEnergyMap(e.challengerWh) - e.summary.ChampionVirtualSoCPct = sumEnergy(e.championWh) / capacity * 100 - e.summary.ChallengerVirtualSoCPct = sumEnergy(e.challengerWh) / capacity * 100 + e.summary.ChampionVirtualSoC = sumEnergy(e.championWh) / capacity * 100 + e.summary.ChallengerVirtualSoC = sumEnergy(e.challengerWh) / capacity * 100 e.summary.ChampionTerminalValueOre = e.params.TerminalSoCPrice * sumEnergy(e.championWh) / 1000 e.summary.ChallengerTerminalValueOre = e.params.TerminalSoCPrice * sumEnergy(e.challengerWh) / 1000 e.summary.ChampionValuedCostOre = e.summary.ChampionCostOre - e.summary.ChampionTerminalValueOre diff --git a/go/internal/mpc/shadow_evaluator_test.go b/go/internal/mpc/shadow_evaluator_test.go index 415203d9b..f1803d548 100644 --- a/go/internal/mpc/shadow_evaluator_test.go +++ b/go/internal/mpc/shadow_evaluator_test.go @@ -13,8 +13,8 @@ func TestStatefulShadowEvaluatorScoresSameRealizationWithIndependentSoC(t *testi Limits: PowerLimits{MaxImportW: 8000, MaxExportW: 8000}, }} p := Params{ - Mode: ModeArbitrage, CapacityWh: 10000, InitialSoCPct: 50, - SoCMinPct: 10, SoCMaxPct: 90, MaxChargeW: 5000, MaxDischargeW: 5000, + Mode: ModeArbitrage, CapacityWh: 10000, InitialSoC: 0.5, + SoCMin: 0.1, SoCMax: 0.9, MaxChargeW: 5000, MaxDischargeW: 5000, ChargeEfficiency: 1, DischargeEfficiency: 1, } champion := &Plan{Actions: []Action{{SlotStartMs: start.UnixMilli(), SlotLenMin: 60, BatteryW: 1000}}} @@ -33,7 +33,7 @@ func TestStatefulShadowEvaluatorScoresSameRealizationWithIndependentSoC(t *testi if math.Abs(score.ChallengerCostOre) > 1e-6 || math.Abs(score.ChallengerMinusChampionOre+100) > 1e-6 { t.Fatalf("unexpected challenger score: %+v", score) } - if math.Abs(score.ChampionVirtualSoCPct-55) > 1e-6 || math.Abs(score.ChallengerVirtualSoCPct-45) > 1e-6 { + if math.Abs(score.ChampionVirtualSoC-55) > 1e-6 || math.Abs(score.ChallengerVirtualSoC-45) > 1e-6 { t.Fatalf("virtual SoC did not evolve independently: %+v", score) } } @@ -42,8 +42,8 @@ func TestStatefulShadowEvaluatorProjectsModeAndEnergyBounds(t *testing.T) { start := time.Unix(1_800_000_000, 0) slots := []Slot{{StartMs: start.UnixMilli(), LenMin: 60, PriceOre: 100, SpotOre: 50}} p := Params{ - Mode: ModePassiveArbitrage, CapacityWh: 10000, InitialSoCPct: 10, - SoCMinPct: 10, SoCMaxPct: 90, MaxChargeW: 5000, MaxDischargeW: 5000, + Mode: ModePassiveArbitrage, CapacityWh: 10000, InitialSoC: 0.1, + SoCMin: 0.1, SoCMax: 0.9, MaxChargeW: 5000, MaxDischargeW: 5000, ChargeEfficiency: 1, DischargeEfficiency: 1, } plan := &Plan{Actions: []Action{{SlotStartMs: start.UnixMilli(), SlotLenMin: 60, BatteryW: -5000}}} @@ -57,14 +57,14 @@ func TestStatefulShadowEvaluatorProjectsModeAndEnergyBounds(t *testing.T) { if score.ChampionClampCount != 30 || score.ChallengerClampCount != 30 { t.Fatalf("expected SoC/mode clamps: %+v", score) } - if math.Abs(score.ChampionVirtualSoCPct-10) > 1e-6 || math.Abs(score.ChallengerVirtualSoCPct-10) > 1e-6 { + if math.Abs(score.ChampionVirtualSoC-10) > 1e-6 || math.Abs(score.ChallengerVirtualSoC-10) > 1e-6 { t.Fatalf("minimum SoC was violated: %+v", score) } } func TestStatefulShadowEvaluatorRestoresPersistedRun(t *testing.T) { now := time.Unix(1_800_000_000, 0) - p := Params{Mode: ModeArbitrage, CapacityWh: 10000, InitialSoCPct: 50, SoCMinPct: 10, SoCMaxPct: 90, MaxChargeW: 5000, MaxDischargeW: 5000, ChargeEfficiency: 1, DischargeEfficiency: 1} + p := Params{Mode: ModeArbitrage, CapacityWh: 10000, InitialSoC: 0.5, SoCMin: 0.1, SoCMax: 0.9, MaxChargeW: 5000, MaxDischargeW: 5000, ChargeEfficiency: 1, DischargeEfficiency: 1} slots := []Slot{{StartMs: now.UnixMilli(), LenMin: 60, PriceOre: 100}} plan := &Plan{Actions: []Action{{SlotStartMs: now.UnixMilli(), SlotLenMin: 60}}} persisted := &ShadowEvaluation{ @@ -76,7 +76,7 @@ func TestStatefulShadowEvaluatorRestoresPersistedRun(t *testing.T) { evaluator.Restore(persisted) evaluator.SetPlans(plan, plan, slots, p, now) score := evaluator.Snapshot() - if score.StartedAtMs != 123 || score.Samples != 42 || score.ChampionVirtualSoCPct != 43 || score.ChallengerVirtualSoCPct != 47 { + if score.StartedAtMs != 123 || score.Samples != 42 || score.ChampionVirtualSoC != 43 || score.ChallengerVirtualSoC != 47 { t.Fatalf("persisted evaluation was not resumed: %+v", score) } } @@ -114,14 +114,14 @@ func TestShadowEvaluatorNeverScoresConditionalMultistageTail(t *testing.T) { func TestShadowEvaluatorDoesNotInventRecoveryFromInitialBandViolation(t *testing.T) { start := time.Unix(1_800_000_000, 0) - p := Params{Mode: ModeArbitrage, CapacityWh: 10000, InitialSoCPct: 5, SoCMinPct: 10, SoCMaxPct: 90, MaxChargeW: 5000, MaxDischargeW: 5000, ChargeEfficiency: 1, DischargeEfficiency: 1} + p := Params{Mode: ModeArbitrage, CapacityWh: 10000, InitialSoC: 0.05, SoCMin: 0.1, SoCMax: 0.9, MaxChargeW: 5000, MaxDischargeW: 5000, ChargeEfficiency: 1, DischargeEfficiency: 1} slots := []Slot{{StartMs: start.UnixMilli(), LenMin: 60, PriceOre: 100}} plan := &Plan{Actions: []Action{{SlotStartMs: start.UnixMilli(), SlotLenMin: 60, BatteryW: -1000}}} evaluator := newStatefulShadowEvaluator() evaluator.SetPlans(plan, plan, slots, p, start) _ = evaluator.Observe(start, 500, 0) score := evaluator.Observe(start.Add(time.Minute), 500, 0) - if score.ChampionVirtualSoCPct != 5 || score.ChallengerVirtualSoCPct != 5 { + if score.ChampionVirtualSoC != 5 || score.ChallengerVirtualSoC != 5 { t.Fatalf("evaluator invented operating-band recovery: %+v", score) } if score.ChampionClampCount != 1 || score.ChallengerClampCount != 1 { diff --git a/go/internal/mpc/stress_test.go b/go/internal/mpc/stress_test.go index 2a7b74c68..bebde1713 100644 --- a/go/internal/mpc/stress_test.go +++ b/go/internal/mpc/stress_test.go @@ -92,14 +92,14 @@ func buildScenarios(rng *rand.Rand) []scenario { s = append(s, scenario{ name: "sunny_mild", slots: makeSlots(nordicPrices(130, 60, rng), pvCurve(8000), loadCurve(500, 1500), start), - initSoC: 50, + initSoC: 0.50, }) // Cloudy day — barely any PV, typical prices. s = append(s, scenario{ name: "cloudy", slots: makeSlots(nordicPrices(160, 80, rng), pvCurve(2000), loadCurve(500, 1500), start), - initSoC: 50, + initSoC: 0.50, }) // Price-spike day (cold winter, low PV, Europe-wide gas crisis). @@ -111,7 +111,7 @@ func buildScenarios(rng *rand.Rand) []scenario { s = append(s, scenario{ name: "price_spike", slots: makeSlots(prices, pvCurve(1000), loadCurve(1200, 3000), start), - initSoC: 50, + initSoC: 0.50, }) // Flat day — nearly constant prices, no arbitrage. @@ -122,7 +122,7 @@ func buildScenarios(rng *rand.Rand) []scenario { s = append(s, scenario{ name: "flat_prices", slots: makeSlots(flat, pvCurve(5000), loadCurve(500, 1500), start), - initSoC: 50, + initSoC: 0.50, }) // Cheap-night scenario — classic overnight charging window. @@ -133,7 +133,7 @@ func buildScenarios(rng *rand.Rand) []scenario { s = append(s, scenario{ name: "cheap_night", slots: makeSlots(nightCheap, pvCurve(3000), loadCurve(600, 2000), start), - initSoC: 20, // start with a near-empty battery to test grid charging + initSoC: 0.20, // start with a near-empty battery to test grid charging }) // Extreme export day — massive PV, low prices from surplus. @@ -144,7 +144,7 @@ func buildScenarios(rng *rand.Rand) []scenario { s = append(s, scenario{ name: "solar_surplus", slots: makeSlots(exportDay, pvCurve(12000), loadCurve(400, 1200), start), - initSoC: 30, + initSoC: 0.30, }) return s @@ -162,9 +162,9 @@ func runMode(slots []Slot, initSoC float64, mode Mode, capWh, maxChg, maxDis flo Mode: mode, SoCLevels: 51, CapacityWh: capWh, - SoCMinPct: 10, - SoCMaxPct: 95, - InitialSoCPct: initSoC, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: initSoC, ActionLevels: 21, MaxChargeW: maxChg, MaxDischargeW: maxDis, @@ -197,7 +197,7 @@ func baselineCost(slots []Slot, exportCredit float64) float64 { // planStats summarises cycles + SoC range for a plan. func planStats(p Plan) (chgKWh, disKWh, socMin, socMax float64) { - socMin, socMax = 100, 0 + socMin, socMax = 1, 0 for _, a := range p.Actions { dt := float64(a.SlotLenMin) / 60.0 if a.BatteryW > 0 { @@ -205,11 +205,11 @@ func planStats(p Plan) (chgKWh, disKWh, socMin, socMax float64) { } else { disKWh += -a.BatteryW * dt / 1000.0 } - if a.SoCPct < socMin { - socMin = a.SoCPct + if a.SoC < socMin { + socMin = a.SoC } - if a.SoCPct > socMax { - socMax = a.SoCPct + if a.SoC > socMax { + socMax = a.SoC } } return @@ -262,12 +262,12 @@ func TestAnnualSavingsProjection(t *testing.T) { } mean /= float64(len(sc.slots)) base := baselineCost(sc.slots, mean*0.7) - initKWh := sc.initSoC * capWh / 100 / 1000 + initKWh := sc.initSoC * capWh / 1000 baseNet := base/100 - mean*initKWh/100 row := fmt.Sprintf("%-18s %-8.0f%%", sc.name, w*100) for _, m := range modes { plan := runMode(sc.slots, sc.initSoC, m, capWh, maxChg, maxDis) - endKWh := plan.Actions[len(plan.Actions)-1].SoCPct * capWh / 100 / 1000 + endKWh := plan.Actions[len(plan.Actions)-1].SoC * capWh / 1000 netSek := plan.TotalCostOre/100 - mean*endKWh/100 savings := baseNet - netSek // positive = saving annual := savings * 365 * w @@ -315,12 +315,12 @@ func TestStrategyComparison(t *testing.T) { } mean /= float64(len(sc.slots)) base := baselineCost(sc.slots, mean*0.7) - initKWh := sc.initSoC * capWh / 100 / 1000 + initKWh := sc.initSoC * capWh / 1000 baseNet := base/100 - mean*initKWh/100 // baseline ends with initial SoC for i, m := range modes { plan := runMode(sc.slots, sc.initSoC, m, capWh, maxChg, maxDis) chgK, disK, smin, smax := planStats(plan) - endKWh := plan.Actions[len(plan.Actions)-1].SoCPct * capWh / 100 / 1000 + endKWh := plan.Actions[len(plan.Actions)-1].SoC * capWh / 1000 costSek := plan.TotalCostOre / 100 netSek := costSek - mean*endKWh/100 vsBase := netSek - baseNet @@ -329,7 +329,7 @@ func TestStrategyComparison(t *testing.T) { label = "" } fmt.Printf("%-18s %-20s %8.2f %8.2f %+8.2f %8.2f %8.2f %5.0f→%3.0f\n", - label, m, costSek, netSek, vsBase, chgK, disK, smin, smax) + label, m, costSek, netSek, vsBase, chgK, disK, smin*100, smax*100) // No ordering assertion: different modes end at different // SoC, so raw spent-energy cost doesn't capture the // terminal value of stored kWh. The DP *does* account for diff --git a/go/internal/mpc/strict_sc_test.go b/go/internal/mpc/strict_sc_test.go index 7b9d23e05..7873c2ecf 100644 --- a/go/internal/mpc/strict_sc_test.go +++ b/go/internal/mpc/strict_sc_test.go @@ -16,7 +16,7 @@ func TestStrictSelfConsumptionDischargesWhenSoCHealthy(t *testing.T) { LoadW: 3480, PVW: -1985, Confidence: 1.0}, } p := baseParams(ModeSelfConsumption) - p.InitialSoCPct = 50 + p.InitialSoC = 0.5 p.TerminalSoCPrice = 107 // roughly selfConsumptionTerminalPrice output plan := Optimize(slots, p) @@ -27,7 +27,7 @@ func TestStrictSelfConsumptionDischargesWhenSoCHealthy(t *testing.T) { if first.BatteryW >= -100 { t.Errorf("strict SC expected battery discharge > 100 W, got BatteryW=%.1f "+ "(GridW=%.1f, SoC=%.1f%%, reason=%q)", - first.BatteryW, first.GridW, first.SoCPct, first.Reason) + first.BatteryW, first.GridW, first.SoC, first.Reason) } // GridW should be lower than baseline 2090 W import — discharging // pulls it down. @@ -64,7 +64,7 @@ func TestStrictSelfConsumptionDoesNotStarveEVDeadline(t *testing.T) { LoadW: 500, PVW: -200, Confidence: 1.0}, } p := baseParams(ModeSelfConsumption) - p.InitialSoCPct = 80 // well above floor+20 + p.InitialSoC = 0.8 // well above floor+20 p.TerminalSoCPrice = 100 p.MaxDischargeW = 1000 // battery can't cover load + EV alone p.MaxChargeW = 1000 @@ -72,10 +72,10 @@ func TestStrictSelfConsumptionDoesNotStarveEVDeadline(t *testing.T) { ID: "garage", CapacityWh: 10000, Levels: 11, - MaxPct: 100, - InitialSoCPct: 20, + SoCMax: 1.0, + InitialSoC: 0.2, PluggedIn: true, - TargetSoCPct: 40, // need 2 kWh + TargetSoC: 0.4, // need 2 kWh TargetSlotIdx: 0, // deadline = slot 0 MaxChargeW: 2500, AllowedStepsW: []float64{0, 2500}, @@ -117,9 +117,9 @@ func TestUpdateCapacityPropagatesToDefaults(t *testing.T) { // TestStrictSelfConsumptionRespectsFloor — replaces the old // BacksOffNearSoCFloor test after #157. The strict-SC bias now -// extends all the way down to SoCMinPct: the operator's configured +// extends all the way down to SoCMin: the operator's configured // floor IS the reserve, no implicit +20 % buffer on top. The DP -// must still never drive SoC below SoCMinPct because the SoC- +// must still never drive SoC below SoCMin because the SoC- // transition feasibility check rejects it. // // We start at SoC = 22 % (12 % above the 10 % floor) so the action @@ -133,27 +133,27 @@ func TestStrictSelfConsumptionRespectsFloor(t *testing.T) { {StartMs: 0, LenMin: 60, PriceOre: 166, LoadW: 3480, PVW: -1390, Confidence: 1.0}, } p := baseParams(ModeSelfConsumption) - p.InitialSoCPct = 22 // comfortably above min (10) but well below the old min+20 buffer + p.InitialSoC = 0.22 // comfortably above min (10) but well below the old min+20 buffer p.TerminalSoCPrice = 200 plan := Optimize(slots, p) - // Floor is hard: SoC must not dip below SoCMinPct. - if plan.Actions[0].SoCPct < p.SoCMinPct-1e-6 { + // Floor is hard: SoC must not dip below SoCMin. + if plan.Actions[0].SoC < p.SoCMin-1e-6 { t.Errorf("SoC went below configured floor: %.2f < %.2f", - plan.Actions[0].SoCPct, p.SoCMinPct) + plan.Actions[0].SoC, p.SoCMin) } // With the +20 buffer removed the bias is active here — DP // should discharge rather than idle, since the slot price is // well above zero and the battery has room above the hard floor. if plan.Actions[0].BatteryW >= -50 { t.Errorf("expected discharge under strict-SC bias at SoC=%.0f%% (floor=%.0f%%); "+ - "got BatteryW=%.1f", p.InitialSoCPct, p.SoCMinPct, plan.Actions[0].BatteryW) + "got BatteryW=%.1f", p.InitialSoC, p.SoCMin, plan.Actions[0].BatteryW) } } // TestStrictSelfConsumptionDischargesBelowOldBufferAtHighPrice is the // regression for #157: field report 2026-04-20 of the planner idling // at Tue 02:15 with SoC ≈ 27 %, importing 5.3 kW at 206 öre, because -// the strict-SC bias used to be gated by `soc > SoCMinPct+20`. Below +// the strict-SC bias used to be gated by `soc > SoCMin+20`. Below // that threshold the DP fell back to pure cost minimisation and // preferred reserving the last 17 % of capacity for the morning peak // — arbitrage behaviour the operator did not ask for. @@ -163,7 +163,7 @@ func TestStrictSelfConsumptionDischargesBelowOldBufferAtHighPrice(t *testing.T) LoadW: 5300, PVW: 0, Confidence: 1.0}, } p := baseParams(ModeSelfConsumption) - p.InitialSoCPct = 28 // just below the old floor+20 threshold (10+20) + p.InitialSoC = 0.28 // just below the old floor+20 threshold (10+20) p.TerminalSoCPrice = 100 // modest terminal; not strong enough to dominate plan := Optimize(slots, p) if len(plan.Actions) == 0 { @@ -173,10 +173,10 @@ func TestStrictSelfConsumptionDischargesBelowOldBufferAtHighPrice(t *testing.T) if first.BatteryW >= -100 { t.Errorf("expected discharge below the old floor+20 buffer at price=206 öre; "+ "got BatteryW=%.1f (SoC=%.1f%%, reason=%q)", - first.BatteryW, first.SoCPct, first.Reason) + first.BatteryW, first.SoC, first.Reason) } // Floor is still respected. - if first.SoCPct < p.SoCMinPct-1e-6 { - t.Errorf("SoC went below configured floor: %.2f < %.2f", first.SoCPct, p.SoCMinPct) + if first.SoC < p.SoCMin-1e-6 { + t.Errorf("SoC went below configured floor: %.2f < %.2f", first.SoC, p.SoCMin) } } diff --git a/go/internal/units/consistency_test.go b/go/internal/units/consistency_test.go new file mode 100644 index 000000000..8c503f75b --- /dev/null +++ b/go/internal/units/consistency_test.go @@ -0,0 +1,108 @@ +package units_test + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/forecast" + "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/units" +) + +// These tests are the harness: if a later change puts kWp or 0–100 SoC +// back into core structs, they fail. Doors (optimizer JSON, appproto) +// are allowed their own types. + +func TestPVArrayCoreFieldIsRatedWatts(t *testing.T) { + typ := reflect.TypeOf(config.PVArray{}) + if _, ok := typ.FieldByName("RatedW"); !ok { + t.Fatal("config.PVArray must store RatedW (watts)") + } + f, ok := typ.FieldByName("KWp") + if ok { + tag := f.Tag.Get("yaml") + if tag != "-" && tag != "kwp,omitempty" { + t.Fatalf("legacy KWp must be omitempty yaml only, got %q", tag) + } + } +} + +func TestForecastArrayHasNoKWp(t *testing.T) { + typ := reflect.TypeOf(forecast.Array{}) + if _, ok := typ.FieldByName("KWp"); ok { + t.Fatal("forecast.Array must not have KWp; store RatedW and convert at the forecast.solar URL") + } + if _, ok := typ.FieldByName("RatedW"); !ok { + t.Fatal("forecast.Array must store RatedW (watts)") + } +} + +func TestMPCParamsSoCIsFraction(t *testing.T) { + typ := reflect.TypeOf(mpc.Params{}) + for _, banned := range []string{"SoCMinPct", "SoCMaxPct", "InitialSoCPct"} { + if _, ok := typ.FieldByName(banned); ok { + t.Fatalf("mpc.Params.%s must not exist; use 0–1 SoCMin/SoCMax/InitialSoC", banned) + } + } + for _, want := range []string{"SoCMin", "SoCMax", "InitialSoC"} { + if _, ok := typ.FieldByName(want); !ok { + t.Fatalf("mpc.Params.%s missing", want) + } + } +} + +func TestActionJSONSoCIsFractionNotPercent(t *testing.T) { + a := mpc.Action{SoC: 0.55, LoadpointSoC: 0.80} + raw, err := json.Marshal(a) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if _, ok := m["soc_pct"]; ok { + t.Fatalf("Action JSON still emits soc_pct: %s", raw) + } + if _, ok := m["loadpoint_soc_pct"]; ok { + t.Fatalf("Action JSON still emits loadpoint_soc_pct: %s", raw) + } + soc, ok := m["soc"].(float64) + if !ok || soc != 0.55 { + t.Fatalf("soc = %v (%T), want 0.55; json=%s", m["soc"], m["soc"], raw) + } + if !units.ValidFraction(soc) { + t.Fatalf("soc %v is not a 0–1 fraction", soc) + } +} + +func TestPlanJSONSoCIsFraction(t *testing.T) { + p := mpc.Plan{InitialSoC: 0.42, Actions: []mpc.Action{{SoC: 0.5}}} + raw, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if _, ok := m["initial_soc_pct"]; ok { + t.Fatalf("Plan JSON still emits initial_soc_pct: %s", raw) + } + got, ok := m["initial_soc"].(float64) + if !ok || got != 0.42 { + t.Fatalf("plan initial_soc = %v, want 0.42; json=%s", m["initial_soc"], raw) + } +} + +func TestNameplateSumsRatedWatts(t *testing.T) { + arrays := []forecast.Array{ + {TiltDeg: 27, AzimuthDeg: 150, RatedW: 12960}, + {TiltDeg: 27, AzimuthDeg: 240, RatedW: 6000}, + } + if got := forecast.NameplateW(18960, arrays); got != 18960 { + t.Fatalf("nameplate = %v, want 18960 W", got) + } +} diff --git a/go/internal/units/units.go b/go/internal/units/units.go new file mode 100644 index 000000000..15d6dac61 --- /dev/null +++ b/go/internal/units/units.go @@ -0,0 +1,119 @@ +// Package units is the source of truth for physical quantities in FTW core. +// +// Core stores and computes: +// +// Power watts (W) +// Energy watt-hours (Wh) +// Irradiance watts per square metre (W/m²) +// Fraction 0..1 (SoC, efficiency, and any other ratio) +// +// Kilo- prefixes and 0–100 percents exist only at a door: +// +// UI display, Home Assistant %, appproto permille, forecast.solar kWp URL, +// optimizer protocol v1 soc_pct JSON, and legacy YAML on load. +// +// Physics gates (nameplate ceiling, SoC in [0,1]) still apply. They are not +// a substitute for storing the right unit. +package units + +import "math" + +// STCIrradianceWm2 is standard-test-condition irradiance. PV power at STC +// equals the array's rated watts; at other irradiance +// +// P = rated_w × (G / STCIrradianceWm2) +const STCIrradianceWm2 = 1000.0 + +// WattsFromKWp converts a vendor/UI kilowatt-peak figure to watts. +// Use only at a door (forecast.solar URL, display). Core stores watts. +func WattsFromKWp(kwp float64) float64 { + return kwp * 1000.0 +} + +// KWpFromWatts converts rated watts to kilowatt-peak for a vendor that +// speaks kWp (forecast.solar). Core must not persist the result. +func KWpFromWatts(w float64) float64 { + if w <= 0 { + return 0 + } + return w / 1000.0 +} + +// PVFromIrradiance is the STC scale: rated watts at 1000 W/m². +func PVFromIrradiance(ratedW, irradianceWm2 float64) float64 { + if ratedW <= 0 || irradianceWm2 <= 0 { + return 0 + } + return ratedW * (irradianceWm2 / STCIrradianceWm2) +} + +// FractionFromLegacyPercent maps a value that may still be 0–100 into 0–1. +// Values already in (0, 1] pass through. 0 stays 0. Call only when loading +// old YAML/JSON; new code writes 0–1. +func FractionFromLegacyPercent(v float64) float64 { + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0 + } + if v > 1 { + return v / 100.0 + } + if v < 0 { + return 0 + } + return v +} + +// PercentFromFraction is the UI/HA door. Core must not store the result. +func PercentFromFraction(f float64) float64 { + return f * 100.0 +} + +// PermilleFromFraction is the appproto door (field battery_soc). +func PermilleFromFraction(f float64) int64 { + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0 + } + return int64(math.Round(f * 1000.0)) +} + +// FractionFromPermille is the appproto inbound door. +func FractionFromPermille(p int64) float64 { + return float64(p) / 1000.0 +} + +// ValidFraction reports whether f is a finite value in [0, 1]. +func ValidFraction(f float64) bool { + return !math.IsNaN(f) && !math.IsInf(f, 0) && f >= 0 && f <= 1 +} + +// ClampFraction folds a finite value onto [0, 1]. Non-finite → 0. +func ClampFraction(f float64) float64 { + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0 + } + if f < 0 { + return 0 + } + if f > 1 { + return 1 + } + return f +} + +// RatedWattsFromLegacyKWp is the YAML door for weather.pv_arrays[].kwp. +// +// Historical configs stored kilowatt-peak in `kwp`. Operators also pasted +// weather.pv_rated_w (watts) into that field, so 18960 meant 18.96 kW. +// Values ≥ 1000 are treated as watts already; smaller values are kWp. +// +// New configs set rated_w (watts) and leave kwp unset. Do not call this +// from forecast/planner math — only from config load. +func RatedWattsFromLegacyKWp(kwp float64) float64 { + if math.IsNaN(kwp) || math.IsInf(kwp, 0) || kwp <= 0 { + return 0 + } + if kwp >= 1000 { + return kwp + } + return WattsFromKWp(kwp) +} diff --git a/go/internal/units/units_test.go b/go/internal/units/units_test.go new file mode 100644 index 000000000..221b8d14b --- /dev/null +++ b/go/internal/units/units_test.go @@ -0,0 +1,83 @@ +package units + +import "testing" + +func TestPVFromIrradianceSTC(t *testing.T) { + if got := PVFromIrradiance(18960, 1000); got != 18960 { + t.Fatalf("STC: got %v, want 18960 W", got) + } +} + +func TestPVFromIrradianceEveningIsKilowattsNotMegawatts(t *testing.T) { + // Björn 2026-08-18: 18960 W roof, ~108 W/m² evening POA. + // The old kWp-as-watts path produced 2046 kW. Rated watts × G/1000 + // is ~2 kW. + got := PVFromIrradiance(18960, 108) + if got < 1900 || got > 2200 { + t.Fatalf("evening PV = %.1f W, want ~2048 W (not megawatts)", got) + } + if got > 18960 { + t.Fatalf("evening PV %.1f W exceeds nameplate 18960 W", got) + } +} + +func TestKWpRoundTrip(t *testing.T) { + if got := WattsFromKWp(12.96); got != 12960 { + t.Fatalf("WattsFromKWp(12.96) = %v", got) + } + if got := KWpFromWatts(12960); got != 12.96 { + t.Fatalf("KWpFromWatts(12960) = %v", got) + } +} + +func TestFractionFromLegacyPercent(t *testing.T) { + cases := []struct { + in, want float64 + }{ + {0, 0}, + {0.10, 0.10}, + {1, 1}, + {10, 0.10}, + {90, 0.90}, + {100, 1}, + {-5, 0}, + } + for _, c := range cases { + if got := FractionFromLegacyPercent(c.in); got != c.want { + t.Errorf("FractionFromLegacyPercent(%v) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestRatedWattsFromLegacyKWp(t *testing.T) { + if got := RatedWattsFromLegacyKWp(12.96); got != 12960 { + t.Fatalf("12.96 kWp → %v W, want 12960", got) + } + if got := RatedWattsFromLegacyKWp(18960); got != 18960 { + t.Fatalf("pasted watts 18960 → %v, want 18960 (not 18.96 MW)", got) + } + if got := RatedWattsFromLegacyKWp(6); got != 6000 { + t.Fatalf("6 kWp → %v W, want 6000", got) + } + if got := RatedWattsFromLegacyKWp(0); got != 0 { + t.Fatalf("empty → %v", got) + } +} + +func TestPermilleDoor(t *testing.T) { + if got := PermilleFromFraction(0.624); got != 624 { + t.Fatalf("permille = %d, want 624", got) + } + if got := FractionFromPermille(624); got != 0.624 { + t.Fatalf("fraction = %v, want 0.624", got) + } +} + +func TestValidFraction(t *testing.T) { + if !ValidFraction(0) || !ValidFraction(1) || !ValidFraction(0.55) { + t.Fatal("expected 0, 1, 0.55 valid") + } + if ValidFraction(1.01) || ValidFraction(-0.01) || ValidFraction(50) { + t.Fatal("1.01, -0.01, 50 must be invalid") + } +} diff --git a/go/internal/v2x/policy.go b/go/internal/v2x/policy.go index f686bcd48..015603bbe 100644 --- a/go/internal/v2x/policy.go +++ b/go/internal/v2x/policy.go @@ -73,9 +73,9 @@ func Evaluate(policy *config.V2XPolicy, snap Snapshot) Envelope { env.Enabled = true env.ExportAllowed = policy.ExportAllowed env.GridChargingAllowed = policy.GridChargingAllowed - env.MinReserveSoCPct = policy.MinReserveSoCPct + env.MinReserveSoCPct = policy.MinReserveSoC env.VehicleCapacityWh = firstPositive(policy.VehicleCapacityWh, snap.CapacityWh) - env.DepartureTargetSoCPct = policy.DepartureTargetSoCPct + env.DepartureTargetSoCPct = policy.DepartureTargetSoC env.CycleCostOreKWh = policy.CycleCostOreKWh if policy.DriverName != "" && snap.Driver != policy.DriverName { @@ -114,13 +114,13 @@ func Evaluate(policy *config.V2XPolicy, snap Snapshot) Envelope { env.addReason("discharge_limit_missing") } - reserve := policy.MinReserveSoCPct / 100.0 + reserve := policy.MinReserveSoC if soc <= reserve { dischargeW = 0 env.addReason("reserve_floor") } - if policy.DepartureTargetSoCPct > 0 { + if policy.DepartureTargetSoC > 0 { departureAt, ok := nextDeparture(now, policy.DepartureTime) if ok { env.DepartureAt = departureAt.Format(time.RFC3339) @@ -130,7 +130,7 @@ func Evaluate(policy *config.V2XPolicy, snap Snapshot) Envelope { dischargeW = 0 env.addReason("capacity_missing") } else { - target := policy.DepartureTargetSoCPct / 100.0 + target := policy.DepartureTargetSoC // recoverableW is the charge power we can *guarantee* before // departure. When grid charging is allowed it's the full // charger limit. When it isn't, the car can only recharge from diff --git a/go/internal/v2x/policy_test.go b/go/internal/v2x/policy_test.go index 71d9cdfde..144479d7e 100644 --- a/go/internal/v2x/policy_test.go +++ b/go/internal/v2x/policy_test.go @@ -52,7 +52,7 @@ func TestEvaluateReserveBlocksDischargeButAllowsCharge(t *testing.T) { func TestEvaluateDepartureTargetBlocksDischargeWhenRechargeWindowIsTight(t *testing.T) { p := basePolicy() - p.DepartureTargetSoCPct = 80 + p.DepartureTargetSoC = 0.80 p.DepartureTime = "08:00" p.VehicleCapacityWh = 100_000 p.MaxChargeW = 7_000 @@ -76,7 +76,7 @@ func TestEvaluateDepartureFloorIgnoresGridRechargeWhenGridChargingForbidden(t *t p := basePolicy() p.ExportAllowed = true p.GridChargingAllowed = false - p.DepartureTargetSoCPct = 80 + p.DepartureTargetSoC = 0.80 p.DepartureTime = "08:00" p.VehicleCapacityWh = 100_000 p.MaxChargeW = 7_000 @@ -127,7 +127,7 @@ func TestEvaluateExportAndGridChargingLimitsUseCurrentGridPower(t *testing.T) { func basePolicy() *config.V2XPolicy { return &config.V2XPolicy{ Enabled: true, - MinReserveSoCPct: 20, + MinReserveSoC: 0.20, MaxChargeW: 3500, MaxDischargeW: 3200, ExportAllowed: true, diff --git a/web/app.js b/web/app.js index a46aa37ea..fe6542121 100644 --- a/web/app.js +++ b/web/app.js @@ -1746,7 +1746,7 @@ { name: "Load pred", val: a.load_w, color: "#fde68a" }, { name: "Battery", val: a.battery_w, color: "#f59e0b", showSign: true }, { name: "Grid", val: a.grid_w, color: "#ef4444", showSign: true }, - { name: "SoC", val: a.soc_pct + "%", color: "#60a5fa", literal: true }, + { name: "SoC", val: a.soc != null ? Math.round(a.soc * 100) + "%" : "—", color: "#60a5fa", literal: true }, { name: "Price", val: fmtPricePerKwh(a.price_ore), color: "#fbbf24", literal: true }, ]; diff --git a/web/components/ftw-pv-arrays-3d.js b/web/components/ftw-pv-arrays-3d.js index 31579be24..e92253797 100644 --- a/web/components/ftw-pv-arrays-3d.js +++ b/web/components/ftw-pv-arrays-3d.js @@ -1,7 +1,7 @@ // — tiny 3D preview of a site's PV-array config. // // Purpose: the settings Weather tab lets the operator list each PV -// plane (name + kWp + tilt_deg + azimuth_deg). Those four numbers +// plane (name + rated_w + tilt_deg + azimuth_deg). Those four numbers // fully specify a panel's orientation but they are hard to cross- // check by eye on a phone in the shed. This component turns the // list into a simple 3D scene — ground plane, compass, and one @@ -18,9 +18,10 @@ // // Public API // ---------- -// el.setArrays([{ name, kwp, tilt_deg, azimuth_deg }, ...]) +// el.setArrays([{ name, rated_w, tilt_deg, azimuth_deg }, ...]) // Replaces the scene contents. Call whenever the list changes -// — pass an empty array to clear. +// — pass an empty array to clear. Visual scale uses rated_w/1000 +// (kW-peak); core stores watts. // // Azimuth convention matches the rest of the stack + the config // help text in settings.js: 0 = N, 90 = E, 180 = S, 270 = W. @@ -42,6 +43,16 @@ const PANEL_EDGE_MIN = 1.4; // floor so a 0.5 kWp array is still visible const PANEL_EDGE_MAX = 7; // cap so a 30 kWp array doesn't swallow the scene const PANEL_ELEV = 0.4; // height above ground plane for the panel base +// Visual scale is kW-peak. Core stores rated_w (watts); legacy kwp is +// kWp unless the value is ≥ 1000 (watts pasted into that field). +function arrayKwpForScale(a) { + const ratedW = Number(a && a.rated_w); + if (ratedW > 0) return ratedW / 1000; + const kwp = Number(a && a.kwp); + if (!(kwp > 0)) return 0; + return kwp >= 1000 ? kwp / 1000 : kwp; +} + // Gap between adjacent panels along the orbit (world units). Small — // the squares are the dominant visual, gap just keeps them from // kissing at the closest angular separation. @@ -150,7 +161,7 @@ class FtwPvArrays3d extends FtwElement { } // Public entry point. Arrays is the same shape as - // config.weather.pv_arrays: [{ name, kwp, tilt_deg, azimuth_deg }, ...] + // config.weather.pv_arrays: [{ name, rated_w, tilt_deg, azimuth_deg }, ...] setArrays(arrays) { this._arrays = Array.isArray(arrays) ? arrays.slice() : []; if (this._three) { @@ -367,11 +378,10 @@ class FtwPvArrays3d extends FtwElement { return; } - // Compute each array's edge length (metres) from its kWp, with - // soft min/max so pathologically small or large ratings don't - // blow the scene out of scale. + // Compute each array's edge length (metres) from rated watts. + // Visual scale is kW-peak (rated_w/1000); core stores watts. const panels = this._arrays.map((a) => { - const kwp = Math.max(0.1, Number(a.kwp) || 0.1); + const kwp = Math.max(0.1, arrayKwpForScale(a)); const edge = Math.max(PANEL_EDGE_MIN, Math.min(PANEL_EDGE_MAX, Math.sqrt(kwp * AREA_PER_KWP))); // Normalize into [0, 360). Otherwise a user-entered 360 (or diff --git a/web/diagnose.js b/web/diagnose.js index 937bde852..b7069030f 100644 --- a/web/diagnose.js +++ b/web/diagnose.js @@ -30,6 +30,12 @@ return u.formatPrice(minor, cur, u.unitFor(cur).scale === 1 ? 0 : 2); } + // Plan/diagnose JSON SoC is a 0–1 fraction. Multiply only when showing "%". + function socPercent(frac) { + if (frac == null || !Number.isFinite(frac)) return null; + return frac * 100; + } + function canvasColors() { return window.ftwThemeColors ? window.ftwThemeColors.palette() @@ -288,7 +294,7 @@ // where the plan said "idle the EV this hour" and operators // would never see the SoC trajectory. const lpActive = d.slots && d.slots.some(x => - x.loadpoint_w || x.loadpoint_soc_pct); + x.loadpoint_w || x.loadpoint_soc); const lpBadges = lpActive ? [ 'EV in plan', params.loadpoint_surplus_only ? 'surplus only' : '', @@ -310,7 +316,7 @@
${escapeHtml(params.mode || '—')} - SoC start ${params.initial_soc_pct != null ? params.initial_soc_pct.toFixed(1) : '—'}% + SoC start ${params.initial_soc != null ? (socPercent(params.initial_soc) || 0).toFixed(1) : '—'}% ${params.capacity_wh ? (params.capacity_wh/1000).toFixed(1)+' kWh' : ''} ${lpBadges}
@@ -429,6 +435,8 @@ const confCls = sl.confidence < 0.9 ? 'conf-low' : ''; const gridCls = sl.grid_w > 0 ? 'val-import' : (sl.grid_w < 0 ? 'val-export' : 'val-neutral'); const batCls = sl.battery_w > 0 ? 'val-charging' : (sl.battery_w < 0 ? 'val-discharging' : 'val-neutral'); + const socPct = socPercent(sl.soc); + const lpSocPct = socPercent(sl.loadpoint_soc); return ` ${sl.idx} ${fmtHHMM(sl.slot_start_ms)} @@ -439,8 +447,8 @@ ${fmtW(sl.load_w)} ${fmtW(sl.battery_w)} ${fmtW(sl.grid_w)} - ${fmt1(sl.soc_pct)}% - ${lpActive ? `${fmtW(sl.loadpoint_w || 0)}${fmt1(sl.loadpoint_soc_pct || 0)}%` : ''} + ${socPct == null ? '—' : fmt1(socPct) + '%'} + ${lpActive ? `${fmtW(sl.loadpoint_w || 0)}${lpSocPct == null ? '—' : fmt1(lpSocPct) + '%'}` : ''} ${fmt1(sl.cost_ore)} ${escapeHtml(sl.reason || '')} `; @@ -573,7 +581,7 @@ ctx.beginPath(); slots.forEach((s, i) => { const x = pad.l + i * barW + barW / 2; - const y = socY0 + socH - ((s.soc_pct || 0) / 100) * socH; + const y = socY0 + socH - ((socPercent(s.soc) || 0) / 100) * socH; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); diff --git a/web/heating.js b/web/heating.js index 56b73664a..c34e2b9e8 100644 --- a/web/heating.js +++ b/web/heating.js @@ -39,13 +39,15 @@ return Math.round(v) + ' W'; } function fmtTemp(v) { return v == null ? '—' : v.toFixed(1) + ' °C'; } - function fmtKW(v) { return v == null ? '—' : v.toFixed(2) + ' kW'; } function fmtHz(v) { return v == null ? '—' : Math.round(v) + ' Hz'; } function fmtAmp(v) { return v == null ? '—' : v.toFixed(1) + ' A'; } function fmtPct(v) { return v == null ? '—' : Math.round(v) + ' %'; } function fmtFlow(v) { return v == null ? '—' : Math.round(v) + ' m³/h'; } function fmtDM(v) { return v == null ? '—' : Math.round(v) + ' DM'; } - function fmtKwh(v) { return v == null ? '—' : Math.round(v).toLocaleString('en-US') + ' kWh'; } + function fmtKwh(wh) { + if (wh == null) return '—'; + return Math.round(wh / 1000).toLocaleString('en-US') + ' kWh'; + } function fmtRaw(v) { return v == null ? '—' : String(Math.round(v * 100) / 100); } function fmtOffset(v) { return v == null ? '—' : (v > 0 ? '+' : '') + Math.round(v); } function fmtOnOff(v) { return v == null ? '—' : (Math.round(v) ? 'On' : 'Off'); } @@ -60,16 +62,16 @@ // lists ALL ~960 signals; this is the curated at-a-glance set. var GROUPS = [ { title: 'Power & electrical', items: [ - { key: 'hp_energy_log_current_power_consumption', label: 'Total power now', fmt: fmtKW, info: "The whole heat pump's instantaneous electrical draw right now — compressor + fan + circulation pumps + electronics." }, + { key: 'hp_energy_log_current_power_consumption', label: 'Total power now', fmt: fmtPower, info: "The whole heat pump's instantaneous electrical draw right now — compressor + fan + circulation pumps + electronics." }, { key: 'hp_power_w', label: 'Compressor', fmt: fmtPower, info: 'Power to the compressor only. 0 W when the compressor is idle — the pump still draws power (see Total power now).' }, - { key: 'hp_power_internal_additional_heat', label: 'Internal add. heat', fmt: fmtKW, info: 'Power to the internal immersion heater (supplementary heat). 0 when only the compressor runs.' }, + { key: 'hp_power_internal_additional_heat', label: 'Internal add. heat', fmt: fmtPower, info: 'Power to the internal immersion heater (supplementary heat). 0 when only the compressor runs.' }, { key: 'hp_compressor_frequency_current', label: 'Compr. freq.', fmt: fmtHz, info: 'Compressor speed/frequency right now. 0 Hz = compressor idle.' }, { key: 'hp_current_be1', label: 'Current', sensor: 'BE1', fmt: fmtAmp, info: 'Measured current on phase 1 (current transformer BE1). Used by the load monitor / fuse protection.' }, { key: 'hp_current_be2', label: 'Current', sensor: 'BE2', fmt: fmtAmp, info: 'Measured current on phase 2 (BE2).' }, { key: 'hp_current_be3', label: 'Current', sensor: 'BE3', fmt: fmtAmp, info: 'Measured current on phase 3 (BE3).' }, { key: 'hp_power_limitation_activation', label: 'Power limit', fmt: fmtOnOff, info: 'Whether the built-in power limitation (load monitor) is actively throttling to stay under the main fuse. Off = not limiting. Throttling only reduces output — it never damages the pump.' }, { key: 'hp_fuse', label: 'Fuse', sensor: null, fmt: fmtAmp, info: 'Main fuse size the pump is configured for. It limits compressor + immersion heater to stay under this.' }, - { key: 'hp_max_internal_additional_heat', label: 'Max add. heat', fmt: fmtKW, info: 'Maximum permitted internal immersion-heater power. Lower this to cap the supplementary heat draw.' }, + { key: 'hp_max_internal_additional_heat', label: 'Max add. heat', fmt: fmtPower, info: 'Maximum permitted internal immersion-heater power. Lower this to cap the supplementary heat draw.' }, ] }, { title: 'Temperatures', items: [ { key: 'hp_hw_top_temp_c', label: 'Hot water top', sensor: 'BT7', fmt: fmtTemp, info: 'Temperature at the top of the hot-water tank — what you get first from the tap.' }, @@ -273,9 +275,9 @@ // Power over 24h — Total drawn / Compressor / Internal add. heat, all in kW. // Separate from sparkline() so the ~937 tiny detail sparklines stay axis-free. var PCHART = [ - { key: 'total', label: 'Total drawn', color: 'var(--accent-e)', scale: 1 }, + { key: 'total', label: 'Total drawn', color: 'var(--accent-e)', scale: 0.001 }, { key: 'compressor', label: 'Compressor', color: '#38bdf8', scale: 0.001 }, - { key: 'internal', label: 'Internal add. heat', color: '#f472b6', scale: 1 }, + { key: 'internal', label: 'Internal add. heat', color: '#f472b6', scale: 0.001 }, ]; function powerChartBlock(power) { if (!power) return ''; @@ -318,7 +320,7 @@ ''; } - // ── Energy per calendar period, from the lifetime kWh counters ─── + // ── Energy per calendar period, from the lifetime Wh counters ─── // delta = current − value at the period boundary; null when the logged // history doesn't reach back that far (shown as "accumulating"). function energyDeltas(points) { @@ -342,7 +344,11 @@ function energyPeriodsBlock(energy) { if (!energy || (energy.consumed || []).length < 2) return ''; var c = energyDeltas(energy.consumed), p = energyDeltas(energy.produced); - function cell(v) { return v == null ? 'accumulating' : (Math.round(v * 10) / 10).toLocaleString('en-US') + ' kWh'; } + function cell(wh) { + if (wh == null) return 'accumulating'; + var kwh = wh / 1000; + return (Math.round(kwh * 10) / 10).toLocaleString('en-US') + ' kWh'; + } var rows = [ { k: 'today', label: 'Today' }, { k: 'week', label: 'This week' }, diff --git a/web/loadpoints.js b/web/loadpoints.js index a2a2cbce0..b71377692 100644 --- a/web/loadpoints.js +++ b/web/loadpoints.js @@ -8,7 +8,7 @@ // current_soc_pct, target_soc_pct, target_time, // vehicle_*, soc_source). // GET /api/mpc/plan — current plan; actions[] include optional -// loadpoint_w + loadpoint_soc_pct when an LP +// loadpoint_w + loadpoint_soc (0–1) when an LP // is part of the DP. Only one LP at a time // today (mpc/service.go: "One loadpoint at a // time — multi-LP support is on the roadmap"). @@ -47,9 +47,9 @@ return Math.round(w) + ' W'; } - function fmtPct(p) { - if (p == null || !isFinite(p)) return '—'; - return p.toFixed(1) + '%'; + function fmtPct(frac) { + if (frac == null || !isFinite(frac)) return '—'; + return (frac * 100).toFixed(1) + '%'; } // Bare number — the column header carries the unit. @@ -215,7 +215,7 @@ return '
No active plan.
'; } // Plan only carries one LP today. Match by checking whether *any* - // action has a loadpoint_w / loadpoint_soc_pct field — if it does, + // action has a loadpoint_w / loadpoint_soc field — if it does, // assume those refer to this LP. Once multi-LP support lands the // plan will need to namespace these by LP id; for now the comment // in mpc/service.go is the source of truth. @@ -225,7 +225,7 @@ return `
Plan is scheduling ${escapeHtml(planLpId)}, not this loadpoint.
`; } const slots = plan.plan.actions - .filter(a => a.loadpoint_w != null || a.loadpoint_soc_pct != null) + .filter(a => a.loadpoint_w != null || a.loadpoint_soc != null) .slice(0, SCHEDULE_SLOTS); if (slots.length === 0) { return '
Planner did not allocate this loadpoint in the current horizon ' + @@ -238,7 +238,7 @@ `${fmtSlotTime(a.slot_start_ms)}` + `${fmtPriceOre(a.price_ore)}` + `${fmtW(a.loadpoint_w || 0)}` + - `${fmtPct(a.loadpoint_soc_pct)}` + + `${fmtPct(a.loadpoint_soc)}` + `${fmtW(a.battery_w)}` + `${escapeHtml(a.reason || '')}` + ''; diff --git a/web/plan-brief.js b/web/plan-brief.js index 57a762d53..8115a0f7c 100644 --- a/web/plan-brief.js +++ b/web/plan-brief.js @@ -53,7 +53,12 @@ function batteryIsPresent(status, actions) { ))) { return true; } - return actions.some((action) => Number.isFinite(action && action.soc_pct)); + return actions.some((action) => Number.isFinite(action && action.soc)); +} + +function socPercent(frac) { + if (!Number.isFinite(frac)) return null; + return frac * 100; } export function unavailablePlannerCopy(reason) { @@ -253,13 +258,15 @@ export function derivePlanBrief({ }; const finalAction = actions[actions.length - 1]; + const nextSocPct = next ? socPercent(next.soc) : null; + const finalSocPct = finalAction ? socPercent(finalAction.soc) : null; const soc = hasBattery ? { - label: next && Number.isFinite(next.soc_pct) - ? `${next.soc_pct.toFixed(0)}% after next step` + label: nextSocPct != null + ? `${nextSocPct.toFixed(0)}% after next step` : "—", - detail: finalAction && Number.isFinite(finalAction.soc_pct) - ? `${finalAction.soc_pct.toFixed(0)}% at the end of the plan` + detail: finalSocPct != null + ? `${finalSocPct.toFixed(0)}% at the end of the plan` : "No battery forecast available", } : null; diff --git a/web/plan-brief.test.mjs b/web/plan-brief.test.mjs index 124e1c1bb..c7378aa5f 100644 --- a/web/plan-brief.test.mjs +++ b/web/plan-brief.test.mjs @@ -63,8 +63,8 @@ describe("plan brief normalization", () => { it("chooses the live meaningful action for an active plan", () => { const plan = { actions: [ - slot(-7, { battery_w: 2400, soc_pct: 48 }), - slot(8, { battery_w: 0, soc_pct: 49 }), + slot(-7, { battery_w: 2400, soc: 0.48 }), + slot(8, { battery_w: 0, soc: 0.49 }), ], solver: { engine: "cvxpy", backend: "osqp", status: "optimal" }, }; diff --git a/web/plan.js b/web/plan.js index 116ce64a5..c5bebcdcc 100644 --- a/web/plan.js +++ b/web/plan.js @@ -36,6 +36,12 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. .replace(/'/g, '''); } + // Plan JSON SoC is a 0–1 fraction. Multiply only when showing "%". + function socPercent(frac) { + if (frac == null || !Number.isFinite(frac)) return null; + return frac * 100; + } + // Hard cut: the roof cannot make more than its nameplate. A stale // megawatt pv_w (kWp pasted as watts) must not paint the chart. function sitePVWCapped(pvW, nameplateW) { @@ -644,14 +650,14 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. ctx.beginPath(); first = true; // Anchor at start SoC at now - if (plan.initial_soc_pct != null) { - ctx.moveTo(xScale(now), socY(plan.initial_soc_pct)); + if (plan.initial_soc != null) { + ctx.moveTo(xScale(now), socY(socPercent(plan.initial_soc))); first = false; } for (const a of plan.actions) { if (a.slot_start_ms > tMax) break; const x = xScale(a.slot_start_ms + a.slot_len_min * 60 * 1000); - const y = socY(a.soc_pct); + const y = socY(socPercent(a.soc) || 0); if (first) { ctx.moveTo(x, y); first = false; } else ctx.lineTo(x, y); } @@ -746,7 +752,7 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. parts.push( `` + `start SoC ` + - `${plan.initial_soc_pct.toFixed(0)}%` + `${(socPercent(plan.initial_soc) || 0).toFixed(0)}%` ); parts.push( `` + @@ -872,7 +878,8 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. lines.push(`
Load forecast${(loadW / 1000).toFixed(1)} kW
`); } if (a.loadpoint_w != null && a.loadpoint_w > 0) { - const evSoc = a.loadpoint_soc_pct != null ? ` → ${a.loadpoint_soc_pct.toFixed(0)}%` : ''; + const lpSoc = socPercent(a.loadpoint_soc); + const evSoc = lpSoc != null ? ` → ${lpSoc.toFixed(0)}%` : ''; lines.push(`
EV charging${(a.loadpoint_w / 1000).toFixed(1)} kW${evSoc}
`); } if (a.battery_w != null) { @@ -883,7 +890,8 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. const gdir = a.grid_w > 0 ? 'import' : 'export'; lines.push(`
Grid${(Math.abs(a.grid_w) / 1000).toFixed(1)} kW ${gdir}
`); } - if (a.soc_pct != null) lines.push(`
SoC (end)${a.soc_pct.toFixed(0)}%
`); + const endSoc = socPercent(a.soc); + if (endSoc != null) lines.push(`
SoC (end)${endSoc.toFixed(0)}%
`); if (a.battery_w != null) { let action, actionHint; if (a.battery_w > 100) { action = 'Charging'; actionHint = 'import to cover load + top up battery'; } diff --git a/web/settings/tabs/caldav.js b/web/settings/tabs/caldav.js index 7d61475db..c733dfc9f 100644 --- a/web/settings/tabs/caldav.js +++ b/web/settings/tabs/caldav.js @@ -110,6 +110,10 @@ render: function (ctx) { var field = ctx.field, config = ctx.config; if (!config.caldav) config.caldav = {}; + if (config.caldav.ev_default_target_soc == null && config.caldav.ev_default_target_soc_pct != null) { + config.caldav.ev_default_target_soc = config.caldav.ev_default_target_soc_pct / 100; + } + delete config.caldav.ev_default_target_soc_pct; var managed = config.caldav.manage_credentials !== false; // default on var html = '
checking…
' + @@ -135,7 +139,7 @@ '
' + field("Poll interval (s)", "caldav.poll_interval_s", "number", 300) + "
" + - field("Default EV target SoC (%)", "caldav.ev_default_target_soc_pct", "number", 80) + + field("Default EV target SoC (0–1)", "caldav.ev_default_target_soc", "number", 0.80) + "
" + '" + '" + diff --git a/web/settings/tabs/control.js b/web/settings/tabs/control.js index 50e451342..0e2b95877 100644 --- a/web/settings/tabs/control.js +++ b/web/settings/tabs/control.js @@ -15,6 +15,11 @@ ''; } + if (!config.site) config.site = {}; + if (config.site.pv_surplus_absorb_soc_cap == null && config.site.pv_surplus_absorb_soc_cap_pct != null) { + config.site.pv_surplus_absorb_soc_cap = config.site.pv_surplus_absorb_soc_cap_pct / 100; + } + delete config.site.pv_surplus_absorb_soc_cap_pct; return '
Site' + field("Name", "site.name", "text", "Home") + '
' + @@ -55,8 +60,9 @@ 'Set a SoC cap below to force the policy for every eligible non-discharge slot.' + '

' + '
' + - field("Operator SoC cap (%)", "site.pv_surplus_absorb_soc_cap_pct", "number", 0, - "Force surplus absorption until average battery SoC reaches this percentage. 0 leaves the decision to the planner's economic comparison. Suggested override: 88, leaving 2 pp below a typical planner soc_max_pct of 90.") + + decimalField("Operator SoC cap (0–1)", "site.pv_surplus_absorb_soc_cap", 0, + "Force surplus absorption until average battery SoC reaches this fraction. 0 leaves the decision to the planner's economic comparison. Suggested override: 0.88, leaving 0.02 below a typical planner soc_max of 0.90.", + "0.01") + '
' + field("Export threshold (W)", "site.pv_surplus_absorb_threshold_w", "number", 100, "Trigger when projected grid export exceeds this many watts after the plan's target. Defaults to 100 W whenever the operator or planner enables absorption.") + diff --git a/web/settings/tabs/planner.js b/web/settings/tabs/planner.js index 6735b8f74..1dce496fe 100644 --- a/web/settings/tabs/planner.js +++ b/web/settings/tabs/planner.js @@ -53,6 +53,15 @@ render: function (ctx) { var field = ctx.field, selectField = ctx.selectField, help = ctx.help, config = ctx.config; if (!config.planner) config.planner = {}; + var planner = config.planner; + if (planner.soc_min == null && planner.soc_min_pct != null) { + planner.soc_min = planner.soc_min_pct / 100; + } + if (planner.soc_max == null && planner.soc_max_pct != null) { + planner.soc_max = planner.soc_max_pct / 100; + } + delete planner.soc_min_pct; + delete planner.soc_max_pct; return '
MPC Planner' + '' + @@ -120,11 +129,11 @@ "Scenario count above which auto mode uses eligible Progressive Hedging or reduces to the exact extensive budget.") + '
' + '
' + - field("SoC min (%)", "planner.soc_min_pct", "number", 10, - "Lowest SoC the planner will discharge to (percent). 10 = 10%.") + + field("Min SoC (0–1)", "planner.soc_min", "number", 0.10, + "Lowest SoC the planner will discharge to. 0.10 = 10%.") + '
' + - field("SoC max (%)", "planner.soc_max_pct", "number", 90, - "Highest SoC the planner will charge to (percent). 90 = 90%.") + + field("Max SoC (0–1)", "planner.soc_max", "number", 0.90, + "Highest SoC the planner will charge to. 0.90 = 90%.") + '
' + '
' + field("PV forecast safety (k)", "planner.pv_forecast_safety_k", "number", 1.0, diff --git a/web/settings/tabs/planner.test.mjs b/web/settings/tabs/planner.test.mjs index 12113e1dc..b4717af6b 100644 --- a/web/settings/tabs/planner.test.mjs +++ b/web/settings/tabs/planner.test.mjs @@ -106,4 +106,30 @@ describe("render", () => { assert.ok(html.includes("[field:planner.optimizer_multistage.service_cvar_weight]")); assert.ok(html.includes('data-checkbox-path="planner.optimizer_recourse_shadow"')); }); + + it("binds SoC bounds as 0–1 fractions", () => { + const html = tab.render(stubCtx()); + assert.ok(html.includes("[field:planner.soc_min]")); + assert.ok(html.includes("[field:planner.soc_max]")); + assert.ok(!html.includes("planner.soc_min_pct")); + assert.ok(!html.includes("planner.soc_max_pct")); + }); + + it("promotes legacy soc_min_pct / soc_max_pct into 0–1 fields", () => { + const ctx = stubCtx(); + ctx.config.planner = { soc_min_pct: 10, soc_max_pct: 90 }; + tab.render(ctx); + assert.equal(ctx.config.planner.soc_min, 0.1); + assert.equal(ctx.config.planner.soc_max, 0.9); + assert.equal(ctx.config.planner.soc_min_pct, undefined); + assert.equal(ctx.config.planner.soc_max_pct, undefined); + }); + + it("keeps an already-set soc_min over a leftover percent key", () => { + const ctx = stubCtx(); + ctx.config.planner = { soc_min: 0.15, soc_min_pct: 10, soc_max: 0.92, soc_max_pct: 90 }; + tab.render(ctx); + assert.equal(ctx.config.planner.soc_min, 0.15); + assert.equal(ctx.config.planner.soc_max, 0.92); + }); }); diff --git a/web/settings/tabs/weather.js b/web/settings/tabs/weather.js index e0f405501..7da52bf55 100644 --- a/web/settings/tabs/weather.js +++ b/web/settings/tabs/weather.js @@ -43,27 +43,33 @@ return pvArraysModulePromise; } + // Legacy yaml/json key `kwp` is kilowatt-peak, except values ≥ 1000 + // which were nameplate watts pasted into that field. + function ratedWattsFromLegacyKwp(kwp) { + var v = Number(kwp); + if (!(v > 0)) return 0; + return v >= 1000 ? v : v * 1000; + } + + function migrateArrayRatedW(a) { + if (!a) return; + if (!(Number(a.rated_w) > 0) && Number(a.kwp) > 0) { + a.rated_w = ratedWattsFromLegacyKwp(a.kwp); + } + delete a.kwp; + } + function renderPVArrays(ctx) { var host = document.getElementById("pv-arrays-list"); if (!host) return; var escHtml = ctx.escHtml; var config = ctx.config; var arrays = (config.weather && config.weather.pv_arrays) || []; + arrays.forEach(migrateArrayRatedW); if (arrays.length === 0) { host.innerHTML = '

No arrays defined — model will learn orientation from telemetry.

'; return; } - var ratedW = Number(config.weather && config.weather.pv_rated_w); - var wattsAsKwp = arrays.some(function (a) { - var kwp = Number(a.kwp); - if (!(kwp > 0)) return false; - if (kwp >= 1000) return true; - // Copied "PV rated (W)" into kWp (18960 W → 18960 kWp). - return ratedW >= 1000 && Math.abs(kwp - ratedW) / ratedW < 0.05; - }); - var kwpWarn = wattsAsKwp - ? '

kWp is kilowatts-peak, not the watts from PV rated (W). An 18.96 kW roof is 18.96, not 18960 — pasting watts makes the PV forecast read as megawatts and provider changes will not fix it.

' - : ""; var previewHtml = '
'; var rows = arrays.map(function (a, i) { @@ -72,8 +78,8 @@ '
' + '' + '
' + - '
' + - '' + + '
' + + '' + '
' + '
' + '' + @@ -84,7 +90,7 @@ '' + '
'; }); - host.innerHTML = kwpWarn + previewHtml + rows.join(""); + host.innerHTML = previewHtml + rows.join(""); var pushArraysToPreview = function () { var el = host.querySelector("ftw-pv-arrays-3d"); if (el && typeof el.setArrays === "function") { @@ -194,7 +200,7 @@ '' + '

' + 'Tilt: 0° = flat roof, 35° = typical pitched roof, 90° = wall. Azimuth: 0 = N, 90 = E, 180 = S, 270 = W. ' + - 'kWp is kilowatts-peak — a 10 kW roof is 10, not 10000 (that field is watts).' + + 'Rated (W) is watts, same unit as PV rated.' + '

' + ''; }, @@ -203,7 +209,7 @@ renderPVArrays(ctx); var addBtn = document.getElementById("pv-array-add"); if (addBtn) addBtn.addEventListener("click", function () { - ctx.config.weather.pv_arrays.push({ name: "", kwp: 0, tilt_deg: 35, azimuth_deg: 180 }); + ctx.config.weather.pv_arrays.push({ name: "", rated_w: 0, tilt_deg: 35, azimuth_deg: 180 }); renderPVArrays(ctx); }); }, diff --git a/web/settings/tabs/weather.test.mjs b/web/settings/tabs/weather.test.mjs new file mode 100644 index 000000000..073b5fc3d --- /dev/null +++ b/web/settings/tabs/weather.test.mjs @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +const source = readFileSync(new URL("./weather.js", import.meta.url), "utf8"); + +describe("weather PV array nameplate", () => { + it("binds Rated (W) to rated_w, not kwp", () => { + assert.match(source, /Rated \(W\)/); + assert.match(source, /data-field="rated_w"/); + assert.match(source, /placeholder="12960"/); + assert.doesNotMatch(source, /data-field="kwp"/); + assert.match(source, /rated_w: 0/); + assert.doesNotMatch(source, /kwp: 0/); + }); + + it("converts a leftover kwp into rated_w", () => { + assert.match(source, /function ratedWattsFromLegacyKwp/); + assert.match(source, /v >= 1000 \? v : v \* 1000/); + assert.match(source, /function migrateArrayRatedW/); + assert.match(source, /delete a\.kwp/); + }); +}); From 57da5e02004944ec8da4a04418526aeccf4bcd4d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 19 Aug 2026 13:48:22 +0200 Subject: [PATCH 02/22] fix(units): loadpoint, vehicle, and calendar SoC are 0-1 Core stored EV SoC as 0-100 while the battery planner already used 0-1. That mix, not a missing clamp, is what let percent-scale values leak into watt math. Loadpoint state, schedules, boost leases, vehicle telemetry, calendar intents, V2X envelopes, and shadow SoC are now fractions. 0-100 remains only at UI, HA, appproto, calendar titles, Tesla verify, and optimizer protocol v1. The units harness fails if _pct names or kWp return to those core structs. Nameplate ceilings stay as physics gates. They are not the unit fix. --- .changeset/si-core-units.md | 2 +- docs/caldav-integration.md | 2 +- go/cmd/ftw/main.go | 57 ++++--- go/internal/api/api.go | 33 ++-- go/internal/api/api_components.go | 2 +- .../api/api_loadpoint_battery_boost.go | 13 +- .../api/api_loadpoint_battery_boost_test.go | 2 +- .../api/api_loadpoint_schedule_test.go | 12 +- go/internal/api/api_test.go | 2 +- go/internal/appproto/ev.go | 9 +- go/internal/appproto/ev_test.go | 2 +- go/internal/appproto/plan.go | 10 +- go/internal/calendar/e2e_test.go | 6 +- go/internal/calendar/integration_test.go | 10 +- go/internal/calendar/parse.go | 53 ++++--- go/internal/calendar/parse_test.go | 16 +- go/internal/calendar/plan.go | 4 +- go/internal/calendar/plan_reconcile_test.go | 2 +- go/internal/calendar/plan_test.go | 20 +-- go/internal/calendar/service.go | 9 +- go/internal/calendar/service_test.go | 14 +- go/internal/config/config.go | 3 +- go/internal/config/units.go | 12 +- go/internal/drivers/host.go | 8 + go/internal/drivers/lua.go | 6 +- go/internal/drivers/lua_test.go | 4 +- .../drivers/lua_vehicle_soc_freshness_test.go | 4 +- go/internal/forecast/forecast.go | 22 ++- go/internal/loadpoint/battery_boost.go | 79 ++++++---- go/internal/loadpoint/battery_boost_test.go | 24 +-- go/internal/loadpoint/controller.go | 15 +- .../controller_bat_soc_unlock_test.go | 30 ++-- .../controller_dispatch_safety_test.go | 4 +- .../loadpoint/controller_phase_mode_test.go | 4 +- go/internal/loadpoint/controller_test.go | 8 +- go/internal/loadpoint/loadpoint.go | 149 +++++++----------- .../loadpoint_anchor_vehicle_test.go | 36 ++--- .../loadpoint_surplus_withheld_test.go | 8 +- go/internal/loadpoint/loadpoint_test.go | 98 ++++++------ go/internal/loadpoint/schedule.go | 62 ++++++-- go/internal/loadpoint/schedule_days_test.go | 20 +-- go/internal/loadpoint/schedule_test.go | 38 ++--- go/internal/loadpoint/session_events_test.go | 6 +- go/internal/loadpoint/surplus_reserve.go | 10 +- go/internal/loadpoint/surplus_reserve_test.go | 22 +-- go/internal/mpc/shadow_evaluator.go | 8 +- go/internal/mpc/shadow_evaluator_test.go | 8 +- go/internal/telemetry/store.go | 15 +- go/internal/telemetry/telemetry_test.go | 8 +- go/internal/telemetry/vehicle.go | 47 +++--- go/internal/telemetry/vehicle_test.go | 96 ++++++----- go/internal/units/consistency_test.go | 146 +++++++++++++++++ go/internal/units/units.go | 14 ++ go/internal/units/units_test.go | 12 ++ go/internal/v2x/policy.go | 18 +-- go/internal/v2x/policy_test.go | 2 +- web/app.js | 26 +-- web/components/ftw-pv-arrays-3d.js | 7 +- web/loadpoint-battery-boost.test.mjs | 2 +- web/loadpoints.js | 36 ++--- 60 files changed, 801 insertions(+), 596 deletions(-) diff --git a/.changeset/si-core-units.md b/.changeset/si-core-units.md index b096a3fc9..fb54bf612 100644 --- a/.changeset/si-core-units.md +++ b/.changeset/si-core-units.md @@ -2,4 +2,4 @@ "ftw": minor --- -Core stores power in watts, energy in watt-hours, SoC as 0–1, and PV arrays as rated watts. kWp and 0–100 percents remain only at UI, Home Assistant, appproto, and the forecast.solar URL. Pasting watts into the old kWp field is converted on config load. Heat-pump diagnostics emit W/Wh. +Core stores power in watts, energy in watt-hours, SoC as 0–1, and PV arrays as rated watts. kWp and 0–100 percents remain only at UI, Home Assistant, appproto, calendar titles, and the forecast.solar URL. Loadpoint, calendar, vehicle telemetry, and V2X envelopes use 0–1 without `_pct` names. Pasting watts into the old kWp field is converted on config load. Heat-pump diagnostics emit W/Wh. diff --git a/docs/caldav-integration.md b/docs/caldav-integration.md index cf7febaae..b89b4196a 100644 --- a/docs/caldav-integration.md +++ b/docs/caldav-integration.md @@ -80,7 +80,7 @@ Events are classified by case-insensitive keyword in the **title**: | Title example | Meaning | |---|---| | `Away`, `Vacation 2 weeks`, `Holiday` | Away interval `[start, end)` → away load profile (~25% load); planner conserves battery. | -| `Charge car 80%` | EV must reach 80% by the event's **start** time. `lp:` selects a loadpoint; no `%` → `ev_default_target_soc_pct`. | +| `Charge car 80%` | EV must reach 80% by the event's **start** time. `lp:` selects a loadpoint; no `%` → `ev_default_target_soc` (0–1). | Keyword lists (`away_keywords`, `ev_keywords`) are configurable for other languages. What FTW parsed is visible at `GET /api/caldav/status`. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 724a75a61..0d58feb63 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -67,7 +67,6 @@ import ( "github.com/srcfl/ftw/go/internal/selfupdate" "github.com/srcfl/ftw/go/internal/state" "github.com/srcfl/ftw/go/internal/telemetry" - "github.com/srcfl/ftw/go/internal/units" ) // Version gets injected at build time via -ldflags. Defaults to "dev" for @@ -1321,14 +1320,14 @@ func main() { // departure. It overrides planning inputs without mutating the // operator's recurring loadpoint schedule; once the lease stops, // the normal schedule is still exactly as it was. - effectiveTargetPct := st.TargetSoCPct + effectiveTarget := st.TargetSoC effectiveTargetTime := st.TargetTime boostActive := false if lpController != nil { if lease, status := lpController.BatteryBoost(st.ID, time.Now()); status.Active { boostActive = true - if lease.EVTargetSoCPct > 0 { - effectiveTargetPct = lease.EVTargetSoCPct + if lease.EVTargetSoC > 0 { + effectiveTarget = lease.EVTargetSoC } if !lease.DepartureAt.IsZero() { effectiveTargetTime = lease.DepartureAt @@ -1343,7 +1342,7 @@ func main() { // against a target the operator never asked for. With // no schedule, EV is left to the loadpoint controller's // reactive surplus-only behaviour. - if effectiveTargetPct <= 0 || effectiveTargetTime.IsZero() || + if effectiveTarget <= 0 || effectiveTargetTime.IsZero() || !effectiveTargetTime.After(time.Now()) { continue } @@ -1367,14 +1366,14 @@ func main() { // loadpoint decoration agrees with us on which vehicle // is "the one". Falls back to inferred SoC when nothing // usable online matches. - initSoC := st.CurrentSoCPct + initSoC := st.CurrentSoC socSource := "inferred" var vehicleChargeLimit float64 // 0 = unknown delivering := st.CurrentPowerW > loadpoint.DeliveringW if pick := telemetry.PickBestVehicleForLoadpoint(tel, delivering, time.Now()); pick.Driver != "" { - initSoC = pick.SoCPct + initSoC = pick.SoC socSource = "vehicle:" + pick.Driver - vehicleChargeLimit = pick.ChargeLimitPct + vehicleChargeLimit = pick.ChargeLimit } // Map target time → slot index using the DP's // actual slot length (hour-of-prices vs. 15-min @@ -1397,9 +1396,9 @@ func main() { // so planning past it is wasted DP grid space. When // the limit is unknown, fall back to the deadline // target itself; never plan beyond what was requested. - maxPct := effectiveTargetPct - if vehicleChargeLimit > 0 && vehicleChargeLimit < maxPct { - maxPct = vehicleChargeLimit + maxSoC := effectiveTarget + if vehicleChargeLimit > 0 && vehicleChargeLimit < maxSoC { + maxSoC = vehicleChargeLimit } // Effective deadline target: when the operator asked // for 100% but the vehicle (Tesla via TeslaBLEProxy @@ -1410,19 +1409,19 @@ func main() { // and MPC keeps committing grid charging chasing an // unreachable goal. Cap target_pct to whatever the // car will physically accept. - targetPct := effectiveTargetPct - if vehicleChargeLimit > 0 && vehicleChargeLimit < targetPct { - targetPct = vehicleChargeLimit + targetSoC := effectiveTarget + if vehicleChargeLimit > 0 && vehicleChargeLimit < targetSoC { + targetSoC = vehicleChargeLimit slog.Info("mpc: target capped to vehicle charge limit", - "lp", st.ID, "operator_target_pct", effectiveTargetPct, - "vehicle_limit_pct", vehicleChargeLimit) + "lp", st.ID, "operator_target", effectiveTarget, + "vehicle_limit", vehicleChargeLimit) } - // Guard against degenerate grids: if current SoC > maxPct + // Guard against degenerate grids: if current SoC > maxSoC // (already over target), grow the ceiling to current so // the DP can at least represent it (no charging will be // scheduled). The deadline penalty handles the rest. - if initSoC > maxPct { - maxPct = initSoC + if initSoC > maxSoC { + maxSoC = initSoC } // Defer grid-funded EV planning when the deadline lies // past the last published price slot AND is more than ~3 h @@ -1455,9 +1454,9 @@ func main() { } } slog.Debug("mpc: loadpoint spec", - "id", st.ID, "soc_pct", initSoC, "soc_source", socSource, - "target_pct", effectiveTargetPct, "target_slot", targetSlot, - "max_pct", maxPct, "vehicle_limit_pct", vehicleChargeLimit, + "id", st.ID, "soc", initSoC, "soc_source", socSource, + "target", effectiveTarget, "target_slot", targetSlot, + "max_soc", maxSoC, "vehicle_limit", vehicleChargeLimit, "defer_grid_plan", deferGridPlan) if deferGridPlan { slog.Info("mpc: LP grid-funded planning deferred — target past published prices", @@ -1505,10 +1504,10 @@ func main() { CapacityWh: capWh, Levels: 11, SoCMin: 0, - SoCMax: units.FractionFromLegacyPercent(maxPct), - InitialSoC: units.FractionFromLegacyPercent(initSoC), + SoCMax: maxSoC, + InitialSoC: initSoC, PluggedIn: true, - TargetSoC: units.FractionFromLegacyPercent(targetPct), + TargetSoC: targetSoC, TargetSlotIdx: targetSlot, MaxChargeW: st.MaxChargeW, AllowedStepsW: st.AllowedStepsW, @@ -1760,7 +1759,7 @@ func main() { return loadpoint.BatteryBoostStoppedBatteryUnavailable } usable++ - floor := lease.MinBatterySoCPct / 100 + floor := lease.MinBatterySoC if staticFloor[name] > floor { floor = staticFloor[name] } @@ -2774,7 +2773,7 @@ func main() { if pick.Driver == "" || pick.Stale { continue } - lpMgr.AnchorVehicleSoC(st.ID, pick.SoCPct) + lpMgr.AnchorVehicleSoC(st.ID, pick.SoC) } if !freshness.Allowed() { @@ -3558,7 +3557,7 @@ func planSlotsFromMPC(mpcSvc *mpc.Service) []calendar.PlanSlot { End: start.Add(time.Duration(ln) * time.Minute), BatteryW: a.BatteryW, GridW: a.GridW, - SoCPct: a.SoC * 100, + SoC: a.SoC, Confidence: a.Confidence, }) } @@ -3588,7 +3587,7 @@ func buildLoadpointConfigs(src []config.Loadpoint) []loadpoint.Config { MaxChargeW: lp.MaxChargeW, AllowedStepsW: lp.AllowedStepsW, VehicleCapacityWh: lp.VehicleCapacityWh, - PluginSoCPct: units.PercentFromFraction(lp.PluginSoC), + PluginSoC: lp.PluginSoC, PhaseMode: lp.PhaseMode, PhaseSplitW: lp.PhaseSplitW, MinPhaseHoldS: lp.MinPhaseHoldS, diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 2aa7b3666..27c2ee4f8 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -54,6 +54,7 @@ import ( "github.com/srcfl/ftw/go/internal/selfupdate" "github.com/srcfl/ftw/go/internal/state" "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/internal/units" v2xpolicy "github.com/srcfl/ftw/go/internal/v2x" ) @@ -3387,11 +3388,11 @@ func (s *Server) handleLoadpoints(w http.ResponseWriter, r *http.Request) { // every tick — the failure mode observed with two Teslas in the same // household. // -// CurrentSoCPct is intentionally NOT overwritten with the BMS reading. -// The loadpoint controller uses CurrentSoCPct as its inference state; +// CurrentSoC is intentionally NOT overwritten with the BMS reading. +// The loadpoint controller uses CurrentSoC as its inference state; // overlaying it from the BMS would mean the UI shows BMS truth while // the controller's plan was computed from the inferred value the -// previous tick — a presentation lie. VehicleSoCPct exposes the BMS +// previous tick — a presentation lie. VehicleSoC exposes the BMS // value separately; the frontend renders both and labels which one // the controller used. func decorateLoadpointsWithVehicle(states []loadpoint.State, tel *telemetry.Store) { @@ -3416,8 +3417,8 @@ func decorateLoadpointsWithVehicle(states []loadpoint.State, tel *telemetry.Stor continue } states[i].VehicleDriver = pick.Driver - states[i].VehicleSoCPct = pick.SoCPct - states[i].VehicleChargeLimitPct = pick.ChargeLimitPct + states[i].VehicleSoC = pick.SoC + states[i].VehicleChargeLimit = pick.ChargeLimit states[i].VehicleChargingState = pick.ChargingState states[i].VehicleStale = pick.Stale states[i].SoCSource = "vehicle" @@ -3457,6 +3458,7 @@ func (s *Server) handleLoadpointTarget(w http.ResponseWriter, r *http.Request) { // to nil for *struct pointers, which would lose the explicit-clear // signal the UI needs. var req struct { + SoC *float64 `json:"soc,omitempty"` SoCPct *float64 `json:"soc_pct,omitempty"` TargetTimeMs *int64 `json:"target_time_ms,omitempty"` SurplusOnly *bool `json:"surplus_only,omitempty"` @@ -3466,7 +3468,7 @@ func (s *Server) handleLoadpointTarget(w http.ResponseWriter, r *http.Request) { writeJSON(w, 400, map[string]string{"error": err.Error()}) return } - if req.SoCPct == nil && req.TargetTimeMs == nil && req.SurplusOnly == nil && len(req.Schedule) == 0 { + if req.SoC == nil && req.SoCPct == nil && req.TargetTimeMs == nil && req.SurplusOnly == nil && len(req.Schedule) == 0 { writeJSON(w, 400, map[string]string{"error": "no fields to update"}) return } @@ -3481,7 +3483,7 @@ func (s *Server) handleLoadpointTarget(w http.ResponseWriter, r *http.Request) { } scheduleChanged = true } - if req.SoCPct != nil || req.TargetTimeMs != nil { + if req.SoC != nil || req.SoCPct != nil || req.TargetTimeMs != nil { // SetTarget always takes both fields, so when the caller // omitted one we have to look up the existing value to // preserve it. Read-modify-write under the manager's lock @@ -3492,9 +3494,16 @@ func (s *Server) handleLoadpointTarget(w http.ResponseWriter, r *http.Request) { writeJSON(w, 404, map[string]string{"error": "loadpoint not found"}) return } - soc := st.TargetSoCPct - if req.SoCPct != nil { - soc = *req.SoCPct + soc := st.TargetSoC + if req.SoC != nil || req.SoCPct != nil { + var canonical, legacy float64 + if req.SoC != nil { + canonical = *req.SoC + } + if req.SoCPct != nil { + legacy = *req.SoCPct + } + soc = units.DecodeJSONFraction(canonical, legacy) } deadline := st.TargetTime if req.TargetTimeMs != nil { @@ -3703,18 +3712,20 @@ func (s *Server) handleLoadpointSoC(w http.ResponseWriter, r *http.Request) { return } var req struct { + SoC float64 `json:"soc"` SoCPct float64 `json:"soc_pct"` } if err := readJSON(r, &req); err != nil { writeJSON(w, 400, map[string]string{"error": err.Error()}) return } + soc := units.DecodeJSONFraction(req.SoC, req.SoCPct) // Confirm loadpoint exists before inspecting plug state. if _, ok := s.deps.Loadpoints.State(id); !ok { writeJSON(w, 404, map[string]string{"error": "loadpoint not found"}) return } - if !s.deps.Loadpoints.SetCurrentSoC(id, req.SoCPct) { + if !s.deps.Loadpoints.SetCurrentSoC(id, soc) { writeJSON(w, 409, map[string]string{ "error": "loadpoint not plugged in — SoC can only be set during an active session", }) diff --git a/go/internal/api/api_components.go b/go/internal/api/api_components.go index 557686e28..36482c667 100644 --- a/go/internal/api/api_components.go +++ b/go/internal/api/api_components.go @@ -23,7 +23,7 @@ func (s *Server) handleComponents(w http.ResponseWriter, r *http.Request) { "protocol_version": components.OptimizerProtocolVersion, "protocol_min_version": components.OptimizerProtocolMinVersion, }, - "drivers": map[string]any{"host_api": components.DriverHostAPIVersion}, + "drivers": map[string]any{"host_api": components.DriverHostAPIVersion}, } if s.deps.MPC != nil && s.deps.MPC.Optimizer != nil { optimizer := map[string]any{ diff --git a/go/internal/api/api_loadpoint_battery_boost.go b/go/internal/api/api_loadpoint_battery_boost.go index 08af8514f..8d0f2fdf5 100644 --- a/go/internal/api/api_loadpoint_battery_boost.go +++ b/go/internal/api/api_loadpoint_battery_boost.go @@ -6,13 +6,16 @@ import ( "time" "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/units" ) type batteryBoostRequest struct { DurationS int64 `json:"duration_s,omitempty"` ExpiresAtMs int64 `json:"expires_at_ms,omitempty"` + MinBatterySoC float64 `json:"min_battery_soc"` MinBatterySoCPct float64 `json:"min_battery_soc_pct"` - EVTargetSoC float64 `json:"ev_target_soc_pct,omitempty"` + EVTargetSoC float64 `json:"ev_target_soc,omitempty"` + EVTargetSoCPct float64 `json:"ev_target_soc_pct,omitempty"` DepartureAtMs int64 `json:"departure_at_ms,omitempty"` } @@ -60,10 +63,10 @@ func (s *Server) handleLoadpointBatteryBoostEnable(w http.ResponseWriter, r *htt expires = now.Add(time.Duration(req.DurationS) * time.Second) } lease := loadpoint.BatteryBoostLease{ - StartedAt: now, - ExpiresAt: expires, - MinBatterySoCPct: req.MinBatterySoCPct, - EVTargetSoCPct: req.EVTargetSoC, + StartedAt: now, + ExpiresAt: expires, + MinBatterySoC: units.DecodeJSONFraction(req.MinBatterySoC, req.MinBatterySoCPct), + EVTargetSoC: units.DecodeJSONFraction(req.EVTargetSoC, req.EVTargetSoCPct), } if req.DepartureAtMs > 0 { lease.DepartureAt = time.UnixMilli(req.DepartureAtMs) diff --git a/go/internal/api/api_loadpoint_battery_boost_test.go b/go/internal/api/api_loadpoint_battery_boost_test.go index 448771f39..74ba030de 100644 --- a/go/internal/api/api_loadpoint_battery_boost_test.go +++ b/go/internal/api/api_loadpoint_battery_boost_test.go @@ -39,7 +39,7 @@ func TestBatteryBoostAPIEnableStatusCancelLifecycle(t *testing.T) { t.Fatalf("enable = %d: %s", rr.Code, rr.Body.String()) } var status loadpoint.BatteryBoostStatus - if err := json.Unmarshal(rr.Body.Bytes(), &status); err != nil || !status.Active || status.EVTargetSoCPct != 80 { + if err := json.Unmarshal(rr.Body.Bytes(), &status); err != nil || !status.Active || status.EVTargetSoC != 0.8 { t.Fatalf("enable status = %+v, err=%v", status, err) } diff --git a/go/internal/api/api_loadpoint_schedule_test.go b/go/internal/api/api_loadpoint_schedule_test.go index 38d8570a7..9ccfd1364 100644 --- a/go/internal/api/api_loadpoint_schedule_test.go +++ b/go/internal/api/api_loadpoint_schedule_test.go @@ -74,7 +74,7 @@ func TestSchedulePutStoresRollsAndReplans(t *testing.T) { if !ok { t.Fatal("PUT did not store the schedule") } - want := loadpoint.Schedule{SoCPct: 80, TimeOfDayMinUTC: 360, Recurring: true, Days: 31} + want := loadpoint.Schedule{SoC: 0.8, TimeOfDayMinUTC: 360, Recurring: true, Days: 31} if got != want { t.Fatalf("stored schedule = %+v, want %+v", got, want) } @@ -85,8 +85,8 @@ func TestSchedulePutStoresRollsAndReplans(t *testing.T) { if lpState.TargetTime.IsZero() || !lpState.TargetTime.After(time.Now()) { t.Fatalf("PUT did not roll: target_time = %v", lpState.TargetTime) } - if lpState.TargetSoCPct != 80 { - t.Fatalf("PUT did not roll: target_soc_pct = %v, want 80", lpState.TargetSoCPct) + if lpState.TargetSoC != 0.8 { + t.Fatalf("PUT did not roll: target_soc_pct = %v, want 80", lpState.TargetSoC) } if _, reason := svc.LastReplanInfo(); reason != "loadpoint_schedule_changed" { @@ -98,7 +98,7 @@ func TestScheduleDeleteClearsAndReplans(t *testing.T) { srv, mgr, svc := newScheduleServer(t) // Seeded on the manager directly, so the replan reason below can // only have come from the DELETE. - mgr.SetSchedule("garage", loadpoint.Schedule{SoCPct: 80, TimeOfDayMinUTC: 360, Recurring: true}) + mgr.SetSchedule("garage", loadpoint.Schedule{SoC: 0.8, TimeOfDayMinUTC: 360, Recurring: true}) req := httptest.NewRequest(http.MethodDelete, "/api/loadpoints/garage/schedule", nil) rr := httptest.NewRecorder() @@ -119,7 +119,7 @@ func TestScheduleDeleteClearsAndReplans(t *testing.T) { // route's embedded schedule field accepts. func TestSchedulePutNullClears(t *testing.T) { srv, mgr, _ := newScheduleServer(t) - mgr.SetSchedule("garage", loadpoint.Schedule{SoCPct: 80, TimeOfDayMinUTC: 360, Recurring: true}) + mgr.SetSchedule("garage", loadpoint.Schedule{SoC: 0.8, TimeOfDayMinUTC: 360, Recurring: true}) rr := putSchedule(t, srv, "garage", `null`) if rr.Code != http.StatusOK { @@ -183,7 +183,7 @@ func TestTargetRouteStillCarriesSchedule(t *testing.T) { t.Fatalf("POST target status = %d, want 200 (body: %s)", rr.Code, rr.Body.String()) } got, ok := mgr.GetSchedule("garage") - if !ok || got.SoCPct != 70 || got.TimeOfDayMinUTC != 420 { + if !ok || got.SoC != 0.7 || got.TimeOfDayMinUTC != 420 { t.Fatalf("target route stopped storing schedules: got=%+v ok=%v", got, ok) } if _, reason := svc.LastReplanInfo(); reason != "loadpoint_schedule_changed" { diff --git a/go/internal/api/api_test.go b/go/internal/api/api_test.go index a1a80e1e2..574969078 100644 --- a/go/internal/api/api_test.go +++ b/go/internal/api/api_test.go @@ -319,7 +319,7 @@ func TestHandleV2XPolicyReturnsLiveEnvelope(t *testing.T) { Cfg: &config.Config{V2X: &config.V2XPolicy{ Enabled: true, DriverName: "dc2", - MinReserveSoC: 0.20, + MinReserveSoC: 0.20, MaxChargeW: 6000, MaxDischargeW: 4000, ExportAllowed: false, diff --git a/go/internal/appproto/ev.go b/go/internal/appproto/ev.go index 430c69bfe..2fca81843 100644 --- a/go/internal/appproto/ev.go +++ b/go/internal/appproto/ev.go @@ -5,6 +5,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/units" ) // The EV loadpoint operations: loadpoint.hold pins the charger to a fixed @@ -237,10 +238,10 @@ func (h *Handler) loadpointBoost(cmd Cmd, uptimeMs int64) error { minSoC, _ := argNum(cmd.Args, "min_battery_soc_pct") evTarget, _ := argNum(cmd.Args, "ev_target_soc_pct") lease := loadpoint.BatteryBoostLease{ - StartedAt: now, - ExpiresAt: expires, - MinBatterySoCPct: minSoC, - EVTargetSoCPct: evTarget, + StartedAt: now, + ExpiresAt: expires, + MinBatterySoC: units.FractionFromLegacyPercent(minSoC), + EVTargetSoC: units.FractionFromLegacyPercent(evTarget), } if departureAtMs, ok := argInt(cmd.Args, "departure_at_ms"); ok && departureAtMs > 0 { lease.DepartureAt = time.UnixMilli(departureAtMs) diff --git a/go/internal/appproto/ev_test.go b/go/internal/appproto/ev_test.go index 664e52efe..20b2b82f1 100644 --- a/go/internal/appproto/ev_test.go +++ b/go/internal/appproto/ev_test.go @@ -233,7 +233,7 @@ func TestLoadpointBoostAcksThenConfirmsFromAReadBack(t *testing.T) { if want := clock.now.Add(time.Hour); !lp.boostLease.ExpiresAt.Equal(want) { t.Fatalf("lease expires %v, want %v", lp.boostLease.ExpiresAt, want) } - if lp.boostLease.MinBatterySoCPct != 30 || lp.boostLease.EVTargetSoCPct != 80 { + if lp.boostLease.MinBatterySoC != 0.30 || lp.boostLease.EVTargetSoC != 0.80 { t.Fatalf("lease = %+v", lp.boostLease) } } diff --git a/go/internal/appproto/plan.go b/go/internal/appproto/plan.go index b10ac64e5..c5271dce8 100644 --- a/go/internal/appproto/plan.go +++ b/go/internal/appproto/plan.go @@ -35,7 +35,7 @@ func planFrom(p *mpc.Plan, rev uint64, ceilingW *int64, uptimeMs int64, now time out.Stale = now.Sub(time.UnixMilli(p.GeneratedAtMs)) > mpc.MaxPlanAge mean := meanPriceOre(p.Actions) - minSoC := minSoCPct(p.Actions) + minSoC := minSoC(p.Actions) for _, a := range p.Actions { price := int64(math.Round(a.PriceOre)) @@ -71,13 +71,13 @@ func meanPriceOre(actions []mpc.Action) float64 { return sum / weight } -// minSoCPct is the lowest state of charge the plan intends to reach. +// minSoC is the lowest state of charge the plan intends to reach. // // The planner never crosses its own reserve floor, so the deepest point it // schedules is the floor it is defending. That makes it the honest reference // for "held back" without this package having to be told the floor separately // and then drift from it. -func minSoCPct(actions []mpc.Action) float64 { +func minSoC(actions []mpc.Action) float64 { min := math.Inf(1) for _, a := range actions { if a.SoC < min { @@ -95,7 +95,7 @@ func minSoCPct(actions []mpc.Action) float64 { // Derived from the numbers, not parsed from mpc's English. Parsing the prose // would break the first time somebody improved a sentence, and the point of a // stable code is that it survives exactly that. -func reasonCode(a mpc.Action, meanPriceOre, minSoCPct float64, ceilingW *int64) PlanReason { +func reasonCode(a mpc.Action, meanPriceOre, minSoC float64, ceilingW *int64) PlanReason { const idleGateW = mpc.IdleGateThresholdW const gridThreshW = 100.0 @@ -140,7 +140,7 @@ func reasonCode(a mpc.Action, meanPriceOre, minSoCPct float64, ceilingW *int64) // Idle at its deepest planned point during an expensive hour is // the planner refusing to spend the reserve, which is a decision // worth naming rather than calling nothing. - if priceAbove && a.SoC <= minSoCPct+0.005 { + if priceAbove && a.SoC <= minSoC+0.005 { return ReasonReserveHeld } if gridExports { diff --git a/go/internal/calendar/e2e_test.go b/go/internal/calendar/e2e_test.go index 82955ac0a..91e0a4922 100644 --- a/go/internal/calendar/e2e_test.go +++ b/go/internal/calendar/e2e_test.go @@ -51,13 +51,13 @@ func TestEndToEndRealCollaborators(t *testing.T) { // --- EV: the real loadpoint manager receives the target SoC + deadline, // which is exactly what the MPC loadpoint probe reads. --- dep := now.Add(3 * time.Hour) - s.apply(Intents{EV: []EVDeadline{{LoadpointID: "garage", TargetSoCPct: 80, Departure: dep}}}, now) + s.apply(Intents{EV: []EVDeadline{{LoadpointID: "garage", TargetSoC: 0.8, Departure: dep}}}, now) lpState, ok := lpMgr.State("garage") if !ok { t.Fatal("garage loadpoint not found") } - if lpState.TargetSoCPct != 80 { - t.Fatalf("loadpoint target SoC: want 80, got %v", lpState.TargetSoCPct) + if lpState.TargetSoC != 0.8 { + t.Fatalf("loadpoint target SoC: want 80, got %v", lpState.TargetSoC) } if !lpState.TargetTime.Equal(dep) { t.Fatalf("loadpoint target time: want %v, got %v", dep, lpState.TargetTime) diff --git a/go/internal/calendar/integration_test.go b/go/internal/calendar/integration_test.go index cad25d8a2..fcdf44c73 100644 --- a/go/internal/calendar/integration_test.go +++ b/go/internal/calendar/integration_test.go @@ -96,8 +96,8 @@ func TestCalDAVIntegration(t *testing.T) { if len(intents.EV) != 1 { t.Fatalf("expected 1 EV deadline, got %d (%+v)", len(intents.EV), intents.EV) } - if intents.EV[0].TargetSoCPct != 80 { - t.Fatalf("EV target: want 80, got %v", intents.EV[0].TargetSoCPct) + if intents.EV[0].TargetSoC != 0.8 { + t.Fatalf("EV target: want 80, got %v", intents.EV[0].TargetSoC) } t.Logf("inbound OK: away=%+v ev=%+v", intents.Away[0], intents.EV[0]) @@ -169,8 +169,8 @@ func TestCalDAVPlanPublish(t *testing.T) { // Plan v1: a charge window in the near future (two consecutive slots). s.SetPlanSource(func() []PlanSlot { return []PlanSlot{ - {Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), BatteryW: 4000, SoCPct: 60}, - {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), BatteryW: 4000, SoCPct: 80}, + {Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), BatteryW: 4000, SoC: 0.6}, + {Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), BatteryW: 4000, SoC: 0.8}, } }) s.publishPlan(context.Background()) @@ -183,7 +183,7 @@ func TestCalDAVPlanPublish(t *testing.T) { // reconcile must DELETE the stale charge event and PUT the discharge one. s.SetPlanSource(func() []PlanSlot { return []PlanSlot{ - {Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), BatteryW: -3000, SoCPct: 40}, + {Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), BatteryW: -3000, SoC: 0.4}, } }) s.publishPlan(context.Background()) diff --git a/go/internal/calendar/parse.go b/go/internal/calendar/parse.go index db7a25b18..6e249f0ba 100644 --- a/go/internal/calendar/parse.go +++ b/go/internal/calendar/parse.go @@ -21,14 +21,15 @@ func (iv Interval) Contains(t time.Time) bool { return !t.Before(iv.Start) && t.Before(iv.End) } -// EVDeadline is "loadpoint LoadpointID must reach TargetSoCPct% by Departure", -// derived from a calendar event whose start time is the departure. +// EVDeadline is "loadpoint LoadpointID must reach TargetSoC (0–1) by Departure", +// derived from a calendar event whose start time is the departure. Titles +// still write "80%"; that percent is converted here. type EVDeadline struct { - LoadpointID string `json:"loadpoint_id,omitempty"` - TargetSoCPct float64 `json:"target_soc_pct"` - Departure time.Time `json:"departure"` - UID string `json:"uid,omitempty"` - Title string `json:"title,omitempty"` + LoadpointID string `json:"loadpoint_id,omitempty"` + TargetSoC float64 `json:"target_soc"` + Departure time.Time `json:"departure"` + UID string `json:"uid,omitempty"` + Title string `json:"title,omitempty"` } // Intents is the parsed result of one calendar fetch. @@ -47,18 +48,18 @@ var lpRe = regexp.MustCompile(`(?i)\blp[:=]\s*([A-Za-z0-9_-]+)`) // config / network dependency so the title→intent rules are unit-testable in // isolation. Keywords are stored already lower-cased. type parser struct { - awayKeywords []string - evKeywords []string - defaultLoadpointID string - defaultTargetSoCPct float64 + awayKeywords []string + evKeywords []string + defaultLoadpointID string + defaultTargetSoC float64 } -func newParser(awayKeywords, evKeywords []string, defaultLoadpointID string, defaultTargetSoCPct float64) *parser { +func newParser(awayKeywords, evKeywords []string, defaultLoadpointID string, defaultTargetSoC float64) *parser { return &parser{ - awayKeywords: lowerAll(awayKeywords), - evKeywords: lowerAll(evKeywords), - defaultLoadpointID: defaultLoadpointID, - defaultTargetSoCPct: defaultTargetSoCPct, + awayKeywords: lowerAll(awayKeywords), + evKeywords: lowerAll(evKeywords), + defaultLoadpointID: defaultLoadpointID, + defaultTargetSoC: defaultTargetSoC, } } @@ -79,10 +80,10 @@ func (p *parser) classify(title string, start, end time.Time, uid string) (*Inte } if matchesAny(lt, p.evKeywords) { - soc := p.defaultTargetSoCPct + soc := p.defaultTargetSoC if m := pctRe.FindStringSubmatch(title); m != nil { if v, err := strconv.ParseFloat(m[1], 64); err == nil { - soc = clampPct(v) + soc = titlePercentToFraction(v) } } lp := p.defaultLoadpointID @@ -90,11 +91,11 @@ func (p *parser) classify(title string, start, end time.Time, uid string) (*Inte lp = m[1] } return nil, &EVDeadline{ - LoadpointID: lp, - TargetSoCPct: soc, - Departure: start, - UID: uid, - Title: title, + LoadpointID: lp, + TargetSoC: soc, + Departure: start, + UID: uid, + Title: title, } } @@ -131,12 +132,12 @@ func lowerAll(in []string) []string { return out } -func clampPct(v float64) float64 { +func titlePercentToFraction(v float64) float64 { if v < 0 { return 0 } if v > 100 { - return 100 + v = 100 } - return v + return v / 100.0 } diff --git a/go/internal/calendar/parse_test.go b/go/internal/calendar/parse_test.go index 397c5b7c7..70ee4bf36 100644 --- a/go/internal/calendar/parse_test.go +++ b/go/internal/calendar/parse_test.go @@ -10,7 +10,7 @@ func testParser() *parser { []string{"away", "vacation", "holiday"}, []string{"ev", "car", "charge"}, "garage", - 80, + 0.80, ) } @@ -58,8 +58,8 @@ func TestClassifyEVWithPercent(t *testing.T) { if ev == nil { t.Fatal("expected an EV deadline") } - if ev.TargetSoCPct != 65 { - t.Fatalf("target soc: want 65, got %v", ev.TargetSoCPct) + if ev.TargetSoC != 0.65 { + t.Fatalf("target soc: want 65, got %v", ev.TargetSoC) } if !ev.Departure.Equal(start) { t.Fatalf("departure should be the event start: got %v", ev.Departure) @@ -77,8 +77,8 @@ func TestClassifyEVDefaultTargetWhenNoPercent(t *testing.T) { if ev == nil { t.Fatal("expected an EV deadline") } - if ev.TargetSoCPct != 80 { - t.Fatalf("want default 80, got %v", ev.TargetSoCPct) + if ev.TargetSoC != 0.8 { + t.Fatalf("want default 80, got %v", ev.TargetSoC) } } @@ -93,8 +93,8 @@ func TestClassifyEVExplicitLoadpoint(t *testing.T) { if ev.LoadpointID != "carport" { t.Fatalf("explicit loadpoint not honoured: got %q", ev.LoadpointID) } - if ev.TargetSoCPct != 90 { - t.Fatalf("want 90, got %v", ev.TargetSoCPct) + if ev.TargetSoC != 0.9 { + t.Fatalf("want 90, got %v", ev.TargetSoC) } } @@ -102,7 +102,7 @@ func TestClassifyPercentClamped(t *testing.T) { p := testParser() start := time.Now() _, ev := p.classify("charge 150%", start, start.Add(time.Hour), "") - if ev == nil || ev.TargetSoCPct != 100 { + if ev == nil || ev.TargetSoC != 1 { t.Fatalf("percent should clamp to 100: %+v", ev) } } diff --git a/go/internal/calendar/plan.go b/go/internal/calendar/plan.go index 315213400..188049d8c 100644 --- a/go/internal/calendar/plan.go +++ b/go/internal/calendar/plan.go @@ -22,7 +22,7 @@ type PlanSlot struct { Start, End time.Time BatteryW float64 // site sign: + charging, - discharging GridW float64 // resulting grid power (+ import, - export) - SoCPct float64 // SoC at END of slot + SoC float64 // SoC at END of slot (0–1) Confidence float64 } @@ -68,7 +68,7 @@ func buildPlanBlocks(slots []PlanSlot, now time.Time) []planBlock { sum += s.BatteryW } avgKW := math.Abs(sum/float64(len(run))) / 1000 - endSoC := run[len(run)-1].SoCPct + endSoC := run[len(run)-1].SoC * 100 // calendar title door: percent var verb, short string if cat > 0 { verb, short = "Charge battery", "chg" diff --git a/go/internal/calendar/plan_reconcile_test.go b/go/internal/calendar/plan_reconcile_test.go index a62e73d29..361012aee 100644 --- a/go/internal/calendar/plan_reconcile_test.go +++ b/go/internal/calendar/plan_reconcile_test.go @@ -100,7 +100,7 @@ func TestPlanReconcileReclaimsOrphansOnRestart(t *testing.T) { // Fresh service (empty planWritten, planSeeded=false) models the restart. s := planService(t, srv.URL) s.SetPlanSource(func() []PlanSlot { - return []PlanSlot{{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour), BatteryW: 4000, SoCPct: 70}} + return []PlanSlot{{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour), BatteryW: 4000, SoC: 0.7}} }) s.publishPlan(context.Background()) diff --git a/go/internal/calendar/plan_test.go b/go/internal/calendar/plan_test.go index 54805f4fe..e1d7b954c 100644 --- a/go/internal/calendar/plan_test.go +++ b/go/internal/calendar/plan_test.go @@ -8,17 +8,17 @@ import ( func planSlot(now time.Time, offMin, lenMin int, batW, soc float64) PlanSlot { s := now.Add(time.Duration(offMin) * time.Minute) - return PlanSlot{Start: s, End: s.Add(time.Duration(lenMin) * time.Minute), BatteryW: batW, SoCPct: soc} + return PlanSlot{Start: s, End: s.Add(time.Duration(lenMin) * time.Minute), BatteryW: batW, SoC: soc} } func TestBuildPlanBlocksCoalesces(t *testing.T) { now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) slots := []PlanSlot{ - planSlot(now, 0, 15, 3000, 55), // charge - planSlot(now, 15, 15, 3200, 60), // charge (same run) - planSlot(now, 30, 15, 50, 60), // hold → breaks the run, not published - planSlot(now, 45, 15, -2000, 50), // discharge - planSlot(now, 60, 15, -2100, 45), // discharge (same run) + planSlot(now, 0, 15, 3000, 0.55), // charge + planSlot(now, 15, 15, 3200, 0.60), // charge (same run) + planSlot(now, 30, 15, 50, 0.60), // hold → breaks the run, not published + planSlot(now, 45, 15, -2000, 0.50), // discharge + planSlot(now, 60, 15, -2100, 0.45), // discharge (same run) } blocks := buildPlanBlocks(slots, now) if len(blocks) != 2 { @@ -41,8 +41,8 @@ func TestBuildPlanBlocksCoalesces(t *testing.T) { func TestBuildPlanBlocksSkipsPast(t *testing.T) { now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) slots := []PlanSlot{ - planSlot(now, -120, 60, 3000, 50), // ended an hour ago → skipped - planSlot(now, -60, 60, 3000, 60), // ended exactly at now → skipped (End not after now) + planSlot(now, -120, 60, 3000, 0.50), // ended an hour ago → skipped + planSlot(now, -60, 60, 3000, 0.60), // ended exactly at now → skipped (End not after now) } if blocks := buildPlanBlocks(slots, now); len(blocks) != 0 { t.Fatalf("past blocks should be skipped, got %d: %+v", len(blocks), blocks) @@ -51,7 +51,7 @@ func TestBuildPlanBlocksSkipsPast(t *testing.T) { func TestBuildPlanBlocksSpanningNowIncluded(t *testing.T) { now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) - slots := []PlanSlot{planSlot(now, -30, 120, 4000, 70)} // started 30m ago, ends in 90m + slots := []PlanSlot{planSlot(now, -30, 120, 4000, 0.70)} // started 30m ago, ends in 90m blocks := buildPlanBlocks(slots, now) if len(blocks) != 1 { t.Fatalf("block spanning now should be included, got %d", len(blocks)) @@ -63,7 +63,7 @@ func TestBuildPlanBlocksSpanningNowIncluded(t *testing.T) { func TestPlanBlockHashStableAndUID(t *testing.T) { now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) - slots := []PlanSlot{planSlot(now, 60, 60, 5000, 80)} + slots := []PlanSlot{planSlot(now, 60, 60, 5000, 0.80)} a := buildPlanBlocks(slots, now) b := buildPlanBlocks(slots, now) if a[0].uid != b[0].uid { diff --git a/go/internal/calendar/service.go b/go/internal/calendar/service.go index 26316a27f..c0a578858 100644 --- a/go/internal/calendar/service.go +++ b/go/internal/calendar/service.go @@ -15,7 +15,6 @@ import ( "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/loadmodel" - "github.com/srcfl/ftw/go/internal/units" ) // LoadProfiler is the slice of *loadmodel.Service the calendar service needs: @@ -172,9 +171,9 @@ func (s *Service) applyConfig(cfg config.CalDAV, firstLoadpointID string) { if horizonDays <= 0 { horizonDays = config.DefaultCalDAVHorizonDays } - targetSoC := units.PercentFromFraction(cfg.EVDefaultTargetSoC) + targetSoC := cfg.EVDefaultTargetSoC if targetSoC <= 0 { - targetSoC = units.PercentFromFraction(config.DefaultCalDAVEVTargetSoC) + targetSoC = config.DefaultCalDAVEVTargetSoC } away := cfg.AwayKeywords if len(away) == 0 { @@ -506,7 +505,7 @@ func (s *Service) apply(intents Intents, now time.Time) { if next != nil && s.lp != nil { if next.LoadpointID == "" { slog.Warn("caldav: EV event has no loadpoint to target; ignoring", "title", next.Title) - } else if s.lp.SetTarget(next.LoadpointID, next.TargetSoCPct, next.Departure) { + } else if s.lp.SetTarget(next.LoadpointID, next.TargetSoC, next.Departure) { s.mu.Lock() prev := s.lastEV s.lastEV = next @@ -514,7 +513,7 @@ func (s *Service) apply(intents Intents, now time.Time) { if prev == nil || *prev != *next { slog.Info("caldav: EV target set from calendar", "loadpoint", next.LoadpointID, - "target_soc_pct", next.TargetSoCPct, + "target_soc", next.TargetSoC, "departure", next.Departure) } } diff --git a/go/internal/calendar/service_test.go b/go/internal/calendar/service_test.go index 4aad025d1..57064ede2 100644 --- a/go/internal/calendar/service_test.go +++ b/go/internal/calendar/service_test.go @@ -103,12 +103,12 @@ func TestApplyEVTargetSet(t *testing.T) { now := time.Date(2026, 7, 1, 6, 0, 0, 0, time.UTC) dep := now.Add(2 * time.Hour) - s.apply(Intents{EV: []EVDeadline{{LoadpointID: "garage", TargetSoCPct: 80, Departure: dep}}}, now) + s.apply(Intents{EV: []EVDeadline{{LoadpointID: "garage", TargetSoC: 0.8, Departure: dep}}}, now) if lp.called != 1 { t.Fatalf("expected SetTarget once, got %d", lp.called) } - if lp.id != "garage" || lp.soc != 80 || !lp.when.Equal(dep) { + if lp.id != "garage" || lp.soc != 0.8 || !lp.when.Equal(dep) { t.Fatalf("SetTarget args wrong: id=%q soc=%v when=%v", lp.id, lp.soc, lp.when) } } @@ -119,12 +119,12 @@ func TestApplyEVPicksEarliestUpcoming(t *testing.T) { now := time.Date(2026, 7, 1, 6, 0, 0, 0, time.UTC) s.apply(Intents{EV: []EVDeadline{ - {LoadpointID: "garage", TargetSoCPct: 70, Departure: now.Add(-time.Hour)}, // past — ignored - {LoadpointID: "garage", TargetSoCPct: 90, Departure: now.Add(5 * time.Hour)}, - {LoadpointID: "garage", TargetSoCPct: 60, Departure: now.Add(1 * time.Hour)}, // earliest upcoming + {LoadpointID: "garage", TargetSoC: 0.7, Departure: now.Add(-time.Hour)}, // past — ignored + {LoadpointID: "garage", TargetSoC: 0.9, Departure: now.Add(5 * time.Hour)}, + {LoadpointID: "garage", TargetSoC: 0.6, Departure: now.Add(1 * time.Hour)}, // earliest upcoming }}, now) - if lp.soc != 60 { + if lp.soc != 0.6 { t.Fatalf("expected earliest upcoming deadline (soc 60), got %v", lp.soc) } } @@ -134,7 +134,7 @@ func TestApplyEVMissingLoadpointIgnored(t *testing.T) { // firstLoadpointID empty → no default → EV event with no id is unactionable. s := New(config.CalDAV{Enabled: true}, lp, &fakeLM{}, "") now := time.Now() - s.apply(Intents{EV: []EVDeadline{{TargetSoCPct: 80, Departure: now.Add(time.Hour)}}}, now) + s.apply(Intents{EV: []EVDeadline{{TargetSoC: 0.8, Departure: now.Add(time.Hour)}}}, now) if lp.called != 0 { t.Fatalf("EV event without a loadpoint must not call SetTarget") } diff --git a/go/internal/config/config.go b/go/internal/config/config.go index ed93c4c24..7c126d6f0 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -1111,7 +1111,8 @@ type Weather struct { // PVArray is one physically-distinct panel group. Multi-plane // residential installs typically have two or three (e.g. south roof // + east roof + garage) with different tilt/azimuth. The sum of all -// KWp values should match the total PV nameplate at the site. +// rated_w values should match the total PV nameplate at the site. +// kwp is a legacy YAML key folded into rated_w on load. type PVArray struct { Name string `yaml:"name,omitempty" json:"name,omitempty"` RatedW float64 `yaml:"rated_w,omitempty" json:"rated_w,omitempty"` diff --git a/go/internal/config/units.go b/go/internal/config/units.go index 97cd5aa00..783209b5e 100644 --- a/go/internal/config/units.go +++ b/go/internal/config/units.go @@ -42,17 +42,7 @@ func (c *Config) NormalizeUnits() { } func pickFraction(canonical, legacyPercent float64) float64 { - v := canonical - if v == 0 && legacyPercent != 0 { - v = legacyPercent - } - if v > 1 { - return units.FractionFromLegacyPercent(v) - } - if v < 0 { - return 0 - } - return v + return units.DecodeJSONFraction(canonical, legacyPercent) } func (a *PVArray) normalizeRatedW() { diff --git a/go/internal/drivers/host.go b/go/internal/drivers/host.go index 3daebb793..c0c68f2c0 100644 --- a/go/internal/drivers/host.go +++ b/go/internal/drivers/host.go @@ -12,6 +12,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/internal/units" ) // ErrNoCapability is returned by host functions the driver wasn't granted. @@ -644,6 +645,13 @@ func (h *HostEnv) emitTelemetry(rawJSON []byte) error { soc = env.VehicleSoCFract } } + // Driver door: Tesla battery_level and similar vendor percents arrive + // as 0–100 on DerVehicle. Battery and V2X already emit 0–1 and are + // rejected if they do not. Convert only vehicles, before ValidateReading. + if t == telemetry.DerVehicle && soc != nil { + f := units.FractionFromLegacyPercent(*soc) + soc = &f + } if err := telemetry.ValidateReading(t, rawW, soc); err != nil { return fmt.Errorf("emit_telemetry: %w", err) } diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go index 5d02cdef7..c44b9710a 100644 --- a/go/internal/drivers/lua.go +++ b/go/internal/drivers/lua.go @@ -685,8 +685,10 @@ func registerHost(L *lua.LState, env *HostEnv) { // ev_target_energy_req_wh, ev_min_energy_req_wh, // ev_max_energy_req_wh, rated_power_w, status, protocol, // control_mode - // vehicle -> soc (required, vehicle battery level % 0-100), - // charge_limit_pct (optional, vehicle-configured limit), + // vehicle -> soc (required, 0–1 fraction; 0–100 vendor percents + // are converted at this door), + // charge_limit / charge_limit_pct (optional; 0–1 or + // legacy 0–100, converted at PickBestVehicle), // charging_state (optional, e.g. "Charging"|"Stopped"|"Complete"), // time_to_full_min (optional), // stale (optional bool, true when data hasn't refreshed diff --git a/go/internal/drivers/lua_test.go b/go/internal/drivers/lua_test.go index 18c1a520b..a8f328ece 100644 --- a/go/internal/drivers/lua_test.go +++ b/go/internal/drivers/lua_test.go @@ -452,8 +452,8 @@ function driver_poll() if got := tel.Get("boundary", telemetry.DerPV); got == nil || got.RawW != -500 { t.Fatalf("valid negative pv reading should pass, got %+v", got) } - if got := tel.Get("boundary", telemetry.DerVehicle); got == nil || got.SoC == nil || *got.SoC != 55 { - t.Fatalf("vehicle percent soc should pass, got %+v", got) + if got := tel.Get("boundary", telemetry.DerVehicle); got == nil || got.SoC == nil || *got.SoC != 0.55 { + t.Fatalf("vehicle percent soc should convert to 0.55, got %+v", got) } if got := tel.Get("boundary", telemetry.DerV2X); got == nil || got.RawW != -2500 || got.SoC == nil || *got.SoC != 0.44 { t.Fatalf("v2x signed reading should pass and expose vehicle_soc, got %+v", got) diff --git a/go/internal/drivers/lua_vehicle_soc_freshness_test.go b/go/internal/drivers/lua_vehicle_soc_freshness_test.go index fe100bece..058368015 100644 --- a/go/internal/drivers/lua_vehicle_soc_freshness_test.go +++ b/go/internal/drivers/lua_vehicle_soc_freshness_test.go @@ -50,7 +50,7 @@ end t.Fatalf("fresh poll: %v", err) } first := store.Get("vehicle-cache", telemetry.DerVehicle) - if first == nil || first.SoC == nil || *first.SoC != 61 || first.SoCUpdatedAt.IsZero() { + if first == nil || first.SoC == nil || *first.SoC != 0.61 || first.SoCUpdatedAt.IsZero() { t.Fatalf("fresh vehicle reading = %+v", first) } firstSoCUpdatedAt := first.SoCUpdatedAt @@ -62,7 +62,7 @@ end t.Fatalf("cached poll: %v", err) } cached := store.Get("vehicle-cache", telemetry.DerVehicle) - if cached == nil || cached.SoC == nil || *cached.SoC != 61 { + if cached == nil || cached.SoC == nil || *cached.SoC != 0.61 { t.Fatalf("cached vehicle reading = %+v", cached) } if !cached.SoCUpdatedAt.Equal(firstSoCUpdatedAt) { diff --git a/go/internal/forecast/forecast.go b/go/internal/forecast/forecast.go index 58be0ee31..acefbc773 100644 --- a/go/internal/forecast/forecast.go +++ b/go/internal/forecast/forecast.go @@ -445,10 +445,9 @@ func (s *Service) nameplateW() float64 { return NameplateW(s.RatedPVW, s.Arrays) } -// NameplateW is the site PV ceiling used to clamp wild forecasts. -// Prefer the sanitized array sum; if that is >10× the explicit -// pv_rated_w, the arrays still look like watts and we keep the -// smaller explicit rating. +// NameplateW is the site PV ceiling. Arrays store rated watts; the +// sum of those is the nameplate. pv_rated_w is the fallback when no +// complete array geometry exists. func NameplateW(ratedPVW float64, arrays []Array) float64 { var sumW float64 for _, a := range arrays { @@ -467,9 +466,9 @@ func NameplateW(ratedPVW float64, arrays []Array) float64 { } // nameplateHeadroom is 1: a forecast must not exceed the site -// nameplate. STC/POA can theoretically overshoot a little on a cold -// clear day; that is sacrificed so a kWp-as-watts paste cannot plan -// on megawatts. 0 nameplate disables the cut. +// nameplate. This is a physics gate, not a unit conversion. Core +// stores rated watts so the gate should rarely fire. 0 nameplate +// disables the cut. const nameplateHeadroom = 1.0 func clampPVToNameplate(pvW, nameplateW float64) (float64, bool) { @@ -525,9 +524,8 @@ func poaPVWattsFromGHI(lat, lon float64, t time.Time, ghiWm2 float64, arrays []A return total } -// Load returns forecasts in [sinceMs, untilMs]. Estimates stored -// before kWp-as-watts was sanitized are clamped to nameplate so a -// 3-hour-old 10 MW row cannot keep driving the plan chart. +// Load returns forecasts in [sinceMs, untilMs]. A last-resort +// nameplate gate still clips a stored row that exceeds the roof. func (s *Service) Load(sinceMs, untilMs int64) ([]state.ForecastPoint, error) { rows, err := s.Store.LoadForecasts(sinceMs, untilMs) if err != nil { @@ -537,9 +535,7 @@ func (s *Service) Load(sinceMs, untilMs int64) ([]state.ForecastPoint, error) { } // ClampForecasts copies any estimate above nameplate down onto that -// ceiling. The planner and /api/forecast both read through this so a -// row saved before kWp-as-watts was sanitized cannot keep showing -// 2046 kW on an 18.96 kW roof. +// ceiling. Physics gate only: stored units are already watts. func ClampForecasts(rows []state.ForecastPoint, nameplateW float64) []state.ForecastPoint { if nameplateW <= 0 { return rows diff --git a/go/internal/loadpoint/battery_boost.go b/go/internal/loadpoint/battery_boost.go index 487e646cc..10a274df8 100644 --- a/go/internal/loadpoint/battery_boost.go +++ b/go/internal/loadpoint/battery_boost.go @@ -1,8 +1,11 @@ package loadpoint import ( + "encoding/json" "errors" "time" + + "github.com/srcfl/ftw/go/internal/units" ) // MaxBatteryBoostDuration is deliberately short: a forgotten permission can @@ -37,25 +40,47 @@ const ( // an absolute expiry; StartedAt plus the duration cap prevents a hand-edited or // old row from reviving a permission indefinitely after restart. type BatteryBoostLease struct { - StartedAt time.Time `json:"started_at"` - ExpiresAt time.Time `json:"expires_at"` - MinBatterySoCPct float64 `json:"min_battery_soc_pct"` - EVTargetSoCPct float64 `json:"ev_target_soc_pct,omitempty"` - DepartureAt time.Time `json:"departure_at,omitempty"` + StartedAt time.Time `json:"started_at"` + ExpiresAt time.Time `json:"expires_at"` + MinBatterySoC float64 `json:"min_battery_soc"` + EVTargetSoC float64 `json:"ev_target_soc,omitempty"` + DepartureAt time.Time `json:"departure_at,omitempty"` +} + +func (l *BatteryBoostLease) UnmarshalJSON(b []byte) error { + type alias BatteryBoostLease + aux := struct { + alias + MinBatterySoCPct *float64 `json:"min_battery_soc_pct"` + EVTargetSoCPct *float64 `json:"ev_target_soc_pct"` + }{} + if err := json.Unmarshal(b, &aux); err != nil { + return err + } + *l = BatteryBoostLease(aux.alias) + if l.MinBatterySoC == 0 && aux.MinBatterySoCPct != nil { + l.MinBatterySoC = *aux.MinBatterySoCPct + } + if l.EVTargetSoC == 0 && aux.EVTargetSoCPct != nil { + l.EVTargetSoC = *aux.EVTargetSoCPct + } + l.MinBatterySoC = units.FractionFromLegacyPercent(l.MinBatterySoC) + l.EVTargetSoC = units.FractionFromLegacyPercent(l.EVTargetSoC) + return nil } // BatteryBoostStatus is returned by the dedicated status endpoint and embedded // in GET /api/loadpoints. State is one of inactive, active, or stopped. type BatteryBoostStatus struct { - State string `json:"state"` - Active bool `json:"active"` - StartedAtMs int64 `json:"started_at_ms,omitempty"` - ExpiresAtMs int64 `json:"expires_at_ms,omitempty"` - MinBatterySoCPct float64 `json:"min_battery_soc_pct,omitempty"` - EVTargetSoCPct float64 `json:"ev_target_soc_pct,omitempty"` - DepartureAtMs int64 `json:"departure_at_ms,omitempty"` - StopReason BatteryBoostStopReason `json:"stop_reason,omitempty"` - StoppedAtMs int64 `json:"stopped_at_ms,omitempty"` + State string `json:"state"` + Active bool `json:"active"` + StartedAtMs int64 `json:"started_at_ms,omitempty"` + ExpiresAtMs int64 `json:"expires_at_ms,omitempty"` + MinBatterySoC float64 `json:"min_battery_soc,omitempty"` + EVTargetSoC float64 `json:"ev_target_soc,omitempty"` + DepartureAtMs int64 `json:"departure_at_ms,omitempty"` + StopReason BatteryBoostStopReason `json:"stop_reason,omitempty"` + StoppedAtMs int64 `json:"stopped_at_ms,omitempty"` } // BatteryBoostSafetyFunc is wired by core and returns a stop reason whenever @@ -85,11 +110,11 @@ func ValidateBatteryBoostLease(lease BatteryBoostLease, now time.Time) error { if lease.ExpiresAt.Sub(now) > MaxBatteryBoostDuration { return errors.New("lease remaining duration exceeds four hours") } - if lease.MinBatterySoCPct < 5 || lease.MinBatterySoCPct > 100 { - return errors.New("min_battery_soc_pct must be 5..100") + if lease.MinBatterySoC < 0.05 || lease.MinBatterySoC > 1 { + return errors.New("min_battery_soc must be 0.05..1") } - if lease.EVTargetSoCPct < 0 || lease.EVTargetSoCPct > 100 { - return errors.New("ev_target_soc_pct must be 0..100") + if lease.EVTargetSoC < 0 || lease.EVTargetSoC > 1 { + return errors.New("ev_target_soc must be 0..1") } if !lease.DepartureAt.IsZero() { if !lease.DepartureAt.After(now) { @@ -104,12 +129,12 @@ func ValidateBatteryBoostLease(lease BatteryBoostLease, now time.Time) error { func batteryBoostStatusFromLease(lease BatteryBoostLease) BatteryBoostStatus { status := BatteryBoostStatus{ - State: "active", - Active: true, - StartedAtMs: lease.StartedAt.UnixMilli(), - ExpiresAtMs: lease.ExpiresAt.UnixMilli(), - MinBatterySoCPct: lease.MinBatterySoCPct, - EVTargetSoCPct: lease.EVTargetSoCPct, + State: "active", + Active: true, + StartedAtMs: lease.StartedAt.UnixMilli(), + ExpiresAtMs: lease.ExpiresAt.UnixMilli(), + MinBatterySoC: lease.MinBatterySoC, + EVTargetSoC: lease.EVTargetSoC, } if !lease.DepartureAt.IsZero() { status.DepartureAtMs = lease.DepartureAt.UnixMilli() @@ -332,7 +357,7 @@ func (c *Controller) evaluateBatteryBoost(id string, now time.Time, connected, d c.stopBatteryBoost(id, BatteryBoostStoppedSurplusOnly, now) return } - if lease.EVTargetSoCPct > 0 && st.CurrentSoCPct >= lease.EVTargetSoCPct { + if lease.EVTargetSoC > 0 && st.CurrentSoC >= lease.EVTargetSoC { c.stopBatteryBoost(id, BatteryBoostStoppedEVTargetReached, now) return } @@ -363,8 +388,8 @@ func (c *Controller) ActiveBatteryBoostTotals(states []State, now time.Time) (po if st.CurrentPowerW > 0 { powerW += st.CurrentPowerW } - if lease.MinBatterySoCPct/100 > reserveSoC { - reserveSoC = lease.MinBatterySoCPct / 100 + if lease.MinBatterySoC > reserveSoC { + reserveSoC = lease.MinBatterySoC } } return powerW, reserveSoC diff --git a/go/internal/loadpoint/battery_boost_test.go b/go/internal/loadpoint/battery_boost_test.go index c9ac6157a..1fc6085bd 100644 --- a/go/internal/loadpoint/battery_boost_test.go +++ b/go/internal/loadpoint/battery_boost_test.go @@ -10,7 +10,7 @@ func newBatteryBoostController(t *testing.T, surplusOnly bool) (*Controller, *Ma t.Helper() mgr := NewManager() mgr.Load([]Config{{ - ID: "garage", DriverName: "charger", PluginSoCPct: 40, + ID: "garage", DriverName: "charger", PluginSoC: 0.4, MinChargeW: 1380, MaxChargeW: 11000, SurplusOnly: surplusOnly, }}) sample := &EVSample{Connected: true, RequestActive: true} @@ -28,7 +28,7 @@ func newBatteryBoostController(t *testing.T, surplusOnly bool) (*Controller, *Ma func validBatteryBoost(now time.Time) BatteryBoostLease { return BatteryBoostLease{ - StartedAt: now, ExpiresAt: now.Add(time.Hour), MinBatterySoCPct: 30, + StartedAt: now, ExpiresAt: now.Add(time.Hour), MinBatterySoC: 0.3, } } @@ -50,7 +50,7 @@ func TestBatteryBoostEnableStatusCancelAndPersistence(t *testing.T) { if err != nil { t.Fatalf("enable: %v", err) } - if !status.Active || status.State != "active" || status.MinBatterySoCPct != 30 { + if !status.Active || status.State != "active" || status.MinBatterySoC != 0.3 { t.Fatalf("active status = %+v", status) } if len(saves) != 1 || saves[0].cleared { @@ -77,10 +77,10 @@ func TestBatteryBoostValidationAndOperatorClamps(t *testing.T) { surplusOnly bool hold bool }{ - {"short duration", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(30 * time.Second), MinBatterySoCPct: 30}, false, false}, - {"long duration", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(MaxBatteryBoostDuration + time.Second), MinBatterySoCPct: 30}, false, false}, - {"low reserve", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(time.Hour), MinBatterySoCPct: 4}, false, false}, - {"departure after expiry", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(time.Hour), DepartureAt: now.Add(2 * time.Hour), MinBatterySoCPct: 30}, false, false}, + {"short duration", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(30 * time.Second), MinBatterySoC: 0.3}, false, false}, + {"long duration", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(MaxBatteryBoostDuration + time.Second), MinBatterySoC: 0.3}, false, false}, + {"low reserve", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(time.Hour), MinBatterySoC: 0.04}, false, false}, + {"departure after expiry", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(time.Hour), DepartureAt: now.Add(2 * time.Hour), MinBatterySoC: 0.3}, false, false}, {"surplus only", validBatteryBoost(now), true, false}, {"manual hold", validBatteryBoost(now), false, true}, } @@ -152,7 +152,7 @@ func TestBatteryBoostSafetyAndEVTargetStop(t *testing.T) { ctrl.SetBatteryBoostSafety(nil) lease := validBatteryBoost(now) - lease.EVTargetSoCPct = 40 + lease.EVTargetSoC = 0.40 if _, err := ctrl.EnableBatteryBoost("garage", lease, now); err != nil { t.Fatal(err) } @@ -168,8 +168,8 @@ func TestBatteryBoostRestoreRejectsUnboundedOrExpiredRows(t *testing.T) { ctrl, _, _ := newBatteryBoostController(t, false) now := time.Now() for _, lease := range []BatteryBoostLease{ - {StartedAt: now.Add(-5 * time.Hour), ExpiresAt: now.Add(time.Hour), MinBatterySoCPct: 30}, - {StartedAt: now.Add(-time.Hour), ExpiresAt: now.Add(-time.Second), MinBatterySoCPct: 30}, + {StartedAt: now.Add(-5 * time.Hour), ExpiresAt: now.Add(time.Hour), MinBatterySoC: 0.3}, + {StartedAt: now.Add(-time.Hour), ExpiresAt: now.Add(-time.Second), MinBatterySoC: 0.3}, } { if ctrl.RestoreBatteryBoost("garage", lease, now) { t.Fatalf("restored unsafe lease %+v", lease) @@ -247,9 +247,9 @@ func TestActiveBatteryBoostTotalsArePerLoadpointAndUseStrictestReserve(t *testin } ctrl := NewController(mgr, nil, nil, nil) now := time.Now() - for id, reserve := range map[string]float64{"a": 20, "b": 35} { + for id, reserve := range map[string]float64{"a": 0.20, "b": 0.35} { lease := validBatteryBoost(now) - lease.MinBatterySoCPct = reserve + lease.MinBatterySoC = reserve if _, err := ctrl.EnableBatteryBoost(id, lease, now); err != nil { t.Fatal(err) } diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index ace08e202..cfa3319a8 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -861,7 +861,7 @@ func (c *Controller) SetBatSoCProvider(f func() (float64, bool)) { // threshold AND there's live PV to grab (battery discharge alone is // not surplus — that's just self-consumption or arbitrage the planner // is already orchestrating). Releases when SoC drops below -// (threshold − BatSoCUnlockHystPp), or after a sustained run of +// (threshold − BatSoCUnlockHyst), or after a sustained run of // zero/negative live surplus (batSoCPVGoneTicks). // // Returns false when no threshold is configured or the bat_soc reader @@ -902,12 +902,11 @@ func (c *Controller) evalBatSoCArm(lpID string, threshold float64) bool { } else { c.batSoCNoPV[lpID] = 0 } - socPct := soc * 100 armed := prev switch { - case socPct < threshold-BatSoCUnlockHystPp: + case soc < threshold-BatSoCUnlockHyst: armed = false - case socPct >= threshold && !pvGone: + case soc >= threshold && !pvGone: armed = true case c.batSoCNoPV[lpID] >= batSoCPVGoneTicks: // SoC may still be high but PV has been gone long enough that @@ -975,13 +974,13 @@ func (c *Controller) surplusActive(lpCfg Config, sched Schedule) bool { // available surplus and the deadline is missed. The explicit SurplusOnly // config above still wins, so a "surplus-preferred with a deadline floor" // combo is unaffected. Operator directive 2026-05-30. - if sched.SoCPct > 0 { + if sched.SoC > 0 { return false } if c.gridDeferredFor(lpCfg.ID) { return true } - return c.evalBatSoCArm(lpCfg.ID, sched.SurplusUnlockBatSoCPct) + return c.evalBatSoCArm(lpCfg.ID, sched.SurplusUnlockBatSoC) } // AnyLoadpointSurplusActive reports whether any configured loadpoint @@ -1005,7 +1004,7 @@ func (c *Controller) AnyLoadpointSurplusActive() bool { return true } sched, _ := c.manager.GetSchedule(cfg.ID) - if sched.SurplusUnlockBatSoCPct > 0 { + if sched.SurplusUnlockBatSoC > 0 { c.batSoCArmedMu.Lock() armed := c.batSoCArmed[cfg.ID] c.batSoCArmedMu.Unlock() @@ -1683,7 +1682,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d // rationale in one testable place. Operator directive 2026-05-30. phaseMode := resolvePhaseMode( lpCfg.PhaseMode, - sched.SoCPct > 0, + sched.SoC > 0, c.surplusLockedTo1P(lpCfg.ID), surplusOn, c.dwellSelectedPhaseMode(lpCfg.ID), diff --git a/go/internal/loadpoint/controller_bat_soc_unlock_test.go b/go/internal/loadpoint/controller_bat_soc_unlock_test.go index 374b1fe3e..ed89cdda5 100644 --- a/go/internal/loadpoint/controller_bat_soc_unlock_test.go +++ b/go/internal/loadpoint/controller_bat_soc_unlock_test.go @@ -29,17 +29,17 @@ func TestEvalBatSoCArm_RequiresLivePV(t *testing.T) { soc := 0.85 surplus := 1500.0 // PV producing c := armCtrl(&soc, &surplus) - if !c.evalBatSoCArm("garage", 80) { + if !c.evalBatSoCArm("garage", 0.8) { t.Fatal("should arm: bat 85% >= threshold AND PV > 0") } // PV gone — staying armed for a few ticks (hysteresis), then released. surplus = -100 for i := 0; i < batSoCPVGoneTicks-1; i++ { - if !c.evalBatSoCArm("garage", 80) { + if !c.evalBatSoCArm("garage", 0.8) { t.Errorf("tick %d: should still be armed during PV-gone hysteresis", i) } } - if c.evalBatSoCArm("garage", 80) { + if c.evalBatSoCArm("garage", 0.8) { t.Error("after PV-gone hysteresis ticks expired, should release") } } @@ -48,16 +48,16 @@ func TestEvalBatSoCArm_PVReturnRearms(t *testing.T) { soc := 0.85 surplus := 1500.0 c := armCtrl(&soc, &surplus) - c.evalBatSoCArm("garage", 80) // arm + c.evalBatSoCArm("garage", 0.8) // arm // Brief PV dip: 3 ticks no PV (below batSoCPVGoneTicks threshold) surplus = 0 for i := 0; i < 3; i++ { - c.evalBatSoCArm("garage", 80) + c.evalBatSoCArm("garage", 0.8) } // PV comes back before hysteresis expires surplus = 1500 - if !c.evalBatSoCArm("garage", 80) { + if !c.evalBatSoCArm("garage", 0.8) { t.Error("brief PV dip must not disarm before hysteresis ticks expire") } } @@ -66,9 +66,9 @@ func TestEvalBatSoCArm_SoCBelowReleaseDisarms(t *testing.T) { soc := 0.85 surplus := 1500.0 c := armCtrl(&soc, &surplus) - c.evalBatSoCArm("garage", 80) // arm - soc = 0.74 // below threshold-hyst (80-5=75) → release - if c.evalBatSoCArm("garage", 80) { + c.evalBatSoCArm("garage", 0.8) // arm + soc = 0.74 // below threshold-hyst (80-5=75) → release + if c.evalBatSoCArm("garage", 0.8) { t.Error("soc 74% < 75% release floor must disarm regardless of PV") } } @@ -77,10 +77,10 @@ func TestEvalBatSoCArm_StalePreservesState(t *testing.T) { soc := 0.85 surplus := 1500.0 c := armCtrl(&soc, &surplus) - c.evalBatSoCArm("garage", 80) // arm + c.evalBatSoCArm("garage", 0.8) // arm // Stale bat_soc — must preserve. c.SetBatSoCProvider(func() (float64, bool) { return 0, false }) - if !c.evalBatSoCArm("garage", 80) { + if !c.evalBatSoCArm("garage", 0.8) { t.Error("stale bat_soc reading must preserve previous arm state") } } @@ -97,7 +97,7 @@ func TestEvalBatSoCArm_ZeroThresholdDisables(t *testing.T) { func TestSurplusActive_NilProviderGracefullyOff(t *testing.T) { c := NewController(NewManager(), nil, nil, nil) cfg := Config{ID: "garage", SurplusOnly: false} - sched := Schedule{SurplusUnlockBatSoCPct: 80} + sched := Schedule{SurplusUnlockBatSoC: 0.8} if c.surplusActive(cfg, sched) { t.Error("nil bat-soc provider: surplusActive should be false when SurplusOnly is off") } @@ -166,20 +166,20 @@ func TestAnyLoadpointSurplusActive(t *testing.T) { } // Schedule with bat-SoC unlock, but not yet evaluated (no Tick) → false - m.SetSchedule("garage", Schedule{SurplusUnlockBatSoCPct: 80}) + m.SetSchedule("garage", Schedule{SurplusUnlockBatSoC: 0.8}) if c.AnyLoadpointSurplusActive() { t.Error("schedule alone (without evalBatSoCArm being called) must not flip true") } // Evaluate arm — bat 85% with PV > 0 should arm; then aggregator true. - c.evalBatSoCArm("garage", 80) + c.evalBatSoCArm("garage", 0.8) if !c.AnyLoadpointSurplusActive() { t.Error("after arm via evalBatSoCArm, aggregator must report true") } // Disarm via SoC drop → aggregator back to false. soc = 0.50 - c.evalBatSoCArm("garage", 80) + c.evalBatSoCArm("garage", 0.8) if c.AnyLoadpointSurplusActive() { t.Error("after disarm, aggregator must report false") } diff --git a/go/internal/loadpoint/controller_dispatch_safety_test.go b/go/internal/loadpoint/controller_dispatch_safety_test.go index 91c176721..f18417e7d 100644 --- a/go/internal/loadpoint/controller_dispatch_safety_test.go +++ b/go/internal/loadpoint/controller_dispatch_safety_test.go @@ -30,7 +30,7 @@ func TestStaleSiteMeterStopsScheduledChargeAndRecovers(t *testing.T) { } c := newTestController(t, []Config{cfg}, directive, samples, sender) if !c.manager.SetSchedule(cfg.ID, Schedule{ - SoCPct: 80, + SoC: 0.8, TimeOfDayMinUTC: 13 * 60, Recurring: true, }) { @@ -43,7 +43,7 @@ func TestStaleSiteMeterStopsScheduledChargeAndRecovers(t *testing.T) { t.Fatalf("stale site meter must stand scheduled EV down: %+v", sender.calls) } state, ok := c.manager.State(cfg.ID) - if !ok || !state.PluggedIn || state.TargetSoCPct != 80 || state.TargetTime.IsZero() { + if !ok || !state.PluggedIn || state.TargetSoC != 0.8 || state.TargetTime.IsZero() { t.Fatalf("schedule/observation did not stay live while blocked: %+v", state) } diff --git a/go/internal/loadpoint/controller_phase_mode_test.go b/go/internal/loadpoint/controller_phase_mode_test.go index 39c6f18f7..f3f24860c 100644 --- a/go/internal/loadpoint/controller_phase_mode_test.go +++ b/go/internal/loadpoint/controller_phase_mode_test.go @@ -60,11 +60,11 @@ func TestSurplusActive_ScheduleOverridesRuntimeClamp(t *testing.T) { if !c.surplusActive(cfg, Schedule{}) { t.Fatal("grid-deferred + no schedule should be surplus-active") } - if c.surplusActive(cfg, Schedule{SoCPct: 50}) { + if c.surplusActive(cfg, Schedule{SoC: 0.5}) { t.Error("active schedule (surplus_only off) must disable the runtime surplus clamp") } cfg.SurplusOnly = true - if !c.surplusActive(cfg, Schedule{SoCPct: 50}) { + if !c.surplusActive(cfg, Schedule{SoC: 0.5}) { t.Error("explicit SurplusOnly config must stay surplus-active even with a schedule") } } diff --git a/go/internal/loadpoint/controller_test.go b/go/internal/loadpoint/controller_test.go index 5e0b57854..5af308208 100644 --- a/go/internal/loadpoint/controller_test.go +++ b/go/internal/loadpoint/controller_test.go @@ -46,7 +46,7 @@ func (f *fakeSender) Send(ctx context.Context, driver string, payload []byte) er phaseMode: d.PhaseMode, phaseSplitW: d.PhaseSplitW, minPhaseHoldS: d.MinPhaseHoldS, voltage: d.Voltage, maxAmpsPerPhase: d.MaxAmpsPerPhase, - sitePhases: d.SitePhases, + sitePhases: d.SitePhases, }) return f.err } @@ -215,7 +215,7 @@ func TestTickFeedsObservationsToManager(t *testing.T) { ID: "garage", DriverName: "easee", VehicleCapacityWh: 60000, - PluginSoCPct: 30, + PluginSoC: 0.3, }} samples := map[string]EVSample{"easee": {PowerW: 7400, Connected: true, SessionWh: 6000}} c := newTestController(t, cfgs, nil, samples, sender) @@ -227,8 +227,8 @@ func TestTickFeedsObservationsToManager(t *testing.T) { t.Fatal("manager state missing after Tick") } // 30 + 6000/60000*100 = 40. - if st.CurrentSoCPct < 39 || st.CurrentSoCPct > 41 { - t.Errorf("expected ~40 %% SoC after Observe, got %.2f", st.CurrentSoCPct) + if st.CurrentSoC < 0.39 || st.CurrentSoC > 0.41 { + t.Errorf("expected ~40 %% SoC after Observe, got %.2f", st.CurrentSoC) } if !st.PluggedIn || st.CurrentPowerW != 7400 { t.Errorf("observation not propagated: %+v", st) diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 40521cd7d..eff5f6a0c 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -15,6 +15,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/events" + "github.com/srcfl/ftw/go/internal/units" ) // DeliveringW is the current_power_w threshold above which we treat a @@ -45,12 +46,12 @@ type Config struct { // back to a typical 60 kWh assumption. VehicleCapacityWh float64 `yaml:"vehicle_capacity_wh,omitempty" json:"vehicle_capacity_wh,omitempty"` - // Assumed EV SoC % at plug-in. Chargers like Easee don't report + // Assumed EV SoC at plug-in (0–1). Chargers like Easee don't report // the vehicle's SoC directly — only cumulative session energy. - // Current SoC is then estimated as `PluginSoCPct + delivered / cap`. - // 0 defaults to 20 % (conservative). Operators who care can + // Current SoC is then estimated as PluginSoC + deliveredWh/capacityWh. + // 0 defaults to units.DefaultPluginSoC (0.20). Operators who care can // override per-loadpoint or pre-plug-in. - PluginSoCPct float64 `yaml:"plugin_soc_pct,omitempty" json:"plugin_soc_pct,omitempty"` + PluginSoC float64 `yaml:"plugin_soc,omitempty" json:"plugin_soc,omitempty"` // PhaseMode selects how the controller picks between 1Φ and 3Φ // delivery each tick. "3p" (default) and "1p" lock the install to @@ -123,25 +124,25 @@ type State struct { ID string `json:"id"` DriverName string `json:"driver_name"` PluggedIn bool `json:"plugged_in"` - CurrentSoCPct float64 `json:"current_soc_pct"` // observed or estimated + CurrentSoC float64 `json:"current_soc"` // observed or estimated CurrentPowerW float64 `json:"current_power_w"` // actual draw (site sign: + = charging) DeliveredWhSession float64 `json:"delivered_wh_session"` // since plug-in - TargetSoCPct float64 `json:"target_soc_pct"` // user intent + TargetSoC float64 `json:"target_soc"` // user intent TargetTime time.Time `json:"target_time,omitempty"` // user intent UpdatedAtMs int64 `json:"updated_at_ms"` // Vehicle-side telemetry, populated by the API layer from the most // recent DerVehicle reading whose charging_state indicates a likely // physical connection. Zero values when no online vehicle driver is - // reporting. SoCSource is "vehicle" when CurrentSoCPct was overridden + // reporting. SoCSource is "vehicle" when CurrentSoC was overridden // from the car's BMS, "inferred" when it's the loadpoint manager's // pluginSoC + deliveredWh estimate, "" when not plugged in. - VehicleSoCPct float64 `json:"vehicle_soc_pct,omitempty"` - VehicleChargeLimitPct float64 `json:"vehicle_charge_limit_pct,omitempty"` - VehicleChargingState string `json:"vehicle_charging_state,omitempty"` - VehicleDriver string `json:"vehicle_driver,omitempty"` - VehicleStale bool `json:"vehicle_stale,omitempty"` - SoCSource string `json:"soc_source,omitempty"` + VehicleSoC float64 `json:"vehicle_soc,omitempty"` + VehicleChargeLimit float64 `json:"vehicle_charge_limit,omitempty"` + VehicleChargingState string `json:"vehicle_charging_state,omitempty"` + VehicleDriver string `json:"vehicle_driver,omitempty"` + VehicleStale bool `json:"vehicle_stale,omitempty"` + SoCSource string `json:"soc_source,omitempty"` // MinChargeW / MaxChargeW / AllowedStepsW are repeated here so the // UI has everything for rendering in one fetch. @@ -254,19 +255,19 @@ type loadpointRuntime struct { Config pluggedIn bool - currentSoCPct float64 + currentSoC float64 currentPowerW float64 deliveredWhSession float64 - targetSoCPct float64 + targetSoC float64 targetTime time.Time updatedAtMs int64 // Plug-in anchor: the SoC we believe the vehicle was at when // this session began. Persisted across Observe() calls so SoC // inference (pluginSoC + deliveredWh/capacity) stays stable - // even as session_wh grows. Reset to Config.PluginSoCPct on + // even as session_wh grows. Reset to Config.PluginSoC on // every plug-in transition (prev !pluggedIn → now pluggedIn). - sessionPluginSoCPct float64 + sessionPluginSoC float64 // schedule carries the operator's persistent intent. Empty when // none is set. Survives config hot-reload because Load() copies @@ -288,7 +289,7 @@ type loadpointRuntime struct { // sessionComplete latches once the vehicle has held "not // requesting" past SessionCompletionTimeout for this session. - // While set, the inferred SoC is pinned to targetSoCPct so the + // While set, the inferred SoC is pinned to targetSoC so the // MPC stops allocating PV surplus to a sink the vehicle has // already declined. Cleared on plug-out. sessionComplete bool @@ -372,15 +373,15 @@ func (m *Manager) Load(cfgs []Config) { // plug-in anchor is carried too — otherwise a config // hot-reload during a charging session would drop our // SoC reference and reset the estimate back to - // PluginSoCPct even though delivered_wh has grown. + // PluginSoC even though delivered_wh has grown. lp.pluggedIn = existing.pluggedIn - lp.currentSoCPct = existing.currentSoCPct + lp.currentSoC = existing.currentSoC lp.currentPowerW = existing.currentPowerW lp.deliveredWhSession = existing.deliveredWhSession - lp.targetSoCPct = existing.targetSoCPct + lp.targetSoC = existing.targetSoC lp.targetTime = existing.targetTime lp.updatedAtMs = existing.updatedAtMs - lp.sessionPluginSoCPct = existing.sessionPluginSoCPct + lp.sessionPluginSoC = existing.sessionPluginSoC lp.schedule = existing.schedule lp.lastRolledFor = existing.lastRolledFor lp.notRequestingSince = existing.notRequestingSince @@ -456,7 +457,7 @@ func (m *Manager) Configs() []Config { // don't report the vehicle's actual SoC). // // Plug-in transitions (prev !pluggedIn → now pluggedIn) reset the -// session anchor to Config.PluginSoCPct (default 20 %) so the +// session anchor to Config.PluginSoC (default 20 %) so the // inference is stable across plug cycles even if the underlying // charger's session counter wraps or resets. // @@ -466,7 +467,7 @@ func (m *Manager) Configs() []Config { // false on the latter; drivers without that distinction always pass // true and pre-existing behaviour is preserved. After // SessionCompletionTimeout of sustained !requestActive on a connected -// session, the inferred SoC is pinned to targetSoCPct so the MPC stops +// session, the inferred SoC is pinned to targetSoC so the MPC stops // allocating PV surplus to a sink the vehicle has already declined. // // No-op for unknown IDs — a misconfigured driver shouldn't crash the @@ -500,11 +501,11 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 if pluggedIn && !lp.pluggedIn { // Plug-in transition: seed the session anchor and clear any // session-completion latched from a prior session. - anchor := lp.PluginSoCPct + anchor := lp.PluginSoC if anchor <= 0 { - anchor = 20 // conservative default + anchor = units.DefaultPluginSoC } - lp.sessionPluginSoCPct = anchor + lp.sessionPluginSoC = anchor lp.notRequestingSince = time.Time{} lp.sessionComplete = false lp.socSource = "" @@ -535,7 +536,7 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 if lp.notRequestingSince.IsZero() { lp.notRequestingSince = now } - if !lp.sessionComplete && lp.targetSoCPct > 0 && + if !lp.sessionComplete && lp.targetSoC > 0 && !lp.notRequestingSince.IsZero() && now.Sub(lp.notRequestingSince) >= SessionCompletionTimeout { lp.sessionComplete = true @@ -601,17 +602,17 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 } if pluggedIn { - if lp.sessionComplete && lp.targetSoCPct > 0 { + if lp.sessionComplete && lp.targetSoC > 0 { // Snap the inferred SoC to target; the planner reads - // currentSoCPct as the MPC LoadpointSpec.InitialSoCPct, - // so InitialSoCPct == TargetSoCPct → DP allocates 0 W. - lp.currentSoCPct = lp.targetSoCPct + // currentSoC as the MPC LoadpointSpec.InitialSoCPct, + // so InitialSoCPct == TargetSoC → DP allocates 0 W. + lp.currentSoC = lp.targetSoC } else { - lp.currentSoCPct = estimateSoCPct(lp.sessionPluginSoCPct, + lp.currentSoC = estimateSoC(lp.sessionPluginSoC, deliveredWh, lp.VehicleCapacityWh) } } else { - lp.currentSoCPct = 0 + lp.currentSoC = 0 } lp.updatedAtMs = now.UnixMilli() m.mu.Unlock() @@ -648,42 +649,29 @@ func (m *Manager) SetLocation(loc *time.Location) { m.loc = loc } -// estimateSoCPct returns the vehicle SoC % inferred from the session +// estimateSoC returns the vehicle SoC (0–1) inferred from the session // anchor + energy delivered. Chargers like Easee don't expose the // car's BMS; this is the best-effort estimate the MPC uses. // -// Clamps to [0, 100]. Falls back to the anchor when capacity is -// unknown (can't translate Wh → %). -func estimateSoCPct(pluginSoCPct, deliveredWh, capacityWh float64) float64 { +// Clamps to [0, 1]. Falls back to the anchor when capacity is +// unknown (can't translate Wh → fraction). +func estimateSoC(pluginSoC, deliveredWh, capacityWh float64) float64 { if capacityWh <= 0 { - return pluginSoCPct + return units.ClampFraction(pluginSoC) } - soc := pluginSoCPct + deliveredWh/capacityWh*100.0 - if soc < 0 { - return 0 - } - if soc > 100 { - return 100 - } - return soc + return units.ClampFraction(pluginSoC + deliveredWh/capacityWh) } // SetTarget updates the user-intent fields for an existing loadpoint. // targetTime zero = no deadline. Returns false for unknown IDs. -func (m *Manager) SetTarget(id string, socPct float64, targetTime time.Time) bool { +func (m *Manager) SetTarget(id string, soc float64, targetTime time.Time) bool { m.mu.Lock() defer m.mu.Unlock() lp, ok := m.byID[id] if !ok { return false } - if socPct < 0 { - socPct = 0 - } - if socPct > 100 { - socPct = 100 - } - lp.targetSoCPct = socPct + lp.targetSoC = units.ClampFraction(soc) lp.targetTime = targetTime return true } @@ -798,28 +786,15 @@ func (m *Manager) AnchorVehicleSoC(id string, socPct float64) bool { // m.mu and have verified the loadpoint is plugged in. Shared by the // manual (SetCurrentSoC) and automatic (AnchorVehicleSoC) correction // paths so they stay arithmetically identical. -func reanchorSoCLocked(lp *loadpointRuntime, socPct float64) { - if socPct < 0 { - socPct = 0 - } - if socPct > 100 { - socPct = 100 - } - // Re-anchor: new_anchor + delivered/capacity*100 == socPct. - // → new_anchor = socPct − delivered/capacity*100 - deliveredPct := 0.0 +func reanchorSoCLocked(lp *loadpointRuntime, soc float64) { + soc = units.ClampFraction(soc) + // Re-anchor: new_anchor + delivered/capacity == soc. + delivered := 0.0 if lp.VehicleCapacityWh > 0 { - deliveredPct = lp.deliveredWhSession / lp.VehicleCapacityWh * 100.0 + delivered = lp.deliveredWhSession / lp.VehicleCapacityWh } - anchor := socPct - deliveredPct - if anchor < 0 { - anchor = 0 - } - if anchor > 100 { - anchor = 100 - } - lp.sessionPluginSoCPct = anchor - lp.currentSoCPct = estimateSoCPct(anchor, lp.deliveredWhSession, lp.VehicleCapacityWh) + lp.sessionPluginSoC = units.ClampFraction(soc - delivered) + lp.currentSoC = estimateSoC(lp.sessionPluginSoC, lp.deliveredWhSession, lp.VehicleCapacityWh) lp.updatedAtMs = time.Now().UnixMilli() } @@ -831,10 +806,10 @@ func (lp *loadpointRuntime) snapshot() State { ID: lp.ID, DriverName: lp.DriverName, PluggedIn: lp.pluggedIn, - CurrentSoCPct: lp.currentSoCPct, + CurrentSoC: lp.currentSoC, CurrentPowerW: lp.currentPowerW, DeliveredWhSession: lp.deliveredWhSession, - TargetSoCPct: lp.targetSoCPct, + TargetSoC: lp.targetSoC, TargetTime: lp.targetTime, UpdatedAtMs: lp.updatedAtMs, MinChargeW: lp.MinChargeW, @@ -865,18 +840,7 @@ func (m *Manager) SetSchedule(id string, s Schedule) bool { m.mu.Unlock() return false } - if s.SoCPct < 0 { - s.SoCPct = 0 - } - if s.SoCPct > 100 { - s.SoCPct = 100 - } - if s.SurplusUnlockBatSoCPct < 0 { - s.SurplusUnlockBatSoCPct = 0 - } - if s.SurplusUnlockBatSoCPct > 100 { - s.SurplusUnlockBatSoCPct = 100 - } + s.Normalize() // The weekday mask is 7 bits; a stray high bit from a future // client is dropped rather than left to confuse the roll. s.Days &= 0x7F @@ -894,7 +858,7 @@ func (m *Manager) SetSchedule(id string, s Schedule) bool { // seed it from the new schedule. Applies to both recurring and // non-recurring saves. lp.targetTime = time.Time{} - lp.targetSoCPct = 0 + lp.targetSoC = 0 saver := m.scheduleSaver m.mu.Unlock() if saver != nil { @@ -959,6 +923,7 @@ func (m *Manager) HydrateSchedules(loader func(id string) (Schedule, bool)) { if !found || s.Empty() { continue } + s.Normalize() lp.schedule = s } } @@ -990,7 +955,7 @@ func (m *Manager) RollSchedules(now time.Time) { continue } lp.targetTime = next - lp.targetSoCPct = s.SoCPct + lp.targetSoC = s.SoC lp.lastRolledFor = next continue } @@ -999,7 +964,7 @@ func (m *Manager) RollSchedules(now time.Time) { // re-save with a non-recurring schedule re-seeds. if lp.lastRolledFor.IsZero() { lp.targetTime = next - lp.targetSoCPct = s.SoCPct + lp.targetSoC = s.SoC lp.lastRolledFor = next } } diff --git a/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go b/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go index ea429a855..989fde3c3 100644 --- a/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go +++ b/go/internal/loadpoint/loadpoint_anchor_vehicle_test.go @@ -10,25 +10,25 @@ import "testing" // automatic counterpart to the operator's manual SetCurrentSoC. func TestAnchorVehicleSoC(t *testing.T) { m := NewManager() - m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoCPct: 25}}) + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.25}}) // Plug in, deliver 9 kWh → naive estimate = 25 + 9000/60000*100 = 40 %. m.Observe("a", true, 7400, 9000, true) - if st, _ := m.State("a"); st.CurrentSoCPct < 39 || st.CurrentSoCPct > 41 { - t.Fatalf("pre-anchor SoC: got %.2f want ~40", st.CurrentSoCPct) + if st, _ := m.State("a"); st.CurrentSoC < 0.39 || st.CurrentSoC > 0.41 { + t.Fatalf("pre-anchor SoC: got %.2f want ~40", st.CurrentSoC) } // The bound vehicle's BMS reports the real SoC is 31 %. - if !m.AnchorVehicleSoC("a", 31) { + if !m.AnchorVehicleSoC("a", 0.31) { t.Fatal("AnchorVehicleSoC returned false on plugged-in loadpoint") } st, _ := m.State("a") - if st.CurrentSoCPct < 30.5 || st.CurrentSoCPct > 31.5 { - t.Errorf("post-anchor SoC: got %.2f want ~31", st.CurrentSoCPct) + if st.CurrentSoC < 0.305 || st.CurrentSoC > 0.315 { + t.Errorf("post-anchor SoC: got %.2f want ~31", st.CurrentSoC) } // Deliver another 3 kWh → should be ~36 % (31 + 3000/60000*100). m.Observe("a", true, 7400, 12000, true) st, _ = m.State("a") - if st.CurrentSoCPct < 35 || st.CurrentSoCPct > 37 { - t.Errorf("after more delivery SoC: got %.2f want ~36", st.CurrentSoCPct) + if st.CurrentSoC < 0.35 || st.CurrentSoC > 0.37 { + t.Errorf("after more delivery SoC: got %.2f want ~36", st.CurrentSoC) } } @@ -39,20 +39,20 @@ func TestAnchorVehicleSoC(t *testing.T) { // delivered-Wh estimate. func TestAnchorVehicleSoCEveryTickStaysLocked(t *testing.T) { m := NewManager() - m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoCPct: 25}}) + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.25}}) m.Observe("a", true, 7400, 9000, true) // Tick 1: BMS says 31. - m.AnchorVehicleSoC("a", 31) - if st, _ := m.State("a"); st.CurrentSoCPct < 30.5 || st.CurrentSoCPct > 31.5 { - t.Fatalf("tick1 SoC: got %.2f want ~31", st.CurrentSoCPct) + m.AnchorVehicleSoC("a", 0.31) + if st, _ := m.State("a"); st.CurrentSoC < 0.305 || st.CurrentSoC > 0.315 { + t.Fatalf("tick1 SoC: got %.2f want ~31", st.CurrentSoC) } // Tick 2: more energy delivered AND BMS refreshes to 32. Observe runs // the inference first (as the controller does), then we re-anchor. m.Observe("a", true, 7400, 10000, true) - m.AnchorVehicleSoC("a", 32) + m.AnchorVehicleSoC("a", 0.32) st, _ := m.State("a") - if st.CurrentSoCPct < 31.5 || st.CurrentSoCPct > 32.5 { - t.Errorf("tick2 SoC: got %.2f want ~32 (locked to latest BMS)", st.CurrentSoCPct) + if st.CurrentSoC < 0.315 || st.CurrentSoC > 0.325 { + t.Errorf("tick2 SoC: got %.2f want ~32 (locked to latest BMS)", st.CurrentSoC) } } @@ -61,15 +61,15 @@ func TestAnchorVehicleSoCEveryTickStaysLocked(t *testing.T) { func TestAnchorVehicleSoCRejectsUnpluggedAndUnknown(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000}}) - if m.AnchorVehicleSoC("a", 40) { + if m.AnchorVehicleSoC("a", 0.4) { t.Error("should reject AnchorVehicleSoC on never-plugged loadpoint") } - if m.AnchorVehicleSoC("ghost", 40) { + if m.AnchorVehicleSoC("ghost", 0.4) { t.Error("should reject AnchorVehicleSoC on unknown id") } m.Observe("a", true, 0, 0, true) m.Observe("a", false, 0, 0, true) - if m.AnchorVehicleSoC("a", 40) { + if m.AnchorVehicleSoC("a", 0.4) { t.Error("should reject AnchorVehicleSoC after unplug") } } diff --git a/go/internal/loadpoint/loadpoint_surplus_withheld_test.go b/go/internal/loadpoint/loadpoint_surplus_withheld_test.go index ccfac4c3d..e5bfbafe6 100644 --- a/go/internal/loadpoint/loadpoint_surplus_withheld_test.go +++ b/go/internal/loadpoint/loadpoint_surplus_withheld_test.go @@ -15,9 +15,9 @@ func TestSelfWithheldNCRQDoesNotComplete(t *testing.T) { m := NewManager() m.Load([]Config{{ ID: "garage", DriverName: "ctek", - VehicleCapacityWh: 60000, PluginSoCPct: 20, + VehicleCapacityWh: 60000, PluginSoC: 0.2, }}) - m.SetTarget("garage", 80, time.Date(2026, 6, 8, 6, 0, 0, 0, time.UTC)) + m.SetTarget("garage", 0.8, time.Date(2026, 6, 8, 6, 0, 0, 0, time.UTC)) clock := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC) m.SetNowFn(func() time.Time { return clock }) @@ -41,9 +41,9 @@ func TestGenuineNCRQStillCompletesAfterWithheldClears(t *testing.T) { m := NewManager() m.Load([]Config{{ ID: "garage", DriverName: "ctek", - VehicleCapacityWh: 60000, PluginSoCPct: 20, + VehicleCapacityWh: 60000, PluginSoC: 0.2, }}) - m.SetTarget("garage", 80, time.Date(2026, 6, 8, 6, 0, 0, 0, time.UTC)) + m.SetTarget("garage", 0.8, time.Date(2026, 6, 8, 6, 0, 0, 0, time.UTC)) clock := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC) m.SetNowFn(func() time.Time { return clock }) diff --git a/go/internal/loadpoint/loadpoint_test.go b/go/internal/loadpoint/loadpoint_test.go index a5cab6920..194caad66 100644 --- a/go/internal/loadpoint/loadpoint_test.go +++ b/go/internal/loadpoint/loadpoint_test.go @@ -28,16 +28,16 @@ func TestReloadPreservesObservedState(t *testing.T) { m := NewManager() m.Load([]Config{{ ID: "garage", DriverName: "easee-cloud", - VehicleCapacityWh: 60000, PluginSoCPct: 40, + VehicleCapacityWh: 60000, PluginSoC: 0.4, }}) m.Observe("garage", true, 7400, 1200, true) // 1.2 kWh into session target := time.Date(2026, 4, 18, 6, 0, 0, 0, time.UTC) - m.SetTarget("garage", 80, target) + m.SetTarget("garage", 0.8, target) // Reload with same ID — state should persist. m.Load([]Config{{ ID: "garage", DriverName: "easee-cloud", MaxChargeW: 11000, - VehicleCapacityWh: 60000, PluginSoCPct: 40, + VehicleCapacityWh: 60000, PluginSoC: 0.4, }}) st, ok := m.State("garage") if !ok { @@ -47,10 +47,10 @@ func TestReloadPreservesObservedState(t *testing.T) { if !st.PluggedIn || st.CurrentPowerW != 7400 { t.Errorf("observed state lost: %+v", st) } - if got := st.CurrentSoCPct; got < 41.5 || got > 42.5 { - t.Errorf("SoC estimate: got %.2f, want ~42", got) + if got := st.CurrentSoC; got < 0.415 || got > 0.425 { + t.Errorf("SoC estimate: got %.2f, want ~0.42", got) } - if st.TargetSoCPct != 80 || !st.TargetTime.Equal(target) { + if st.TargetSoC != 0.8 || !st.TargetTime.Equal(target) { t.Errorf("target lost: %+v", st) } // But config should update. @@ -80,15 +80,15 @@ func TestObserveOnUnknownIsNoop(t *testing.T) { func TestSetTargetClamp(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "a"}}) - m.SetTarget("a", 250, time.Time{}) + m.SetTarget("a", 2.5, time.Time{}) st, _ := m.State("a") - if st.TargetSoCPct != 100 { - t.Errorf("should clamp to 100; got %f", st.TargetSoCPct) + if st.TargetSoC != 1 { + t.Errorf("should clamp to 100; got %f", st.TargetSoC) } m.SetTarget("a", -10, time.Time{}) st, _ = m.State("a") - if st.TargetSoCPct != 0 { - t.Errorf("should clamp to 0; got %f", st.TargetSoCPct) + if st.TargetSoC != 0 { + t.Errorf("should clamp to 0; got %f", st.TargetSoC) } } @@ -98,33 +98,33 @@ func TestSetTargetClamp(t *testing.T) { // away. func TestObserveUnpluggedClearsSoCEstimate(t *testing.T) { m := NewManager() - m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoCPct: 30}}) + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.3}}) m.Observe("a", true, 7400, 1800, true) // charging → SoC = 30 + 3 = 33 - if st, _ := m.State("a"); st.CurrentSoCPct < 32.5 || st.CurrentSoCPct > 33.5 { - t.Fatalf("expected ~33 %% while plugged in, got %.2f", st.CurrentSoCPct) + if st, _ := m.State("a"); st.CurrentSoC < 0.325 || st.CurrentSoC > 0.335 { + t.Fatalf("expected ~33 %% while plugged in, got %.2f", st.CurrentSoC) } m.Observe("a", false, 0, 0, true) - if st, _ := m.State("a"); st.CurrentSoCPct != 0 || st.PluggedIn { + if st, _ := m.State("a"); st.CurrentSoC != 0 || st.PluggedIn { t.Errorf("expected cleared state when unplugged, got %+v", st) } } // TestObserveNewSessionAnchor — on plug-in the anchor resets to -// Config.PluginSoCPct. This prevents residual session_wh from a +// Config.PluginSoC. This prevents residual session_wh from a // previous session leaking into the new one. func TestObserveNewSessionAnchor(t *testing.T) { m := NewManager() - m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoCPct: 50}}) + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.5}}) m.Observe("a", true, 0, 0, true) - if st, _ := m.State("a"); st.CurrentSoCPct < 49 || st.CurrentSoCPct > 51 { - t.Errorf("fresh plug-in should show 50 %%, got %.2f", st.CurrentSoCPct) + if st, _ := m.State("a"); st.CurrentSoC < 0.49 || st.CurrentSoC > 0.51 { + t.Errorf("fresh plug-in should show 50 %%, got %.2f", st.CurrentSoC) } // Disconnect. m.Observe("a", false, 0, 0, true) // Re-plug — session delivered counter starts fresh. m.Observe("a", true, 0, 0, true) - if st, _ := m.State("a"); st.CurrentSoCPct < 49 || st.CurrentSoCPct > 51 { - t.Errorf("re-plug should re-anchor at 50 %%, got %.2f", st.CurrentSoCPct) + if st, _ := m.State("a"); st.CurrentSoC < 0.49 || st.CurrentSoC > 0.51 { + t.Errorf("re-plug should re-anchor at 50 %%, got %.2f", st.CurrentSoC) } } @@ -135,25 +135,25 @@ func TestObserveNewSessionAnchor(t *testing.T) { // operator can correct drift. func TestSetCurrentSoCReAnchors(t *testing.T) { m := NewManager() - m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoCPct: 25}}) + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.25}}) // Plug in, deliver 9 kWh → naive estimate = 25 + 9000/60000*100 = 40 %. m.Observe("a", true, 7400, 9000, true) - if st, _ := m.State("a"); st.CurrentSoCPct < 39 || st.CurrentSoCPct > 41 { - t.Fatalf("pre-correction SoC: got %.2f want ~40", st.CurrentSoCPct) + if st, _ := m.State("a"); st.CurrentSoC < 0.39 || st.CurrentSoC > 0.41 { + t.Fatalf("pre-correction SoC: got %.2f want ~40", st.CurrentSoC) } // Operator looks at their dashboard: car is actually 60 %. - if !m.SetCurrentSoC("a", 60) { + if !m.SetCurrentSoC("a", 0.6) { t.Fatal("SetCurrentSoC returned false on plugged-in loadpoint") } st, _ := m.State("a") - if st.CurrentSoCPct < 59 || st.CurrentSoCPct > 61 { - t.Errorf("post-correction SoC: got %.2f want ~60", st.CurrentSoCPct) + if st.CurrentSoC < 0.59 || st.CurrentSoC > 0.61 { + t.Errorf("post-correction SoC: got %.2f want ~60", st.CurrentSoC) } // Deliver another 3 kWh → should be ~65 % (60 + 3000/60000*100). m.Observe("a", true, 7400, 12000, true) st, _ = m.State("a") - if st.CurrentSoCPct < 64 || st.CurrentSoCPct > 66 { - t.Errorf("after more delivery SoC: got %.2f want ~65", st.CurrentSoCPct) + if st.CurrentSoC < 0.64 || st.CurrentSoC > 0.66 { + t.Errorf("after more delivery SoC: got %.2f want ~65", st.CurrentSoC) } } @@ -163,12 +163,12 @@ func TestSetCurrentSoCReAnchors(t *testing.T) { func TestSetCurrentSoCRejectsUnplugged(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000}}) - if m.SetCurrentSoC("a", 55) { + if m.SetCurrentSoC("a", 0.55) { t.Error("should reject SetCurrentSoC on never-plugged loadpoint") } m.Observe("a", true, 0, 0, true) m.Observe("a", false, 0, 0, true) - if m.SetCurrentSoC("a", 55) { + if m.SetCurrentSoC("a", 0.55) { t.Error("should reject SetCurrentSoC after unplug") } } @@ -177,13 +177,13 @@ func TestSetCurrentSoCClampsRange(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000}}) m.Observe("a", true, 0, 0, true) - m.SetCurrentSoC("a", 150) - if st, _ := m.State("a"); st.CurrentSoCPct != 100 { - t.Errorf("clamp high: got %.2f", st.CurrentSoCPct) + m.SetCurrentSoC("a", 1.5) + if st, _ := m.State("a"); st.CurrentSoC != 1 { + t.Errorf("clamp high: got %.2f", st.CurrentSoC) } m.SetCurrentSoC("a", -10) - if st, _ := m.State("a"); st.CurrentSoCPct != 0 { - t.Errorf("clamp low: got %.2f", st.CurrentSoCPct) + if st, _ := m.State("a"); st.CurrentSoC != 0 { + t.Errorf("clamp low: got %.2f", st.CurrentSoC) } } @@ -221,9 +221,9 @@ func TestSessionCompletionSnapsToTarget(t *testing.T) { m := NewManager() m.Load([]Config{{ ID: "garage", DriverName: "evse-test", - VehicleCapacityWh: 60000, PluginSoCPct: 20, MaxChargeW: 11000, + VehicleCapacityWh: 60000, PluginSoC: 0.2, MaxChargeW: 11000, }}) - m.SetTarget("garage", 60, time.Date(2026, 5, 27, 15, 0, 0, 0, time.UTC)) + m.SetTarget("garage", 0.6, time.Date(2026, 5, 27, 15, 0, 0, 0, time.UTC)) clock := time.Date(2026, 5, 27, 10, 19, 0, 0, time.UTC) m.SetNowFn(func() time.Time { return clock }) @@ -237,8 +237,8 @@ func TestSessionCompletionSnapsToTarget(t *testing.T) { // Tick 2 (T+1m of charging): some energy delivered, inferred SoC rises. clock = clock.Add(60 * time.Second) m.Observe("garage", true, 7400, 1000, true) // SoC = 20 + 1000/60000*100 ≈ 21.67 - if st, _ := m.State("garage"); st.CurrentSoCPct < 21 || st.CurrentSoCPct > 22.5 { - t.Errorf("expected inferred SoC ~21.67, got %.2f", st.CurrentSoCPct) + if st, _ := m.State("garage"); st.CurrentSoC < 0.21 || st.CurrentSoC > 0.225 { + t.Errorf("expected inferred SoC ~21.67, got %.2f", st.CurrentSoC) } // Tick 3 (T+1m6s): vehicle stops requesting current. @@ -263,8 +263,8 @@ func TestSessionCompletionSnapsToTarget(t *testing.T) { if st.SoCSource != "completed" { t.Errorf("expected SoCSource='completed' after threshold, got %q (state=%+v)", st.SoCSource, st) } - if st.CurrentSoCPct != 60 { - t.Errorf("expected inferred SoC pinned to target 60, got %.2f", st.CurrentSoCPct) + if st.CurrentSoC != 0.6 { + t.Errorf("expected inferred SoC pinned to target 60, got %.2f", st.CurrentSoC) } // Tick 6: a transient request_active=true blip (EVSE retried and @@ -274,7 +274,7 @@ func TestSessionCompletionSnapsToTarget(t *testing.T) { // clears it. clock = clock.Add(15 * time.Second) m.Observe("garage", true, 0, 1000, true) - if st, _ := m.State("garage"); st.SoCSource != "completed" || st.CurrentSoCPct != 60 { + if st, _ := m.State("garage"); st.SoCSource != "completed" || st.CurrentSoC != 0.6 { t.Errorf("brief request_active flicker should not clear latch: %+v", st) } @@ -294,9 +294,9 @@ func TestSessionCompletionRequiresTarget(t *testing.T) { m := NewManager() m.Load([]Config{{ ID: "garage", DriverName: "evse-test", - VehicleCapacityWh: 60000, PluginSoCPct: 20, + VehicleCapacityWh: 60000, PluginSoC: 0.2, }}) - // No SetTarget call — targetSoCPct stays 0. + // No SetTarget call — targetSoC stays 0. clock := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) m.SetNowFn(func() time.Time { return clock }) @@ -319,9 +319,9 @@ func TestRequestActiveDefaultPreservesInference(t *testing.T) { m := NewManager() m.Load([]Config{{ ID: "garage", DriverName: "evse-test", - VehicleCapacityWh: 60000, PluginSoCPct: 30, + VehicleCapacityWh: 60000, PluginSoC: 0.3, }}) - m.SetTarget("garage", 80, time.Date(2026, 4, 18, 6, 0, 0, 0, time.UTC)) + m.SetTarget("garage", 0.8, time.Date(2026, 4, 18, 6, 0, 0, 0, time.UTC)) clock := time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC) m.SetNowFn(func() time.Time { return clock }) @@ -335,7 +335,7 @@ func TestRequestActiveDefaultPreservesInference(t *testing.T) { if st.SoCSource == "completed" { t.Errorf("request_active=true must not trigger completion: %+v", st) } - if st.CurrentSoCPct < 30.5 || st.CurrentSoCPct > 31.5 { - t.Errorf("inferred SoC drifted: %.2f", st.CurrentSoCPct) + if st.CurrentSoC < 0.305 || st.CurrentSoC > 0.315 { + t.Errorf("inferred SoC drifted: %.2f", st.CurrentSoC) } } diff --git a/go/internal/loadpoint/schedule.go b/go/internal/loadpoint/schedule.go index 78e61d813..75c1ee835 100644 --- a/go/internal/loadpoint/schedule.go +++ b/go/internal/loadpoint/schedule.go @@ -1,24 +1,29 @@ package loadpoint -import "time" +import ( + "encoding/json" + "time" + + "github.com/srcfl/ftw/go/internal/units" +) // Schedule is the user's persistent charging intent for one loadpoint: -// "be at SoCPct by TimeOfDayMinUTC each day". When Recurring is true the +// "be at SoC by TimeOfDayMinUTC each day". When Recurring is true the // Manager rolls the loadpoint's targetTime forward to tomorrow once // today's deadline passes; when false the schedule still hydrates the // one-shot target_soc_pct/target_time fields on save but doesn't refresh // itself. // -// SurplusUnlockBatSoCPct, if > 0, tells the dispatch controller to grab +// SurplusUnlockBatSoC, if > 0, tells the dispatch controller to grab // PV surplus into this loadpoint whenever the home battery's SoC sits at // or above the threshold — even when SurplusOnly is off and the MPC has -// nothing planned. Hysteresis (release at threshold − BatSoCUnlockHystPp) +// nothing planned. Hysteresis (release at threshold − BatSoCUnlockHyst) // keeps the contactor from flapping at the boundary. // // Zero value (Empty) means "no schedule configured". Persistence keys // off this — Empty schedules are not written to disk. type Schedule struct { - SoCPct float64 `json:"soc_pct"` + SoC float64 `json:"soc"` TimeOfDayMinUTC int `json:"time_of_day_min_utc"` // 0..1439 Recurring bool `json:"recurring"` // Days restricts which weekdays the deadline may land on: a 7-bit @@ -27,22 +32,53 @@ type Schedule struct { // field existed decodes to, so old rows and old clients keep // their behaviour. The weekday is the household's, not UTC's: the // mask is read in the box's own time zone (see NextDeadlineUTC). - Days uint8 `json:"days,omitempty"` - SurplusUnlockBatSoCPct float64 `json:"surplus_unlock_bat_soc_pct,omitempty"` + Days uint8 `json:"days,omitempty"` + SurplusUnlockBatSoC float64 `json:"surplus_unlock_bat_soc,omitempty"` +} + +// BatSoCUnlockHyst is the fraction gap between arm and release for the +// bat-SoC surplus unlock. Armed at threshold, released at threshold − +// 0.05. Tuned to swallow normal Kalman noise on bat SoC (~0.005–0.01) +// without ever flapping the contactor. +const BatSoCUnlockHyst = 0.05 + +// Normalize folds a persisted or inbound schedule onto 0–1 fractions. +// Old rows stored soc_pct as 0–100; new rows store soc as 0–1. +func (s *Schedule) Normalize() { + if s == nil { + return + } + s.SoC = units.ClampFraction(units.FractionFromLegacyPercent(s.SoC)) + s.SurplusUnlockBatSoC = units.ClampFraction(units.FractionFromLegacyPercent(s.SurplusUnlockBatSoC)) } -// BatSoCUnlockHystPp is the percentage-point gap between arm and release -// for the bat-SoC surplus unlock. Armed at threshold, released at -// threshold − 5 pp. Tuned to swallow normal Kalman noise on bat_soc -// readings (~0.5–1 pp) without ever flapping the contactor. -const BatSoCUnlockHystPp = 5.0 +func (s *Schedule) UnmarshalJSON(b []byte) error { + type alias Schedule + aux := struct { + alias + SoCPct *float64 `json:"soc_pct"` + SurplusUnlockBatSoCPct *float64 `json:"surplus_unlock_bat_soc_pct"` + }{} + if err := json.Unmarshal(b, &aux); err != nil { + return err + } + *s = Schedule(aux.alias) + if s.SoC == 0 && aux.SoCPct != nil { + s.SoC = *aux.SoCPct + } + if s.SurplusUnlockBatSoC == 0 && aux.SurplusUnlockBatSoCPct != nil { + s.SurplusUnlockBatSoC = *aux.SurplusUnlockBatSoCPct + } + s.Normalize() + return nil +} // Empty reports whether the schedule carries no operator intent. The // persistence layer writes nothing on Empty so a stale-loadpoint // schedule on disk is naturally GC'd when the operator clears it via // the API. func (s Schedule) Empty() bool { - return s.SoCPct == 0 && s.TimeOfDayMinUTC == 0 && !s.Recurring && s.SurplusUnlockBatSoCPct == 0 + return s.SoC == 0 && s.TimeOfDayMinUTC == 0 && !s.Recurring && s.SurplusUnlockBatSoC == 0 } // NextDailyUTC returns the next time-of-day deadline (in UTC) strictly diff --git a/go/internal/loadpoint/schedule_days_test.go b/go/internal/loadpoint/schedule_days_test.go index df3c8c03c..c75c257dc 100644 --- a/go/internal/loadpoint/schedule_days_test.go +++ b/go/internal/loadpoint/schedule_days_test.go @@ -30,7 +30,7 @@ func TestNextDeadlineUTC_ZeroMaskIsEveryDay(t *testing.T) { loc := stockholm(t) base := time.Date(2026, 5, 10, 8, 0, 0, 0, time.UTC) for _, min := range []int{0, 360, 480, 600, 1439} { - s := Schedule{SoCPct: 80, TimeOfDayMinUTC: min} + s := Schedule{SoC: 0.8, TimeOfDayMinUTC: min} if got, want := s.NextDeadlineUTC(base, loc), NextDailyUTC(base, min); !got.Equal(want) { t.Errorf("zero mask, min=%d: got %v, want NextDailyUTC's %v", min, got, want) } @@ -51,7 +51,7 @@ func TestNextDeadlineUTC_ZeroMaskIsEveryDay(t *testing.T) { // the deadline lands on Monday, at the same stored UTC time-of-day. func TestNextDeadlineUTC_WeekdayMaskRollsPastTheWeekend(t *testing.T) { loc := stockholm(t) - s := Schedule{SoCPct: 80, TimeOfDayMinUTC: 360, Recurring: true, Days: maskMonToFri} + s := Schedule{SoC: 0.8, TimeOfDayMinUTC: 360, Recurring: true, Days: maskMonToFri} // Friday 2026-05-15 07:00 UTC — today's 06:00 slot has passed. now := time.Date(2026, 5, 15, 7, 0, 0, 0, time.UTC) got := s.NextDeadlineUTC(now, loc) @@ -68,7 +68,7 @@ func TestNextDeadlineUTC_WeekdayMaskRollsPastTheWeekend(t *testing.T) { // skipping until Sunday 22:30 UTC — Monday 00:30 local. func TestNextDeadlineUTC_WeekdayIsTheHouseholdsNotUTCs(t *testing.T) { loc := stockholm(t) - s := Schedule{SoCPct: 80, TimeOfDayMinUTC: 22*60 + 30, Days: maskMonToFri} + s := Schedule{SoC: 0.8, TimeOfDayMinUTC: 22*60 + 30, Days: maskMonToFri} now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) // Friday noon UTC got := s.NextDeadlineUTC(now, loc) @@ -85,8 +85,8 @@ func TestNextDeadlineUTC_WeekdayIsTheHouseholdsNotUTCs(t *testing.T) { // A single-day mask reaches its day from anywhere in the week. func TestNextDeadlineUTC_SingleDayMask(t *testing.T) { loc := stockholm(t) - s := Schedule{SoCPct: 80, TimeOfDayMinUTC: 360, Days: 1 << 6} // Sunday only - now := time.Date(2026, 5, 18, 7, 0, 0, 0, time.UTC) // Monday + s := Schedule{SoC: 0.8, TimeOfDayMinUTC: 360, Days: 1 << 6} // Sunday only + now := time.Date(2026, 5, 18, 7, 0, 0, 0, time.UTC) // Monday got := s.NextDeadlineUTC(now, loc) want := time.Date(2026, 5, 24, 6, 0, 0, 0, time.UTC) // next Sunday if !got.Equal(want) { @@ -102,7 +102,7 @@ func TestManager_RollSchedules_WeekdayMaskSkipsWeekend(t *testing.T) { m.Load([]Config{{ID: "garage", DriverName: "easee"}}) m.SetLocation(stockholm(t)) m.SetSchedule("garage", Schedule{ - SoCPct: 80, TimeOfDayMinUTC: 360, Recurring: true, Days: maskMonToFri, + SoC: 0.8, TimeOfDayMinUTC: 360, Recurring: true, Days: maskMonToFri, }) // Friday 2026-05-15 19:00 UTC, past today's 06:00 slot. @@ -114,8 +114,8 @@ func TestManager_RollSchedules_WeekdayMaskSkipsWeekend(t *testing.T) { if !st.TargetTime.Equal(want) { t.Fatalf("target_time = %v (%v), want Monday %v", st.TargetTime, st.TargetTime.Weekday(), want) } - if st.TargetSoCPct != 80 { - t.Fatalf("target_soc_pct = %v, want 80", st.TargetSoCPct) + if st.TargetSoC != 0.8 { + t.Fatalf("target_soc_pct = %v, want 80", st.TargetSoC) } } @@ -125,7 +125,7 @@ func TestManager_RollSchedules_ZeroMaskStillRollsDaily(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "garage", DriverName: "easee"}}) m.SetLocation(stockholm(t)) - m.SetSchedule("garage", Schedule{SoCPct: 80, TimeOfDayMinUTC: 360, Recurring: true}) + m.SetSchedule("garage", Schedule{SoC: 0.8, TimeOfDayMinUTC: 360, Recurring: true}) now := time.Date(2026, 5, 15, 19, 0, 0, 0, time.UTC) // Friday evening m.RollSchedules(now) @@ -143,7 +143,7 @@ func TestSetSchedule_MasksDaysToSevenBits(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "garage", DriverName: "easee"}}) m.SetSchedule("garage", Schedule{ - SoCPct: 80, TimeOfDayMinUTC: 360, Recurring: true, Days: 0x80 | maskMonToFri, + SoC: 0.8, TimeOfDayMinUTC: 360, Recurring: true, Days: 0x80 | maskMonToFri, }) got, ok := m.GetSchedule("garage") if !ok { diff --git a/go/internal/loadpoint/schedule_test.go b/go/internal/loadpoint/schedule_test.go index 334e1929a..169d84fa6 100644 --- a/go/internal/loadpoint/schedule_test.go +++ b/go/internal/loadpoint/schedule_test.go @@ -39,10 +39,10 @@ func TestScheduleEmpty(t *testing.T) { if !(Schedule{}).Empty() { t.Error("zero Schedule should be Empty") } - if (Schedule{SoCPct: 50}).Empty() { - t.Error("Schedule with SoCPct set should not be Empty") + if (Schedule{SoC: 0.5}).Empty() { + t.Error("Schedule with SoC set should not be Empty") } - if (Schedule{SurplusUnlockBatSoCPct: 80}).Empty() { + if (Schedule{SurplusUnlockBatSoC: 0.8}).Empty() { t.Error("Schedule with bat-soc unlock should not be Empty") } } @@ -58,7 +58,7 @@ func TestManager_SetGetClearSchedule(t *testing.T) { t.Fatal("unknown ID should return ok=false") } - s := Schedule{SoCPct: 50, TimeOfDayMinUTC: 360, Recurring: true, SurplusUnlockBatSoCPct: 80} + s := Schedule{SoC: 0.5, TimeOfDayMinUTC: 360, Recurring: true, SurplusUnlockBatSoC: 0.8} if !m.SetSchedule("garage", s) { t.Fatal("SetSchedule should succeed for known ID") } @@ -81,15 +81,15 @@ func TestManager_SetGetClearSchedule(t *testing.T) { func TestManager_RollSchedules_RecurringPromotes(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "garage", DriverName: "easee"}}) - s := Schedule{SoCPct: 50, TimeOfDayMinUTC: 360, Recurring: true} + s := Schedule{SoC: 0.5, TimeOfDayMinUTC: 360, Recurring: true} m.SetSchedule("garage", s) now := time.Date(2026, 5, 11, 7, 0, 0, 0, time.UTC) // past 06:00 m.RollSchedules(now) st, _ := m.State("garage") - if st.TargetSoCPct != 50 { - t.Errorf("expected target_soc_pct=50 after roll, got %v", st.TargetSoCPct) + if st.TargetSoC != 0.5 { + t.Errorf("expected target_soc_pct=50 after roll, got %v", st.TargetSoC) } want := time.Date(2026, 5, 12, 6, 0, 0, 0, time.UTC) if !st.TargetTime.Equal(want) { @@ -107,7 +107,7 @@ func TestManager_RollSchedules_RecurringPromotes(t *testing.T) { func TestManager_RollSchedules_NonRecurringSeedsOnce(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "garage", DriverName: "easee"}}) - s := Schedule{SoCPct: 50, TimeOfDayMinUTC: 360, Recurring: false} + s := Schedule{SoC: 0.5, TimeOfDayMinUTC: 360, Recurring: false} m.SetSchedule("garage", s) // Now is 07:00; today's 06:00 has already passed → seed for tomorrow. @@ -119,8 +119,8 @@ func TestManager_RollSchedules_NonRecurringSeedsOnce(t *testing.T) { t.Errorf("non-recurring first seed: target_time = %v, want %v", st.TargetTime, want) } - if st.TargetSoCPct != 50 { - t.Errorf("non-recurring first seed: target_soc_pct = %v, want 50", st.TargetSoCPct) + if st.TargetSoC != 0.5 { + t.Errorf("non-recurring first seed: target_soc_pct = %v, want 50", st.TargetSoC) } // After the deadline passes, RollSchedules must NOT re-seed — @@ -139,15 +139,15 @@ func TestManager_RollSchedules_RecurringSeedsFirstTarget(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "garage", DriverName: "easee"}}) // No SetTarget called yet — schedule alone should populate it. - s := Schedule{SoCPct: 50, TimeOfDayMinUTC: 360, Recurring: true} + s := Schedule{SoC: 0.5, TimeOfDayMinUTC: 360, Recurring: true} m.SetSchedule("garage", s) now := time.Date(2026, 5, 11, 7, 0, 0, 0, time.UTC) m.RollSchedules(now) st, _ := m.State("garage") - if st.TargetSoCPct != 50 { - t.Errorf("expected initial target_soc_pct=50 from schedule, got %v", st.TargetSoCPct) + if st.TargetSoC != 0.5 { + t.Errorf("expected initial target_soc_pct=50 from schedule, got %v", st.TargetSoC) } want := time.Date(2026, 5, 12, 6, 0, 0, 0, time.UTC) if !st.TargetTime.Equal(want) { @@ -158,7 +158,7 @@ func TestManager_RollSchedules_RecurringSeedsFirstTarget(t *testing.T) { func TestManager_HydrateSchedules(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "garage", DriverName: "easee"}, {ID: "street"}}) - want := Schedule{SoCPct: 80, TimeOfDayMinUTC: 420, Recurring: true, SurplusUnlockBatSoCPct: 75} + want := Schedule{SoC: 0.8, TimeOfDayMinUTC: 420, Recurring: true, SurplusUnlockBatSoC: 0.75} m.HydrateSchedules(func(id string) (Schedule, bool) { if id == "garage" { return want, true @@ -188,10 +188,10 @@ func TestManager_SetScheduleOverridesStaleTargetTime_Recurring(t *testing.T) { // Operator (or stale state.db) leaves a one-shot target at 07:00 // tomorrow morning. staleTarget := time.Date(2026, 5, 13, 5, 0, 0, 0, time.UTC) - m.SetTarget("garage", 100, staleTarget) + m.SetTarget("garage", 1, staleTarget) // Operator now saves a recurring schedule for 17:00 local CEST // (15:00 UTC = 900 min). - m.SetSchedule("garage", Schedule{SoCPct: 100, TimeOfDayMinUTC: 900, Recurring: true}) + m.SetSchedule("garage", Schedule{SoC: 1, TimeOfDayMinUTC: 900, Recurring: true}) m.RollSchedules(now) st, _ := m.State("garage") want := time.Date(2026, 5, 13, 15, 0, 0, 0, time.UTC) @@ -206,8 +206,8 @@ func TestManager_SetScheduleOverridesStaleTargetTime_NonRecurring(t *testing.T) m.Load([]Config{{ID: "garage", DriverName: "easee"}}) now := time.Date(2026, 5, 12, 20, 0, 0, 0, time.UTC) staleTarget := time.Date(2026, 5, 13, 5, 0, 0, 0, time.UTC) - m.SetTarget("garage", 100, staleTarget) - m.SetSchedule("garage", Schedule{SoCPct: 100, TimeOfDayMinUTC: 900, Recurring: false}) + m.SetTarget("garage", 1, staleTarget) + m.SetSchedule("garage", Schedule{SoC: 1, TimeOfDayMinUTC: 900, Recurring: false}) m.RollSchedules(now) st, _ := m.State("garage") want := time.Date(2026, 5, 13, 15, 0, 0, 0, time.UTC) @@ -220,7 +220,7 @@ func TestManager_SetScheduleOverridesStaleTargetTime_NonRecurring(t *testing.T) func TestManager_LoadPreservesSchedule(t *testing.T) { m := NewManager() m.Load([]Config{{ID: "garage", DriverName: "easee"}}) - s := Schedule{SoCPct: 50, TimeOfDayMinUTC: 360, Recurring: true} + s := Schedule{SoC: 0.5, TimeOfDayMinUTC: 360, Recurring: true} m.SetSchedule("garage", s) // Re-load (config hot reload): same id should keep its schedule. m.Load([]Config{{ID: "garage", DriverName: "easee", MaxChargeW: 11000}}) diff --git a/go/internal/loadpoint/session_events_test.go b/go/internal/loadpoint/session_events_test.go index 86508dc32..52e4cab0b 100644 --- a/go/internal/loadpoint/session_events_test.go +++ b/go/internal/loadpoint/session_events_test.go @@ -70,7 +70,7 @@ func (r *sessionRig) tick(d time.Duration, plugged bool, powerW, deliveredWh flo // session meter's own kWh — once, however long the finished car stays in. func TestSessionCompletePublishesOnceWithSessionKWh(t *testing.T) { r := newSessionRig(t) - r.mgr.SetTarget("garage", 80, time.Time{}) + r.mgr.SetTarget("garage", 0.8, time.Time{}) r.tick(0, true, 11_000, 0, true) r.tick(5*time.Minute, true, 11_000, 900, true) @@ -98,7 +98,7 @@ func TestSessionCompletePublishesOnceWithSessionKWh(t *testing.T) { // A new session may speak again. r.tick(time.Minute, false, 0, 0, false) - r.mgr.SetTarget("garage", 80, time.Time{}) + r.mgr.SetTarget("garage", 0.8, time.Time{}) r.tick(time.Minute, true, 11_000, 0, true) r.tick(time.Minute, true, 0, 500, false) r.tick(SessionCompletionTimeout, true, 0, 500, false) @@ -180,7 +180,7 @@ func TestBoxOrderedPauseIsNotAnInterruption(t *testing.T) { // session cannot also be an interrupted one. func TestVehicleDeclineIsNotAnInterruption(t *testing.T) { r := newSessionRig(t) - r.mgr.SetTarget("garage", 80, time.Time{}) + r.mgr.SetTarget("garage", 0.8, time.Time{}) r.mgr.SetCommandedW("garage", 11_000) r.tick(0, true, 11_000, 0, true) diff --git a/go/internal/loadpoint/surplus_reserve.go b/go/internal/loadpoint/surplus_reserve.go index dd4bde3fe..9005dd72c 100644 --- a/go/internal/loadpoint/surplus_reserve.go +++ b/go/internal/loadpoint/surplus_reserve.go @@ -128,8 +128,8 @@ func SurplusReserveW(states []State, wakeKickActiveIDs map[string]bool) float64 // (exporting instead of charging the home battery) until it's // unplugged — surfacing the charger's own "done" state into the // loadpoint State would let us skip that too (follow-up). - knownFull := st.VehicleSoCPct > 0 && st.VehicleChargeLimitPct > 0 && - st.VehicleSoCPct >= st.VehicleChargeLimitPct + knownFull := st.VehicleSoC > 0 && st.VehicleChargeLimit > 0 && + st.VehicleSoC >= st.VehicleChargeLimit if knownFull { continue } @@ -190,7 +190,7 @@ func surplusOnlyProtected(st State) bool { // either is unknown — be optimistic when telemetry is partial) // contributes its MaxChargeW. That's the upper bound on what curtail // must preserve PV headroom for. Drivers report "no headroom" by -// setting VehicleSoCPct >= VehicleChargeLimitPct, which excludes +// setting VehicleSoC >= VehicleChargeLimit, which excludes // already-full EVs from the calculation. func SurplusPotentialW(states []State) float64 { var sum float64 @@ -200,8 +200,8 @@ func SurplusPotentialW(states []State) float64 { } // Skip when the vehicle is already at/above its charge limit // — both must be > 0 for the comparison to be meaningful. - if st.VehicleSoCPct > 0 && st.VehicleChargeLimitPct > 0 && - st.VehicleSoCPct >= st.VehicleChargeLimitPct { + if st.VehicleSoC > 0 && st.VehicleChargeLimit > 0 && + st.VehicleSoC >= st.VehicleChargeLimit { continue } head := st.MaxChargeW diff --git a/go/internal/loadpoint/surplus_reserve_test.go b/go/internal/loadpoint/surplus_reserve_test.go index 3a8e937a2..d32a4f349 100644 --- a/go/internal/loadpoint/surplus_reserve_test.go +++ b/go/internal/loadpoint/surplus_reserve_test.go @@ -176,11 +176,11 @@ func TestSurplusReserveWAllowsOnePhaseLadderClimb(t *testing.T) { func TestSurplusReserveWPluggedStoppedWithHeadroomReservesMin(t *testing.T) { states := []State{{ ID: "garage", SurplusOnly: true, PluggedIn: true, - CurrentPowerW: 0, // not drawing - MinChargeW: 1380, - MaxChargeW: 11000, - VehicleSoCPct: 34, // below limit - VehicleChargeLimitPct: 60, + CurrentPowerW: 0, // not drawing + MinChargeW: 1380, + MaxChargeW: 11000, + VehicleSoC: 0.34, // below limit + VehicleChargeLimit: 0.6, }} got := SurplusReserveW(states, nil) if got != 1380 { @@ -194,11 +194,11 @@ func TestSurplusReserveWPluggedStoppedWithHeadroomReservesMin(t *testing.T) { func TestSurplusReserveWPluggedStoppedAtLimitNoReserve(t *testing.T) { states := []State{{ ID: "garage", SurplusOnly: true, PluggedIn: true, - CurrentPowerW: 0, - MinChargeW: 1380, - MaxChargeW: 11000, - VehicleSoCPct: 60, // at limit - VehicleChargeLimitPct: 60, + CurrentPowerW: 0, + MinChargeW: 1380, + MaxChargeW: 11000, + VehicleSoC: 0.6, // at limit + VehicleChargeLimit: 0.6, }} if got := SurplusReserveW(states, nil); got != 0 { t.Errorf("SurplusReserveW = %.0f, want 0 (EV at limit, no headroom)", got) @@ -218,7 +218,7 @@ func TestSurplusReserveWPluggedStoppedSoCUnknownBootstraps(t *testing.T) { CurrentPowerW: 0, MinChargeW: 1380, MaxChargeW: 11000, - // VehicleSoCPct + VehicleChargeLimitPct both 0 (unknown — dumb charger) + // VehicleSoC + VehicleChargeLimit both 0 (unknown — dumb charger) }} if got := SurplusReserveW(states, nil); got != 1380 { t.Errorf("SurplusReserveW = %.0f, want 1380 (MinChargeW bootstrap for dumb charger)", got) diff --git a/go/internal/mpc/shadow_evaluator.go b/go/internal/mpc/shadow_evaluator.go index 7f4de33ca..8e45108f1 100644 --- a/go/internal/mpc/shadow_evaluator.go +++ b/go/internal/mpc/shadow_evaluator.go @@ -31,8 +31,8 @@ type ShadowEvaluation struct { ChampionValuedCostOre float64 `json:"champion_valued_cost_ore"` ChallengerValuedCostOre float64 `json:"challenger_valued_cost_ore"` ChallengerMinusChampionValuedOre float64 `json:"challenger_minus_champion_valued_ore"` - ChampionVirtualSoC float64 `json:"champion_virtual_soc_pct"` - ChallengerVirtualSoC float64 `json:"challenger_virtual_soc_pct"` + ChampionVirtualSoC float64 `json:"champion_virtual_soc"` + ChallengerVirtualSoC float64 `json:"challenger_virtual_soc"` ChampionStorageEnergyWh map[string]float64 `json:"champion_storage_energy_wh,omitempty"` ChallengerStorageEnergyWh map[string]float64 `json:"challenger_storage_energy_wh,omitempty"` ChampionClampCount int64 `json:"champion_clamp_count"` @@ -400,8 +400,8 @@ func (e *StatefulShadowEvaluator) updateVirtualSoCLocked() { } e.summary.ChampionStorageEnergyWh = cloneEnergyMap(e.championWh) e.summary.ChallengerStorageEnergyWh = cloneEnergyMap(e.challengerWh) - e.summary.ChampionVirtualSoC = sumEnergy(e.championWh) / capacity * 100 - e.summary.ChallengerVirtualSoC = sumEnergy(e.challengerWh) / capacity * 100 + e.summary.ChampionVirtualSoC = sumEnergy(e.championWh) / capacity + e.summary.ChallengerVirtualSoC = sumEnergy(e.challengerWh) / capacity e.summary.ChampionTerminalValueOre = e.params.TerminalSoCPrice * sumEnergy(e.championWh) / 1000 e.summary.ChallengerTerminalValueOre = e.params.TerminalSoCPrice * sumEnergy(e.challengerWh) / 1000 e.summary.ChampionValuedCostOre = e.summary.ChampionCostOre - e.summary.ChampionTerminalValueOre diff --git a/go/internal/mpc/shadow_evaluator_test.go b/go/internal/mpc/shadow_evaluator_test.go index f1803d548..0b49ed071 100644 --- a/go/internal/mpc/shadow_evaluator_test.go +++ b/go/internal/mpc/shadow_evaluator_test.go @@ -33,7 +33,7 @@ func TestStatefulShadowEvaluatorScoresSameRealizationWithIndependentSoC(t *testi if math.Abs(score.ChallengerCostOre) > 1e-6 || math.Abs(score.ChallengerMinusChampionOre+100) > 1e-6 { t.Fatalf("unexpected challenger score: %+v", score) } - if math.Abs(score.ChampionVirtualSoC-55) > 1e-6 || math.Abs(score.ChallengerVirtualSoC-45) > 1e-6 { + if math.Abs(score.ChampionVirtualSoC-0.55) > 1e-6 || math.Abs(score.ChallengerVirtualSoC-0.45) > 1e-6 { t.Fatalf("virtual SoC did not evolve independently: %+v", score) } } @@ -57,7 +57,7 @@ func TestStatefulShadowEvaluatorProjectsModeAndEnergyBounds(t *testing.T) { if score.ChampionClampCount != 30 || score.ChallengerClampCount != 30 { t.Fatalf("expected SoC/mode clamps: %+v", score) } - if math.Abs(score.ChampionVirtualSoC-10) > 1e-6 || math.Abs(score.ChallengerVirtualSoC-10) > 1e-6 { + if math.Abs(score.ChampionVirtualSoC-0.10) > 1e-6 || math.Abs(score.ChallengerVirtualSoC-0.10) > 1e-6 { t.Fatalf("minimum SoC was violated: %+v", score) } } @@ -76,7 +76,7 @@ func TestStatefulShadowEvaluatorRestoresPersistedRun(t *testing.T) { evaluator.Restore(persisted) evaluator.SetPlans(plan, plan, slots, p, now) score := evaluator.Snapshot() - if score.StartedAtMs != 123 || score.Samples != 42 || score.ChampionVirtualSoC != 43 || score.ChallengerVirtualSoC != 47 { + if score.StartedAtMs != 123 || score.Samples != 42 || score.ChampionVirtualSoC != 0.43 || score.ChallengerVirtualSoC != 0.47 { t.Fatalf("persisted evaluation was not resumed: %+v", score) } } @@ -121,7 +121,7 @@ func TestShadowEvaluatorDoesNotInventRecoveryFromInitialBandViolation(t *testing evaluator.SetPlans(plan, plan, slots, p, start) _ = evaluator.Observe(start, 500, 0) score := evaluator.Observe(start.Add(time.Minute), 500, 0) - if score.ChampionVirtualSoC != 5 || score.ChallengerVirtualSoC != 5 { + if score.ChampionVirtualSoC != 0.05 || score.ChallengerVirtualSoC != 0.05 { t.Fatalf("evaluator invented operating-band recovery: %+v", score) } if score.ChampionClampCount != 1 || score.ChallengerClampCount != 1 { diff --git a/go/internal/telemetry/store.go b/go/internal/telemetry/store.go index 9d80aac8b..340ac0a26 100644 --- a/go/internal/telemetry/store.go +++ b/go/internal/telemetry/store.go @@ -7,6 +7,8 @@ import ( "math" "sync" "time" + + "github.com/srcfl/ftw/go/internal/units" ) // DerType classifies what kind of reading a DER produces. @@ -86,7 +88,7 @@ type DerReading struct { DerType DerType RawW float64 SmoothedW float64 - SoC *float64 // optional; 0..1 for batteries/V2X, 0..100 for DerVehicle + SoC *float64 // optional; 0..1 fraction for every DER including vehicles SoCUpdatedAt time.Time // last fresh SoC receipt time; preserved across cached updates Data json.RawMessage UpdatedAt time.Time @@ -331,6 +333,9 @@ func ValidateReading(t DerType, rawW float64, soc *float64) error { if soc != nil && !finite(*soc) { return fmt.Errorf("%s soc is non-finite: %v", t, *soc) } + if soc != nil && !units.ValidFraction(*soc) { + return fmt.Errorf("%s soc must be a 0..1 fraction, got %.3f", t, *soc) + } switch t { case DerPV: if rawW > 0 { @@ -340,14 +345,6 @@ func ValidateReading(t DerType, rawW float64, soc *float64) error { if rawW < 0 { return fmt.Errorf("ev power must be >= 0 in site convention, got %.3f W", rawW) } - case DerBattery: - if soc != nil && (*soc < 0 || *soc > 1) { - return fmt.Errorf("battery soc must be a 0..1 fraction, got %.3f", *soc) - } - case DerV2X: - if soc != nil && (*soc < 0 || *soc > 1) { - return fmt.Errorf("v2x_charger vehicle soc must be a 0..1 fraction, got %.3f", *soc) - } } return nil } diff --git a/go/internal/telemetry/telemetry_test.go b/go/internal/telemetry/telemetry_test.go index 55927b6c6..48c592638 100644 --- a/go/internal/telemetry/telemetry_test.go +++ b/go/internal/telemetry/telemetry_test.go @@ -328,7 +328,7 @@ func TestStorePreservesSoCWhenMissing(t *testing.T) { func TestStorePreservesVehicleSoCWhenDriverReplaysCache(t *testing.T) { s := NewStore() - freshSoC := 61.0 + freshSoC := 0.61 s.Update("tesla", DerVehicle, 0, &freshSoC, json.RawMessage(`{"soc_fresh":true}`)) first := s.Get("tesla", DerVehicle) if first == nil || first.SoC == nil || first.SoCUpdatedAt.IsZero() { @@ -339,7 +339,7 @@ func TestStorePreservesVehicleSoCWhenDriverReplaysCache(t *testing.T) { t.Fatalf("fresh vehicle samples = %+v, want power plus SoC", samples) } - cachedSoC := 62.0 // cached replays must not replace the last proven value + cachedSoC := 0.62 // cached replays must not replace the last proven value s.Update("tesla", DerVehicle, 0, &cachedSoC, json.RawMessage(`{"soc_fresh":false}`)) got := s.Get("tesla", DerVehicle) if got == nil || got.SoC == nil || *got.SoC != freshSoC { @@ -360,7 +360,7 @@ func TestStoreFailsClosedOnInvalidVehicleSoCFreshness(t *testing.T) { json.RawMessage(`{`), } { s := NewStore() - soc := 61.0 + soc := 0.61 s.Update("vehicle", DerVehicle, 0, &soc, data) got := s.Get("vehicle", DerVehicle) if got == nil || got.SoC != nil || !got.SoCUpdatedAt.IsZero() { @@ -374,7 +374,7 @@ func TestStoreFailsClosedOnInvalidVehicleSoCFreshness(t *testing.T) { func TestStoreDropsCachedVehicleSoCWithoutFreshPredecessor(t *testing.T) { s := NewStore() - cachedSoC := 61.0 + cachedSoC := 0.61 s.Update("vehicle", DerVehicle, 0, &cachedSoC, json.RawMessage(`{"soc_fresh":false}`)) got := s.Get("vehicle", DerVehicle) if got == nil || got.SoC != nil || !got.SoCUpdatedAt.IsZero() { diff --git a/go/internal/telemetry/vehicle.go b/go/internal/telemetry/vehicle.go index 82dcb9d58..5d349bd9d 100644 --- a/go/internal/telemetry/vehicle.go +++ b/go/internal/telemetry/vehicle.go @@ -3,6 +3,8 @@ package telemetry import ( "encoding/json" "time" + + "github.com/srcfl/ftw/go/internal/units" ) // VehicleMaxAge is the freshness window past which a DerVehicle reading @@ -19,12 +21,12 @@ const VehicleMaxAge = 5 * time.Minute // Empty Driver means "no usable reading" — the caller should fall back // to whatever inferred SoC was already in place. type VehiclePick struct { - Driver string - SoCPct float64 // bounded [0,100] - ChargeLimitPct float64 // bounded [0,100] - ChargingState string - Stale bool // false for usable picks; retained in the result shape - UpdatedAt time.Time // wall-clock of the last fresh SoC observation + Driver string + SoC float64 // bounded [0,1] + ChargeLimit float64 // bounded [0,1] + ChargingState string + Stale bool // false for usable picks; retained in the result shape + UpdatedAt time.Time // wall-clock of the last fresh SoC observation } // VehicleConnectedRank scores how likely a DerVehicle driver is to be @@ -60,9 +62,9 @@ func VehicleConnectedRank(chargingState string) int { // Defenses applied here (do NOT skip — every vehicle driver pulls // from a network trust boundary, whether a local BLE proxy, an // in-LAN OEM gateway, or a cloud API): -// - SoC bounded to [0,100] — a misbehaving driver reporting 200 % or -// -50 % must not be able to overcharge or freeze EV charging. -// - ChargeLimitPct bounded to [0,100] — same risk. +// - SoC bounded to [0,1] — a misbehaving driver reporting 2.0 or +// -0.5 must not be able to overcharge or freeze EV charging. +// - ChargeLimit bounded to [0,1] — same risk. // - Stale by `now − SoCUpdatedAt > VehicleMaxAge` — wallclock check on // the last fresh SoC observation, even when newer power or metadata // updates carry the last-known value forward. A driver that stops @@ -123,6 +125,7 @@ func pickBestVehicle(s *Store, minRank int, now time.Time) VehiclePick { } var meta struct { ChargingState string `json:"charging_state"` + ChargeLimit float64 `json:"charge_limit"` ChargeLimitPct float64 `json:"charge_limit_pct"` Stale bool `json:"stale"` } @@ -139,25 +142,15 @@ func pickBestVehicle(s *Store, minRank int, now time.Time) VehiclePick { if rank < bestRank || (rank == bestRank && !socUpdatedAt.After(best.UpdatedAt)) { continue } - soc := *vr.SoC - if soc < 0 { - soc = 0 - } else if soc > 100 { - soc = 100 - } - limit := meta.ChargeLimitPct - if limit < 0 { - limit = 0 - } else if limit > 100 { - limit = 100 - } + soc := units.ClampFraction(*vr.SoC) + limit := units.ClampFraction(units.DecodeJSONFraction(meta.ChargeLimit, meta.ChargeLimitPct)) best = VehiclePick{ - Driver: vr.Driver, - SoCPct: soc, - ChargeLimitPct: limit, - ChargingState: meta.ChargingState, - Stale: meta.Stale, - UpdatedAt: socUpdatedAt, + Driver: vr.Driver, + SoC: soc, + ChargeLimit: limit, + ChargingState: meta.ChargingState, + Stale: meta.Stale, + UpdatedAt: socUpdatedAt, } bestRank = rank } diff --git a/go/internal/telemetry/vehicle_test.go b/go/internal/telemetry/vehicle_test.go index bac8d2110..640d6dc27 100644 --- a/go/internal/telemetry/vehicle_test.go +++ b/go/internal/telemetry/vehicle_test.go @@ -28,21 +28,19 @@ func TestVehicleConnectedRankOrdering(t *testing.T) { } } -// pushVehicle is a tiny helper that publishes a DerVehicle reading -// with the given fields. Mirrors what tesla_vehicle.lua does on poll -// — DerVehicle SoC is stored in percent (0-100), not 0-1, because -// vehicles report battery_level as percent and the picker bounds -// against [0,100]. +// pushVehicle publishes a DerVehicle reading. soc and limit are 0–1 +// fractions (core SI). charge_limit_pct in the driver blob is the +// legacy vendor door and is converted at PickBestVehicle. func pushVehicle(t *testing.T, s *Store, driver string, soc, limit float64, state string, stale bool, age time.Duration) { t.Helper() - socPct := soc + stored := soc data, _ := json.Marshal(map[string]any{ - "charging_state": state, - "charge_limit_pct": limit, - "stale": stale, + "charging_state": state, + "charge_limit": limit, + "stale": stale, }) - s.Update(driver, DerVehicle, 0, &socPct, data) + s.Update(driver, DerVehicle, 0, &stored, data) // Mark health online + age the reading and SoC observation by // reaching into the store directly; a follow-up Update with a // known timestamp would be invasive; instead the test passes @@ -61,8 +59,8 @@ func pushVehicle(t *testing.T, s *Store, driver string, soc, limit float64, func TestPickBestVehicleHonoursRank(t *testing.T) { s := NewStore() - pushVehicle(t, s, "garage", 50, 80, "Stopped", false, 0) - pushVehicle(t, s, "driveway", 30, 80, "Charging", false, 0) + pushVehicle(t, s, "garage", 0.50, 0.80, "Stopped", false, 0) + pushVehicle(t, s, "driveway", 0.30, 0.80, "Charging", false, 0) pick := PickBestVehicle(s, time.Now()) if pick.Driver != "driveway" { t.Errorf("expected Charging vehicle to win, got %q", pick.Driver) @@ -71,33 +69,45 @@ func TestPickBestVehicleHonoursRank(t *testing.T) { func TestPickBestVehicleSkipsDisconnected(t *testing.T) { s := NewStore() - pushVehicle(t, s, "garage", 50, 80, "Disconnected", false, 0) + pushVehicle(t, s, "garage", 0.50, 0.80, "Disconnected", false, 0) pick := PickBestVehicle(s, time.Now()) if pick.Driver != "" { t.Errorf("Disconnected reading must not be picked, got %q", pick.Driver) } } -func TestPickBestVehicleBoundsSoC(t *testing.T) { +func TestPickBestVehicleDropsInvalidSoC(t *testing.T) { s := NewStore() - // Lying proxy reports out-of-range SoC + limit. - pushVehicle(t, s, "rogue", 250, 200, "Charging", false, 0) - pick := PickBestVehicle(s, time.Now()) - if pick.SoCPct != 100 { - t.Errorf("SoC = %v, want clamped to 100", pick.SoCPct) - } - if pick.ChargeLimitPct != 100 { - t.Errorf("ChargeLimit = %v, want clamped to 100", pick.ChargeLimitPct) + // Store.Update validates 0–1 before the row exists, so a lying + // proxy cannot park 2.5 in core for the picker to paper over. + pushVehicle(t, s, "rogue", 2.50, 0.80, "Charging", false, 0) + if pick := PickBestVehicle(s, time.Now()); pick.Driver != "" { + t.Errorf("invalid SoC must not be stored, got %+v", pick) } s2 := NewStore() - pushVehicle(t, s2, "rogue2", -50, -10, "Charging", false, 0) - pick2 := PickBestVehicle(s2, time.Now()) - if pick2.SoCPct != 0 { - t.Errorf("negative SoC = %v, want clamped to 0", pick2.SoCPct) + pushVehicle(t, s2, "rogue2", -0.50, 0.80, "Charging", false, 0) + if pick := PickBestVehicle(s2, time.Now()); pick.Driver != "" { + t.Errorf("negative SoC must not be stored, got %+v", pick) + } +} + +func TestPickBestVehicleClampsLegacyChargeLimit(t *testing.T) { + s := NewStore() + soc := 0.50 + data, _ := json.Marshal(map[string]any{ + "charging_state": "Charging", + "charge_limit_pct": 200, + "stale": false, + }) + s.Update("rogue", DerVehicle, 0, &soc, data) + s.DriverHealthMut("rogue").RecordSuccess() + pick := PickBestVehicle(s, time.Now()) + if pick.Driver != "rogue" { + t.Fatalf("expected pick, got %+v", pick) } - if pick2.ChargeLimitPct != 0 { - t.Errorf("negative limit = %v, want clamped to 0", pick2.ChargeLimitPct) + if pick.ChargeLimit != 1 { + t.Errorf("legacy 200%% charge limit = %v, want clamped to 1", pick.ChargeLimit) } } @@ -107,7 +117,7 @@ func TestPickBestVehicleSkipsStaleByWallclock(t *testing.T) { // the proxy didn't set the `stale` flag, freshness is decided // by wallclock — a proxy that stops talking can't keep the // last-known SoC live forever. - pushVehicle(t, s, "asleep", 50, 80, "Charging", false, 10*time.Minute) + pushVehicle(t, s, "asleep", 0.50, 0.80, "Charging", false, 10*time.Minute) pick := PickBestVehicle(s, time.Now()) if pick.Driver != "" { t.Errorf("stale-by-wallclock reading must not be picked, got %q", pick.Driver) @@ -116,21 +126,21 @@ func TestPickBestVehicleSkipsStaleByWallclock(t *testing.T) { func TestPickBestVehicleDoesNotFreshenCarriedSoC(t *testing.T) { s := NewStore() - pushVehicle(t, s, "asleep", 50, 80, "Charging", false, 0) + pushVehicle(t, s, "asleep", 0.50, 0.80, "Charging", false, 0) staleAt := time.Now().Add(-VehicleMaxAge - time.Minute) s.mu.Lock() s.readings[key("asleep", DerVehicle)].SoCUpdatedAt = staleAt s.mu.Unlock() data, err := json.Marshal(map[string]any{ - "charging_state": "Charging", - "charge_limit_pct": 80, - "soc_fresh": false, + "charging_state": "Charging", + "charge_limit": 0.80, + "soc_fresh": false, }) if err != nil { t.Fatal(err) } - cachedSoC := 50.0 + cachedSoC := 0.50 s.Update("asleep", DerVehicle, 0, &cachedSoC, data) r := s.Get("asleep", DerVehicle) if r == nil || !r.SoCUpdatedAt.Equal(staleAt) { @@ -146,7 +156,7 @@ func TestPickBestVehicleDoesNotFreshenCarriedSoC(t *testing.T) { func TestPickBestVehicleSkipsDriverMarkedStale(t *testing.T) { s := NewStore() - pushVehicle(t, s, "driver-stale", 50, 80, "Charging", true, 0) + pushVehicle(t, s, "driver-stale", 0.50, 0.80, "Charging", true, 0) if pick := PickBestVehicle(s, time.Now()); pick.Driver != "" { t.Fatalf("driver-marked stale SoC must not be selectable, got %+v", pick) } @@ -161,11 +171,11 @@ func TestPickBestVehicleNilStoreSafe(t *testing.T) { func TestPickBestVehicleTiebreakByFreshness(t *testing.T) { s := NewStore() - pushVehicle(t, s, "a", 40, 80, "Charging", false, 60*time.Second) - pushVehicle(t, s, "b", 60, 80, "Charging", false, 5*time.Second) + pushVehicle(t, s, "a", 0.40, 0.80, "Charging", false, 60*time.Second) + pushVehicle(t, s, "b", 0.60, 0.80, "Charging", false, 5*time.Second) pick := PickBestVehicle(s, time.Now()) if pick.Driver != "b" { - t.Errorf("fresher reading should win tiebreak, got %q (soc=%v)", pick.Driver, pick.SoCPct) + t.Errorf("fresher reading should win tiebreak, got %q (soc=%v)", pick.Driver, pick.SoC) } } @@ -178,9 +188,9 @@ func TestPickBestVehicleTiebreakByFreshness(t *testing.T) { func TestPickBestVehicleForLoadpointGatesByDeliveringPower(t *testing.T) { s := NewStore() // Tesla #1: actively charging on this loadpoint. - pushVehicle(t, s, "tesla-charging", 50, 80, "Charging", false, 0) + pushVehicle(t, s, "tesla-charging", 0.50, 0.80, "Charging", false, 0) // Tesla #2: parked elsewhere, fresher reading but not charging. - pushVehicle(t, s, "tesla-parked", 60, 80, "Stopped", false, 0) + pushVehicle(t, s, "tesla-parked", 0.60, 0.80, "Stopped", false, 0) // Loadpoint NOT delivering power → existing behaviour: rank-based // pick still wins, charging > stopped. @@ -204,7 +214,7 @@ func TestPickBestVehicleForLoadpointStrictExcludesStopped(t *testing.T) { // driveway), with no Charging-state counterpart. When the // loadpoint is delivering power, the gate must reject — there's // no evidence this Stopped car is the one connected. - pushVehicle(t, s, "tesla-parked", 60, 80, "Stopped", false, 0) + pushVehicle(t, s, "tesla-parked", 0.60, 0.80, "Stopped", false, 0) pick := PickBestVehicleForLoadpoint(s, true, time.Now()) if pick.Driver != "" { t.Errorf("delivering loadpoint with only Stopped vehicle: must return zero pick, got %q", pick.Driver) @@ -222,8 +232,8 @@ func TestPickBestVehicleForLoadpointStrictExcludesStopped(t *testing.T) { // freshness, exactly as before). func TestPickBestVehicleForLoadpointStrictTiebreakByFreshness(t *testing.T) { s := NewStore() - pushVehicle(t, s, "a", 40, 80, "Charging", false, 60*time.Second) - pushVehicle(t, s, "b", 60, 80, "Charging", false, 5*time.Second) + pushVehicle(t, s, "a", 0.40, 0.80, "Charging", false, 60*time.Second) + pushVehicle(t, s, "b", 0.60, 0.80, "Charging", false, 5*time.Second) pick := PickBestVehicleForLoadpoint(s, true, time.Now()) if pick.Driver != "b" { t.Errorf("strict gate: fresher reading should win tiebreak, got %q", pick.Driver) diff --git a/go/internal/units/consistency_test.go b/go/internal/units/consistency_test.go index 8c503f75b..eeabe410b 100644 --- a/go/internal/units/consistency_test.go +++ b/go/internal/units/consistency_test.go @@ -5,10 +5,14 @@ import ( "reflect" "testing" + "github.com/srcfl/ftw/go/internal/calendar" "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/forecast" + "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/telemetry" "github.com/srcfl/ftw/go/internal/units" + "github.com/srcfl/ftw/go/internal/v2x" ) // These tests are the harness: if a later change puts kWp or 0–100 SoC @@ -106,3 +110,145 @@ func TestNameplateSumsRatedWatts(t *testing.T) { t.Fatalf("nameplate = %v, want 18960 W", got) } } + +func TestLoadpointStateJSONIsFractionNotPercent(t *testing.T) { + st := loadpoint.State{CurrentSoC: 0.42, TargetSoC: 0.80, VehicleSoC: 0.41, VehicleChargeLimit: 0.60} + raw, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, banned := range []string{"current_soc_pct", "target_soc_pct", "vehicle_soc_pct", "vehicle_charge_limit_pct"} { + if _, ok := m[banned]; ok { + t.Fatalf("loadpoint.State JSON still emits %s: %s", banned, raw) + } + } + if m["current_soc"] != 0.42 { + t.Fatalf("current_soc = %v, want 0.42; json=%s", m["current_soc"], raw) + } + if !units.ValidFraction(m["current_soc"].(float64)) { + t.Fatalf("current_soc is not a 0–1 fraction") + } +} + +func TestLoadpointScheduleJSONIsFraction(t *testing.T) { + s := loadpoint.Schedule{SoC: 0.80, SurplusUnlockBatSoC: 0.75, TimeOfDayMinUTC: 360} + raw, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if _, ok := m["soc_pct"]; ok { + t.Fatalf("Schedule JSON still emits soc_pct: %s", raw) + } + if m["soc"] != 0.80 { + t.Fatalf("soc = %v, want 0.80", m["soc"]) + } + var back loadpoint.Schedule + if err := json.Unmarshal([]byte(`{"soc_pct":80,"time_of_day_min_utc":360}`), &back); err != nil { + t.Fatal(err) + } + if back.SoC != 0.80 { + t.Fatalf("legacy soc_pct 80 must hydrate as 0.80, got %v", back.SoC) + } +} + +func TestLoadpointConfigHasNoPluginSoCPct(t *testing.T) { + typ := reflect.TypeOf(loadpoint.Config{}) + if _, ok := typ.FieldByName("PluginSoCPct"); ok { + t.Fatal("loadpoint.Config.PluginSoCPct must not exist; store PluginSoC as 0–1") + } + if _, ok := typ.FieldByName("PluginSoC"); !ok { + t.Fatal("loadpoint.Config.PluginSoC missing") + } +} + +func TestVehiclePickSoCIsFraction(t *testing.T) { + typ := reflect.TypeOf(telemetry.VehiclePick{}) + for _, banned := range []string{"SoCPct", "ChargeLimitPct"} { + if _, ok := typ.FieldByName(banned); ok { + t.Fatalf("telemetry.VehiclePick.%s must not exist; use SoC/ChargeLimit 0–1", banned) + } + } +} + +func TestCalendarDeadlineSoCIsFraction(t *testing.T) { + typ := reflect.TypeOf(calendar.EVDeadline{}) + if _, ok := typ.FieldByName("TargetSoCPct"); ok { + t.Fatal("calendar.EVDeadline.TargetSoCPct must not exist; store TargetSoC as 0–1") + } + d := calendar.EVDeadline{TargetSoC: 0.80} + raw, err := json.Marshal(d) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if _, ok := m["target_soc_pct"]; ok { + t.Fatalf("EVDeadline JSON still emits target_soc_pct: %s", raw) + } +} + +func TestV2XEnvelopeSoCJSONIsFraction(t *testing.T) { + env := v2x.Envelope{MinReserveSoC: 0.35, DepartureTargetSoC: 0.80} + raw, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if _, ok := m["min_reserve_soc_pct"]; ok { + t.Fatalf("v2x Envelope JSON still emits min_reserve_soc_pct: %s", raw) + } + if m["min_reserve_soc"] != 0.35 { + t.Fatalf("min_reserve_soc = %v, want 0.35", m["min_reserve_soc"]) + } +} + +func TestShadowEvaluationJSONIsFraction(t *testing.T) { + s := mpc.ShadowEvaluation{ChampionVirtualSoC: 0.55, ChallengerVirtualSoC: 0.45} + raw, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if _, ok := m["champion_virtual_soc_pct"]; ok { + t.Fatalf("shadow JSON still emits champion_virtual_soc_pct: %s", raw) + } + if m["champion_virtual_soc"] != 0.55 { + t.Fatalf("champion_virtual_soc = %v, want 0.55", m["champion_virtual_soc"]) + } +} + +func TestCoreBannedSoCPercentFieldNames(t *testing.T) { + types := []reflect.Type{ + reflect.TypeOf(loadpoint.State{}), + reflect.TypeOf(loadpoint.Config{}), + reflect.TypeOf(loadpoint.Schedule{}), + reflect.TypeOf(mpc.Params{}), + reflect.TypeOf(mpc.Action{}), + reflect.TypeOf(mpc.Plan{}), + reflect.TypeOf(forecast.Array{}), + } + banned := []string{"CurrentSoCPct", "TargetSoCPct", "PluginSoCPct", "VehicleSoCPct", "SoCPct", "SoCMinPct", "SoCMaxPct", "KWp"} + for _, typ := range types { + for _, name := range banned { + if _, ok := typ.FieldByName(name); ok { + t.Errorf("%s.%s must not exist in core", typ.String(), name) + } + } + } +} diff --git a/go/internal/units/units.go b/go/internal/units/units.go index 15d6dac61..0197ff696 100644 --- a/go/internal/units/units.go +++ b/go/internal/units/units.go @@ -63,6 +63,20 @@ func FractionFromLegacyPercent(v float64) float64 { return v } +// DecodeJSONFraction is the HTTP/JSON inbound door. Canonical (already 0–1) +// wins when set; otherwise legacyPercent (0–100 or already 0–1) is folded. +func DecodeJSONFraction(canonical, legacyPercent float64) float64 { + v := canonical + if v == 0 && legacyPercent != 0 { + v = legacyPercent + } + return FractionFromLegacyPercent(v) +} + +// DefaultPluginSoC is the conservative EV plug-in assumption when the +// operator has not set plugin_soc. Chargers like Easee do not report BMS. +const DefaultPluginSoC = 0.20 + // PercentFromFraction is the UI/HA door. Core must not store the result. func PercentFromFraction(f float64) float64 { return f * 100.0 diff --git a/go/internal/units/units_test.go b/go/internal/units/units_test.go index 221b8d14b..cc258e3bd 100644 --- a/go/internal/units/units_test.go +++ b/go/internal/units/units_test.go @@ -73,6 +73,18 @@ func TestPermilleDoor(t *testing.T) { } } +func TestDecodeJSONFraction(t *testing.T) { + if got := DecodeJSONFraction(0.80, 0); got != 0.80 { + t.Fatalf("canonical 0.80 = %v", got) + } + if got := DecodeJSONFraction(0, 80); got != 0.80 { + t.Fatalf("legacy 80 = %v", got) + } + if got := DecodeJSONFraction(0.50, 80); got != 0.50 { + t.Fatalf("canonical wins = %v", got) + } +} + func TestValidFraction(t *testing.T) { if !ValidFraction(0) || !ValidFraction(1) || !ValidFraction(0.55) { t.Fatal("expected 0, 1, 0.55 valid") diff --git a/go/internal/v2x/policy.go b/go/internal/v2x/policy.go index 015603bbe..f0644d6d2 100644 --- a/go/internal/v2x/policy.go +++ b/go/internal/v2x/policy.go @@ -44,13 +44,13 @@ type Envelope struct { GridChargingAllowed bool `json:"grid_charging_allowed"` - MinReserveSoCPct float64 `json:"min_reserve_soc_pct,omitempty"` - VehicleSoC *float64 `json:"vehicle_soc,omitempty"` - VehicleCapacityWh float64 `json:"vehicle_capacity_wh,omitempty"` - DepartureTargetSoCPct float64 `json:"departure_target_soc_pct,omitempty"` - DepartureAt string `json:"departure_at,omitempty"` - HoursToDeparture float64 `json:"hours_to_departure,omitempty"` - CycleCostOreKWh float64 `json:"cycle_cost_ore_kwh,omitempty"` + MinReserveSoC float64 `json:"min_reserve_soc,omitempty"` + VehicleSoC *float64 `json:"vehicle_soc,omitempty"` + VehicleCapacityWh float64 `json:"vehicle_capacity_wh,omitempty"` + DepartureTargetSoC float64 `json:"departure_target_soc,omitempty"` + DepartureAt string `json:"departure_at,omitempty"` + HoursToDeparture float64 `json:"hours_to_departure,omitempty"` + CycleCostOreKWh float64 `json:"cycle_cost_ore_kwh,omitempty"` Reasons []string `json:"reasons,omitempty"` } @@ -73,9 +73,9 @@ func Evaluate(policy *config.V2XPolicy, snap Snapshot) Envelope { env.Enabled = true env.ExportAllowed = policy.ExportAllowed env.GridChargingAllowed = policy.GridChargingAllowed - env.MinReserveSoCPct = policy.MinReserveSoC + env.MinReserveSoC = policy.MinReserveSoC env.VehicleCapacityWh = firstPositive(policy.VehicleCapacityWh, snap.CapacityWh) - env.DepartureTargetSoCPct = policy.DepartureTargetSoC + env.DepartureTargetSoC = policy.DepartureTargetSoC env.CycleCostOreKWh = policy.CycleCostOreKWh if policy.DriverName != "" && snap.Driver != policy.DriverName { diff --git a/go/internal/v2x/policy_test.go b/go/internal/v2x/policy_test.go index 144479d7e..53947f62a 100644 --- a/go/internal/v2x/policy_test.go +++ b/go/internal/v2x/policy_test.go @@ -127,7 +127,7 @@ func TestEvaluateExportAndGridChargingLimitsUseCurrentGridPower(t *testing.T) { func basePolicy() *config.V2XPolicy { return &config.V2XPolicy{ Enabled: true, - MinReserveSoC: 0.20, + MinReserveSoC: 0.20, MaxChargeW: 3500, MaxDischargeW: 3200, ExportAllowed: true, diff --git a/web/app.js b/web/app.js index fe6542121..e5caed7f0 100644 --- a/web/app.js +++ b/web/app.js @@ -802,15 +802,15 @@ if (lpEv) { // Prefer vehicle-reported when present; fall back to // the inferred SoC the manager computed from session_wh. - if (lpEv.vehicle_soc_pct > 0) { - evSoc = lpEv.vehicle_soc_pct; + if (lpEv.vehicle_soc > 0) { + evSoc = lpEv.vehicle_soc * 100; evSocSource = "vehicle"; - } else if (lpEv.current_soc_pct > 0) { - evSoc = lpEv.current_soc_pct; + } else if (lpEv.current_soc > 0) { + evSoc = lpEv.current_soc * 100; evSocSource = lpEv.soc_source || "inferred"; } - if (lpEv.vehicle_charge_limit_pct > 0) { - evLimit = lpEv.vehicle_charge_limit_pct; + if (lpEv.vehicle_charge_limit > 0) { + evLimit = lpEv.vehicle_charge_limit * 100; } evSocStale = !!lpEv.vehicle_stale; } @@ -3026,7 +3026,7 @@ var socBox = document.createElement("div"); socBox.style.marginTop = "0.25rem"; - var curSoc = (lp && lp.current_soc_pct != null) ? lp.current_soc_pct : null; + var curSoc = (lp && lp.current_soc != null) ? lp.current_soc * 100 : null; var socSource = (lp && lp.soc_source) ? lp.soc_source : ""; var sourceNote = socSource === "vehicle" ? "Live from the car — drag only to correct drift." @@ -3074,7 +3074,7 @@ apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/soc", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ soc_pct: v }), + body: JSON.stringify({ soc: v / 100 }), }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); }) .then(function (res) { socSetBtn.disabled = false; @@ -3182,11 +3182,11 @@ // Convert "minutes-of-day-UTC" to a "HH:MM" string in the browser's // local zone. The UI shows local time everywhere; we marshal back to // UTC minutes on save. - var hasSched = !!(sched.soc_pct || sched.recurring || sched.surplus_unlock_bat_soc_pct); + var hasSched = !!(sched.soc || sched.recurring || sched.surplus_unlock_bat_soc); var initLocalHHMM = utcMinsToLocalHHMM(typeof sched.time_of_day_min_utc === "number" ? sched.time_of_day_min_utc : 360); - var initSoC = typeof sched.soc_pct === "number" && sched.soc_pct > 0 ? sched.soc_pct : 50; + var initSoC = typeof sched.soc === "number" && sched.soc > 0 ? sched.soc * 100 : 50; var initRec = !!sched.recurring; - var savedUnlock = typeof sched.surplus_unlock_bat_soc_pct === "number" ? sched.surplus_unlock_bat_soc_pct : 0; + var savedUnlock = typeof sched.surplus_unlock_bat_soc === "number" ? sched.surplus_unlock_bat_soc * 100 : 0; // Surplus on/off is derived from the saved threshold: > 0 ⇒ enabled. // The threshold input retains the last-used value (or defaults to 50) // so unchecking + re-checking doesn't wipe the user's pick. @@ -3434,10 +3434,10 @@ var unlockVal = (hasPV && surCb.checked) ? Number(unlockWrap.input.value) : 0; var body = { schedule: { - soc_pct: Number(targetSlider.value), + soc: Number(targetSlider.value) / 100, time_of_day_min_utc: minUTC, recurring: !!recCb.checked, - surplus_unlock_bat_soc_pct: unlockVal, + surplus_unlock_bat_soc: unlockVal > 0 ? unlockVal / 100 : 0, }, }; // CONTROL write — strict (FIX-B): loadpoint schedule save. diff --git a/web/components/ftw-pv-arrays-3d.js b/web/components/ftw-pv-arrays-3d.js index e92253797..130d45ccc 100644 --- a/web/components/ftw-pv-arrays-3d.js +++ b/web/components/ftw-pv-arrays-3d.js @@ -43,14 +43,11 @@ const PANEL_EDGE_MIN = 1.4; // floor so a 0.5 kWp array is still visible const PANEL_EDGE_MAX = 7; // cap so a 30 kWp array doesn't swallow the scene const PANEL_ELEV = 0.4; // height above ground plane for the panel base -// Visual scale is kW-peak. Core stores rated_w (watts); legacy kwp is -// kWp unless the value is ≥ 1000 (watts pasted into that field). +// Visual scale is kW-peak. Core stores rated_w (watts). function arrayKwpForScale(a) { const ratedW = Number(a && a.rated_w); if (ratedW > 0) return ratedW / 1000; - const kwp = Number(a && a.kwp); - if (!(kwp > 0)) return 0; - return kwp >= 1000 ? kwp / 1000 : kwp; + return 0; } // Gap between adjacent panels along the orbit (world units). Small — diff --git a/web/loadpoint-battery-boost.test.mjs b/web/loadpoint-battery-boost.test.mjs index 17d74e96b..5def7d180 100644 --- a/web/loadpoint-battery-boost.test.mjs +++ b/web/loadpoint-battery-boost.test.mjs @@ -6,7 +6,7 @@ const source = readFileSync(new URL('./loadpoints.js', import.meta.url), 'utf8') test('loadpoint UI exposes a bounded battery boost lease with reserve', () => { assert.match(source, /\/battery_boost/); - assert.match(source, /min_battery_soc_pct: reserve/); + assert.match(source, /min_battery_soc: reserve \/ 100/); assert.match(source, /duration_s: duration/); assert.match(source, /value="14400">4 h/); assert.match(source, /EV target %/); diff --git a/web/loadpoints.js b/web/loadpoints.js index b71377692..40c961675 100644 --- a/web/loadpoints.js +++ b/web/loadpoints.js @@ -5,7 +5,7 @@ // Data sources: // GET /api/loadpoints — array of LP states (id, driver, surplus_only, // plugged_in, max_charge_w, allowed_steps_w, -// current_soc_pct, target_soc_pct, target_time, +// current_soc, target_soc, target_time, // vehicle_*, soc_source). // GET /api/mpc/plan — current plan; actions[] include optional // loadpoint_w + loadpoint_soc (0–1) when an LP @@ -120,10 +120,10 @@ function batteryBoostBlock(lp) { const boost = lp.battery_boost || { state: 'inactive', active: false }; if (boost.active) { - const target = boost.ev_target_soc_pct > 0 ? ` · EV target ${boost.ev_target_soc_pct.toFixed(0)}%` : ''; + const target = boost.ev_target_soc > 0 ? ` · EV target ${fmtPct(boost.ev_target_soc)}` : ''; return '
' + '
Battery boost ' + badge('ACTIVE', true) + '
' + - `
${fmtRemaining(boost.expires_at_ms)} left · home reserve ${boost.min_battery_soc_pct.toFixed(0)}%${target}
` + + `
${fmtRemaining(boost.expires_at_ms)} left · home reserve ${fmtPct(boost.min_battery_soc)}${target}
` + `` + '
'; } @@ -161,35 +161,35 @@ function configBlock(lp) { const d = fmtDeadline(lp.target_time); - const target = (lp.target_soc_pct > 0) - ? `${lp.target_soc_pct.toFixed(0)}%${d ? ' by ' + d : ''}` + const target = (lp.target_soc > 0) + ? `${fmtPct(lp.target_soc)}${d ? ' by ' + d : ''}` : 'opportunistic'; const vehicle = (lp.vehicle_driver) ? `${escapeHtml(lp.vehicle_driver)}${lp.vehicle_charging_state ? ' · ' + escapeHtml(lp.vehicle_charging_state) : ''}${lp.vehicle_stale ? ' · stale' : ''}` : '—'; - // When soc_source is "vehicle", the BMS reading (vehicle_soc_pct) + // When soc_source is "vehicle", the BMS reading (vehicle_soc) // is ground truth and what the operator expects to see — render - // that. current_soc_pct stays as the controller's inference state + // that. current_soc stays as the controller's inference state // (the planner's input for stability across ticks) and is shown // in parens as "(inferred: 65.1%)" so the discrepancy is visible // when it exists. When soc_source is "inferred" the inference // value is the only one we have, so display it directly. let soc = '—'; - if (lp.soc_source === 'vehicle' && lp.vehicle_soc_pct != null) { - soc = `${lp.vehicle_soc_pct.toFixed(0)}% (vehicle)`; - if (lp.current_soc_pct != null && - Math.abs(lp.vehicle_soc_pct - lp.current_soc_pct) >= 1) { - soc += ` · inferred ${lp.current_soc_pct.toFixed(1)}%`; + if (lp.soc_source === 'vehicle' && lp.vehicle_soc != null) { + soc = `${fmtPct(lp.vehicle_soc)} (vehicle)`; + if (lp.current_soc != null && + Math.abs(lp.vehicle_soc - lp.current_soc) >= 0.01) { + soc += ` · inferred ${fmtPct(lp.current_soc)}`; } - } else if (lp.current_soc_pct != null) { - soc = `${lp.current_soc_pct.toFixed(1)}%${lp.soc_source ? ' (' + escapeHtml(lp.soc_source) + ')' : ''}`; + } else if (lp.current_soc != null) { + soc = `${fmtPct(lp.current_soc)}${lp.soc_source ? ' (' + escapeHtml(lp.soc_source) + ')' : ''}`; } // Inline manual SoC correction. The backend (POST /api/loadpoints/{id}/soc) // re-anchors the inferred SoC, so it only works during an active session — // show the ✎ affordance only when plugged in. let socCell = soc; if (lp.plugged_in) { - const cur = (lp.current_soc_pct != null) ? lp.current_soc_pct.toFixed(1) : ''; + const cur = (lp.current_soc != null) ? (lp.current_soc * 100).toFixed(1) : ''; socCell = `${soc} `; } @@ -350,7 +350,7 @@ apiFetch('/api/loadpoints/' + encodeURIComponent(btn.dataset.lp) + '/soc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ soc_pct: val }), + body: JSON.stringify({ soc: val / 100 }), }) .then(r => r.json().then(j => ({ ok: r.ok, body: j }))) .then(res => { @@ -385,8 +385,8 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ duration_s: duration, - min_battery_soc_pct: reserve, - ev_target_soc_pct: target, + min_battery_soc: reserve / 100, + ev_target_soc: target ? target / 100 : 0, departure_at_ms: departure, }), }) From 32ef9a6275c4c77db15d611e116b186b034b021b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 19 Aug 2026 14:01:05 +0200 Subject: [PATCH 03/22] fix(units): drop _pct names on 0-1 planner and dispatch SoC Slot directives, live PV surplus caps, and the operator absorb cap already stored 0-1. The Pct suffix made them look like 0-100. Rename the fields, convert golden dump keys, and fail the units harness if those names return. --- go/cmd/ftw/control_state.go | 2 +- go/cmd/ftw/control_state_test.go | 4 +- go/cmd/ftw/main.go | 22 +- go/internal/config/config.go | 4 +- go/internal/configreload/watcher.go | 2 +- go/internal/control/control_test.go | 68 +-- go/internal/control/dispatch.go | 35 +- go/internal/control/golden_dump_test.go | 34 +- .../golden/early_exit_protections.json | 44 +- .../testdata/golden/incident_scenarios.json | 86 ++-- .../testdata/golden/seeded_ev_boost.json | 166 +++---- .../control/testdata/golden/seeded_fuse.json | 244 +++++----- .../testdata/golden/seeded_planner.json | 384 +++++++-------- .../testdata/golden/seeded_reactive.json | 200 ++++---- .../testdata/golden/seeded_siblings.json | 144 +++--- .../control/testdata/golden/slew_limiter.json | 448 +++++++++--------- .../testdata/golden/targeted_invariants.json | 48 +- go/internal/loadpoint/loadpoint.go | 6 +- go/internal/mpc/loadpoint_service_test.go | 64 +-- go/internal/mpc/service.go | 38 +- go/internal/mpc/service_test.go | 16 +- go/internal/units/consistency_test.go | 3 +- 22 files changed, 1030 insertions(+), 1032 deletions(-) diff --git a/go/cmd/ftw/control_state.go b/go/cmd/ftw/control_state.go index 33962e74a..2f6acb2ce 100644 --- a/go/cmd/ftw/control_state.go +++ b/go/cmd/ftw/control_state.go @@ -31,7 +31,7 @@ func newControlStateFromConfig(cfg *config.Config) *control.State { // `<= 0 -> default` shortcut clobbered the disable case. ctrl.SiteFuseSafetyA = cfg.Fuse.EffectiveSafetyMarginA() // PV surplus absorber underlay (opt-in). cap == 0 keeps it off. - ctrl.PVSurplusAbsorbSoCCapPct = cfg.Site.PVSurplusAbsorbSoCCap + ctrl.PVSurplusAbsorbSoCCap = cfg.Site.PVSurplusAbsorbSoCCap ctrl.PVSurplusAbsorbThresholdW = cfg.Site.PVSurplusAbsorbThresholdW // DC-link protective curtail — opt-in, default off. SoC threshold // and margin fall back to dispatch defaults (0.80 / 1000 W) when diff --git a/go/cmd/ftw/control_state_test.go b/go/cmd/ftw/control_state_test.go index 5ebcfc686..07f975ae1 100644 --- a/go/cmd/ftw/control_state_test.go +++ b/go/cmd/ftw/control_state_test.go @@ -32,8 +32,8 @@ func TestControlSlotDirectiveFromMPCPreservesDecisionIdentity(t *testing.T) { in := mpc.SlotDirective{ DecisionID: "00000000-0000-4000-8000-000000000123", SlotStart: start, SlotEnd: start.Add(15 * time.Minute), - BatteryEnergyWh: 100, SoCTargetPct: 0.42, Strategy: mpc.ModePassiveArbitrage, - PVLimitW: 7000, GridW: -6400, LivePVSurplusSoCCapPct: 0.65, + BatteryEnergyWh: 100, SoCTarget: 0.42, Strategy: mpc.ModePassiveArbitrage, + PVLimitW: 7000, GridW: -6400, LivePVSurplusSoCCap: 0.65, LoadpointEnergyWh: loadpoints, } diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 0d58feb63..92bf2ca7c 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -139,17 +139,17 @@ func decimalDigits(s string) bool { // decision ID is report metadata; control does not use it for dispatch math. func controlSlotDirectiveFromMPC(d mpc.SlotDirective) control.SlotDirective { return control.SlotDirective{ - DecisionID: d.DecisionID, - SlotStart: d.SlotStart, - SlotEnd: d.SlotEnd, - BatteryEnergyWh: d.BatteryEnergyWh, - SoCTargetPct: d.SoCTargetPct, - Strategy: string(d.Strategy), - PVLimitW: d.PVLimitW, - PlannedGridW: d.GridW, - HasPlannedGridW: true, - LivePVSurplusSoCCapPct: d.LivePVSurplusSoCCapPct, - LoadpointEnergyWh: d.LoadpointEnergyWh, + DecisionID: d.DecisionID, + SlotStart: d.SlotStart, + SlotEnd: d.SlotEnd, + BatteryEnergyWh: d.BatteryEnergyWh, + SoCTarget: d.SoCTarget, + Strategy: string(d.Strategy), + PVLimitW: d.PVLimitW, + PlannedGridW: d.GridW, + HasPlannedGridW: true, + LivePVSurplusSoCCap: d.LivePVSurplusSoCCap, + LoadpointEnergyWh: d.LoadpointEnergyWh, } } diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 7c126d6f0..6ed18af55 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -725,8 +725,8 @@ type Site struct { // still enable a slot when capture displaces a more expensive future // grid-funded charge. // - // Suggested 88 — leaves 2 pp margin below the planner's typical - // soc_max_pct = 90 so the absorber doesn't slam into the wall. + // Suggested 0.88 — leaves a little margin below the planner's typical + // soc_max = 0.90 so the absorber doesn't slam into the wall. PVSurplusAbsorbSoCCap float64 `yaml:"pv_surplus_absorb_soc_cap,omitempty" json:"pv_surplus_absorb_soc_cap,omitempty"` PVSurplusAbsorbSoCCapPct float64 `yaml:"pv_surplus_absorb_soc_cap_pct,omitempty" json:"pv_surplus_absorb_soc_cap_pct,omitempty"` diff --git a/go/internal/configreload/watcher.go b/go/internal/configreload/watcher.go index b46d46763..09e186778 100644 --- a/go/internal/configreload/watcher.go +++ b/go/internal/configreload/watcher.go @@ -207,7 +207,7 @@ func Apply( slog.Info("config reload: pv_surplus_absorb_soc_cap", "old", oldCfg.Site.PVSurplusAbsorbSoCCap, "new", newCfg.Site.PVSurplusAbsorbSoCCap) - ctrl.PVSurplusAbsorbSoCCapPct = newCfg.Site.PVSurplusAbsorbSoCCap + ctrl.PVSurplusAbsorbSoCCap = newCfg.Site.PVSurplusAbsorbSoCCap } if newCfg.Site.PVSurplusAbsorbThresholdW != oldCfg.Site.PVSurplusAbsorbThresholdW { ctrl.PVSurplusAbsorbThresholdW = newCfg.Site.PVSurplusAbsorbThresholdW diff --git a/go/internal/control/control_test.go b/go/internal/control/control_test.go index 2fa8409d6..f10c3c15a 100644 --- a/go/internal/control/control_test.go +++ b/go/internal/control/control_test.go @@ -5291,7 +5291,7 @@ func TestMeterClampRespectsNonZeroGridTarget(t *testing.T) { // ---- PV surplus absorber underlay ---- // // Opt-in policy reversal of TestEnergyDispatchDoesNotAbsorbPVSurprise: when -// the operator sets a SoC cap (PVSurplusAbsorbSoCCapPct > 0), the dispatch +// the operator sets a SoC cap (PVSurplusAbsorbSoCCap > 0), the dispatch // catches the gap between the planner's 15-min slot allocation and the // live PV/load drift — additional export beyond plan flows into the // battery instead of out the meter at low spot price. Only kicks in @@ -5321,7 +5321,7 @@ func TestPVSurplusAbsorberAbsorbsExtraExportWhenEnabled(t *testing.T) { store.DriverHealthMut("pv-1").RecordSuccess() st := newStateWithEnergyDispatch(dir, "ferroamp") - st.PVSurplusAbsorbSoCCapPct = 0.88 // enable + st.PVSurplusAbsorbSoCCap = 0.88 // enable st.PVSurplusAbsorbThresholdW = 100 targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) @@ -5350,13 +5350,13 @@ func TestPVSurplusAbsorberAbsorbsExtraExportWhenEnabled(t *testing.T) { func TestPVSurplusAbsorberDisplacesLaterGridCharge(t *testing.T) { now := time.Now() dir := SlotDirective{ - SlotStart: now, - SlotEnd: now.Add(15 * time.Minute), - BatteryEnergyWh: 200, // plan only asked for ~800 W now - Strategy: "arbitrage", - PlannedGridW: -1000, // preserve 1 kW of planned PV export - HasPlannedGridW: true, - LivePVSurplusSoCCapPct: 0.8, // energy-derived replacement ceiling + SlotStart: now, + SlotEnd: now.Add(15 * time.Minute), + BatteryEnergyWh: 200, // plan only asked for ~800 W now + Strategy: "arbitrage", + PlannedGridW: -1000, // preserve 1 kW of planned PV export + HasPlannedGridW: true, + LivePVSurplusSoCCap: 0.8, // energy-derived replacement ceiling } store := seedStore(-4000, []struct { name string @@ -5368,7 +5368,7 @@ func TestPVSurplusAbsorberDisplacesLaterGridCharge(t *testing.T) { store.DriverHealthMut("pv-1").RecordSuccess() st := newStateWithEnergyDispatch(dir, "ferroamp") - // No PVSurplusAbsorbSoCCapPct operator override: the plan directive + // No PVSurplusAbsorbSoCCap operator override: the plan directive // alone must enable this economically justified slot. targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) if len(targets) != 1 { @@ -5379,7 +5379,7 @@ func TestPVSurplusAbsorberDisplacesLaterGridCharge(t *testing.T) { } } -// Defaults preserve back-compat: PVSurplusAbsorbSoCCapPct = 0 means +// Defaults preserve back-compat: PVSurplusAbsorbSoCCap = 0 means // disabled; original "don't absorb surprise" behavior holds. func TestPVSurplusAbsorberDisabledByDefault(t *testing.T) { now := time.Now() @@ -5399,7 +5399,7 @@ func TestPVSurplusAbsorberDisabledByDefault(t *testing.T) { store.DriverHealthMut("pv-1").RecordSuccess() st := newStateWithEnergyDispatch(dir, "ferroamp") - // No PVSurplusAbsorbSoCCapPct set → defaults to 0 → feature off. + // No PVSurplusAbsorbSoCCap set → defaults to 0 → feature off. targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) got := targets[0].TargetW @@ -5431,7 +5431,7 @@ func TestPVSurplusAbsorberHoldsAtCap(t *testing.T) { store.DriverHealthMut("pv-1").RecordSuccess() st := newStateWithEnergyDispatch(dir, "ferroamp") - st.PVSurplusAbsorbSoCCapPct = 0.88 + st.PVSurplusAbsorbSoCCap = 0.88 targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) got := targets[0].TargetW @@ -5582,12 +5582,12 @@ func TestEnergyDispatchAbsorbsSurplusBeyondEVReserveActualDraw(t *testing.T) { func TestEnergyDispatchHonoursPlannedGridChargeWithIdleSurplusOnlyEV(t *testing.T) { now := time.Now() dir := SlotDirective{ - SlotStart: now, - SlotEnd: now.Add(15 * time.Minute), - BatteryEnergyWh: 1250, // ~5 kW over 15 min - Strategy: "arbitrage", - HasPlannedGridW: true, - PlannedGridW: 5500, + SlotStart: now, + SlotEnd: now.Add(15 * time.Minute), + BatteryEnergyWh: 1250, // ~5 kW over 15 min + Strategy: "arbitrage", + HasPlannedGridW: true, + PlannedGridW: 5500, } store := seedStore(500, []struct { name string @@ -5692,7 +5692,7 @@ func TestPVSurplusAbsorberDoesNotReverseDischarge(t *testing.T) { store.DriverHealthMut("pv-1").RecordSuccess() st := newStateWithEnergyDispatch(dir, "ferroamp") - st.PVSurplusAbsorbSoCCapPct = 0.88 + st.PVSurplusAbsorbSoCCap = 0.88 targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) got := targets[0].TargetW @@ -6332,13 +6332,13 @@ func TestPlannerArbitrageIdleSlotDoesNotAbsorbLiveSurplus(t *testing.T) { func TestPlannerPassiveArbitrageIdleSlotPreservesPlannedExportWithAbsorber(t *testing.T) { now := time.Now() dir := SlotDirective{ - SlotStart: now, - SlotEnd: now.Add(15 * time.Minute), - BatteryEnergyWh: 0, - Strategy: "passive_arbitrage", - PlannedGridW: -2000, - HasPlannedGridW: true, - LivePVSurplusSoCCapPct: 0.8, + SlotStart: now, + SlotEnd: now.Add(15 * time.Minute), + BatteryEnergyWh: 0, + Strategy: "passive_arbitrage", + PlannedGridW: -2000, + HasPlannedGridW: true, + LivePVSurplusSoCCap: 0.8, } // Meter exports only 100 W because the battery is already absorbing // 1900 W. Without battery charge the site would export the planned 2 kW. @@ -6370,13 +6370,13 @@ func TestPlannerPassiveArbitrageIdleSlotPreservesPlannedExportWithAbsorber(t *te func TestPlannerArbitrageIdleSlotAbsorbsSurplusThatDisplacesGridCharge(t *testing.T) { now := time.Now() dir := SlotDirective{ - SlotStart: now, - SlotEnd: now.Add(15 * time.Minute), - BatteryEnergyWh: 0, - Strategy: "arbitrage", - PlannedGridW: 2000, // plan forecast a load deficit - HasPlannedGridW: true, - LivePVSurplusSoCCapPct: 0.8, + SlotStart: now, + SlotEnd: now.Add(15 * time.Minute), + BatteryEnergyWh: 0, + Strategy: "arbitrage", + PlannedGridW: 2000, // plan forecast a load deficit + HasPlannedGridW: true, + LivePVSurplusSoCCap: 0.8, } store := seedStore(-2000, []struct { name string diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 6ab2dd9b9..3d0bf5d87 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -123,7 +123,7 @@ type SlotDirective struct { SlotStart time.Time SlotEnd time.Time BatteryEnergyWh float64 // site-signed: + = charge, − = discharge - SoCTargetPct float64 + SoCTarget float64 Strategy string // echoed for logging / API; mirrors mpc.Mode PVLimitW float64 // 0 = no curtail; > 0 = cap aggregate PV output @@ -158,12 +158,12 @@ type SlotDirective struct { PlannedGridW float64 HasPlannedGridW bool - // LivePVSurplusSoCCapPct mirrors mpc.SlotDirective. When non-zero, + // LivePVSurplusSoCCap mirrors mpc.SlotDirective. When non-zero, // the plan has proved that enough later grid-funded battery charge clears // the economic threshold. Runtime may absorb live PV surprise now up to // the current planned SoC plus that replaceable energy. Zero preserves // deliberate export. - LivePVSurplusSoCCapPct float64 + LivePVSurplusSoCCap float64 // LoadpointEnergyWh is the current slot's planned EV charging budget. // Runtime uses planned EV energy to keep surplus-only EV from being @@ -504,7 +504,7 @@ type State struct { // and flipped via config. Default off preserves today's behavior. UseEnergyDispatch bool - // PVSurplusAbsorbSoCCapPct is the operator override for the PV-surplus absorber + // PVSurplusAbsorbSoCCap is the operator override for the PV-surplus absorber // underlay: in planner_cheap / planner_arbitrage, when live grid is // exporting more than PVSurplusAbsorbThresholdW BEYOND what the // planner's slot allocation would produce, AND the fleet's average @@ -513,14 +513,14 @@ type State struct { // the battery instead of crossing the meter at low spot price. // // 0 = no operator override. The planner may still enable capture for - // a slot via SlotDirective.LivePVSurplusSoCCapPct when doing so moves + // a slot via SlotDirective.LivePVSurplusSoCCap when doing so moves // a more expensive future grid charge into live PV. >0 turns the - // absorber on unconditionally with that percentage as the SoC ceiling. + // absorber on unconditionally with that 0–1 SoC as the ceiling. // // Never reverses a discharge plan: when the planner has already // committed to discharge this slot (targetTotalW < 0, e.g. // evening-peak export), the absorber stays passive. - PVSurplusAbsorbSoCCapPct float64 + PVSurplusAbsorbSoCCap float64 // PVSurplusAbsorbThresholdW is the dead-band on the absorber: how // much live export beyond plan we tolerate before charging the @@ -1739,7 +1739,7 @@ func ComputeDispatch( arbitrageFamilyIdleLiveExportGate := false arbitrageFamilyIdleAbsorbCapPct := 0.0 if arbitrageFamilyIdleSlot { - arbitrageFamilyIdleAbsorbCapPct = pvSurplusAbsorbCapPct(state, currentDirective) + arbitrageFamilyIdleAbsorbCapPct = pvSurplusAbsorbCap(state, currentDirective) } if arbitrageFamilyIdleSlot || coverLoadDischargeSlot { baselineGridW := gridW - currentTotal @@ -1920,7 +1920,7 @@ func ComputeDispatch( // Order: runs BEFORE the surplus-only EV reserve cap below, so // any addition the absorber makes is then re-capped if an EV is // reserving PV headroom for itself. - absorbCapPct := pvSurplusAbsorbCapPct(state, currentDirective) + absorbCapPct := pvSurplusAbsorbCap(state, currentDirective) if absorbCapPct > 0 && targetTotalW >= 0 { threshold := state.PVSurplusAbsorbThresholdW if threshold <= 0 { @@ -3590,23 +3590,20 @@ func unexpectedIdleExportBeyondPlanW(surplus surplusAccounting, dir SlotDirectiv return unexpectedW } -func pvSurplusAbsorbCapPct(state *State, dir SlotDirective) float64 { +func pvSurplusAbsorbCap(state *State, dir SlotDirective) float64 { if state == nil { return 0 } - operatorCap := units.FractionFromLegacyPercent(state.PVSurplusAbsorbSoCCapPct) - plannerCap := units.FractionFromLegacyPercent(dir.LivePVSurplusSoCCapPct) - var capPct float64 + operatorCap := units.ClampFraction(state.PVSurplusAbsorbSoCCap) + plannerCap := units.ClampFraction(dir.LivePVSurplusSoCCap) + var cap float64 switch { case operatorCap > 0: - capPct = operatorCap + cap = operatorCap case plannerCap > 0: - capPct = plannerCap + cap = plannerCap } - if capPct > 1 { - return 1 - } - return capPct + return cap } // pvSurplusAbsorbHeadroomW converts the fleet's quantified SoC headroom into diff --git a/go/internal/control/golden_dump_test.go b/go/internal/control/golden_dump_test.go index 6ddb9c6de..c3448bb64 100644 --- a/go/internal/control/golden_dump_test.go +++ b/go/internal/control/golden_dump_test.go @@ -78,12 +78,12 @@ type goldenPhases struct { type goldenSlot struct { // Present=false models a stale/missing plan (SlotDirectiveFunc ok=false). - Present bool `json:"present"` - BatteryEnergyWh float64 `json:"battery_energy_wh"` - Strategy string `json:"strategy"` - PlannedGridW float64 `json:"planned_grid_w"` - HasPlannedGridW bool `json:"has_planned_grid_w"` - LivePVSurplusSoCCapPct float64 `json:"live_pv_surplus_soc_cap_pct"` + Present bool `json:"present"` + BatteryEnergyWh float64 `json:"battery_energy_wh"` + Strategy string `json:"strategy"` + PlannedGridW float64 `json:"planned_grid_w"` + HasPlannedGridW bool `json:"has_planned_grid_w"` + LivePVSurplusSoCCap float64 `json:"live_pv_surplus_soc_cap"` // Nominal slot geometry relative to the dispatch call: // SlotStart = now - elapsed_s, SlotEnd = now + remaining_s. ElapsedS float64 `json:"elapsed_s"` @@ -117,7 +117,7 @@ type goldenStateInputs struct { SiteFuseVoltage float64 `json:"site_fuse_voltage"` SiteFusePhases int `json:"site_fuse_phases"` SiteFuseSafetyA float64 `json:"site_fuse_safety_a"` - PVSurplusAbsorbSoCCapPct float64 `json:"pv_surplus_absorb_soc_cap_pct"` + PVSurplusAbsorbSoCCap float64 `json:"pv_surplus_absorb_soc_cap"` PVSurplusAbsorbThresholdW float64 `json:"pv_surplus_absorb_threshold_w"` InverterGroups map[string]string `json:"inverter_groups,omitempty"` PriorityOrder []string `json:"priority_order,omitempty"` @@ -292,7 +292,7 @@ func runGoldenScenarioAt(sc goldenScenario, scenarioNow time.Time, slotDirective st.SiteFuseVoltage = si.SiteFuseVoltage st.SiteFusePhases = si.SiteFusePhases st.SiteFuseSafetyA = si.SiteFuseSafetyA - st.PVSurplusAbsorbSoCCapPct = si.PVSurplusAbsorbSoCCapPct + st.PVSurplusAbsorbSoCCap = si.PVSurplusAbsorbSoCCap st.PVSurplusAbsorbThresholdW = si.PVSurplusAbsorbThresholdW if len(limits) > 0 { st.DriverLimits = limits @@ -320,13 +320,13 @@ func runGoldenScenarioAt(sc goldenScenario, scenarioNow time.Time, slotDirective time.Sleep(slotDirectiveDelay) } return SlotDirective{ - SlotStart: scenarioNow.Add(-time.Duration(slot.ElapsedS * float64(time.Second))), - SlotEnd: scenarioNow.Add(time.Duration(slot.RemainingS * float64(time.Second))), - BatteryEnergyWh: slot.BatteryEnergyWh, - Strategy: slot.Strategy, - PlannedGridW: slot.PlannedGridW, - HasPlannedGridW: slot.HasPlannedGridW, - LivePVSurplusSoCCapPct: slot.LivePVSurplusSoCCapPct, + SlotStart: scenarioNow.Add(-time.Duration(slot.ElapsedS * float64(time.Second))), + SlotEnd: scenarioNow.Add(time.Duration(slot.RemainingS * float64(time.Second))), + BatteryEnergyWh: slot.BatteryEnergyWh, + Strategy: slot.Strategy, + PlannedGridW: slot.PlannedGridW, + HasPlannedGridW: slot.HasPlannedGridW, + LivePVSurplusSoCCap: slot.LivePVSurplusSoCCap, }, true } } @@ -1815,12 +1815,12 @@ func seededPlanner() []goldenScenario { slot.PlannedGridW = roundW(rng.Float64()*6000 - 3000) } if rng.Float64() < 0.15 { - slot.LivePVSurplusSoCCapPct = 0.60 + rng.Float64()*0.35 + slot.LivePVSurplusSoCCap = 0.60 + rng.Float64()*0.35 } in.Slot = slot } if rng.Float64() < 0.10 { - in.State.PVSurplusAbsorbSoCCapPct = 0.70 + rng.Float64()*0.20 + in.State.PVSurplusAbsorbSoCCap = 0.70 + rng.Float64()*0.20 } out = append(out, goldenScenario{ ID: fmt.Sprintf("seeded_planner/%03d_%s", i, mode), Seed: seed, Inputs: in, diff --git a/go/internal/control/testdata/golden/early_exit_protections.json b/go/internal/control/testdata/golden/early_exit_protections.json index 8c2b96c25..e1147757e 100644 --- a/go/internal/control/testdata/golden/early_exit_protections.json +++ b/go/internal/control/testdata/golden/early_exit_protections.json @@ -45,7 +45,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -123,7 +123,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -193,7 +193,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -271,7 +271,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -353,7 +353,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -437,7 +437,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -512,7 +512,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -595,7 +595,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -670,7 +670,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 1, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -759,7 +759,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -834,7 +834,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 1, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -917,7 +917,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 1, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -992,7 +992,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1001,7 +1001,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -1080,7 +1080,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1163,7 +1163,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1246,7 +1246,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 1, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1329,7 +1329,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1405,7 +1405,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1488,7 +1488,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1571,7 +1571,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 1, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1654,7 +1654,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, diff --git a/go/internal/control/testdata/golden/incident_scenarios.json b/go/internal/control/testdata/golden/incident_scenarios.json index ad46b8f99..2553d4c4f 100644 --- a/go/internal/control/testdata/golden/incident_scenarios.json +++ b/go/internal/control/testdata/golden/incident_scenarios.json @@ -51,7 +51,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -60,7 +60,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -137,7 +137,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -146,7 +146,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -223,7 +223,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -232,7 +232,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -317,7 +317,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -326,7 +326,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -411,7 +411,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -420,7 +420,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -503,7 +503,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -512,7 +512,7 @@ "strategy": "self_consumption", "planned_grid_w": -2000, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -589,7 +589,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -671,7 +671,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -747,7 +747,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -823,7 +823,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -899,7 +899,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -908,7 +908,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -987,7 +987,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -996,7 +996,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -1073,7 +1073,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1082,7 +1082,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1161,7 +1161,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1239,7 +1239,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1248,7 +1248,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1327,7 +1327,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1403,7 +1403,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1481,7 +1481,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1490,7 +1490,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1575,7 +1575,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1584,7 +1584,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1661,7 +1661,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1743,7 +1743,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1836,7 +1836,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1912,7 +1912,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1921,7 +1921,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -1998,7 +1998,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2007,7 +2007,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -2084,7 +2084,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2093,7 +2093,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -2170,7 +2170,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2179,7 +2179,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2256,7 +2256,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, diff --git a/go/internal/control/testdata/golden/seeded_ev_boost.json b/go/internal/control/testdata/golden/seeded_ev_boost.json index 7aa80ab95..83492f59c 100644 --- a/go/internal/control/testdata/golden/seeded_ev_boost.json +++ b/go/internal/control/testdata/golden/seeded_ev_boost.json @@ -57,7 +57,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -138,7 +138,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -147,7 +147,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -232,7 +232,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -241,7 +241,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -345,7 +345,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -438,7 +438,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -447,7 +447,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -529,7 +529,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -538,7 +538,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -623,7 +623,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -712,7 +712,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -721,7 +721,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -812,7 +812,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -821,7 +821,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -918,7 +918,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1007,7 +1007,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1016,7 +1016,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1101,7 +1101,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1110,7 +1110,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1201,7 +1201,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1294,7 +1294,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1303,7 +1303,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1401,7 +1401,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1410,7 +1410,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1495,7 +1495,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1571,7 +1571,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1580,7 +1580,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1678,7 +1678,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1687,7 +1687,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1790,7 +1790,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1896,7 +1896,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1905,7 +1905,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2006,7 +2006,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2015,7 +2015,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2107,7 +2107,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2189,7 +2189,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2198,7 +2198,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2295,7 +2295,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2304,7 +2304,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2401,7 +2401,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2489,7 +2489,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2498,7 +2498,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2590,7 +2590,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2599,7 +2599,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2694,7 +2694,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2775,7 +2775,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2784,7 +2784,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2875,7 +2875,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2884,7 +2884,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2974,7 +2974,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3056,7 +3056,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3065,7 +3065,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -3144,7 +3144,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3153,7 +3153,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -3244,7 +3244,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3331,7 +3331,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3340,7 +3340,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -3442,7 +3442,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3451,7 +3451,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -3536,7 +3536,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3624,7 +3624,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3633,7 +3633,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -3742,7 +3742,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3751,7 +3751,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -3854,7 +3854,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3937,7 +3937,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3946,7 +3946,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -4032,7 +4032,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4041,7 +4041,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -4133,7 +4133,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4215,7 +4215,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4224,7 +4224,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -4322,7 +4322,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4331,7 +4331,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -4416,7 +4416,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4500,7 +4500,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4509,7 +4509,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -4601,7 +4601,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4610,7 +4610,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -4689,7 +4689,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4777,7 +4777,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4786,7 +4786,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } diff --git a/go/internal/control/testdata/golden/seeded_fuse.json b/go/internal/control/testdata/golden/seeded_fuse.json index 1c6b7100a..0100f4d67 100644 --- a/go/internal/control/testdata/golden/seeded_fuse.json +++ b/go/internal/control/testdata/golden/seeded_fuse.json @@ -62,7 +62,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 1, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -181,7 +181,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -279,7 +279,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -366,7 +366,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -375,7 +375,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -383,7 +383,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -0, + "target_w": 0, "clamped": true } ], @@ -466,7 +466,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -558,7 +558,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -639,7 +639,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -740,7 +740,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -749,7 +749,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -757,12 +757,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -0, + "target_w": 0, "clamped": true }, { "driver": "sungrow", - "target_w": -0, + "target_w": 0, "clamped": true } ], @@ -863,7 +863,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0.5, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -971,7 +971,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1057,7 +1057,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0.5, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1141,7 +1141,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1150,7 +1150,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -1253,7 +1253,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1357,7 +1357,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1462,7 +1462,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1570,7 +1570,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1579,7 +1579,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -1681,7 +1681,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1771,7 +1771,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1876,7 +1876,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0.5, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2001,7 +2001,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 1, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2010,7 +2010,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -2018,17 +2018,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -0, + "target_w": 0, "clamped": true }, { "driver": "pixii", - "target_w": -0, + "target_w": 0, "clamped": true }, { "driver": "sungrow", - "target_w": -0, + "target_w": 0, "clamped": true } ], @@ -2101,7 +2101,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2185,7 +2185,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2266,7 +2266,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2362,7 +2362,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2371,7 +2371,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -2379,12 +2379,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -0, + "target_w": 0, "clamped": true }, { "driver": "sungrow", - "target_w": -0, + "target_w": 0, "clamped": true } ], @@ -2473,7 +2473,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0.5, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2563,7 +2563,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2651,7 +2651,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2753,7 +2753,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2762,7 +2762,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -2847,7 +2847,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2931,7 +2931,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3012,7 +3012,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0.5, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3120,7 +3120,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3129,7 +3129,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -3137,17 +3137,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -0, + "target_w": 0, "clamped": true }, { "driver": "pixii", - "target_w": -0, + "target_w": 0, "clamped": true }, { "driver": "sungrow", - "target_w": -0, + "target_w": 0, "clamped": true } ], @@ -3220,7 +3220,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3333,7 +3333,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3431,7 +3431,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3547,7 +3547,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3556,7 +3556,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -3564,17 +3564,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -0, + "target_w": 0, "clamped": true }, { "driver": "pixii", - "target_w": -0, + "target_w": 0, "clamped": true }, { "driver": "sungrow", - "target_w": -0, + "target_w": 0, "clamped": true } ], @@ -3676,7 +3676,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 1, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3785,7 +3785,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0.5, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3890,7 +3890,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3987,7 +3987,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 1, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3996,7 +3996,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -4004,7 +4004,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -0, + "target_w": 0, "clamped": true } ], @@ -4087,7 +4087,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4174,7 +4174,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4262,7 +4262,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4376,7 +4376,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4385,7 +4385,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -4488,7 +4488,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4577,7 +4577,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4682,7 +4682,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 1, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4783,7 +4783,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4792,7 +4792,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -4888,7 +4888,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4990,7 +4990,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5100,7 +5100,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 1, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5201,7 +5201,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 1, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5210,7 +5210,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -5218,7 +5218,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -0, + "target_w": 0, "clamped": true } ], @@ -5306,7 +5306,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 1, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5408,7 +5408,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5501,7 +5501,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5612,7 +5612,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5621,7 +5621,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -5629,17 +5629,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -0, + "target_w": 0, "clamped": true }, { "driver": "pixii", - "target_w": -0, + "target_w": 0, "clamped": true }, { "driver": "sungrow", - "target_w": -0, + "target_w": 0, "clamped": true } ], @@ -5736,7 +5736,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5833,7 +5833,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 1, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5909,7 +5909,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6015,7 +6015,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6024,7 +6024,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -6139,7 +6139,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6260,7 +6260,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6375,7 +6375,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 1, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6496,7 +6496,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0.5, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6505,7 +6505,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -6601,7 +6601,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6697,7 +6697,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6778,7 +6778,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6879,7 +6879,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6888,7 +6888,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -6985,7 +6985,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7077,7 +7077,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0.5, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7158,7 +7158,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7242,7 +7242,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7251,7 +7251,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -7259,7 +7259,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -0, + "target_w": 0, "clamped": true } ], @@ -7330,7 +7330,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7443,7 +7443,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7553,7 +7553,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7666,7 +7666,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0.5, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7675,7 +7675,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } @@ -7683,12 +7683,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -0, + "target_w": 0, "clamped": true }, { "driver": "sungrow", - "target_w": -0, + "target_w": 0, "clamped": true } ], @@ -7789,7 +7789,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 1, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7910,7 +7910,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 1, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -8001,7 +8001,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -8114,7 +8114,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8123,7 +8123,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 300 } diff --git a/go/internal/control/testdata/golden/seeded_planner.json b/go/internal/control/testdata/golden/seeded_planner.json index ef55ecdcb..47225d95c 100644 --- a/go/internal/control/testdata/golden/seeded_planner.json +++ b/go/internal/control/testdata/golden/seeded_planner.json @@ -51,7 +51,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -60,7 +60,7 @@ "strategy": "self_consumption", "planned_grid_w": 2498, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 549, "remaining_s": 273 } @@ -139,7 +139,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "legacy_plan": { @@ -231,7 +231,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -240,7 +240,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": -2183, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 382, "remaining_s": 465 } @@ -343,7 +343,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -352,7 +352,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 84, "remaining_s": 316 } @@ -443,7 +443,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -452,7 +452,7 @@ "strategy": "self_consumption", "planned_grid_w": -2028, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 93, + "live_pv_surplus_soc_cap": 0.93, "elapsed_s": 357, "remaining_s": 689 } @@ -529,7 +529,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -538,7 +538,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 590, "remaining_s": 594 } @@ -617,7 +617,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -626,7 +626,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": -2367, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 170, "remaining_s": 313 } @@ -723,7 +723,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -732,7 +732,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -826,7 +826,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -835,7 +835,7 @@ "strategy": "self_consumption", "planned_grid_w": 2456, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 86, + "live_pv_surplus_soc_cap": 0.86, "elapsed_s": 110, "remaining_s": 220 } @@ -923,7 +923,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -932,7 +932,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 283, "remaining_s": 311 } @@ -1011,7 +1011,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1020,7 +1020,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 1199, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 595, "remaining_s": 717 } @@ -1109,7 +1109,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1118,7 +1118,7 @@ "strategy": "arbitrage", "planned_grid_w": -1593, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 99, "remaining_s": 411 } @@ -1218,7 +1218,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1227,7 +1227,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 621, "remaining_s": 382 } @@ -1327,7 +1327,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1336,7 +1336,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 70, "remaining_s": 749 } @@ -1421,7 +1421,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1430,7 +1430,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 323, "remaining_s": 387 } @@ -1519,7 +1519,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1528,7 +1528,7 @@ "strategy": "arbitrage", "planned_grid_w": -2805, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 556, "remaining_s": 572 } @@ -1616,7 +1616,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1625,7 +1625,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 563, "remaining_s": 417 } @@ -1716,7 +1716,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1725,7 +1725,7 @@ "strategy": "cheap", "planned_grid_w": -2034, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 402, "remaining_s": 558 } @@ -1825,7 +1825,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 84, + "pv_surplus_absorb_soc_cap": 0.84, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1834,7 +1834,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 348, "remaining_s": 377 } @@ -1931,7 +1931,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1940,7 +1940,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -2028,7 +2028,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2037,7 +2037,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 194, "remaining_s": 280 } @@ -2120,7 +2120,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2129,7 +2129,7 @@ "strategy": "cheap", "planned_grid_w": -2366, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 674, "remaining_s": 663 } @@ -2218,7 +2218,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2227,7 +2227,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 2319, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 301, "remaining_s": 242 } @@ -2327,7 +2327,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2336,7 +2336,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -2436,7 +2436,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2445,7 +2445,7 @@ "strategy": "self_consumption", "planned_grid_w": 936, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 76, "remaining_s": 299 } @@ -2533,7 +2533,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2542,7 +2542,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -2619,7 +2619,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2628,7 +2628,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 92, + "live_pv_surplus_soc_cap": 0.92, "elapsed_s": 392, "remaining_s": 582 } @@ -2719,7 +2719,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2728,7 +2728,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 526, "remaining_s": 517 } @@ -2825,7 +2825,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2834,7 +2834,7 @@ "strategy": "self_consumption", "planned_grid_w": 911, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 306, "remaining_s": 581 } @@ -2925,7 +2925,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2934,7 +2934,7 @@ "strategy": "cheap", "planned_grid_w": 738, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 609, "remaining_s": 644 } @@ -3013,7 +3013,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3022,7 +3022,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 282, "remaining_s": 251 } @@ -3101,7 +3101,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3110,7 +3110,7 @@ "strategy": "arbitrage", "planned_grid_w": 1069, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 61, + "live_pv_surplus_soc_cap": 0.61, "elapsed_s": 167, "remaining_s": 702 } @@ -3205,7 +3205,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3214,7 +3214,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 363, "remaining_s": 205 } @@ -3302,7 +3302,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3311,7 +3311,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 382, "remaining_s": 338 } @@ -3394,7 +3394,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3403,7 +3403,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": -600, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 77, + "live_pv_surplus_soc_cap": 0.77, "elapsed_s": 225, "remaining_s": 623 } @@ -3500,7 +3500,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3509,7 +3509,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 430, "remaining_s": 609 } @@ -3597,7 +3597,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3606,7 +3606,7 @@ "strategy": "self_consumption", "planned_grid_w": 1836, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 689, "remaining_s": 422 } @@ -3691,7 +3691,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 73, + "pv_surplus_absorb_soc_cap": 0.73, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3700,7 +3700,7 @@ "strategy": "cheap", "planned_grid_w": -2489, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 185, "remaining_s": 255 } @@ -3789,7 +3789,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3798,7 +3798,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": -23, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 91, + "live_pv_surplus_soc_cap": 0.91, "elapsed_s": 184, "remaining_s": 309 } @@ -3892,7 +3892,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3901,7 +3901,7 @@ "strategy": "arbitrage", "planned_grid_w": 789, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 72, "remaining_s": 753 } @@ -3983,7 +3983,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3992,7 +3992,7 @@ "strategy": "self_consumption", "planned_grid_w": 1101, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 145, "remaining_s": 310 } @@ -4075,7 +4075,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4084,7 +4084,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -4179,7 +4179,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4188,7 +4188,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 490, "remaining_s": 372 } @@ -4291,7 +4291,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "legacy_plan": { @@ -4379,7 +4379,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4388,7 +4388,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 520, "remaining_s": 564 } @@ -4471,7 +4471,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "legacy_plan": { @@ -4557,7 +4557,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4566,7 +4566,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -4655,7 +4655,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4664,7 +4664,7 @@ "strategy": "arbitrage", "planned_grid_w": -1555, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 272, "remaining_s": 360 } @@ -4764,7 +4764,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4773,7 +4773,7 @@ "strategy": "self_consumption", "planned_grid_w": -1468, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 313, "remaining_s": 377 } @@ -4861,7 +4861,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4870,7 +4870,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 615, "remaining_s": 531 } @@ -4955,7 +4955,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4964,7 +4964,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 60, + "live_pv_surplus_soc_cap": 0.6, "elapsed_s": 82, "remaining_s": 541 } @@ -5049,7 +5049,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5058,7 +5058,7 @@ "strategy": "arbitrage", "planned_grid_w": -1598, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 281, "remaining_s": 357 } @@ -5147,7 +5147,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 82, + "pv_surplus_absorb_soc_cap": 0.82, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5156,7 +5156,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 334, "remaining_s": 250 } @@ -5256,7 +5256,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5265,7 +5265,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -5359,7 +5359,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5368,7 +5368,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 2507, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 309, "remaining_s": 783 } @@ -5456,7 +5456,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5465,7 +5465,7 @@ "strategy": "arbitrage", "planned_grid_w": 58, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 69, + "live_pv_surplus_soc_cap": 0.69, "elapsed_s": 85, "remaining_s": 572 } @@ -5542,7 +5542,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5551,7 +5551,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 84, + "live_pv_surplus_soc_cap": 0.84, "elapsed_s": 582, "remaining_s": 307 } @@ -5628,7 +5628,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5637,7 +5637,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -5726,7 +5726,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5735,7 +5735,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": -20, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 700, "remaining_s": 689 } @@ -5835,7 +5835,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "legacy_plan": { @@ -5920,7 +5920,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5929,7 +5929,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 397, "remaining_s": 729 } @@ -6006,7 +6006,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6015,7 +6015,7 @@ "strategy": "cheap", "planned_grid_w": 1019, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 695, "remaining_s": 410 } @@ -6098,7 +6098,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6107,7 +6107,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -6196,7 +6196,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 72, + "pv_surplus_absorb_soc_cap": 0.72, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6205,7 +6205,7 @@ "strategy": "arbitrage", "planned_grid_w": 2170, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 363, "remaining_s": 625 } @@ -6293,7 +6293,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6302,7 +6302,7 @@ "strategy": "self_consumption", "planned_grid_w": 2519, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 63, + "live_pv_surplus_soc_cap": 0.63, "elapsed_s": 270, "remaining_s": 706 } @@ -6391,7 +6391,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "legacy_plan": { @@ -6485,7 +6485,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6494,7 +6494,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 2326, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 398, "remaining_s": 730 } @@ -6577,7 +6577,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6586,7 +6586,7 @@ "strategy": "arbitrage", "planned_grid_w": -2097, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 514, "remaining_s": 247 } @@ -6681,7 +6681,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6690,7 +6690,7 @@ "strategy": "self_consumption", "planned_grid_w": 856, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 696, "remaining_s": 651 } @@ -6772,7 +6772,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "legacy_plan": { @@ -6860,7 +6860,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6869,7 +6869,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 99, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 89, + "live_pv_surplus_soc_cap": 0.89, "elapsed_s": 204, "remaining_s": 246 } @@ -6964,7 +6964,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6973,7 +6973,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 304, "remaining_s": 435 } @@ -7070,7 +7070,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 86, + "pv_surplus_absorb_soc_cap": 0.86, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7079,7 +7079,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 92, + "live_pv_surplus_soc_cap": 0.92, "elapsed_s": 253, "remaining_s": 707 } @@ -7179,7 +7179,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7188,7 +7188,7 @@ "strategy": "cheap", "planned_grid_w": 1686, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 650, "remaining_s": 679 } @@ -7270,7 +7270,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7279,7 +7279,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 82, + "live_pv_surplus_soc_cap": 0.82, "elapsed_s": 190, "remaining_s": 608 } @@ -7364,7 +7364,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 79, + "pv_surplus_absorb_soc_cap": 0.79, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7373,7 +7373,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -7456,7 +7456,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7465,7 +7465,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 91, + "live_pv_surplus_soc_cap": 0.91, "elapsed_s": 479, "remaining_s": 733 } @@ -7554,7 +7554,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 78, + "pv_surplus_absorb_soc_cap": 0.78, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7563,7 +7563,7 @@ "strategy": "cheap", "planned_grid_w": -1016, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 297, "remaining_s": 351 } @@ -7660,7 +7660,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7669,7 +7669,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 259, "remaining_s": 461 } @@ -7769,7 +7769,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "legacy_plan": { @@ -7875,7 +7875,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7884,7 +7884,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 67, + "live_pv_surplus_soc_cap": 0.67, "elapsed_s": 90, "remaining_s": 718 } @@ -7966,7 +7966,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7975,7 +7975,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 340, "remaining_s": 715 } @@ -8072,7 +8072,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8081,7 +8081,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": -2295, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 78, "remaining_s": 480 } @@ -8178,7 +8178,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8187,7 +8187,7 @@ "strategy": "arbitrage", "planned_grid_w": -1552, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 67, + "live_pv_surplus_soc_cap": 0.67, "elapsed_s": 105, "remaining_s": 488 } @@ -8287,7 +8287,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8296,7 +8296,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 86, "remaining_s": 316 } @@ -8390,7 +8390,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8399,7 +8399,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -8483,7 +8483,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8492,7 +8492,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 259, "remaining_s": 201 } @@ -8571,7 +8571,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8580,7 +8580,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 453, "remaining_s": 331 } @@ -8671,7 +8671,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8680,7 +8680,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 79, + "live_pv_surplus_soc_cap": 0.79, "elapsed_s": 138, "remaining_s": 562 } @@ -8774,7 +8774,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8783,7 +8783,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 408, "remaining_s": 418 } @@ -8886,7 +8886,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8895,7 +8895,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 1419, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 374, "remaining_s": 388 } @@ -8983,7 +8983,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8992,7 +8992,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -9075,7 +9075,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 77, + "pv_surplus_absorb_soc_cap": 0.77, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -9084,7 +9084,7 @@ "strategy": "self_consumption", "planned_grid_w": -1490, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 316, "remaining_s": 314 } @@ -9179,7 +9179,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -9188,7 +9188,7 @@ "strategy": "cheap", "planned_grid_w": -1828, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 513, "remaining_s": 295 } @@ -9273,7 +9273,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -9282,7 +9282,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": -182, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 532, "remaining_s": 574 } @@ -9361,7 +9361,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -9370,7 +9370,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 211, "remaining_s": 533 } @@ -9453,7 +9453,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -9462,7 +9462,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -9545,7 +9545,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 81, + "pv_surplus_absorb_soc_cap": 0.81, "pv_surplus_absorb_threshold_w": 0 }, "legacy_plan": { @@ -9643,7 +9643,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 73, + "pv_surplus_absorb_soc_cap": 0.73, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -9652,7 +9652,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 104, "remaining_s": 651 } @@ -9746,7 +9746,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -9755,7 +9755,7 @@ "strategy": "arbitrage", "planned_grid_w": 2172, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 535, "remaining_s": 771 } diff --git a/go/internal/control/testdata/golden/seeded_reactive.json b/go/internal/control/testdata/golden/seeded_reactive.json index 96e20b757..1ae4f4ffc 100644 --- a/go/internal/control/testdata/golden/seeded_reactive.json +++ b/go/internal/control/testdata/golden/seeded_reactive.json @@ -63,7 +63,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -150,7 +150,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -244,7 +244,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -336,7 +336,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -442,7 +442,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "sungrow", @@ -553,7 +553,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 0.7, @@ -668,7 +668,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -787,7 +787,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -881,7 +881,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -962,7 +962,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1050,7 +1050,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp", @@ -1165,7 +1165,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 1.8, @@ -1275,7 +1275,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1373,7 +1373,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1484,7 +1484,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1568,7 +1568,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1644,7 +1644,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp" @@ -1737,7 +1737,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 2.5, @@ -1822,7 +1822,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1900,7 +1900,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1988,7 +1988,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2088,7 +2088,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2204,7 +2204,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp", @@ -2319,7 +2319,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 1.2, @@ -2416,7 +2416,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2492,7 +2492,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2600,7 +2600,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2702,7 +2702,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2778,7 +2778,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp" @@ -2865,7 +2865,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 1 @@ -2944,7 +2944,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3040,7 +3040,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3146,7 +3146,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3248,7 +3248,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3354,7 +3354,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "sungrow", @@ -3463,7 +3463,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 1.3, @@ -3578,7 +3578,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3688,7 +3688,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3781,7 +3781,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3862,7 +3862,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3968,7 +3968,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "pixii", @@ -4059,7 +4059,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 1.8 @@ -4156,7 +4156,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4262,7 +4262,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4362,7 +4362,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4460,7 +4460,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4571,7 +4571,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp", @@ -4692,7 +4692,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 0.8, @@ -4807,7 +4807,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4921,7 +4921,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5037,7 +5037,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5133,7 +5133,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5239,7 +5239,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "pixii", @@ -5342,7 +5342,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 1.5, @@ -5433,7 +5433,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5515,7 +5515,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5617,7 +5617,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5707,7 +5707,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5813,7 +5813,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp", @@ -5917,7 +5917,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 0.6, @@ -6014,7 +6014,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6120,7 +6120,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6206,7 +6206,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6312,7 +6312,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6416,7 +6416,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "sungrow", @@ -6531,7 +6531,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 2, @@ -6642,7 +6642,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6724,7 +6724,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6832,7 +6832,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6922,7 +6922,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7010,7 +7010,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp", @@ -7119,7 +7119,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 2.2, @@ -7240,7 +7240,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7330,7 +7330,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7430,7 +7430,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7514,7 +7514,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7590,7 +7590,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp" @@ -7669,7 +7669,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 0.9 @@ -7780,7 +7780,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7868,7 +7868,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7962,7 +7962,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -8049,7 +8049,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -8143,7 +8143,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "sungrow", @@ -8234,7 +8234,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 1.6 @@ -8325,7 +8325,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -8418,7 +8418,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -8514,7 +8514,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -8622,7 +8622,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -8726,7 +8726,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "sungrow", @@ -8813,7 +8813,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 1.3 @@ -8918,7 +8918,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -9031,7 +9031,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -9117,7 +9117,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -9199,7 +9199,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -9293,7 +9293,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp", @@ -9387,7 +9387,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 0.7, @@ -9472,7 +9472,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -9548,7 +9548,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -9656,7 +9656,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -9758,7 +9758,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, diff --git a/go/internal/control/testdata/golden/seeded_siblings.json b/go/internal/control/testdata/golden/seeded_siblings.json index 07a859306..f14522be2 100644 --- a/go/internal/control/testdata/golden/seeded_siblings.json +++ b/go/internal/control/testdata/golden/seeded_siblings.json @@ -65,7 +65,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -179,7 +179,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -277,7 +277,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -386,7 +386,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -482,7 +482,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -491,7 +491,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -606,7 +606,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -714,7 +714,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -831,7 +831,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -931,7 +931,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1040,7 +1040,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -1052,7 +1052,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1146,7 +1146,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1259,7 +1259,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1367,7 +1367,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -1483,7 +1483,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1583,7 +1583,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1592,7 +1592,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1704,7 +1704,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -1800,7 +1800,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1913,7 +1913,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2021,7 +2021,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -2135,7 +2135,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2144,7 +2144,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2246,7 +2246,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2355,7 +2355,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -2451,7 +2451,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2562,7 +2562,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2668,7 +2668,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -2680,7 +2680,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2795,7 +2795,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2895,7 +2895,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3006,7 +3006,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -3102,7 +3102,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3215,7 +3215,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3224,7 +3224,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -3333,7 +3333,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -3447,7 +3447,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3545,7 +3545,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3654,7 +3654,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -3750,7 +3750,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3759,7 +3759,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -3873,7 +3873,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3981,7 +3981,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -4097,7 +4097,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4195,7 +4195,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4304,7 +4304,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -4316,7 +4316,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -4410,7 +4410,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4523,7 +4523,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4631,7 +4631,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -4745,7 +4745,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4845,7 +4845,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4854,7 +4854,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -4966,7 +4966,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -5062,7 +5062,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5173,7 +5173,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5281,7 +5281,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -5397,7 +5397,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5406,7 +5406,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -5507,7 +5507,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5616,7 +5616,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -5712,7 +5712,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5825,7 +5825,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5933,7 +5933,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -5945,7 +5945,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -6060,7 +6060,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6161,7 +6161,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6270,7 +6270,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -6366,7 +6366,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6479,7 +6479,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6488,7 +6488,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } diff --git a/go/internal/control/testdata/golden/slew_limiter.json b/go/internal/control/testdata/golden/slew_limiter.json index ffc76a865..470487437 100644 --- a/go/internal/control/testdata/golden/slew_limiter.json +++ b/go/internal/control/testdata/golden/slew_limiter.json @@ -45,7 +45,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -129,7 +129,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -207,7 +207,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -291,7 +291,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -369,7 +369,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -453,7 +453,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -531,7 +531,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -615,7 +615,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -693,7 +693,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -775,7 +775,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -851,7 +851,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -933,7 +933,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1009,7 +1009,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1093,7 +1093,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1171,7 +1171,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1247,7 +1247,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1256,7 +1256,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1335,7 +1335,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1344,7 +1344,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1423,7 +1423,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1432,7 +1432,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1511,7 +1511,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1520,7 +1520,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1599,7 +1599,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1608,7 +1608,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1687,7 +1687,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1696,7 +1696,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1779,7 +1779,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1788,7 +1788,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1867,7 +1867,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1876,7 +1876,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1967,7 +1967,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1976,7 +1976,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2067,7 +2067,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2076,7 +2076,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -2167,7 +2167,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2269,7 +2269,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2359,7 +2359,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2368,7 +2368,7 @@ "strategy": "self_consumption", "planned_grid_w": -2000, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2451,7 +2451,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2460,7 +2460,7 @@ "strategy": "self_consumption", "planned_grid_w": -2000, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2543,7 +2543,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2552,7 +2552,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -2631,7 +2631,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2640,7 +2640,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2719,7 +2719,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2728,7 +2728,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -2807,7 +2807,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2885,7 +2885,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -2963,7 +2963,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -2972,7 +2972,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -3051,7 +3051,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3135,14 +3135,14 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -0, + "target_w": 0, "clamped": true } ], @@ -3218,7 +3218,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3296,7 +3296,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3374,7 +3374,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3458,7 +3458,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3536,7 +3536,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3620,7 +3620,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3710,7 +3710,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3817,7 +3817,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -3919,7 +3919,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4009,7 +4009,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "sungrow", @@ -4108,7 +4108,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 2, @@ -4196,7 +4196,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4274,7 +4274,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4376,7 +4376,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4495,7 +4495,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -4608,7 +4608,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp", @@ -4733,7 +4733,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 2.9, @@ -4858,7 +4858,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4867,7 +4867,7 @@ "strategy": "self_consumption", "planned_grid_w": 2300, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 312, "remaining_s": 524 } @@ -4960,7 +4960,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -4969,7 +4969,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 83, "remaining_s": 724 } @@ -5066,7 +5066,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5075,7 +5075,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -5159,7 +5159,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5168,7 +5168,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 249, "remaining_s": 481 } @@ -5253,7 +5253,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5359,7 +5359,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -5477,7 +5477,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp", @@ -5586,7 +5586,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 1.3, @@ -5679,7 +5679,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5688,7 +5688,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 196, "remaining_s": 641 } @@ -5791,7 +5791,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5800,7 +5800,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 676, "remaining_s": 363 } @@ -5890,7 +5890,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5899,7 +5899,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": -950, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 287, "remaining_s": 436 } @@ -5984,7 +5984,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5993,7 +5993,7 @@ "strategy": "arbitrage", "planned_grid_w": -1681, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 167, "remaining_s": 395 } @@ -6090,7 +6090,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6192,7 +6192,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -6281,7 +6281,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp" @@ -6380,7 +6380,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 1.8, @@ -6486,7 +6486,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6495,7 +6495,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 682, "remaining_s": 253 } @@ -6586,7 +6586,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6595,7 +6595,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -6690,7 +6690,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6699,7 +6699,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 502, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 359, "remaining_s": 201 } @@ -6807,7 +6807,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6816,7 +6816,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 626, "remaining_s": 361 } @@ -6931,7 +6931,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7038,7 +7038,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7125,7 +7125,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp" @@ -7212,7 +7212,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 1.1 @@ -7317,7 +7317,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7326,7 +7326,7 @@ "strategy": "self_consumption", "planned_grid_w": 2356, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 550, "remaining_s": 669 } @@ -7447,7 +7447,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7456,7 +7456,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 463, "remaining_s": 398 } @@ -7563,7 +7563,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7572,7 +7572,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 463, "remaining_s": 704 } @@ -7675,7 +7675,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7684,7 +7684,7 @@ "strategy": "arbitrage", "planned_grid_w": 1988, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 364, "remaining_s": 456 } @@ -7796,7 +7796,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7885,7 +7885,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -7973,7 +7973,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp", @@ -8091,7 +8091,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 2.4, @@ -8204,7 +8204,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8213,7 +8213,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 449, "remaining_s": 262 } @@ -8327,7 +8327,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8336,7 +8336,7 @@ "strategy": "cheap", "planned_grid_w": -2437, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 335, "remaining_s": 398 } @@ -8456,7 +8456,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8465,7 +8465,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 562, "remaining_s": 542 } @@ -8562,7 +8562,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -8571,7 +8571,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 366, "remaining_s": 498 } @@ -8656,7 +8656,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -8740,7 +8740,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -8836,7 +8836,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp", @@ -8930,7 +8930,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 0.8 @@ -9035,7 +9035,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -9044,7 +9044,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 211, "remaining_s": 624 } @@ -9147,7 +9147,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -9156,7 +9156,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -9259,7 +9259,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -9268,7 +9268,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 442, "remaining_s": 493 } @@ -9371,7 +9371,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -9380,7 +9380,7 @@ "strategy": "arbitrage", "planned_grid_w": 1312, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 524, "remaining_s": 558 } @@ -9471,7 +9471,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -9567,7 +9567,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -9681,7 +9681,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "pixii", @@ -9798,7 +9798,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 1.3, @@ -9898,7 +9898,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -9907,7 +9907,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 334, "remaining_s": 787 } @@ -10004,7 +10004,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -10013,7 +10013,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 521, "remaining_s": 615 } @@ -10103,7 +10103,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -10112,7 +10112,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 297, "remaining_s": 411 } @@ -10215,7 +10215,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -10224,7 +10224,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 112, "remaining_s": 724 } @@ -10314,7 +10314,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -10392,7 +10392,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -10468,7 +10468,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp" @@ -10567,7 +10567,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 2.7, @@ -10678,7 +10678,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -10687,7 +10687,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 237, "remaining_s": 347 } @@ -10795,7 +10795,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -10804,7 +10804,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 465, "remaining_s": 414 } @@ -10907,7 +10907,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -10916,7 +10916,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 1353, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 162, "remaining_s": 629 } @@ -11031,7 +11031,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -11040,7 +11040,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 187, "remaining_s": 610 } @@ -11161,7 +11161,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -11281,7 +11281,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -11397,7 +11397,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "sungrow", @@ -11518,7 +11518,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 2.4, @@ -11618,7 +11618,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -11627,7 +11627,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -11712,7 +11712,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -11721,7 +11721,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -11830,7 +11830,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -11839,7 +11839,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 79, "remaining_s": 706 } @@ -11934,7 +11934,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -11943,7 +11943,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 258, "remaining_s": 304 } @@ -12038,7 +12038,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -12151,7 +12151,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -12249,7 +12249,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp", @@ -12366,7 +12366,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 2.8, @@ -12467,7 +12467,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -12476,7 +12476,7 @@ "strategy": "self_consumption", "planned_grid_w": 1723, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 111, "remaining_s": 788 } @@ -12571,7 +12571,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -12580,7 +12580,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 93, "remaining_s": 551 } @@ -12683,7 +12683,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -12692,7 +12692,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 6, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 305, "remaining_s": 765 } @@ -12807,7 +12807,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -12816,7 +12816,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 268, "remaining_s": 680 } @@ -12907,7 +12907,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -13003,7 +13003,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -13116,7 +13116,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "ferroamp", @@ -13217,7 +13217,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 1.4 @@ -13298,7 +13298,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -13307,7 +13307,7 @@ "strategy": "self_consumption", "planned_grid_w": -1276, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 628, "remaining_s": 698 } @@ -13414,7 +13414,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -13423,7 +13423,7 @@ "strategy": "cheap", "planned_grid_w": 110, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 338, "remaining_s": 711 } @@ -13531,7 +13531,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -13540,7 +13540,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -13654,7 +13654,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -13663,7 +13663,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 475, "remaining_s": 549 } @@ -13772,7 +13772,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -13871,7 +13871,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -13971,7 +13971,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "sungrow", @@ -14071,7 +14071,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 1.9 @@ -14182,7 +14182,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -14191,7 +14191,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 513, "remaining_s": 548 } @@ -14306,7 +14306,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -14315,7 +14315,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 115, "remaining_s": 373 } @@ -14424,7 +14424,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -14433,7 +14433,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 226, "remaining_s": 761 } @@ -14517,7 +14517,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -14526,7 +14526,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 231, "remaining_s": 519 } @@ -14611,7 +14611,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -14707,7 +14707,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -14820,7 +14820,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "priority_order": [ "pixii", @@ -14931,7 +14931,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "weights": { "ferroamp": 0.8, @@ -15031,7 +15031,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -15040,7 +15040,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 462, "remaining_s": 769 } @@ -15155,7 +15155,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -15164,7 +15164,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 65, "remaining_s": 565 } @@ -15260,7 +15260,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -15269,7 +15269,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } @@ -15378,7 +15378,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -15387,7 +15387,7 @@ "strategy": "arbitrage", "planned_grid_w": -200, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 638, "remaining_s": 237 } @@ -15496,7 +15496,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, diff --git a/go/internal/control/testdata/golden/targeted_invariants.json b/go/internal/control/testdata/golden/targeted_invariants.json index c4e4fe888..467ca3418 100644 --- a/go/internal/control/testdata/golden/targeted_invariants.json +++ b/go/internal/control/testdata/golden/targeted_invariants.json @@ -57,7 +57,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -152,7 +152,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -247,7 +247,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -343,7 +343,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -439,7 +439,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -515,7 +515,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -593,7 +593,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -676,7 +676,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -765,7 +765,7 @@ "site_fuse_voltage": 230, "site_fuse_phases": 3, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -774,7 +774,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -853,7 +853,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -862,7 +862,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -941,7 +941,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -950,7 +950,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1029,7 +1029,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1107,7 +1107,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1205,7 +1205,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0, "inverter_groups": { "pv-fa": "fa" @@ -1301,7 +1301,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1310,7 +1310,7 @@ "strategy": "cheap", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1413,7 +1413,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1422,7 +1422,7 @@ "strategy": "arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 450, "remaining_s": 450 } @@ -1507,7 +1507,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 } }, @@ -1585,7 +1585,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap_pct": 0, + "pv_surplus_absorb_soc_cap": 0, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1594,7 +1594,7 @@ "strategy": "", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap_pct": 0, + "live_pv_surplus_soc_cap": 0, "elapsed_s": 0, "remaining_s": 0 } diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index eff5f6a0c..d4ae1b9ec 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -604,8 +604,8 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 if pluggedIn { if lp.sessionComplete && lp.targetSoC > 0 { // Snap the inferred SoC to target; the planner reads - // currentSoC as the MPC LoadpointSpec.InitialSoCPct, - // so InitialSoCPct == TargetSoC → DP allocates 0 W. + // currentSoC as the MPC LoadpointSpec.InitialSoC, + // so InitialSoC == TargetSoC → DP allocates 0 W. lp.currentSoC = lp.targetSoC } else { lp.currentSoC = estimateSoC(lp.sessionPluginSoC, @@ -753,7 +753,7 @@ func (m *Manager) SetCurrentSoC(id string, socPct float64) bool { // SetCurrentSoC: the control loop calls it every tick with the SoC from // the vehicle driver paired to this loadpoint (e.g. Tesla via // TeslaBLEProxy), so the dashboard's current_soc and the planner's -// InitialSoCPct both reflect BMS ground truth instead of the +// InitialSoC both reflect BMS ground truth instead of the // delivered-Wh estimate, which is blind to the real pack (Easee and // other chargers can't read the car). // diff --git a/go/internal/mpc/loadpoint_service_test.go b/go/internal/mpc/loadpoint_service_test.go index 386448e68..c4586d647 100644 --- a/go/internal/mpc/loadpoint_service_test.go +++ b/go/internal/mpc/loadpoint_service_test.go @@ -31,9 +31,9 @@ func TestSlotDirectiveCarriesLoadpointEnergyWh(t *testing.T) { Mode: ModeCheapCharge, SoCLevels: 11, CapacityWh: 5000, - SoCMin: 0.1, - SoCMax: 0.95, - InitialSoC: 0.5, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.5, ActionLevels: 5, MaxChargeW: 2000, MaxDischargeW: 2000, @@ -44,9 +44,9 @@ func TestSlotDirectiveCarriesLoadpointEnergyWh(t *testing.T) { ID: "garage", CapacityWh: 60000, Levels: 11, - InitialSoC: 0.2, + InitialSoC: 0.2, PluggedIn: true, - TargetSoC: 0.4, + TargetSoC: 0.4, TargetSlotIdx: 3, MaxChargeW: 11000, AllowedStepsW: []float64{0, 11000}, @@ -91,8 +91,8 @@ func TestSlotDirectiveCarriesLoadpointEnergyWh(t *testing.T) { if wh <= 0 { t.Errorf("LoadpointEnergyWh[garage] = %.1f, want > 0", wh) } - if _, ok := d.LoadpointSoCTargetPct["garage"]; !ok { - t.Errorf("LoadpointSoCTargetPct missing garage entry") + if _, ok := d.LoadpointSoCTarget["garage"]; !ok { + t.Errorf("LoadpointSoCTarget missing garage entry") } } @@ -161,9 +161,9 @@ func TestNoBatteryToEVForbidsBatteryFeedingEV(t *testing.T) { Mode: ModeArbitrage, SoCLevels: 11, CapacityWh: 20000, - SoCMin: 0.1, - SoCMax: 0.95, - InitialSoC: 0.9, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.9, ActionLevels: 11, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -174,9 +174,9 @@ func TestNoBatteryToEVForbidsBatteryFeedingEV(t *testing.T) { ID: "garage", CapacityWh: 60000, Levels: 11, - InitialSoC: 0.2, + InitialSoC: 0.2, PluggedIn: true, - TargetSoC: 0.3, + TargetSoC: 0.3, TargetSlotIdx: 1, MaxChargeW: 11000, AllowedStepsW: []float64{0, 11000}, @@ -242,9 +242,9 @@ func TestSurplusOnlyForbidsBatteryFeedingEVEvenWhenCoverEVEnabled(t *testing.T) Mode: ModeArbitrage, SoCLevels: 11, CapacityWh: 20000, - SoCMin: 0.1, - SoCMax: 0.95, - InitialSoC: 0.9, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.9, ActionLevels: 11, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -255,9 +255,9 @@ func TestSurplusOnlyForbidsBatteryFeedingEVEvenWhenCoverEVEnabled(t *testing.T) ID: "garage", CapacityWh: 10000, Levels: 11, - InitialSoC: 0.2, + InitialSoC: 0.2, PluggedIn: true, - TargetSoC: 0.7, + TargetSoC: 0.7, TargetSlotIdx: 1, MaxChargeW: 5000, AllowedStepsW: []float64{0, 5000}, @@ -310,9 +310,9 @@ func TestArbitrageGridChargesWhileSurplusOnlyEVIsConnected(t *testing.T) { Mode: ModeArbitrage, SoCLevels: 11, CapacityWh: 20000, - SoCMin: 0.1, - SoCMax: 0.95, - InitialSoC: 0.2, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.2, ActionLevels: 11, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -323,9 +323,9 @@ func TestArbitrageGridChargesWhileSurplusOnlyEVIsConnected(t *testing.T) { ID: "garage", CapacityWh: 60000, Levels: 11, - InitialSoC: 0.8, + InitialSoC: 0.8, PluggedIn: true, - TargetSoC: 0.8, // already at target — EV should stay off + TargetSoC: 0.8, // already at target — EV should stay off TargetSlotIdx: 1, MaxChargeW: 3000, AllowedStepsW: []float64{0, 3000}, @@ -359,9 +359,9 @@ func TestPassiveArbitrageGridChargesWhileSurplusOnlyEVIsConnected(t *testing.T) Mode: ModePassiveArbitrage, SoCLevels: 18, CapacityWh: 20000, - SoCMin: 0.1, - SoCMax: 0.95, - InitialSoC: 0.1, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.1, ActionLevels: 11, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -371,9 +371,9 @@ func TestPassiveArbitrageGridChargesWhileSurplusOnlyEVIsConnected(t *testing.T) ID: "garage", CapacityWh: 60000, Levels: 11, - InitialSoC: 0.8, + InitialSoC: 0.8, PluggedIn: true, - TargetSoC: 0.8, + TargetSoC: 0.8, TargetSlotIdx: 1, MaxChargeW: 3000, AllowedStepsW: []float64{0, 3000}, @@ -404,9 +404,9 @@ func TestSurplusOnlyEVCannotImportEvenWithDeadline(t *testing.T) { Mode: ModeArbitrage, SoCLevels: 11, CapacityWh: 10000, - SoCMin: 0.1, - SoCMax: 0.95, - InitialSoC: 0.5, + SoCMin: 0.1, + SoCMax: 0.95, + InitialSoC: 0.5, ActionLevels: 11, MaxChargeW: 5000, MaxDischargeW: 5000, @@ -417,9 +417,9 @@ func TestSurplusOnlyEVCannotImportEvenWithDeadline(t *testing.T) { ID: "garage", CapacityWh: 40000, Levels: 11, - InitialSoC: 0.2, + InitialSoC: 0.2, PluggedIn: true, - TargetSoC: 0.8, + TargetSoC: 0.8, TargetSlotIdx: 0, MaxChargeW: 7000, AllowedStepsW: []float64{0, 7000}, diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index 9783fc84e..90edce248 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -396,7 +396,7 @@ type SlotDirective struct { SlotStart time.Time SlotEnd time.Time BatteryEnergyWh float64 // total energy for the slot (site-signed) - SoCTargetPct float64 // plan's SoC at SlotEnd — used by divergence detector + SoCTarget float64 // plan's SoC at SlotEnd — used by divergence detector Strategy Mode // echoed for logging + API // PVLimitW is the recommended cap on aggregate PV inverter output @@ -416,14 +416,14 @@ type SlotDirective struct { // and docs/safety.md §8 for the asymmetry rationale. GridW float64 - // LivePVSurplusSoCCapPct enables economically justified live surplus + // LivePVSurplusSoCCap enables economically justified live surplus // capture for this slot. It is the current planned SoC plus the stored // energy from later grid-funded charge actions whose import price clears // 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. - LivePVSurplusSoCCapPct float64 + LivePVSurplusSoCCap float64 // LoadpointEnergyWh carries per-loadpoint EV energy budgets for // this slot. Keyed by Loadpoint.ID. Positive = charging energy @@ -433,9 +433,9 @@ type SlotDirective struct { // remaining_s` formula it uses for the battery. LoadpointEnergyWh map[string]float64 - // LoadpointSoCTargetPct is the plan's EV SoC at SlotEnd per + // LoadpointSoCTarget is the plan's EV SoC at SlotEnd per // loadpoint. Used by the per-loadpoint divergence check. - LoadpointSoCTargetPct map[string]float64 + LoadpointSoCTarget map[string]float64 } // SlotDirectiveAt returns the energy-allocation directive for the slot @@ -474,29 +474,29 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) { // energy_wh = power_w * hours. a.SlotLenMin/60 gives hours. energyWh := a.BatteryW * float64(a.SlotLenMin) / 60.0 d := SlotDirective{ - DecisionID: p.DecisionID, - SlotStart: time.UnixMilli(a.SlotStartMs), - SlotEnd: time.UnixMilli(endMs), - BatteryEnergyWh: energyWh, - SoCTargetPct: a.SoC, - Strategy: params.Mode, - PVLimitW: a.PVLimitW, - GridW: a.GridW, - LivePVSurplusSoCCapPct: livePVSurplusSoCCapPct(p.Actions, i, params), + DecisionID: p.DecisionID, + SlotStart: time.UnixMilli(a.SlotStartMs), + SlotEnd: time.UnixMilli(endMs), + BatteryEnergyWh: energyWh, + SoCTarget: a.SoC, + Strategy: params.Mode, + PVLimitW: a.PVLimitW, + GridW: a.GridW, + LivePVSurplusSoCCap: livePVSurplusSoCCap(p.Actions, i, params), } if len(a.LoadpointPowerW) > 0 { d.LoadpointEnergyWh = make(map[string]float64, len(a.LoadpointPowerW)) - d.LoadpointSoCTargetPct = make(map[string]float64, len(a.LoadpointPowerW)) + d.LoadpointSoCTarget = make(map[string]float64, len(a.LoadpointPowerW)) for id, powerW := range a.LoadpointPowerW { d.LoadpointEnergyWh[id] = powerW * float64(a.SlotLenMin) / 60.0 - d.LoadpointSoCTargetPct[id] = a.LoadpointSoCByID[id] + d.LoadpointSoCTarget[id] = a.LoadpointSoCByID[id] } } else if a.LoadpointW > 0 && lpID != "" { lpEnergyWh := a.LoadpointW * float64(a.SlotLenMin) / 60.0 d.LoadpointEnergyWh = map[string]float64{ lpID: lpEnergyWh, } - d.LoadpointSoCTargetPct = map[string]float64{ + d.LoadpointSoCTarget = map[string]float64{ lpID: a.LoadpointSoC, } } @@ -505,7 +505,7 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) { return SlotDirective{}, false } -// livePVSurplusSoCCapPct returns a quantified ceiling for moving later +// 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 // "always self-consume" override: @@ -518,7 +518,7 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) { // - only the grid-funded part of later charge actions contributes headroom, // so opportunistic capture cannot store more energy than the plan intended // to buy from the grid. -func livePVSurplusSoCCapPct(actions []Action, current int, p Params) float64 { +func livePVSurplusSoCCap(actions []Action, current int, p Params) float64 { if current < 0 || current >= len(actions) { return 0 } diff --git a/go/internal/mpc/service_test.go b/go/internal/mpc/service_test.go index 6e9a59d51..16ce90880 100644 --- a/go/internal/mpc/service_test.go +++ b/go/internal/mpc/service_test.go @@ -924,7 +924,7 @@ func TestSlotDirectiveAt(t *testing.T) { SlotLenMin: slotLenMin, SpotOre: 40, BatteryW: 800, // 800 W × 15/60 h = 200 Wh for the slot - SoC: 0.455, + SoC: 0.455, GridW: -150, // plan expects 150 W export }, { @@ -933,7 +933,7 @@ func TestSlotDirectiveAt(t *testing.T) { PriceOre: 120, // later grid charge costs more than export earns now BatteryW: 2000, GridW: 1500, - SoC: 0.8, + SoC: 0.8, }, }, }, @@ -955,8 +955,8 @@ func TestSlotDirectiveAt(t *testing.T) { if want := slotStart.Add(15 * time.Minute); !d.SlotEnd.Equal(want) { t.Errorf("SlotEnd = %v, want %v", d.SlotEnd, want) } - if d.SoCTargetPct != 0.455 { - t.Errorf("SoCTargetPct = %f, want 0.455", d.SoCTargetPct) + if d.SoCTarget != 0.455 { + t.Errorf("SoCTarget = %f, want 0.455", d.SoCTarget) } if d.Strategy != ModeArbitrage { t.Errorf("Strategy = %v, want arbitrage", d.Strategy) @@ -967,12 +967,12 @@ func TestSlotDirectiveAt(t *testing.T) { if d.GridW != -150 { t.Errorf("GridW = %f, want −150 (must propagate from Action.GridW)", d.GridW) } - if math.Abs(d.LivePVSurplusSoCCapPct-0.4925) > 1e-9 { - t.Errorf("LivePVSurplusSoCCapPct = %f, want 0.4925 from 375 Wh of later grid-funded charge", d.LivePVSurplusSoCCapPct) + if math.Abs(d.LivePVSurplusSoCCap-0.4925) > 1e-9 { + t.Errorf("LivePVSurplusSoCCap = %f, want 0.4925 from 375 Wh of later grid-funded charge", d.LivePVSurplusSoCCap) } } -func TestLivePVSurplusSoCCapPctEconomicGate(t *testing.T) { +func TestLivePVSurplusSoCCapEconomicGate(t *testing.T) { base := []Action{ {SpotOre: 50, BatteryW: 0, SoC: 0.4}, {SlotLenMin: 15, PriceOre: 120, BatteryW: 2000, GridW: 1500, SoC: 0.75}, @@ -1004,7 +1004,7 @@ func TestLivePVSurplusSoCCapPctEconomicGate(t *testing.T) { p := tc.params p.CapacityWh = 10000 p.ChargeEfficiency = 1 - if got := livePVSurplusSoCCapPct(actions, 0, p); got != tc.wantCap { + if got := livePVSurplusSoCCap(actions, 0, p); got != tc.wantCap { t.Errorf("cap = %.1f, want %.1f", got, tc.wantCap) } }) diff --git a/go/internal/units/consistency_test.go b/go/internal/units/consistency_test.go index eeabe410b..54ce42874 100644 --- a/go/internal/units/consistency_test.go +++ b/go/internal/units/consistency_test.go @@ -241,9 +241,10 @@ func TestCoreBannedSoCPercentFieldNames(t *testing.T) { reflect.TypeOf(mpc.Params{}), reflect.TypeOf(mpc.Action{}), reflect.TypeOf(mpc.Plan{}), + reflect.TypeOf(mpc.SlotDirective{}), reflect.TypeOf(forecast.Array{}), } - banned := []string{"CurrentSoCPct", "TargetSoCPct", "PluginSoCPct", "VehicleSoCPct", "SoCPct", "SoCMinPct", "SoCMaxPct", "KWp"} + banned := []string{"CurrentSoCPct", "TargetSoCPct", "PluginSoCPct", "VehicleSoCPct", "SoCPct", "SoCMinPct", "SoCMaxPct", "SoCTargetPct", "LivePVSurplusSoCCapPct", "LoadpointSoCTargetPct", "KWp"} for _, typ := range types { for _, name := range banned { if _, ok := typ.FieldByName(name); ok { From b3f073e1b60d76088e8865b1fc4e1495e0971f6a Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 09:52:21 +0200 Subject: [PATCH 04/22] feat(ocpp): restore the OCPP 1.6J Central System package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings back go/internal/ocpp, retired as unused in #578, so EV chargers can connect to FTW directly instead of through a vendor cloud. The package is restored unchanged and still builds, vets and passes its own tests against the current tree. github.com/lorenzodonini/ocpp-go resolves to v0.19.0 at @latest — the same version that was removed, since upstream has not cut a release since August 2025. Nothing is wired into main.go yet, so this changes no runtime behaviour. Two known gaps are carried over from the original and must be closed before the server is enabled: - Config.Bind is advisory only. ocpp-go does not expose a bind address, so the listener takes 0.0.0.0 regardless, and Phase 1 has no TLS. - Handlers are read-only. Charge Amps needs SetChargingProfile-based control, because its RemoteStopTransaction is unreliable in the field. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- go/go.mod | 7 + go/go.sum | 32 ++++ go/internal/ocpp/config.go | 37 +++++ go/internal/ocpp/handlers.go | 286 ++++++++++++++++++++++++++++++++ go/internal/ocpp/server.go | 112 +++++++++++++ go/internal/ocpp/server_test.go | 188 +++++++++++++++++++++ 6 files changed, 662 insertions(+) create mode 100644 go/internal/ocpp/config.go create mode 100644 go/internal/ocpp/handlers.go create mode 100644 go/internal/ocpp/server.go create mode 100644 go/internal/ocpp/server_test.go diff --git a/go/go.mod b/go/go.mod index c1ca4e9af..b5c0e36b9 100644 --- a/go/go.mod +++ b/go/go.mod @@ -12,6 +12,7 @@ require ( github.com/goburrow/serial v0.1.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/lorenzodonini/ocpp-go v0.19.0 github.com/mochi-mqtt/server/v2 v2.7.9 github.com/parquet-go/parquet-go v0.29.0 github.com/shirou/gopsutil/v4 v4.26.3 @@ -29,11 +30,15 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-playground/locales v0.12.1 // indirect + github.com/go-playground/universal-translator v0.16.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.2.6 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-tpm v0.9.8 // indirect + github.com/gorilla/mux v1.8.1 // indirect github.com/klauspost/compress v1.17.11 // indirect + github.com/leodido/go-urn v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -42,6 +47,7 @@ require ( github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/relvacode/iso8601 v1.6.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/xid v1.4.0 // indirect github.com/teambition/rrule-go v1.8.2 // indirect @@ -54,6 +60,7 @@ require ( golang.org/x/sync v0.19.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/go-playground/validator.v9 v9.30.0 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go/go.sum b/go/go.sum index 96f0b7042..6eacd0e5e 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,11 +1,14 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -26,6 +29,10 @@ github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc= +github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= +github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM= +github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= @@ -47,6 +54,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -57,10 +66,18 @@ github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg= github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8= +github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= +github.com/lorenzodonini/ocpp-go v0.19.0 h1:THNriVV3bUWkGzaIkDeytcgoeBRjoN9ezPHsddIDfgc= +github.com/lorenzodonini/ocpp-go v0.19.0/go.mod h1:2kcukDdhui4u730VfnYVWuwzDLgw+mBRGDir/QAyBhg= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -83,6 +100,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= +github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -93,6 +112,13 @@ github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfx github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/simonvetter/modbus v1.6.4 h1:E03lBz/JftDza/+Ue+vxwkNZ/WW1xiqyFCUQ4NhqHn0= github.com/simonvetter/modbus v1.6.4/go.mod h1:hh90ZaTaPLcK2REj6/fpTbiV0J6S7GWmd8q+GVRObPw= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= @@ -123,8 +149,10 @@ golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220804214406-8e32c043e418/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= @@ -136,6 +164,10 @@ google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWn gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v9 v9.30.0 h1:Wk0Z37oBmKj9/n+tPyBHZmeL19LaCoK3Qq48VwYENss= +gopkg.in/go-playground/validator.v9 v9.30.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= diff --git a/go/internal/ocpp/config.go b/go/internal/ocpp/config.go new file mode 100644 index 000000000..39f560c65 --- /dev/null +++ b/go/internal/ocpp/config.go @@ -0,0 +1,37 @@ +package ocpp + +// Config controls the OCPP 1.6J Central System. +// +// Bind to LAN-only addresses by default — there is no TLS in Phase 1. +// Charge points connect via ws://:/; the chargerId +// segment becomes the driver name in telemetry.Store and shows up in +// /api/devices and /api/status.drivers. +// +// NOTE: Bind is advisory-only — the ocpp-go library does not expose a +// bind-address parameter, so the listener currently binds to 0.0.0.0 +// regardless of this field. See the TODO in server.go. +type Config struct { + Enabled bool `yaml:"enabled"` + Bind string `yaml:"bind"` + Port int `yaml:"port"` + Path string `yaml:"path"` + Username string `yaml:"username"` + Password string `yaml:"password"` + HeartbeatIntervalS int `yaml:"heartbeat_interval_s"` +} + +// Defaults fills in any unset fields with safe values. +func (c *Config) Defaults() { + if c.Bind == "" { + c.Bind = "0.0.0.0" + } + if c.Port == 0 { + c.Port = 8887 + } + if c.Path == "" { + c.Path = "/" + } + if c.HeartbeatIntervalS == 0 { + c.HeartbeatIntervalS = 60 + } +} diff --git a/go/internal/ocpp/handlers.go b/go/internal/ocpp/handlers.go new file mode 100644 index 000000000..866266a7f --- /dev/null +++ b/go/internal/ocpp/handlers.go @@ -0,0 +1,286 @@ +package ocpp + +import ( + "encoding/json" + "log/slog" + "strconv" + "sync" + "time" + + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Handler implements ocpp1.6/core.CentralSystemHandler. One Handler is shared +// across every connected charger; per-charger state lives in the chargers +// map. All access is mutex-guarded — handler callbacks fire from the OCPP +// library's goroutines. +type Handler struct { + tel *telemetry.Store + heartbeatIntervalS int + + mu sync.Mutex + chargers map[string]*chargerState + nextTxID int +} + +// chargerState is what we accumulate from successive OCPP messages for one +// charge point. Survives the OCPP library's stateless handler invocations. +type chargerState struct { + connected bool + charging bool + transactionID int + sessionStartMeterWh float64 + sessionMeterWh float64 + lastPowerW float64 +} + +// NewHandler returns a Handler ready to register with a CentralSystem. +// heartbeatIntervalS is what we tell each charger to use in the +// BootNotification confirmation. +func NewHandler(tel *telemetry.Store, heartbeatIntervalS int) *Handler { + return &Handler{ + tel: tel, + heartbeatIntervalS: heartbeatIntervalS, + chargers: map[string]*chargerState{}, + nextTxID: 1, + } +} + +// state returns the per-charger state, creating it lazily on first sight. +func (h *Handler) state(id string) *chargerState { + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok { + s = &chargerState{transactionID: -1} + h.chargers[id] = s + } + return s +} + +// Snapshot returns a copy of all charger states for /api/status etc. +func (h *Handler) Snapshot() map[string]ChargerView { + h.mu.Lock() + defer h.mu.Unlock() + out := make(map[string]ChargerView, len(h.chargers)) + for id, s := range h.chargers { + out[id] = ChargerView{ + Connected: s.connected, + Charging: s.charging, + PowerW: s.lastPowerW, + SessionWh: s.sessionMeterWh, + TxID: s.transactionID, + } + } + return out +} + +// ChargerView is the public snapshot of a charger's state. +type ChargerView struct { + Connected bool `json:"connected"` + Charging bool `json:"charging"` + PowerW float64 `json:"power_w"` + SessionWh float64 `json:"session_wh"` + TxID int `json:"tx_id"` +} + +// OnConnect / OnDisconnect are wired by the Server to the OCPP library's +// connection callbacks, not part of CoreHandler. +func (h *Handler) OnConnect(id string) { + slog.Info("OCPP charger connected", "charger", id) + h.tel.RecordDriverSuccess(id) +} + +func (h *Handler) OnDisconnect(id string) { + slog.Info("OCPP charger disconnected", "charger", id) + s := h.state(id) + h.mu.Lock() + s.connected = false + s.charging = false + s.lastPowerW = 0 + h.mu.Unlock() + // Push a zero so the dispatch clamp releases — otherwise the last known + // non-zero w would survive until staleness kicks in. + h.pushReading(id, s) +} + +// ---- core.CentralSystemHandler ---- + +func (h *Handler) OnBootNotification(id string, req *core.BootNotificationRequest) (*core.BootNotificationConfirmation, error) { + slog.Info("OCPP boot", + "charger", id, + "vendor", req.ChargePointVendor, + "model", req.ChargePointModel, + "fw", req.FirmwareVersion) + h.tel.RecordDriverSuccess(id) + return core.NewBootNotificationConfirmation( + types.NewDateTime(time.Now()), + h.heartbeatIntervalS, + core.RegistrationStatusAccepted, + ), nil +} + +func (h *Handler) OnHeartbeat(id string, _ *core.HeartbeatRequest) (*core.HeartbeatConfirmation, error) { + h.tel.RecordDriverSuccess(id) + return core.NewHeartbeatConfirmation(types.NewDateTime(time.Now())), nil +} + +func (h *Handler) OnAuthorize(id string, req *core.AuthorizeRequest) (*core.AuthorizeConfirmation, error) { + // Phase 1: auto-authorize every tag. RFID gating is a Phase 2 feature + // alongside RemoteStartTransaction. + slog.Debug("OCPP authorize", "charger", id, "tag", req.IdTag) + return core.NewAuthorizationConfirmation( + types.NewIdTagInfo(types.AuthorizationStatusAccepted), + ), nil +} + +func (h *Handler) OnDataTransfer(id string, req *core.DataTransferRequest) (*core.DataTransferConfirmation, error) { + // Vendor-specific extensions — accept gracefully but do nothing. + slog.Debug("OCPP DataTransfer ignored", "charger", id, "vendor", req.VendorId) + return core.NewDataTransferConfirmation(core.DataTransferStatusUnknownVendorId), nil +} + +func (h *Handler) OnStatusNotification(id string, req *core.StatusNotificationRequest) (*core.StatusNotificationConfirmation, error) { + s := h.state(id) + h.mu.Lock() + switch req.Status { + case core.ChargePointStatusAvailable, core.ChargePointStatusUnavailable: + s.connected = false + s.charging = false + case core.ChargePointStatusPreparing, + core.ChargePointStatusFinishing, + core.ChargePointStatusSuspendedEV, + core.ChargePointStatusSuspendedEVSE, + core.ChargePointStatusReserved: + s.connected = true + s.charging = false + case core.ChargePointStatusCharging: + s.connected = true + s.charging = true + case core.ChargePointStatusFaulted: + s.connected = true + s.charging = false + } + h.mu.Unlock() + + if req.Status == core.ChargePointStatusFaulted { + h.tel.EmitMetric(id, "ev_fault", 1, "", "", "") + slog.Warn("OCPP charger faulted", "charger", id, "errorCode", req.ErrorCode, "info", req.Info) + } + slog.Info("OCPP status", + "charger", id, "connector", req.ConnectorId, "status", req.Status) + + h.pushReading(id, s) + h.tel.RecordDriverSuccess(id) + return core.NewStatusNotificationConfirmation(), nil +} + +func (h *Handler) OnMeterValues(id string, req *core.MeterValuesRequest) (*core.MeterValuesConfirmation, error) { + s := h.state(id) + h.mu.Lock() + for _, mv := range req.MeterValue { + for _, sv := range mv.SampledValue { + measurand := sv.Measurand + // OCPP 1.6 default measurand if unspecified. + if measurand == "" { + measurand = types.MeasurandEnergyActiveImportRegister + } + val, err := strconv.ParseFloat(sv.Value, 64) + if err != nil { + continue + } + switch measurand { + case types.MeasurandPowerActiveImport: + if sv.Unit == types.UnitOfMeasureKW { + val *= 1000 + } + s.lastPowerW = val + case types.MeasurandEnergyActiveImportRegister: + if sv.Unit == types.UnitOfMeasureKWh { + val *= 1000 + } + if s.transactionID >= 0 { + s.sessionMeterWh = val - s.sessionStartMeterWh + } + } + } + } + h.mu.Unlock() + + h.pushReading(id, s) + h.tel.RecordDriverSuccess(id) + return core.NewMeterValuesConfirmation(), nil +} + +func (h *Handler) OnStartTransaction(id string, req *core.StartTransactionRequest) (*core.StartTransactionConfirmation, error) { + h.mu.Lock() + txID := h.nextTxID + h.nextTxID++ + s := h.chargersLocked(id) + s.transactionID = txID + s.sessionStartMeterWh = float64(req.MeterStart) + s.sessionMeterWh = 0 + s.connected = true + s.charging = true + h.mu.Unlock() + + slog.Info("OCPP transaction started", + "charger", id, "txid", txID, "tag", req.IdTag, "meter_start_wh", req.MeterStart) + h.pushReading(id, s) + h.tel.RecordDriverSuccess(id) + return core.NewStartTransactionConfirmation( + types.NewIdTagInfo(types.AuthorizationStatusAccepted), + txID, + ), nil +} + +func (h *Handler) OnStopTransaction(id string, req *core.StopTransactionRequest) (*core.StopTransactionConfirmation, error) { + s := h.state(id) + h.mu.Lock() + sessionWh := float64(req.MeterStop) - s.sessionStartMeterWh + s.transactionID = -1 + s.charging = false + s.lastPowerW = 0 + s.sessionMeterWh = sessionWh + h.mu.Unlock() + + slog.Info("OCPP transaction stopped", + "charger", id, "txid", req.TransactionId, + "session_wh", sessionWh, "reason", req.Reason) + h.pushReading(id, s) + h.tel.EmitMetric(id, "ev_session_wh", sessionWh, "Wh", "", "") + h.tel.RecordDriverSuccess(id) + return core.NewStopTransactionConfirmation(), nil +} + +// chargersLocked is the same as state(id) but assumes h.mu is already held. +func (h *Handler) chargersLocked(id string) *chargerState { + s, ok := h.chargers[id] + if !ok { + s = &chargerState{transactionID: -1} + h.chargers[id] = s + } + return s +} + +// pushReading pushes the current state as a DerEV reading. The dispatch +// clamp (control/dispatch.go) sums all DerEV readings into state.EVChargingW +// every tick — so the charger's lastPowerW immediately suppresses home +// battery discharge. +func (h *Handler) pushReading(id string, s *chargerState) { + h.mu.Lock() + w := s.lastPowerW + data := map[string]any{ + "type": "ev", + "w": w, + "connected": s.connected, + "charging": s.charging, + "session_wh": s.sessionMeterWh, + } + h.mu.Unlock() + blob, _ := json.Marshal(data) + h.tel.Update(id, telemetry.DerEV, w, nil, blob) +} diff --git a/go/internal/ocpp/server.go b/go/internal/ocpp/server.go new file mode 100644 index 000000000..79ae077dd --- /dev/null +++ b/go/internal/ocpp/server.go @@ -0,0 +1,112 @@ +// Package ocpp is the OCPP 1.6J Central System for FTW. +// +// EV chargers connect to us via WebSocket. We translate every BootNotification, +// MeterValues, and StatusNotification into a DerEV reading in telemetry.Store, +// keyed by the chargePointId from the URL path. The dispatch layer +// (control/dispatch.go:199-216) sums DerEV readings and prevents home batteries +// from discharging into an active EV charge. +// +// Phase 1 is read-only — handlers below ack everything but do not push remote +// commands. Phase 2 will add RemoteStartTransaction / SetChargingProfile. +// +// The library backing this is github.com/lorenzodonini/ocpp-go (MIT, also used +// by SteVe) — it owns the WebSocket + JSON layer; we own the message handlers +// and the telemetry mapping. +package ocpp + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ws" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Server is a running OCPP 1.6J Central System. +type Server struct { + cfg *Config + cs ocpp16.CentralSystem + handler *Handler + done chan struct{} + stopOnce sync.Once +} + +// Start brings up the OCPP CS on the configured bind:port. Returns +// immediately once the listener is up; the WebSocket loop runs in its own +// goroutine until ctx is cancelled or Stop() is called. +// +// The returned Server is the handle for shutdown — main.go is expected to +// call Stop() during graceful drain. +func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, error) { + if cfg == nil { + return nil, errors.New("ocpp: nil config") + } + if tel == nil { + return nil, errors.New("ocpp: nil telemetry store") + } + cfg.Defaults() + + wsServer := ws.NewServer() + if cfg.Username != "" || cfg.Password != "" { + u, p := cfg.Username, cfg.Password + wsServer.SetBasicAuthHandler(func(user, pass string) bool { + return user == u && pass == p + }) + } + + cs := ocpp16.NewCentralSystem(nil, wsServer) + h := NewHandler(tel, cfg.HeartbeatIntervalS) + cs.SetCoreHandler(h) + cs.SetNewChargePointHandler(func(cp ocpp16.ChargePointConnection) { + h.OnConnect(cp.ID()) + }) + cs.SetChargePointDisconnectedHandler(func(cp ocpp16.ChargePointConnection) { + h.OnDisconnect(cp.ID()) + }) + + s := &Server{cfg: cfg, cs: cs, handler: h, done: make(chan struct{})} + go func() { + defer close(s.done) + slog.Info("OCPP central system listening", + "bind", cfg.Bind, "port", cfg.Port, "path", cfg.Path, + "basic_auth", cfg.Username != "") + // TODO: cfg.Bind is not honored here. The ocpp-go library's + // CentralSystem.Start(port, path) and ws.Server.Start(port, path) + // only accept a port — there is no SetAddr or bind-address parameter. + // To support bind-address natively we would need to either: + // (a) upstream a PR to ocpp-go adding a SetListenAddr method, or + // (b) create our own net.Listener bound to cfg.Bind:cfg.Port and + // serve the ws.Server's http.Handler on it. + // For now cfg.Bind is advisory-only (documented in Config). + // cs.Start blocks until cs.Stop is called. + s.cs.Start(cfg.Port, fmt.Sprintf("%s{ws}", cfg.Path)) + }() + go func() { + <-ctx.Done() + s.Stop() + }() + return s, nil +} + +// Stop closes the WebSocket server and waits for the listener goroutine to exit. +// A 5-second timeout prevents deadlock if the listener goroutine is stuck. +func (s *Server) Stop() { + if s == nil || s.cs == nil { + return + } + s.stopOnce.Do(func() { s.cs.Stop() }) + select { + case <-s.done: + case <-time.After(5 * time.Second): + slog.Warn("ocpp: shutdown timeout — forcing close") + } +} + +// Handler exposes per-charger state for tests + introspection. +func (s *Server) Handler() *Handler { return s.handler } diff --git a/go/internal/ocpp/server_test.go b/go/internal/ocpp/server_test.go new file mode 100644 index 000000000..05eb88c7a --- /dev/null +++ b/go/internal/ocpp/server_test.go @@ -0,0 +1,188 @@ +package ocpp + +import ( + "context" + "fmt" + "net" + "sync" + "testing" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" + "github.com/lorenzodonini/ocpp-go/ws" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// freePort returns a port the kernel just allocated and immediately gave back. +// Cheaper than racing a hardcoded port across parallel test runs. +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("free port: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +// startServer brings up an OCPP CS on a free port and returns the port + a +// matching ws://… URL plus the running server. Caller must defer Stop. +func startServer(t *testing.T, tel *telemetry.Store) (int, *Server) { + t.Helper() + port := freePort(t) + cfg := &Config{Enabled: true, Bind: "127.0.0.1", Port: port, HeartbeatIntervalS: 60} + srv, err := Start(context.Background(), cfg, tel) + if err != nil { + t.Fatalf("start: %v", err) + } + // Wait for the listener to be reachable. cs.Start launches goroutines — + // without a tiny pause the client will racily connect to nothing. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond) + if err == nil { + c.Close() + return port, srv + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server did not bind on port %d within deadline", port) + return 0, nil +} + +func TestStopIsConcurrentAndIdempotent(t *testing.T) { + _, srv := startServer(t, telemetry.NewStore()) + + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + srv.Stop() + }() + } + wg.Wait() + srv.Stop() +} + +func TestBootAndMeterValuesPushDerEV(t *testing.T) { + tel := telemetry.NewStore() + port, srv := startServer(t, tel) + defer srv.Stop() + + cp := ocpp16.NewChargePoint("EH123456", nil, nil) + url := fmt.Sprintf("ws://127.0.0.1:%d", port) + if err := cp.Start(url); err != nil { + t.Fatalf("client connect: %v", err) + } + defer cp.Stop() + + if _, err := cp.BootNotification("EaseeHome", "Easee"); err != nil { + t.Fatalf("boot: %v", err) + } + + // Charging status — handler should mark connected+charging. + if _, err := cp.StatusNotification(1, core.NoError, core.ChargePointStatusCharging); err != nil { + t.Fatalf("status: %v", err) + } + + // MeterValues with a 7200 W power sample. + mv := []types.MeterValue{{ + Timestamp: types.NewDateTime(time.Now()), + SampledValue: []types.SampledValue{ + { + Value: "7200", + Measurand: types.MeasurandPowerActiveImport, + Unit: types.UnitOfMeasureW, + }, + }, + }} + if _, err := cp.MeterValues(1, mv); err != nil { + t.Fatalf("meter values: %v", err) + } + + // Allow the handler goroutine to flush. + deadline := time.Now().Add(2 * time.Second) + var r *telemetry.DerReading + for time.Now().Before(deadline) { + r = tel.Get("EH123456", telemetry.DerEV) + if r != nil && r.RawW > 0 { + break + } + time.Sleep(50 * time.Millisecond) + } + if r == nil { + t.Fatal("expected DerEV reading for EH123456, got nil") + } + if r.RawW != 7200 { + t.Errorf("expected 7200 W, got %f", r.RawW) + } + + view := srv.Handler().Snapshot()["EH123456"] + if !view.Connected || !view.Charging { + t.Errorf("expected connected+charging, got %+v", view) + } +} + +func TestStartStopTransactionTracksSession(t *testing.T) { + tel := telemetry.NewStore() + port, srv := startServer(t, tel) + defer srv.Stop() + + cp := ocpp16.NewChargePoint("EH-SESSION", nil, nil) + if err := cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("connect: %v", err) + } + defer cp.Stop() + + if _, err := cp.BootNotification("Home", "Easee"); err != nil { + t.Fatalf("boot: %v", err) + } + startConf, err := cp.StartTransaction(1, "RFID-ABCD", 1000, types.NewDateTime(time.Now())) + if err != nil { + t.Fatalf("start tx: %v", err) + } + if startConf.IdTagInfo.Status != types.AuthorizationStatusAccepted { + t.Errorf("expected accepted, got %s", startConf.IdTagInfo.Status) + } + + if _, err := cp.StopTransaction(8500, types.NewDateTime(time.Now()), startConf.TransactionId); err != nil { + t.Fatalf("stop tx: %v", err) + } + + // Session energy = 8500 - 1000 = 7500 Wh + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + v := srv.Handler().Snapshot()["EH-SESSION"] + if v.SessionWh == 7500 { + return + } + time.Sleep(50 * time.Millisecond) + } + view := srv.Handler().Snapshot()["EH-SESSION"] + t.Errorf("expected session_wh=7500, got %+v", view) +} + +func TestBasicAuthRejectsWrongCredentials(t *testing.T) { + tel := telemetry.NewStore() + port := freePort(t) + cfg := &Config{Enabled: true, Bind: "127.0.0.1", Port: port, Username: "easee", Password: "secret"} + srv, err := Start(context.Background(), cfg, tel) + if err != nil { + t.Fatalf("start: %v", err) + } + defer srv.Stop() + time.Sleep(100 * time.Millisecond) + + wsClient := ws.NewClient() + wsClient.SetBasicAuth("easee", "wrong-password") + cp := ocpp16.NewChargePoint("EH-AUTH", nil, wsClient) + err = cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)) + if err == nil { + cp.Stop() + t.Error("expected auth failure with wrong password, got nil") + } +} From 3e8773d9993f9d4c1cb84f27e31a8915cb337ea2 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 09:53:05 +0200 Subject: [PATCH 05/22] chore: add changeset for the OCPP central system restore Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- .changeset/ocpp-central-system-restore.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/ocpp-central-system-restore.md diff --git a/.changeset/ocpp-central-system-restore.md b/.changeset/ocpp-central-system-restore.md new file mode 100644 index 000000000..f467d9a6d --- /dev/null +++ b/.changeset/ocpp-central-system-restore.md @@ -0,0 +1,15 @@ +--- +"ftw": minor +--- + +Restore the OCPP 1.6J Central System so EV chargers can connect to FTW directly +instead of through a vendor cloud. + +`go/internal/ocpp` was retired as unused in #578. Bringing it back is the only +integration path for several common chargers: Charge Amps has no FTW driver at +all and every current model speaks OCPP 1.6J, while Easee and Zaptec can both be +commissioned once through their vendor portal and then run against a local +central system with no cloud in the runtime path. + +The package is restored unchanged and is not yet wired into the process, so this +release carries no behaviour change on its own. From 635b33726e745f51ed6daf917bedab59dc85c1a3 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 14:08:23 +0200 Subject: [PATCH 06/22] feat(ocpp): enable the central system and document driverless OCPP chargers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the restored OCPP 1.6J Central System into the process behind a new opt-in ocpp config section, and documents the part that is easy to miss: an OCPP charger needs no driver at all. OCPP is vendor-neutral, so one server in core covers every charger that speaks it. Chargers dial FTW rather than being polled, so there is nothing to add under drivers: — a charge point becomes a device on its first BootNotification, keyed by the last segment of the URL it connected to. Enabling the server requires a username and password, and config validation rejects an enabled section without them. This is deliberate. ocpp-go builds its listen address from the port alone, so the socket is reachable on every interface and cannot be pinned to one; basic auth is the only gate in front of it. That is a mitigation, not a fix, and the docs say so. Documented in docs/ocpp.md, with pointers from the README, config.example.yaml and writing-a-driver.md so nobody starts a Lua driver for a charger that does not need one. Tests cover the fail-closed credential rule and assert the shipped example config still parses, ships disabled and validates. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- .changeset/ocpp-central-system-restore.md | 29 ++++-- README.md | 8 +- config.example.yaml | 17 ++++ docs/ocpp.md | 116 ++++++++++++++++++++++ docs/writing-a-driver.md | 5 + go/cmd/ftw/main.go | 29 ++++++ go/internal/config/config.go | 42 ++++++++ go/internal/config/example_config_test.go | 31 ++++++ go/internal/config/ocpp_test.go | 96 ++++++++++++++++++ go/internal/ocpp/server.go | 18 ++++ 10 files changed, 381 insertions(+), 10 deletions(-) create mode 100644 docs/ocpp.md create mode 100644 go/internal/config/example_config_test.go create mode 100644 go/internal/config/ocpp_test.go diff --git a/.changeset/ocpp-central-system-restore.md b/.changeset/ocpp-central-system-restore.md index f467d9a6d..17a9f90c3 100644 --- a/.changeset/ocpp-central-system-restore.md +++ b/.changeset/ocpp-central-system-restore.md @@ -2,14 +2,25 @@ "ftw": minor --- -Restore the OCPP 1.6J Central System so EV chargers can connect to FTW directly -instead of through a vendor cloud. +Add OCPP 1.6J support so EV chargers connect to FTW directly instead of through +a vendor cloud. An OCPP charger needs no driver: the protocol is vendor-neutral, +so one server in core handles every charger that speaks it. -`go/internal/ocpp` was retired as unused in #578. Bringing it back is the only -integration path for several common chargers: Charge Amps has no FTW driver at -all and every current model speaks OCPP 1.6J, while Easee and Zaptec can both be -commissioned once through their vendor portal and then run against a local -central system with no cloud in the runtime path. +Chargers dial FTW rather than the other way round, so there is nothing to add +under `drivers:`. A charge point becomes a device on its first BootNotification, +keyed by the last segment of the URL it connected to, and dispatch treats it +like any other EV reading. -The package is restored unchanged and is not yet wired into the process, so this -release carries no behaviour change on its own. +This reinstates `go/internal/ocpp`, retired as unused in #578, and wires it into +the process behind a new `ocpp` config section. It matters because Charge Amps +has no FTW driver at all and every current model speaks OCPP, while Easee and +Zaptec can be commissioned once through their vendor portal and then run with no +cloud in the runtime path. + +The server is off by default. Enabling it requires a username and password, and +FTW refuses to start without them: the OCPP library builds its listen address +from the port alone, so the socket is reachable on every interface and basic +auth is the only gate. Keep the port closed at your router. + +Phase 1 is read-only — chargers are metered and monitored, but FTW does not yet +start, stop or throttle a charge. diff --git a/README.md b/README.md index 9c2bd2404..006771d43 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ rule. See [docs/architecture.md](docs/architecture.md). - local web UI, SQLite history and Parquet rolloff; - Home Assistant MQTT discovery; - CalDAV planning intents and published schedules; -- hot-reloadable, independently released Lua drivers. +- hot-reloadable, independently released Lua drivers; +- a built-in OCPP 1.6J server, so OCPP chargers connect with no driver at all. The local catalog is generated from `DRIVER` metadata. The public [`srcfl/device-drivers`](https://github.com/srcfl/device-drivers) repo is the @@ -177,6 +178,10 @@ driver, which can update or roll back without a new FTW core release. Device Support may consume the same public source later for other products or a higher support level. +EV chargers that speak OCPP are the exception: they need no driver. FTW runs an +OCPP 1.6J Central System, so the charger connects to FTW and registers itself. +See [docs/ocpp.md](docs/ocpp.md). + ## Releases There are two channels: @@ -201,6 +206,7 @@ metadata are the detailed reference. - [Full backup and safe restore](docs/backup-and-restore.md) - [Writing a driver](docs/writing-a-driver.md) - [Device driver catalog](https://srcfl.github.io/device-drivers/) — every supported device and the evidence behind it +- [OCPP chargers (no driver needed)](docs/ocpp.md) - [Self-update and release channels](docs/self-update.md) - [Home Assistant](docs/ha-integration.md) - [CalDAV](docs/caldav-integration.md) diff --git a/config.example.yaml b/config.example.yaml index cb6ec1c14..bb1855a0a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -173,6 +173,23 @@ caldav: plan_path: /ftw/plan/ # plan_publish_interval_s: 900 # how often the plan calendar is reconciled +# Built-in OCPP 1.6J Central System. EV chargers that speak OCPP connect to FTW +# directly — there is no driver to write and nothing to add under `drivers:`. +# A charger appears as a device on its first BootNotification, keyed by the last +# segment of the URL it dialled: ws://:8887/garage-left +# +# Credentials are required when enabled, and FTW refuses to start without them: +# the OCPP library builds its listen address from the port alone, so the socket +# is reachable on every interface and basic auth is the only gate. Keep the port +# closed at your router. See docs/ocpp.md. +ocpp: + enabled: false + port: 8887 + path: / + username: ftw + password: "" # required when enabled; use a long random string + heartbeat_interval_s: 60 + # Persistent state (SQLite) state: path: state.db diff --git a/docs/ocpp.md b/docs/ocpp.md new file mode 100644 index 000000000..f266a0e20 --- /dev/null +++ b/docs/ocpp.md @@ -0,0 +1,116 @@ +# OCPP chargers + +FTW has a built-in OCPP 1.6J Central System. An EV charger that speaks OCPP +connects straight to FTW over your local network. + +**An OCPP charger does not need a driver.** There is no Lua file to write, no +entry in `drivers:`, and nothing to add to the device catalog. OCPP is a vendor- +neutral protocol, so one server in core handles every charger that speaks it — +Charge Amps, Easee, Zaptec, ABB, Alfen and the rest are all the same code path. + +This is the opposite of how the rest of FTW works. Every other device needs a +driver because every vendor invented its own protocol. OCPP is the standard that +makes drivers unnecessary, so the driver boundary does not apply. + +## How a charger becomes a device + +Drivers poll outward: FTW opens the connection and asks for data. OCPP runs the +other way — the charger dials FTW and pushes. + +So there is nothing to configure per charger. You point the charger at FTW, and +it appears as a device the moment it sends its first `BootNotification`. Its +identity is the last segment of the URL it connected to: + +``` +ws://:8887/garage-left + └── this becomes the device key +``` + +Give each charger a distinct identity segment. Reuse one and two chargers will +collapse into a single device. + +From there it behaves like any other EV reading: `MeterValues` and +`StatusNotification` become telemetry, and dispatch stops the home battery +discharging into an active EV charge. + +## Enabling the server + +OCPP is off by default. Add an `ocpp` section: + +```yaml +ocpp: + enabled: true + port: 8887 # default 8887 + path: / # default / + username: ftw + password: + heartbeat_interval_s: 60 +``` + +**Credentials are mandatory.** FTW refuses to start with `enabled: true` and an +empty username or password. That is deliberate, and the reason is below. + +## Security: the listener is on every interface + +The OCPP library builds its listen address from the port alone, so the socket +binds to every interface the host has. There is no bind-address setting, and +there is no TLS on this path yet. + +On a Raspberry Pi with one LAN connection that is usually fine. It is not fine +if the host also has a public interface or a permissive port forward. + +So: + +- Keep the port closed at your router. Never forward it from the internet. +- Treat the password as a real secret; it is the only gate in front of the + server. +- Basic auth over `ws://` sends the credential unencrypted. Anyone who can sniff + your LAN can read it. + +Credentials being required is a mitigation, not a fix. Binding to one interface +needs a change to the upstream library. + +## Pointing a charger at FTW + +The backend URL is always `ws://:`. What differs +is how you reach the charger to set it. + +| Charger | Where you set it | Cloud needed? | +|---|---|---| +| Charge Amps Halo, Aura | WiFi hotspot → `192.168.250.1` → Settings → OCPP | no | +| Charge Amps Dawn, Luna | Charge Amps Installer app over Bluetooth → CPMS settings | no | +| Easee | Easee's commissioning API, once | one-time | +| Zaptec | Zaptec Portal, needs the `Allow OCPP 1.6J` permission | one-time | + +Easee and Zaptec need a one-time commissioning step through the vendor portal. +After that the charger talks only to FTW and the cloud is out of the runtime +path. Charge Amps needs no cloud at all. + +Two traps worth knowing: + +- **Charge Amps Bluetooth is only discoverable for 10 minutes after power-up.** + If the unit has been on longer, cut the fuse and re-energise before searching. +- **Zaptec appends the charger serial to the URL itself.** Enter the URL without + it. + +For the full commissioning and factory-reset detail per model, see the bench +guide in the device-drivers repository. + +## Current limits + +- **Read-only.** The server accepts and records everything a charger reports, + but sends no commands, so FTW cannot yet start, stop or throttle an OCPP + charge. Control is the next step. +- **No TLS**, and the listener cannot be pinned to one interface. +- Chargers that also have a native protocol may work better through a driver. + Easee over Modbus and Zaptec over its cloud API already have drivers; OCPP is + the option when you want the cloud out of the loop. + +## When you still need a driver + +Only for chargers that do not speak OCPP, or where a vendor protocol exposes +something OCPP does not. The Ambibox V2G / InterControl ambiCHARGE is the +example on the bench: it is DC-coupled and bidirectional, runs over MQTT, and +uses the `ambibox_v2x` driver. + +See [writing-a-driver.md](writing-a-driver.md) for that path. diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md index 53a3b93f6..bd149acd6 100644 --- a/docs/writing-a-driver.md +++ b/docs/writing-a-driver.md @@ -17,6 +17,11 @@ signed channel is FTW's default source: - [device driver catalog](https://srcfl.github.io/device-drivers/) — which devices are already covered, and on what evidence. +> **An EV charger that speaks OCPP does not need a driver.** FTW has a built-in +> OCPP 1.6J Central System, and one server in core handles every charger that +> speaks the protocol. Point the charger at FTW and it registers itself. See +> [ocpp.md](ocpp.md) before writing anything. + This page is the other half: what FTW's host gives a driver, how FTW grants it, where FTW loads it from, and how to test one against a running instance. It is deliberately not a second authoring guide — two of those drift, and the one diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 92bf2ca7c..b60dccaa2 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -59,6 +59,7 @@ import ( mqttcli "github.com/srcfl/ftw/go/internal/mqtt" "github.com/srcfl/ftw/go/internal/notifications" "github.com/srcfl/ftw/go/internal/nova" + "github.com/srcfl/ftw/go/internal/ocpp" "github.com/srcfl/ftw/go/internal/priceforecast" "github.com/srcfl/ftw/go/internal/prices" "github.com/srcfl/ftw/go/internal/proxy" @@ -1256,6 +1257,34 @@ func main() { slog.Info("caldav started", "listen", cfg.CalDAV.ListenAddr(), "url", cfg.CalDAV.URL, "calendar", cfg.CalDAV.CalendarPath) } + // ---- Start OCPP 1.6J Central System (optional) ---- + // Chargers dial us, so there is nothing to add to cfg.Drivers and no Lua + // driver involved. A charge point becomes a device in tel the moment it + // sends its first BootNotification, keyed by the identity segment of the + // URL it connected to, and dispatch picks it up from there like any other + // EV reading. + if cfg.OCPP != nil && cfg.OCPP.Enabled { + ocppSrv, err := ocpp.Start(ctx, &ocpp.Config{ + Enabled: cfg.OCPP.Enabled, + Port: cfg.OCPP.Port, + Path: cfg.OCPP.Path, + Username: cfg.OCPP.Username, + Password: cfg.OCPP.Password, + HeartbeatIntervalS: cfg.OCPP.HeartbeatIntervalS, + }, tel) + if err != nil { + // A charger that cannot reach us is a missing device, not a + // broken site, so keep the rest of the process running. + slog.Error("ocpp: central system failed to start", "err", err) + } else { + defer ocppSrv.Stop() + slog.Info("ocpp: central system started", + "port", ocppSrv.Port(), + "path", ocppSrv.Path(), + "note", "listener is reachable on every interface; basic auth is the only gate") + } + } + // ---- Start MPC planner (optional) ---- mpcSvc = buildMPC(cfg, st, tel, capacities) if mpcSvc != nil { diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 6ed18af55..831837265 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -42,6 +42,45 @@ type Config struct { FleetPing *FleetPing `yaml:"fleet_ping,omitempty" json:"fleet_ping,omitempty"` Nova *Nova `yaml:"nova,omitempty" json:"nova,omitempty"` DeviceRepository *DeviceRepository `yaml:"device_repository,omitempty" json:"device_repository,omitempty"` + OCPP *OCPP `yaml:"ocpp,omitempty" json:"ocpp,omitempty"` +} + +// OCPP configures the built-in OCPP 1.6J Central System. Chargers connect to +// us, so there is no driver and no per-charger config entry — a charge point +// appears as a device the moment it sends its first BootNotification, keyed by +// the identity segment of the URL it dialled. +// +// Disabled by default, and enabling it requires credentials. The listener +// cannot be restricted to one interface: the OCPP library builds its own +// listen address from the port alone, so the socket is reachable on every +// interface the host has. Basic auth is the only thing standing in front of +// it, which is why an empty Username or Password is rejected rather than +// silently accepted. +type OCPP struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` + Path string `yaml:"path,omitempty" json:"path,omitempty"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` + HeartbeatIntervalS int `yaml:"heartbeat_interval_s,omitempty" json:"heartbeat_interval_s,omitempty"` +} + +// Validate rejects an enabled server that would accept anonymous charge +// points. A nil or disabled section is fine — OCPP is opt-in. +func (o *OCPP) Validate() error { + if o == nil || !o.Enabled { + return nil + } + if o.Username == "" || o.Password == "" { + return errors.New("ocpp: username and password are required when enabled, because the listener cannot be bound to a single interface") + } + if o.Port < 0 || o.Port > 65535 { + return fmt.Errorf("ocpp.port must be between 0 and 65535, got %d", o.Port) + } + if o.HeartbeatIntervalS < 0 { + return fmt.Errorf("ocpp.heartbeat_interval_s must be >= 0, got %d", o.HeartbeatIntervalS) + } + return nil } // AppLink controls the outbound connection the FTW app reaches this box @@ -1712,6 +1751,9 @@ func (c *Config) Validate() error { if err := c.FleetPing.Validate(); err != nil { return err } + if err := c.OCPP.Validate(); err != nil { + return err + } // Empty drivers list is a valid shape — e.g. an EV-only site that // configured a cloud EV charger in the setup wizard and doesn't diff --git a/go/internal/config/example_config_test.go b/go/internal/config/example_config_test.go new file mode 100644 index 000000000..c7d9e0429 --- /dev/null +++ b/go/internal/config/example_config_test.go @@ -0,0 +1,31 @@ +package config + +import ( + "os" + "testing" +) + +func TestExampleConfigParsesOCPP(t *testing.T) { + data, err := os.ReadFile("../../../config.example.yaml") + if err != nil { + t.Fatalf("read example: %v", err) + } + c, err := Parse(data, ".") + if err != nil { + t.Fatalf("parse example config: %v", err) + } + if c.OCPP == nil { + t.Fatal("example config has no ocpp section") + } + if c.OCPP.Enabled { + t.Error("example config must ship with ocpp disabled") + } + if c.OCPP.Port != 8887 { + t.Errorf("port: got %d want 8887", c.OCPP.Port) + } + if err := c.Validate(); err != nil { + t.Fatalf("example config must validate: %v", err) + } + t.Logf("ocpp parsed: enabled=%v port=%d path=%q heartbeat=%d", + c.OCPP.Enabled, c.OCPP.Port, c.OCPP.Path, c.OCPP.HeartbeatIntervalS) +} diff --git a/go/internal/config/ocpp_test.go b/go/internal/config/ocpp_test.go new file mode 100644 index 000000000..bd32ac8ab --- /dev/null +++ b/go/internal/config/ocpp_test.go @@ -0,0 +1,96 @@ +package config + +import "testing" + +// The OCPP listener cannot be pinned to one interface — the library builds its +// listen address from the port alone. Basic auth is therefore the only thing +// between an enabled server and any host that can route to it, so an enabled +// section without credentials has to fail rather than start. +func TestOCPPValidate(t *testing.T) { + tests := []struct { + name string + ocpp *OCPP + wantErr bool + }{ + { + name: "absent section is fine", + ocpp: nil, + }, + { + name: "disabled without credentials is fine", + ocpp: &OCPP{Enabled: false}, + }, + { + name: "enabled without credentials is rejected", + ocpp: &OCPP{Enabled: true}, + wantErr: true, + }, + { + name: "enabled with only a username is rejected", + ocpp: &OCPP{Enabled: true, Username: "ftw"}, + wantErr: true, + }, + { + name: "enabled with only a password is rejected", + ocpp: &OCPP{Enabled: true, Password: "secret"}, + wantErr: true, + }, + { + name: "enabled with both is accepted", + ocpp: &OCPP{Enabled: true, Username: "ftw", Password: "secret"}, + }, + { + name: "port out of range is rejected", + ocpp: &OCPP{Enabled: true, Username: "ftw", Password: "secret", Port: 70000}, + wantErr: true, + }, + { + name: "negative heartbeat is rejected", + ocpp: &OCPP{Enabled: true, Username: "ftw", Password: "secret", HeartbeatIntervalS: -1}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.ocpp.Validate() + if tt.wantErr && err == nil { + t.Fatal("expected an error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} + +// A disabled or absent OCPP section must not stop the rest of the config from +// validating, and an enabled one without credentials must take the whole +// config down with it rather than being skipped. +func TestConfigValidateCoversOCPP(t *testing.T) { + // SmoothingAlpha and MaxAmps are normally filled in by defaults, which this + // test does not run; set them so the only thing under test is OCPP. + base := func() *Config { + return &Config{ + Site: Site{SmoothingAlpha: 0.3}, + Fuse: Fuse{MaxAmps: 20, Phases: 3, Voltage: 230}, + } + } + + c := base() + if err := c.Validate(); err != nil { + t.Fatalf("config without an ocpp section should validate, got %v", err) + } + + c = base() + c.OCPP = &OCPP{Enabled: true} + if err := c.Validate(); err == nil { + t.Fatal("config with an enabled, credential-less ocpp section should fail") + } + + c = base() + c.OCPP = &OCPP{Enabled: true, Username: "ftw", Password: "secret"} + if err := c.Validate(); err != nil { + t.Fatalf("config with a credentialed ocpp section should validate, got %v", err) + } +} diff --git a/go/internal/ocpp/server.go b/go/internal/ocpp/server.go index 79ae077dd..75a4dc06c 100644 --- a/go/internal/ocpp/server.go +++ b/go/internal/ocpp/server.go @@ -110,3 +110,21 @@ func (s *Server) Stop() { // Handler exposes per-charger state for tests + introspection. func (s *Server) Handler() *Handler { return s.handler } + +// Port is the port the listener actually took, after defaults were applied. +// Callers configuring an unset port need this to log or display the real value. +func (s *Server) Port() int { + if s == nil || s.cfg == nil { + return 0 + } + return s.cfg.Port +} + +// Path is the URL prefix charge points connect to, after defaults were applied. +// A charger dials , and that identity becomes its device key. +func (s *Server) Path() string { + if s == nil || s.cfg == nil { + return "" + } + return s.cfg.Path +} From bf8ee16299d25fdb4694d99ee5adec41b2aec921 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 14:27:32 +0200 Subject: [PATCH 07/22] feat(ocpp): control chargers with charging profiles instead of remote stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FTW can now throttle, pause and resume an OCPP charger. Command has the same signature as drivers.Registry.Send and speaks the vocabulary every EV driver already implements, so main.go routes by name and loadpoints never learn that an OCPP charger is not a Lua driver. Every command is a current limit, never a remote start or stop. RemoteStopTransaction is unreliable on Charge Amps hardware — units acknowledge the stop and then resume charging on their own — while a 0 A charging profile is honoured consistently. It also leaves the transaction open, so the session meter keeps counting across a pause instead of splitting into two sessions. Two safety decisions worth calling out: - Below the IEC 61851 minimum of 6 A the charger is told 0 A rather than rounded up. When the allocator has less headroom than that to give, rounding up draws current the site fuse was never asked to carry, so refusing to charge is the safe direction of error. - A pause records no limit, so resuming returns to the last non-zero rate rather than the fallback ceiling. Caught by TestResumeRestoresLastLimit. Also splits charger state in two. connected already meant "a connector has a vehicle on it" and was never set by OnConnect, so it could not gate control: a default charging profile is exactly the thing you set on an idle charger. online now tracks the WebSocket session and is what control gates on. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- .changeset/ocpp-central-system-restore.md | 11 +- docs/ocpp.md | 30 +- go/cmd/ftw/main.go | 22 +- go/internal/ocpp/control.go | 308 +++++++++++++++++ go/internal/ocpp/control_test.go | 403 ++++++++++++++++++++++ go/internal/ocpp/handlers.go | 13 + 6 files changed, 779 insertions(+), 8 deletions(-) create mode 100644 go/internal/ocpp/control.go create mode 100644 go/internal/ocpp/control_test.go diff --git a/.changeset/ocpp-central-system-restore.md b/.changeset/ocpp-central-system-restore.md index 17a9f90c3..e1d37ca77 100644 --- a/.changeset/ocpp-central-system-restore.md +++ b/.changeset/ocpp-central-system-restore.md @@ -17,10 +17,15 @@ has no FTW driver at all and every current model speaks OCPP, while Easee and Zaptec can be commissioned once through their vendor portal and then run with no cloud in the runtime path. +FTW throttles, pauses and resumes an OCPP charger like any other EV charger. +Every command is a current limit rather than a remote start or stop, because +`RemoteStopTransaction` is unreliable on Charge Amps hardware — units +acknowledge the stop and resume charging on their own, while a 0 A charging +profile is honoured consistently and keeps the session meter intact across a +pause. Below the IEC 61851 minimum of 6 A the charger is told 0 A rather than +being rounded up to current the site fuse was not asked to carry. + The server is off by default. Enabling it requires a username and password, and FTW refuses to start without them: the OCPP library builds its listen address from the port alone, so the socket is reachable on every interface and basic auth is the only gate. Keep the port closed at your router. - -Phase 1 is read-only — chargers are metered and monitored, but FTW does not yet -start, stop or throttle a charge. diff --git a/docs/ocpp.md b/docs/ocpp.md index f266a0e20..d46330c68 100644 --- a/docs/ocpp.md +++ b/docs/ocpp.md @@ -96,11 +96,35 @@ Two traps worth knowing: For the full commissioning and factory-reset detail per model, see the bench guide in the device-drivers repository. +## Control + +FTW throttles, pauses and resumes an OCPP charger the same way it steers any +other EV charger. Loadpoints do not know the difference. + +Every command is expressed as a **current limit**, never as a remote start or +stop. Pausing means "you may draw zero amps"; resuming raises the limit again. + +That is not a stylistic choice. `RemoteStopTransaction` is unreliable on Charge +Amps hardware — units acknowledge the stop and then resume charging on their +own. A charging profile of 0 A is honoured consistently. It also leaves the +transaction open, so the session meter keeps counting across a pause instead of +being split into two sessions. + +Two limits worth knowing: + +- **Below 6 A, FTW sends 0 A.** IEC 61851 has no duty cycle under 6 A. When the + allocator has less headroom than that to give, rounding up would draw current + the site fuse was never asked to carry, so charging stops instead. +- **A pause remembers the previous rate.** Resuming returns to the last non-zero + limit, not to the maximum. + +If FTW loses contact, the charger holds its last granted limit. Dropping to zero +would strand a driver with an uncharged car because the EMS went down, and the +last limit was already judged safe for the site. This matches what every EV +driver in FTW already does. + ## Current limits -- **Read-only.** The server accepts and records everything a charger reports, - but sends no commands, so FTW cannot yet start, stop or throttle an OCPP - charge. Control is the next step. - **No TLS**, and the listener cannot be pinned to one interface. - Chargers that also have a native protocol may work better through a driver. Easee over Modbus and Zaptec over its cloud API already have drivers; OCPP is diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index b60dccaa2..7d0ee368e 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1263,8 +1263,9 @@ func main() { // sends its first BootNotification, keyed by the identity segment of the // URL it connected to, and dispatch picks it up from there like any other // EV reading. + var ocppSrv *ocpp.Server if cfg.OCPP != nil && cfg.OCPP.Enabled { - ocppSrv, err := ocpp.Start(ctx, &ocpp.Config{ + srv, err := ocpp.Start(ctx, &ocpp.Config{ Enabled: cfg.OCPP.Enabled, Port: cfg.OCPP.Port, Path: cfg.OCPP.Path, @@ -1277,6 +1278,7 @@ func main() { // broken site, so keep the rest of the process running. slog.Error("ocpp: central system failed to start", "err", err) } else { + ocppSrv = srv defer ocppSrv.Stop() slog.Info("ocpp: central system started", "port", ocppSrv.Port(), @@ -1689,7 +1691,20 @@ func main() { RequestActive: reqActive, }, true } - lpController = loadpoint.NewController(lpMgr, planAdapter, telAdapter, reg.Send) + // An OCPP charge point is not in the driver registry — it connected to + // us rather than being dialled — so route by name: if an online charger + // answers to it, command it over OCPP, otherwise fall through to the + // Lua driver registry. Loadpoints stay unaware of the difference. + send := reg.Send + if ocppSrv != nil { + send = func(ctx context.Context, name string, payload []byte) error { + if ocppSrv.Handler().IsOnline(name) { + return ocppSrv.Command(ctx, name, payload) + } + return reg.Send(ctx, name, payload) + } + } + lpController = loadpoint.NewController(lpMgr, planAdapter, telAdapter, send) // A charger that answers every poll and refuses every setpoint is // the storage bug of #800 on the EV wire: it holds its last // current and the plan keeps counting the load. Only the periodic @@ -1699,6 +1714,9 @@ func main() { lpController.SetCycleSender(reg.SendEVContinuation) lpController.SetDispatchOutcome(actuation.recordCommandOutcome) lpController.SetDriverOnline(func(name string) bool { + if ocppSrv != nil && ocppSrv.Handler().IsOnline(name) { + return true + } health := tel.DriverHealth(name) return health != nil && health.IsOnline() }) diff --git a/go/internal/ocpp/control.go b/go/internal/ocpp/control.go new file mode 100644 index 000000000..a67497da4 --- /dev/null +++ b/go/internal/ocpp/control.go @@ -0,0 +1,308 @@ +package ocpp + +// Control for OCPP chargers. +// +// Command has the same shape as drivers.Registry.Send, so the loadpoint +// controller dispatches to an OCPP charger without knowing it is not a Lua +// driver. The command vocabulary is the one every EV driver already implements +// — ev_set_current, ev_pause, ev_start, ev_resume — so nothing upstream of here +// needs a special case. +// +// Everything is expressed as a current limit rather than a remote start/stop. +// That is deliberate: RemoteStopTransaction is unreliable in the field on +// Charge Amps hardware, where units acknowledge the stop and return to charging +// on their own. A charging profile of 0 A is honoured consistently, so pausing +// is "allow zero amps" rather than "end the transaction", and resuming raises +// the limit again. It also leaves the transaction open, so the session meter +// keeps accumulating across a pause instead of being split in two. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" +) + +const ( + // IEC 61851 does not allow a duty cycle below 6 A. Charging is either off + // or at least this much; anything between is refused by the vehicle or + // leaves it drawing an unpredictable current. + minChargeAmps = 6.0 + + // Fallback electrical assumptions, used only when the command omits them. + // A command from the loadpoint controller carries the real site values. + defaultVoltage = 230.0 + defaultPhases = 3 + + // Ceiling applied when a command asks to resume without saying how fast + // and we have never set a limit for this charger. + defaultMaxAmps = 16.0 + + // How long to wait for a charger to confirm a profile before giving up. + // Control runs on a tick; a command that has not landed by now is better + // reported as failed than left blocking the loop. + commandTimeout = 10 * time.Second + + // Charge-point-wide default profile. Connector 0 means "every connector", + // which avoids depending on per-connector ids — those are unreliable on + // dual-socket units such as the Charge Amps Aura. + allConnectors = 0 + + // A single, stable profile id and stack level means each new limit + // replaces the previous one instead of stacking on top of it. + ftwProfileID = 1 + ftwStackLevel = 0 + scheduleStartS = 0 +) + +// command is the JSON payload the loadpoint controller sends to EV drivers. +// Only the fields that affect a current limit are read here. +type command struct { + Action string `json:"action"` + PowerW float64 `json:"power_w"` + Voltage float64 `json:"voltage"` + SitePhases int `json:"site_phases"` + MaxAmpsPerPhase float64 `json:"max_amps_per_phase"` + PhaseMode string `json:"phase_mode"` +} + +// ErrNotConnected is returned when a command targets a charge point that is not +// currently connected. Callers use it to tell "wrong name" apart from "charger +// is offline right now". +var ErrNotConnected = errors.New("ocpp: charger not connected") + +// Command applies an EV control command to a connected charge point. The +// signature matches drivers.Registry.Send so it can back a loadpoint +// SenderFunc directly. +func (s *Server) Command(ctx context.Context, id string, payload []byte) error { + if s == nil || s.cs == nil { + return errors.New("ocpp: server not running") + } + if !s.handler.IsOnline(id) { + return fmt.Errorf("%w: %s", ErrNotConnected, id) + } + + var c command + if err := json.Unmarshal(payload, &c); err != nil { + return fmt.Errorf("ocpp: bad command payload for %s: %w", id, err) + } + + switch c.Action { + case "init", "deinit": + // Lifecycle hooks that only mean something to a Lua VM. + return nil + + case "ev_set_current": + return s.setLimit(ctx, id, c.amps(), c.numberPhases()) + + case "ev_pause": + // Phase count is meaningless at zero amps. + return s.setLimit(ctx, id, 0, nil) + + case "ev_start", "ev_resume": + // A resume without a rate means "as fast as previously allowed". + amps := c.amps() + if amps <= 0 { + amps = s.handler.LastAmps(id, c.ceiling()) + } + return s.setLimit(ctx, id, amps, c.numberPhases()) + + default: + return fmt.Errorf("ocpp: unknown action %q for %s", c.Action, id) + } +} + +// ceiling is the highest per-phase current this command permits. +func (c command) ceiling() float64 { + if c.MaxAmpsPerPhase > 0 { + return c.MaxAmpsPerPhase + } + return defaultMaxAmps +} + +// amps converts the requested site power into a per-phase current limit. +// +// Below the IEC minimum the result is zero rather than the minimum: when the +// allocator has less than 6 A of headroom to give, rounding up would draw +// current the site fuse was not asked to carry. Refusing to charge is the safe +// direction of error. +func (c command) amps() float64 { + if c.PowerW <= 0 { + return 0 + } + + voltage := c.Voltage + if voltage <= 0 { + voltage = defaultVoltage + } + phases := c.SitePhases + if phases <= 0 { + phases = defaultPhases + } + if c.PhaseMode == "1p" { + phases = 1 + } + + amps := c.PowerW / (voltage * float64(phases)) + if ceiling := c.ceiling(); amps > ceiling { + amps = ceiling + } + if amps < minChargeAmps { + return 0 + } + return amps +} + +// numberPhases is what to declare in the schedule period, or nil to let the +// charger decide. Only a pinned single-phase command is worth stating. +func (c command) numberPhases() *int { + if c.PhaseMode == "1p" { + n := 1 + return &n + } + return nil +} + +// setLimit installs a charge-point-wide default profile capping the per-phase +// current, and blocks until the charger confirms it, the context ends, or the +// command times out. +func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPhases *int) error { + if amps < 0 { + amps = 0 + } + + period := types.NewChargingSchedulePeriod(scheduleStartS, amps) + // Declared only when the loadpoint pinned single-phase charging. Left + // unset otherwise so a charger that can switch phases keeps deciding. + period.NumberPhases = numberPhases + schedule := types.NewChargingSchedule(types.ChargingRateUnitAmperes, period) + profile := types.NewChargingProfile( + ftwProfileID, + ftwStackLevel, + types.ChargingProfilePurposeTxDefaultProfile, + types.ChargingProfileKindAbsolute, + schedule, + ) + + type result struct { + conf *smartcharging.SetChargingProfileConfirmation + err error + } + // Buffered: the library's callback must never block if we have already + // stopped waiting. + done := make(chan result, 1) + + err := s.cs.SetChargingProfile(id, func(conf *smartcharging.SetChargingProfileConfirmation, err error) { + done <- result{conf: conf, err: err} + }, allConnectors, profile) + if err != nil { + return fmt.Errorf("ocpp: send charging profile to %s: %w", id, err) + } + + timeout := time.NewTimer(commandTimeout) + defer timeout.Stop() + + select { + case r := <-done: + if r.err != nil { + return fmt.Errorf("ocpp: %s rejected charging profile: %w", id, r.err) + } + if r.conf == nil { + return fmt.Errorf("ocpp: %s returned no charging profile confirmation", id) + } + if r.conf.Status != smartcharging.ChargingProfileStatusAccepted { + return fmt.Errorf("ocpp: %s answered %s to charging profile", id, r.conf.Status) + } + // Only a real charging rate is worth remembering. Recording the zero + // from a pause would erase the rate a later resume is supposed to + // restore, and the charger would come back at the fallback ceiling + // instead of where it left off. + if amps > 0 { + s.handler.SetLastAmps(id, amps) + } + slog.Info("ocpp: charging limit applied", "charger", id, "amps", amps) + return nil + + case <-ctx.Done(): + return fmt.Errorf("ocpp: charging profile for %s cancelled: %w", id, ctx.Err()) + + case <-timeout.C: + return fmt.Errorf("ocpp: %s did not confirm charging profile within %s", id, commandTimeout) + } +} + +// DefaultMode is what a charger is left in when FTW stops steering it, and +// mirrors the stance every EV driver already takes: hold the last limit. +// +// An EV charger has no autonomous self-consumption mode to fall back to, and +// dropping to zero would strand a driver with an uncharged car because the EMS +// lost contact. Holding the last granted current keeps the site within the +// envelope that was already judged safe. +func (s *Server) DefaultMode(_ context.Context, id string) error { + slog.Info("ocpp: leaving charger at its last granted limit", "charger", id) + return nil +} + +// IsOnline reports whether a charge point currently holds a WebSocket session. +// +// This is deliberately not the same as having a vehicle plugged in. A default +// charging profile is exactly the thing you set on an idle charger, so control +// gates on the session being live, not on the connector being occupied. +func (h *Handler) IsOnline(id string) bool { + if h == nil { + return false + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + return ok && s.online +} + +// LastAmps returns the last limit granted to a charger, or fallback if none +// has been set yet. +func (h *Handler) LastAmps(id string, fallback float64) float64 { + if h == nil { + return fallback + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok || s.lastAmps <= 0 { + return fallback + } + return s.lastAmps +} + +// SetLastAmps records an accepted limit so a later resume can restore it. +func (h *Handler) SetLastAmps(id string, amps float64) { + if h == nil { + return + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok { + return + } + s.lastAmps = amps +} + +// Names returns the charge points seen since start, connected or not. main.go +// uses it to decide whether a loadpoint driver name belongs to OCPP. +func (h *Handler) Names() []string { + if h == nil { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + out := make([]string, 0, len(h.chargers)) + for id := range h.chargers { + out = append(out, id) + } + return out +} diff --git a/go/internal/ocpp/control_test.go b/go/internal/ocpp/control_test.go new file mode 100644 index 000000000..6cc658348 --- /dev/null +++ b/go/internal/ocpp/control_test.go @@ -0,0 +1,403 @@ +package ocpp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "testing" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// fakeCharger is a charge point that records the charging profiles it is sent +// and answers with a configurable status. +type fakeCharger struct { + mu sync.Mutex + status smartcharging.ChargingProfileStatus + profiles []*smartcharging.SetChargingProfileRequest +} + +func newFakeCharger() *fakeCharger { + return &fakeCharger{status: smartcharging.ChargingProfileStatusAccepted} +} + +func (f *fakeCharger) OnSetChargingProfile(req *smartcharging.SetChargingProfileRequest) (*smartcharging.SetChargingProfileConfirmation, error) { + f.mu.Lock() + f.profiles = append(f.profiles, req) + status := f.status + f.mu.Unlock() + return smartcharging.NewSetChargingProfileConfirmation(status), nil +} + +func (f *fakeCharger) OnClearChargingProfile(*smartcharging.ClearChargingProfileRequest) (*smartcharging.ClearChargingProfileConfirmation, error) { + return nil, errors.New("not used") +} + +func (f *fakeCharger) OnGetCompositeSchedule(*smartcharging.GetCompositeScheduleRequest) (*smartcharging.GetCompositeScheduleConfirmation, error) { + return nil, errors.New("not used") +} + +func (f *fakeCharger) setStatus(s smartcharging.ChargingProfileStatus) { + f.mu.Lock() + defer f.mu.Unlock() + f.status = s +} + +// lastLimit returns the amp limit from the most recent profile received. +func (f *fakeCharger) lastLimit(t *testing.T) float64 { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.profiles) == 0 { + t.Fatal("charger received no charging profile") + } + p := f.profiles[len(f.profiles)-1] + if p.ChargingProfile == nil || p.ChargingProfile.ChargingSchedule == nil { + t.Fatal("charging profile had no schedule") + } + periods := p.ChargingProfile.ChargingSchedule.ChargingSchedulePeriod + if len(periods) == 0 { + t.Fatal("charging schedule had no periods") + } + return periods[0].Limit +} + +// lastNumberPhases returns the declared phase count from the most recent +// profile, or nil when the charger was left to decide. +func (f *fakeCharger) lastNumberPhases(t *testing.T) *int { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.profiles) == 0 { + t.Fatal("charger received no charging profile") + } + p := f.profiles[len(f.profiles)-1] + periods := p.ChargingProfile.ChargingSchedule.ChargingSchedulePeriod + if len(periods) == 0 { + t.Fatal("charging schedule had no periods") + } + return periods[0].NumberPhases +} + +func (f *fakeCharger) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.profiles) +} + +// connectCharger brings up a charge point against the server and waits until +// the server has registered the session. +// +// The returned stop is idempotent: ocpp-go panics on a second Stop, and the +// cleanup below would otherwise fire after a test that disconnects on purpose. +func connectCharger(t *testing.T, srv *Server, port int, id string) (ocpp16.ChargePoint, *fakeCharger, func()) { + t.Helper() + fake := newFakeCharger() + cp := ocpp16.NewChargePoint(id, nil, nil) + cp.SetSmartChargingHandler(fake) + + if err := cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("charge point connect: %v", err) + } + var once sync.Once + stop := func() { once.Do(cp.Stop) } + t.Cleanup(stop) + + if _, err := cp.BootNotification("Dawn", "Charge Amps"); err != nil { + t.Fatalf("boot: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if srv.Handler().IsOnline(id) { + return cp, fake, stop + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server never registered charger %s as online", id) + return nil, nil, nil +} + +func mustPayload(t *testing.T, m map[string]any) []byte { + t.Helper() + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + return b +} + +// The whole point of Phase 2: a pause has to arrive as a 0 A limit, not as a +// RemoteStopTransaction, because Charge Amps units resume on their own after a +// remote stop. +func TestPauseSendsZeroAmpLimit(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if err != nil { + t.Fatalf("pause: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Errorf("pause limit: got %v A, want 0 A", got) + } +} + +func TestSetCurrentConvertsPowerToAmps(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 11040 W over 3 phases at 230 V = 16 A per phase. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("limit: got %v A, want 16 A", got) + } +} + +// Below the IEC 61851 minimum the charger must be told zero, never a rounded-up +// 6 A the site fuse was not asked to carry. +func TestBelowMinimumBecomesZero(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 2000 W over 3 phases at 230 V ≈ 2.9 A per phase — under the minimum. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 2000.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Errorf("sub-minimum limit: got %v A, want 0 A", got) + } +} + +func TestMaxAmpsPerPhaseIsRespected(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 22 kW would be 32 A per phase, but the loadpoint allows only 16 A. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 22080.0, + "voltage": 230.0, + "site_phases": 3, + "max_amps_per_phase": 16.0, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("clamped limit: got %v A, want 16 A", got) + } +} + +// A pause must not lose the previous rate: resuming without one restores it. +func TestResumeRestoresLastLimit(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + ctx := context.Background() + if err := srv.Command(ctx, "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 6900.0, // 10 A over 3 phases at 230 V + "voltage": 230.0, + "site_phases": 3, + })); err != nil { + t.Fatalf("set current: %v", err) + } + if err := srv.Command(ctx, "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })); err != nil { + t.Fatalf("pause: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Fatalf("pause limit: got %v A, want 0 A", got) + } + + if err := srv.Command(ctx, "garage-left", mustPayload(t, map[string]any{ + "action": "ev_resume", + })); err != nil { + t.Fatalf("resume: %v", err) + } + if got := fake.lastLimit(t); got != 10 { + t.Errorf("resumed limit: got %v A, want the previous 10 A", got) + } +} + +func TestRejectedProfileIsAnError(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + fake.setStatus(smartcharging.ChargingProfileStatusRejected) + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if err == nil { + t.Fatal("expected an error when the charger rejects the profile") + } +} + +func TestUnknownChargerIsNotConnected(t *testing.T) { + _, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + + err := srv.Command(context.Background(), "nobody-home", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if !errors.Is(err, ErrNotConnected) { + t.Fatalf("expected ErrNotConnected, got %v", err) + } +} + +// A charger that drops off must not silently swallow commands — control needs +// the error so it can fall back. +func TestDisconnectedChargerRejectsCommands(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, _, stop := connectCharger(t, srv, port, "garage-left") + + stop() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && srv.Handler().IsOnline("garage-left") { + time.Sleep(20 * time.Millisecond) + } + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if !errors.Is(err, ErrNotConnected) { + t.Fatalf("expected ErrNotConnected after disconnect, got %v", err) + } +} + +// init/deinit are Lua lifecycle hooks. They must be accepted and do nothing +// rather than reaching the charger or erroring. +func TestLifecycleActionsAreNoOps(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + for _, action := range []string{"init", "deinit"} { + if err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": action, + })); err != nil { + t.Errorf("%s: unexpected error %v", action, err) + } + } + if n := fake.count(); n != 0 { + t.Errorf("lifecycle actions sent %d profiles, want 0", n) + } +} + +func TestUnknownActionIsAnError(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "self_destruct", + })) + if err == nil { + t.Fatal("expected an error for an unknown action") + } + if n := fake.count(); n != 0 { + t.Errorf("unknown action sent %d profiles, want 0", n) + } +} + +// Single-phase mode changes the conversion: the same watts land on one phase. +func TestSinglePhaseModeUsesOnePhase(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 3680 W on one phase at 230 V = 16 A. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 3680.0, + "voltage": 230.0, + "site_phases": 3, + "phase_mode": "1p", + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("1p limit: got %v A, want 16 A", got) + } + if got := fake.lastNumberPhases(t); got == nil || *got != 1 { + t.Errorf("1p numberPhases: got %v, want 1", got) + } +} + +// With no phase mode pinned, the phase count is left unset so a charger that +// can switch phases keeps deciding for itself. +func TestThreePhaseLeavesPhaseCountUnset(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastNumberPhases(t); got != nil { + t.Errorf("numberPhases: got %v, want unset", *got) + } +} + +// The status handler marks a charger connected; a suspended charger is still +// commandable, which is what lets a paused session be resumed. +func TestSuspendedChargerStillAcceptsCommands(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + cp, fake, _ := connectCharger(t, srv, port, "garage-left") + + if _, err := cp.StatusNotification(1, core.NoError, core.ChargePointStatusSuspendedEV); err != nil { + t.Fatalf("status: %v", err) + } + + if err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 6900.0, + "voltage": 230.0, + "site_phases": 3, + })); err != nil { + t.Fatalf("set current on suspended charger: %v", err) + } + if got := fake.lastLimit(t); got != 10 { + t.Errorf("limit: got %v A, want 10 A", got) + } +} diff --git a/go/internal/ocpp/handlers.go b/go/internal/ocpp/handlers.go index 866266a7f..d46b233e6 100644 --- a/go/internal/ocpp/handlers.go +++ b/go/internal/ocpp/handlers.go @@ -29,12 +29,20 @@ type Handler struct { // chargerState is what we accumulate from successive OCPP messages for one // charge point. Survives the OCPP library's stateless handler invocations. type chargerState struct { + // online is whether the charger currently holds a WebSocket session. + // Distinct from connected below, which tracks whether a connector has a + // vehicle on it. A charger can be online with nothing plugged in, and + // still needs to accept a default charging profile in that state. + online bool connected bool charging bool transactionID int sessionStartMeterWh float64 sessionMeterWh float64 lastPowerW float64 + // lastAmps is the most recent per-phase limit this charger accepted. + // A resume with no rate of its own restores it. + lastAmps float64 } // NewHandler returns a Handler ready to register with a CentralSystem. @@ -91,6 +99,10 @@ type ChargerView struct { // connection callbacks, not part of CoreHandler. func (h *Handler) OnConnect(id string) { slog.Info("OCPP charger connected", "charger", id) + s := h.state(id) + h.mu.Lock() + s.online = true + h.mu.Unlock() h.tel.RecordDriverSuccess(id) } @@ -98,6 +110,7 @@ func (h *Handler) OnDisconnect(id string) { slog.Info("OCPP charger disconnected", "charger", id) s := h.state(id) h.mu.Lock() + s.online = false s.connected = false s.charging = false s.lastPowerW = 0 From ef1222585d69386c128dd57ab19cd9628f22d948 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 14:35:32 +0200 Subject: [PATCH 08/22] fix(ocpp): stop serving the OCPP password over /api/config MaskSecrets covered every other credential section but not the new ocpp one, so GET /api/config returned the password in plaintext to any UI client. That is the one credential that must not leak: the OCPP listener is reachable on every interface and basic auth is the only thing in front of it. Masking alone would have traded a leak for a wipe, because the settings tab posts the config back and the masked password returns empty. PreserveMaskedSecrets now keeps the stored value when the incoming one is blank, while a genuinely new password still wins. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- go/internal/config/config.go | 13 +++++++++ go/internal/config/ocpp_test.go | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 831837265..b5ce5d406 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -1208,6 +1208,13 @@ func (c Config) MaskSecrets() Config { cp.Password = "" out.HomeAssistant = &cp } + // The OCPP password is the only thing standing in front of a listener that + // is reachable on every interface, so it must never leave over the API. + if out.OCPP != nil { + cp := *out.OCPP + cp.Password = "" + out.OCPP = &cp + } if out.Price != nil { cp := *out.Price cp.APIKey = "" @@ -1280,6 +1287,12 @@ func (incoming *Config) PreserveMaskedSecrets(existing *Config) { if incoming.CalDAV != nil && existing.CalDAV != nil && incoming.CalDAV.Password == "" { incoming.CalDAV.Password = existing.CalDAV.Password } + // Masked out on the way to the UI, so an unchanged password comes back + // empty. Without this a save from the settings tab would blank it, and an + // enabled server would then fail validation on the next reload. + if incoming.OCPP != nil && existing.OCPP != nil && incoming.OCPP.Password == "" { + incoming.OCPP.Password = existing.OCPP.Password + } if incoming.HomeAssistant != nil && existing.HomeAssistant != nil && incoming.HomeAssistant.Password == "" { incoming.HomeAssistant.Password = existing.HomeAssistant.Password } diff --git a/go/internal/config/ocpp_test.go b/go/internal/config/ocpp_test.go index bd32ac8ab..abe403e34 100644 --- a/go/internal/config/ocpp_test.go +++ b/go/internal/config/ocpp_test.go @@ -64,6 +64,54 @@ func TestOCPPValidate(t *testing.T) { } } +// The OCPP password is the only gate in front of a listener reachable on every +// interface. It must never be served over /api/config, and a settings save that +// returns the masked (empty) value must not wipe it. +func TestOCPPPasswordIsMaskedAndPreserved(t *testing.T) { + stored := &Config{OCPP: &OCPP{ + Enabled: true, + Username: "ftw", + Password: "the-real-secret", + }} + + masked := stored.MaskSecrets() + if masked.OCPP == nil { + t.Fatal("masked config lost the ocpp section") + } + if masked.OCPP.Password != "" { + t.Errorf("password leaked through MaskSecrets: %q", masked.OCPP.Password) + } + if masked.OCPP.Username != "ftw" { + t.Errorf("username should survive masking, got %q", masked.OCPP.Username) + } + // Masking must not mutate the original. + if stored.OCPP.Password != "the-real-secret" { + t.Errorf("MaskSecrets mutated the source config: %q", stored.OCPP.Password) + } + + // The UI round-trips the masked config back on save. + incoming := &Config{OCPP: &OCPP{ + Enabled: true, + Username: "ftw", + Password: "", + }} + incoming.PreserveMaskedSecrets(stored) + if incoming.OCPP.Password != "the-real-secret" { + t.Errorf("password not preserved on save, got %q", incoming.OCPP.Password) + } + + // A genuinely new password must still win. + changed := &Config{OCPP: &OCPP{ + Enabled: true, + Username: "ftw", + Password: "a-new-secret", + }} + changed.PreserveMaskedSecrets(stored) + if changed.OCPP.Password != "a-new-secret" { + t.Errorf("new password was overwritten, got %q", changed.OCPP.Password) + } +} + // A disabled or absent OCPP section must not stop the rest of the config from // validating, and an enabled one without credentials must take the whole // config down with it rather than being skipped. From 2c123f9bcc3a33d37469039aa18b487416d06061 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 14:47:18 +0200 Subject: [PATCH 09/22] feat(ocpp): serve OCPP 2.0.1 alongside 1.6J MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newer chargers now connect without a driver too. Each version listens on its own port, because a charger picks its dialect during the WebSocket handshake before any message is sent, and ocpp-go's ws.Server keeps a single message handler per listener — one port cannot serve both. Set ocpp.port_v201 to enable 2.0.1; leaving it unset keeps 1.6J only. Only the encoding differs. Both dialects share one charger map, one telemetry path and one control path, so a 2.0.1 charger is metered, throttled and paused exactly like a 1.6 one and dispatch cannot tell them apart. Which listener a charger reached is recorded on connect, and control encodes the profile to match. 2.0.1 restructures more than the names suggest: Start/StopTransaction collapse into one TransactionEvent, transaction ids become strings, connector status loses its charging meaning, and meter samples arrive inside transaction events as well as alone. The new handler normalises all of it back to the same state. OCPP 2.1 is deliberately absent. No production-grade Go implementation exists — ocpp-go covers 1.6 and 2.0.1 only, and the Go projects claiming 2.1 are early-stage validators and emulators rather than servers. Adding it later is one handler and one listener; the version-neutral core is unaffected. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- .changeset/ocpp-v201-support.md | 26 +++ README.md | 4 +- config.example.yaml | 11 +- docs/ocpp.md | 40 +++- go/cmd/ftw/main.go | 2 + go/internal/config/config.go | 10 +- go/internal/ocpp/config.go | 1 + go/internal/ocpp/control.go | 116 +++++++--- go/internal/ocpp/control_v201_test.go | 269 +++++++++++++++++++++ go/internal/ocpp/handlers.go | 6 + go/internal/ocpp/handlers_v201.go | 253 ++++++++++++++++++++ go/internal/ocpp/server.go | 322 +++++++++++++++----------- go/internal/ocpp/version.go | 67 ++++++ 13 files changed, 962 insertions(+), 165 deletions(-) create mode 100644 .changeset/ocpp-v201-support.md create mode 100644 go/internal/ocpp/control_v201_test.go create mode 100644 go/internal/ocpp/handlers_v201.go create mode 100644 go/internal/ocpp/version.go diff --git a/.changeset/ocpp-v201-support.md b/.changeset/ocpp-v201-support.md new file mode 100644 index 000000000..597401982 --- /dev/null +++ b/.changeset/ocpp-v201-support.md @@ -0,0 +1,26 @@ +--- +"ftw": minor +--- + +Serve OCPP 2.0.1 alongside 1.6J, so newer chargers connect without a driver too. + +Each version listens on its own port. A charger picks its dialect during the +WebSocket handshake, before any message is sent, and the underlying library +keeps one message handler per listener — so a single port cannot serve both. +Set `ocpp.port_v201` to enable 2.0.1; leaving it unset keeps 1.6J only. + +Only the message encoding differs. Both dialects share one charger map, one +telemetry path and one control path, so a 2.0.1 charger is metered, throttled +and paused exactly like a 1.6 one, and dispatch cannot tell them apart. + +2.0.1 restructures the messages more than the names suggest: StartTransaction +and StopTransaction collapse into a single TransactionEvent, transaction ids +become strings, connector status loses its charging meaning, and meter samples +arrive inside transaction events as well as on their own. The new handler +normalises all of that back to the same charger state. + +OCPP 2.1 is not supported. No production-grade Go implementation of it exists: +the library FTW uses covers 1.6 and 2.0.1 and has no 2.1 support, and the Go +projects that do claim 2.1 are early-stage validators and emulators rather than +servers. Adding it later is one more handler and one more listener; the +version-neutral core does not change. diff --git a/README.md b/README.md index 006771d43..22f9abbb0 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ rule. See [docs/architecture.md](docs/architecture.md). - Home Assistant MQTT discovery; - CalDAV planning intents and published schedules; - hot-reloadable, independently released Lua drivers; -- a built-in OCPP 1.6J server, so OCPP chargers connect with no driver at all. +- a built-in OCPP 1.6J + 2.0.1 server, so OCPP chargers connect with no driver. The local catalog is generated from `DRIVER` metadata. The public [`srcfl/device-drivers`](https://github.com/srcfl/device-drivers) repo is the @@ -179,7 +179,7 @@ Support may consume the same public source later for other products or a higher support level. EV chargers that speak OCPP are the exception: they need no driver. FTW runs an -OCPP 1.6J Central System, so the charger connects to FTW and registers itself. +OCPP Central System (1.6J and 2.0.1), so the charger connects and registers itself. See [docs/ocpp.md](docs/ocpp.md). ## Releases diff --git a/config.example.yaml b/config.example.yaml index bb1855a0a..2e03ad62f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -173,8 +173,9 @@ caldav: plan_path: /ftw/plan/ # plan_publish_interval_s: 900 # how often the plan calendar is reconciled -# Built-in OCPP 1.6J Central System. EV chargers that speak OCPP connect to FTW -# directly — there is no driver to write and nothing to add under `drivers:`. +# Built-in OCPP Central System, speaking 1.6J and 2.0.1. EV chargers that speak +# OCPP connect to FTW directly — there is no driver to write and nothing to add +# under `drivers:`. # A charger appears as a device on its first BootNotification, keyed by the last # segment of the URL it dialled: ws://:8887/garage-left # @@ -182,9 +183,13 @@ caldav: # the OCPP library builds its listen address from the port alone, so the socket # is reachable on every interface and basic auth is the only gate. Keep the port # closed at your router. See docs/ocpp.md. +# Each version needs its own port: a charger picks its dialect in the WebSocket +# handshake, so one listener cannot serve both. 2.1 is not supported — no +# production-grade Go implementation of it exists yet. ocpp: enabled: false - port: 8887 + port: 8887 # OCPP 1.6J + # port_v201: 8888 # OCPP 2.0.1; omit to disable path: / username: ftw password: "" # required when enabled; use a long random string diff --git a/docs/ocpp.md b/docs/ocpp.md index d46330c68..ed227c881 100644 --- a/docs/ocpp.md +++ b/docs/ocpp.md @@ -1,7 +1,7 @@ # OCPP chargers -FTW has a built-in OCPP 1.6J Central System. An EV charger that speaks OCPP -connects straight to FTW over your local network. +FTW has a built-in OCPP Central System speaking **1.6J and 2.0.1**. An EV +charger that speaks either connects straight to FTW over your local network. **An OCPP charger does not need a driver.** There is no Lua file to write, no entry in `drivers:`, and nothing to add to the device catalog. OCPP is a vendor- @@ -33,6 +33,39 @@ From there it behaves like any other EV reading: `MeterValues` and `StatusNotification` become telemetry, and dispatch stops the home battery discharging into an active EV charge. +## Protocol versions + +| Version | Status | Port | +|---|---|---| +| 1.6J | supported | `port`, default 8887 | +| 2.0.1 | supported | `port_v201`, off unless set | +| 2.1 | not yet — see below | — | + +**Each version needs its own port.** A charger picks its dialect in the +WebSocket handshake, before any message is sent, and the underlying library +keeps one message handler per listener — so one port cannot serve both. Point +each charger at the port matching what it speaks. + +Everything above the protocol is shared. Both dialects land in the same charger +map, produce the same telemetry, and take the same commands, so a 2.0.1 charger +is throttled and paused exactly like a 1.6 one and nothing downstream knows the +difference. + +### On OCPP 2.1 + +2.1 is not supported, because no production-grade Go implementation of it +exists. `lorenzodonini/ocpp-go`, the library FTW uses and the only mature option +at 367 stars, implements 1.6 and 2.0.1 and has no 2.1 support. The Go projects +that do claim 2.1 are all early — single-digit stars, and mostly message +validators or emulators rather than servers. + +This is not a blocker in practice. Every charger on the bench speaks 1.6J only, +and 2.0.1 is what current hardware is migrating to. + +Adding 2.1 later means writing one more handler file and one more listener. The +version-neutral core — charger state, telemetry mapping, control semantics — +does not change. + ## Enabling the server OCPP is off by default. Add an `ocpp` section: @@ -40,7 +73,8 @@ OCPP is off by default. Add an `ocpp` section: ```yaml ocpp: enabled: true - port: 8887 # default 8887 + port: 8887 # OCPP 1.6J, default 8887 + port_v201: 8888 # OCPP 2.0.1; omit or 0 to disable path: / # default / username: ftw password: diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 7d0ee368e..38db1a2d3 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1268,6 +1268,7 @@ func main() { srv, err := ocpp.Start(ctx, &ocpp.Config{ Enabled: cfg.OCPP.Enabled, Port: cfg.OCPP.Port, + PortV201: cfg.OCPP.PortV201, Path: cfg.OCPP.Path, Username: cfg.OCPP.Username, Password: cfg.OCPP.Password, @@ -1282,6 +1283,7 @@ func main() { defer ocppSrv.Stop() slog.Info("ocpp: central system started", "port", ocppSrv.Port(), + "port_v201", cfg.OCPP.PortV201, "path", ocppSrv.Path(), "note", "listener is reachable on every interface; basic auth is the only gate") } diff --git a/go/internal/config/config.go b/go/internal/config/config.go index b5ce5d406..516fce8ce 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -45,7 +45,7 @@ type Config struct { OCPP *OCPP `yaml:"ocpp,omitempty" json:"ocpp,omitempty"` } -// OCPP configures the built-in OCPP 1.6J Central System. Chargers connect to +// OCPP configures the built-in OCPP 1.6J and 2.0.1 Central System. Chargers connect to // us, so there is no driver and no per-charger config entry — a charge point // appears as a device the moment it sends its first BootNotification, keyed by // the identity segment of the URL it dialled. @@ -59,6 +59,7 @@ type Config struct { type OCPP struct { Enabled bool `yaml:"enabled" json:"enabled"` Port int `yaml:"port,omitempty" json:"port,omitempty"` + PortV201 int `yaml:"port_v201,omitempty" json:"port_v201,omitempty"` Path string `yaml:"path,omitempty" json:"path,omitempty"` Username string `yaml:"username,omitempty" json:"username,omitempty"` Password string `yaml:"password,omitempty" json:"password,omitempty"` @@ -77,6 +78,13 @@ func (o *OCPP) Validate() error { if o.Port < 0 || o.Port > 65535 { return fmt.Errorf("ocpp.port must be between 0 and 65535, got %d", o.Port) } + if o.PortV201 < 0 || o.PortV201 > 65535 { + return fmt.Errorf("ocpp.port_v201 must be between 0 and 65535, got %d", o.PortV201) + } + // Each version needs its own listener, so they cannot share a port. + if o.PortV201 > 0 && o.PortV201 == o.Port { + return fmt.Errorf("ocpp.port_v201 must differ from ocpp.port, both are %d", o.Port) + } if o.HeartbeatIntervalS < 0 { return fmt.Errorf("ocpp.heartbeat_interval_s must be >= 0, got %d", o.HeartbeatIntervalS) } diff --git a/go/internal/ocpp/config.go b/go/internal/ocpp/config.go index 39f560c65..3b3533fe7 100644 --- a/go/internal/ocpp/config.go +++ b/go/internal/ocpp/config.go @@ -14,6 +14,7 @@ type Config struct { Enabled bool `yaml:"enabled"` Bind string `yaml:"bind"` Port int `yaml:"port"` + PortV201 int `yaml:"port_v201"` Path string `yaml:"path"` Username string `yaml:"username"` Password string `yaml:"password"` diff --git a/go/internal/ocpp/control.go b/go/internal/ocpp/control.go index a67497da4..1e04937ae 100644 --- a/go/internal/ocpp/control.go +++ b/go/internal/ocpp/control.go @@ -26,6 +26,8 @@ import ( "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" + smartcharging201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/smartcharging" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" ) const ( @@ -53,9 +55,15 @@ const ( // dual-socket units such as the Charge Amps Aura. allConnectors = 0 + // The 2.0.1 equivalent: EVSE 0 addresses the whole charging station. + allEVSEs = 0 + // A single, stable profile id and stack level means each new limit // replaces the previous one instead of stacking on top of it. - ftwProfileID = 1 + ftwProfileID = 1 + // 2.0.1 schedules carry their own id; one stable id keeps replacement + // semantics identical to 1.6. + ftwScheduleID = 1 ftwStackLevel = 0 scheduleStartS = 0 ) @@ -176,30 +184,19 @@ func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPh amps = 0 } - period := types.NewChargingSchedulePeriod(scheduleStartS, amps) - // Declared only when the loadpoint pinned single-phase charging. Left - // unset otherwise so a charger that can switch phases keeps deciding. - period.NumberPhases = numberPhases - schedule := types.NewChargingSchedule(types.ChargingRateUnitAmperes, period) - profile := types.NewChargingProfile( - ftwProfileID, - ftwStackLevel, - types.ChargingProfilePurposeTxDefaultProfile, - types.ChargingProfileKindAbsolute, - schedule, - ) - - type result struct { - conf *smartcharging.SetChargingProfileConfirmation - err error - } // Buffered: the library's callback must never block if we have already // stopped waiting. - done := make(chan result, 1) - - err := s.cs.SetChargingProfile(id, func(conf *smartcharging.SetChargingProfileConfirmation, err error) { - done <- result{conf: conf, err: err} - }, allConnectors, profile) + done := make(chan profileResult, 1) + + // The two versions describe the same intent with different types, so the + // request is built per dialect and the outcome normalised back. + var err error + switch version, _ := s.handler.Version(id); version { + case Version201: + err = s.sendProfileV201(id, amps, numberPhases, done) + default: + err = s.sendProfileV16(id, amps, numberPhases, done) + } if err != nil { return fmt.Errorf("ocpp: send charging profile to %s: %w", id, err) } @@ -212,11 +209,11 @@ func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPh if r.err != nil { return fmt.Errorf("ocpp: %s rejected charging profile: %w", id, r.err) } - if r.conf == nil { + if !r.answered { return fmt.Errorf("ocpp: %s returned no charging profile confirmation", id) } - if r.conf.Status != smartcharging.ChargingProfileStatusAccepted { - return fmt.Errorf("ocpp: %s answered %s to charging profile", id, r.conf.Status) + if !r.accepted { + return fmt.Errorf("ocpp: %s answered %s to charging profile", id, r.status) } // Only a real charging rate is worth remembering. Recording the zero // from a pause would erase the rate a later resume is supposed to @@ -236,6 +233,73 @@ func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPh } } +// profileResult is a version-neutral answer to a charging profile request, so +// the waiting code above does not need to know which dialect produced it. +type profileResult struct { + answered bool + accepted bool + status string + err error +} + +// sendProfileV16 issues the limit as an OCPP 1.6 TxDefaultProfile. +func (s *Server) sendProfileV16(id string, amps float64, numberPhases *int, done chan<- profileResult) error { + period := types.NewChargingSchedulePeriod(scheduleStartS, amps) + // Declared only when the loadpoint pinned single-phase charging. Left + // unset otherwise so a charger that can switch phases keeps deciding. + period.NumberPhases = numberPhases + schedule := types.NewChargingSchedule(types.ChargingRateUnitAmperes, period) + profile := types.NewChargingProfile( + ftwProfileID, + ftwStackLevel, + types.ChargingProfilePurposeTxDefaultProfile, + types.ChargingProfileKindAbsolute, + schedule, + ) + + return s.cs.SetChargingProfile(id, func(conf *smartcharging.SetChargingProfileConfirmation, err error) { + r := profileResult{err: err} + if conf != nil { + r.answered = true + r.status = string(conf.Status) + r.accepted = conf.Status == smartcharging.ChargingProfileStatusAccepted + } + done <- r + }, allConnectors, profile) +} + +// sendProfileV201 issues the same limit as an OCPP 2.0.1 TxDefaultProfile. +// +// 2.0.1 carries a list of schedules rather than one, and each schedule needs +// its own id; a single-entry list with a stable id keeps the meaning identical +// to the 1.6 request. +func (s *Server) sendProfileV201(id string, amps float64, numberPhases *int, done chan<- profileResult) error { + if s.csms == nil { + return fmt.Errorf("ocpp: %s speaks %s but no %s listener is configured", id, Version201, Version201) + } + + period := types201.NewChargingSchedulePeriod(scheduleStartS, amps) + period.NumberPhases = numberPhases + schedule := types201.NewChargingSchedule(ftwScheduleID, types201.ChargingRateUnitAmperes, period) + profile := types201.NewChargingProfile( + ftwProfileID, + ftwStackLevel, + types201.ChargingProfilePurposeTxDefaultProfile, + types201.ChargingProfileKindAbsolute, + []types201.ChargingSchedule{*schedule}, + ) + + return s.csms.SetChargingProfile(id, func(conf *smartcharging201.SetChargingProfileResponse, err error) { + r := profileResult{err: err} + if conf != nil { + r.answered = true + r.status = string(conf.Status) + r.accepted = conf.Status == smartcharging201.ChargingProfileStatusAccepted + } + done <- r + }, allEVSEs, profile) +} + // DefaultMode is what a charger is left in when FTW stops steering it, and // mirrors the stance every EV driver already takes: hold the last limit. // diff --git a/go/internal/ocpp/control_v201_test.go b/go/internal/ocpp/control_v201_test.go new file mode 100644 index 000000000..1b0887b9f --- /dev/null +++ b/go/internal/ocpp/control_v201_test.go @@ -0,0 +1,269 @@ +package ocpp + +import ( + "context" + "errors" + "fmt" + "net" + "sync" + "testing" + "time" + + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + smartcharging201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/smartcharging" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// fakeStationV201 is a 2.0.1 charging station that records the charging +// profiles it is sent. +type fakeStationV201 struct { + mu sync.Mutex + status smartcharging201.ChargingProfileStatus + profiles []*smartcharging201.SetChargingProfileRequest +} + +func newFakeStationV201() *fakeStationV201 { + return &fakeStationV201{status: smartcharging201.ChargingProfileStatusAccepted} +} + +func (f *fakeStationV201) OnSetChargingProfile(req *smartcharging201.SetChargingProfileRequest) (*smartcharging201.SetChargingProfileResponse, error) { + f.mu.Lock() + f.profiles = append(f.profiles, req) + status := f.status + f.mu.Unlock() + return smartcharging201.NewSetChargingProfileResponse(status), nil +} + +func (f *fakeStationV201) OnClearChargingProfile(*smartcharging201.ClearChargingProfileRequest) (*smartcharging201.ClearChargingProfileResponse, error) { + return nil, errors.New("not used") +} + +func (f *fakeStationV201) OnGetChargingProfiles(*smartcharging201.GetChargingProfilesRequest) (*smartcharging201.GetChargingProfilesResponse, error) { + return nil, errors.New("not used") +} + +func (f *fakeStationV201) OnGetCompositeSchedule(*smartcharging201.GetCompositeScheduleRequest) (*smartcharging201.GetCompositeScheduleResponse, error) { + return nil, errors.New("not used") +} + +func (f *fakeStationV201) lastLimit(t *testing.T) float64 { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.profiles) == 0 { + t.Fatal("station received no charging profile") + } + p := f.profiles[len(f.profiles)-1] + if p.ChargingProfile == nil { + t.Fatal("request carried no charging profile") + } + if len(p.ChargingProfile.ChargingSchedule) == 0 { + t.Fatal("charging profile carried no schedule") + } + periods := p.ChargingProfile.ChargingSchedule[0].ChargingSchedulePeriod + if len(periods) == 0 { + t.Fatal("charging schedule had no periods") + } + return periods[0].Limit +} + +// startDualServer brings up both listeners on free ports. +func startDualServer(t *testing.T, tel *telemetry.Store) (portV16, portV201 int, srv *Server) { + t.Helper() + portV16 = freePort(t) + portV201 = freePort(t) + cfg := &Config{ + Enabled: true, + Bind: "127.0.0.1", + Port: portV16, + PortV201: portV201, + HeartbeatIntervalS: 60, + } + srv, err := Start(context.Background(), cfg, tel) + if err != nil { + t.Fatalf("start: %v", err) + } + t.Cleanup(srv.Stop) + + // Both listeners must be reachable before a client tries to connect. + for _, port := range []int{portV16, portV201} { + deadline := time.Now().Add(2 * time.Second) + bound := false + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond) + if err == nil { + c.Close() + bound = true + break + } + time.Sleep(20 * time.Millisecond) + } + if !bound { + t.Fatalf("listener never bound on port %d", port) + } + } + return portV16, portV201, srv +} + +// connectStationV201 brings up a 2.0.1 charging station and waits for the +// server to register it. +func connectStationV201(t *testing.T, srv *Server, port int, id string) (*fakeStationV201, func()) { + t.Helper() + fake := newFakeStationV201() + cs := ocpp201.NewChargingStation(id, nil, nil) + cs.SetSmartChargingHandler(fake) + + if err := cs.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("charging station connect: %v", err) + } + var once sync.Once + stop := func() { once.Do(cs.Stop) } + t.Cleanup(stop) + + station := provisioning.ChargingStationType{Model: "Dawn", VendorName: "Charge Amps"} + if _, err := cs.BootNotification(provisioning.BootReasonPowerUp, station.Model, station.VendorName); err != nil { + t.Fatalf("boot: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if srv.Handler().IsOnline(id) { + return fake, stop + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server never registered station %s as online", id) + return nil, nil +} + +// A 2.0.1 station must be steerable exactly like a 1.6 one, and the server has +// to answer it in its own dialect. +func TestV201ChargerAcceptsCurrentLimit(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if v, ok := srv.Handler().Version("garage-v201"); !ok || v != Version201 { + t.Fatalf("version: got %q ok=%v, want %q", v, ok, Version201) + } + + // 11040 W over 3 phases at 230 V = 16 A per phase. + err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("limit: got %v A, want 16 A", got) + } +} + +// Pause is a 0 A limit on 2.0.1 too, not a remote stop. +func TestV201PauseSendsZeroAmpLimit(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_pause", + })); err != nil { + t.Fatalf("pause: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Errorf("pause limit: got %v A, want 0 A", got) + } +} + +// Both dialects served at once, each charger steered in its own, sharing one +// charger map and one telemetry store. +func TestBothVersionsServedSimultaneously(t *testing.T) { + portV16, portV201, srv := startDualServer(t, telemetry.NewStore()) + + _, fake16, _ := connectCharger(t, srv, portV16, "garage-v16") + fake201, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if v, _ := srv.Handler().Version("garage-v16"); v != Version16 { + t.Errorf("v16 charger version: got %q, want %q", v, Version16) + } + if v, _ := srv.Handler().Version("garage-v201"); v != Version201 { + t.Errorf("v201 charger version: got %q, want %q", v, Version201) + } + + ctx := context.Background() + payload := mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 6900.0, // 10 A over 3 phases at 230 V + "voltage": 230.0, + "site_phases": 3, + }) + if err := srv.Command(ctx, "garage-v16", payload); err != nil { + t.Fatalf("v16 set current: %v", err) + } + if err := srv.Command(ctx, "garage-v201", payload); err != nil { + t.Fatalf("v201 set current: %v", err) + } + + if got := fake16.lastLimit(t); got != 10 { + t.Errorf("v16 limit: got %v A, want 10 A", got) + } + if got := fake201.lastLimit(t); got != 10 { + t.Errorf("v201 limit: got %v A, want 10 A", got) + } + + // The two must not have been confused for one another. + snap := srv.Handler().Snapshot() + if len(snap) != 2 { + t.Errorf("expected 2 chargers in the snapshot, got %d: %+v", len(snap), snap) + } +} + +// A rejection from a 2.0.1 station has to surface as an error, same as 1.6. +func TestV201RejectedProfileIsAnError(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + fake.mu.Lock() + fake.status = smartcharging201.ChargingProfileStatusRejected + fake.mu.Unlock() + + err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if err == nil { + t.Fatal("expected an error when the station rejects the profile") + } +} + +// Guard the version-neutral core: a 2.0.1 profile must be a single-entry +// schedule list with amps as the rate unit, matching the 1.6 request's meaning. +func TestV201ProfileShape(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })); err != nil { + t.Fatalf("set current: %v", err) + } + + fake.mu.Lock() + defer fake.mu.Unlock() + p := fake.profiles[len(fake.profiles)-1].ChargingProfile + if len(p.ChargingSchedule) != 1 { + t.Fatalf("schedules: got %d, want exactly 1", len(p.ChargingSchedule)) + } + if unit := p.ChargingSchedule[0].ChargingRateUnit; unit != types201.ChargingRateUnitAmperes { + t.Errorf("rate unit: got %q, want %q", unit, types201.ChargingRateUnitAmperes) + } + if p.ChargingProfilePurpose != types201.ChargingProfilePurposeTxDefaultProfile { + t.Errorf("purpose: got %q, want TxDefaultProfile", p.ChargingProfilePurpose) + } +} diff --git a/go/internal/ocpp/handlers.go b/go/internal/ocpp/handlers.go index d46b233e6..10ea458c0 100644 --- a/go/internal/ocpp/handlers.go +++ b/go/internal/ocpp/handlers.go @@ -43,6 +43,12 @@ type chargerState struct { // lastAmps is the most recent per-phase limit this charger accepted. // A resume with no rate of its own restores it. lastAmps float64 + // version is the OCPP dialect this charger connected with, which decides + // how commands are encoded on the way back. + version Version + // transactionRef is the 2.0.1 transaction id, which is a string rather + // than the int transactionID above. Empty on the 1.6 path. + transactionRef string } // NewHandler returns a Handler ready to register with a CentralSystem. diff --git a/go/internal/ocpp/handlers_v201.go b/go/internal/ocpp/handlers_v201.go new file mode 100644 index 000000000..ae956b4db --- /dev/null +++ b/go/internal/ocpp/handlers_v201.go @@ -0,0 +1,253 @@ +package ocpp + +// OCPP 2.0.1 CSMS handlers. +// +// These translate 2.0.1 messages into the same charger state and DerEV +// telemetry the 1.6 handlers produce, so everything downstream — dispatch, the +// loadpoint controller, control commands — is unaware of which dialect a +// charger speaks. +// +// The shapes differ more than the names suggest: +// +// - Start/StopTransaction collapse into a single TransactionEvent with a +// Started / Updated / Ended trigger, and the transaction id is a string +// rather than an int. +// - StatusNotification reports per-EVSE connector status with a different +// enum, and no longer carries the "charging" meaning — that now comes from +// the transaction event. +// - Meter samples arrive inside TransactionEvent as well as in MeterValues. +// +// Everything not needed to meter and steer a charger is acknowledged and +// dropped. Accepting a message we ignore is correct here: refusing it would +// make the charger retry forever. + +import ( + "log/slog" + "time" + + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/authorization" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/availability" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/meter" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/transactions" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" +) + +// handlerV201 adapts a Handler to the 2.0.1 CSMS interfaces. It owns no state +// of its own — everything lands in the shared Handler. +type handlerV201 struct { + *Handler +} + +// ---- provisioning.CSMSHandler ---- + +func (h *handlerV201) OnBootNotification(id string, req *provisioning.BootNotificationRequest) (*provisioning.BootNotificationResponse, error) { + vendor, model, serial := "", "", "" + if req != nil { + vendor = req.ChargingStation.VendorName + model = req.ChargingStation.Model + serial = req.ChargingStation.SerialNumber + } + slog.Info("OCPP boot", + "charger", id, "version", Version201, + "vendor", vendor, "model", model, "serial", serial) + + h.setVersion(id, Version201) + h.tel.RecordDriverSuccess(id) + + return provisioning.NewBootNotificationResponse( + types201.NewDateTime(time.Now()), + h.heartbeatIntervalS, + provisioning.RegistrationStatusAccepted, + ), nil +} + +// OnNotifyReport carries variable inventory. FTW does not model charger +// configuration, so this is acknowledged and dropped. +func (h *handlerV201) OnNotifyReport(id string, _ *provisioning.NotifyReportRequest) (*provisioning.NotifyReportResponse, error) { + h.tel.RecordDriverSuccess(id) + return provisioning.NewNotifyReportResponse(), nil +} + +// ---- availability.CSMSHandler ---- + +func (h *handlerV201) OnHeartbeat(id string, _ *availability.HeartbeatRequest) (*availability.HeartbeatResponse, error) { + h.tel.RecordDriverSuccess(id) + return availability.NewHeartbeatResponse(*types201.NewDateTime(time.Now())), nil +} + +// OnStatusNotification maps 2.0.1 connector status onto the same +// connected/charging pair the 1.6 path produces. +// +// Unlike 1.6 there is no Charging status here — occupancy is all this tells us, +// and whether energy is actually flowing comes from the transaction event. +func (h *handlerV201) OnStatusNotification(id string, req *availability.StatusNotificationRequest) (*availability.StatusNotificationResponse, error) { + s := h.state(id) + h.mu.Lock() + switch req.ConnectorStatus { + case availability.ConnectorStatusAvailable, availability.ConnectorStatusUnavailable: + s.connected = false + s.charging = false + s.lastPowerW = 0 + case availability.ConnectorStatusOccupied, availability.ConnectorStatusReserved: + s.connected = true + case availability.ConnectorStatusFaulted: + // Matches the 1.6 path: a faulted connector still has a cable in it, + // so it stays connected while charging stops. + s.connected = true + s.charging = false + s.lastPowerW = 0 + } + faulted := req.ConnectorStatus == availability.ConnectorStatusFaulted + h.mu.Unlock() + + slog.Info("OCPP status", + "charger", id, "version", Version201, + "evse", req.EvseID, "connector", req.ConnectorID, "status", req.ConnectorStatus) + + if faulted { + h.tel.RecordDriverError(id, "ocpp: connector faulted") + } else { + h.tel.RecordDriverSuccess(id) + } + h.pushReading(id, s) + return availability.NewStatusNotificationResponse(), nil +} + +// ---- transactions.CSMSHandler ---- + +// OnTransactionEvent replaces 1.6's StartTransaction, StopTransaction and much +// of MeterValues. The trigger says which of those it stands in for. +func (h *handlerV201) OnTransactionEvent(id string, req *transactions.TransactionEventRequest) (*transactions.TransactionEventResponse, error) { + s := h.state(id) + + // Meter samples ride along with every event type. + powerW, energyWh, hasEnergy := sampledValuesV201(req.MeterValue) + + h.mu.Lock() + switch req.EventType { + case transactions.TransactionEventStarted: + // 2.0.1 transaction ids are strings; the shared state keeps an int for + // the 1.6 path, so record presence rather than the id itself and keep + // the real one alongside. + h.nextTxID++ + s.transactionID = h.nextTxID + s.transactionRef = req.TransactionInfo.TransactionID + s.sessionStartMeterWh = energyWh + s.sessionMeterWh = 0 + s.connected = true + s.charging = true + + case transactions.TransactionEventUpdated: + s.connected = true + if hasEnergy && s.transactionID >= 0 { + s.sessionMeterWh = energyWh - s.sessionStartMeterWh + } + // A zero power sample during a live transaction is a genuine pause, + // not a missing reading, so it is taken at face value. + s.charging = powerW > 0 + + case transactions.TransactionEventEnded: + if hasEnergy { + s.sessionMeterWh = energyWh - s.sessionStartMeterWh + } + s.transactionID = -1 + s.transactionRef = "" + s.charging = false + s.lastPowerW = 0 + powerW = 0 + } + + if req.EventType != transactions.TransactionEventEnded { + s.lastPowerW = powerW + } + sessionWh := s.sessionMeterWh + ended := req.EventType == transactions.TransactionEventEnded + h.mu.Unlock() + + slog.Info("OCPP transaction event", + "charger", id, "version", Version201, + "event", req.EventType, "seq", req.SequenceNo, "w", powerW) + + h.pushReading(id, s) + if ended { + h.tel.EmitMetric(id, "ev_session_wh", sessionWh, "Wh", "", "") + } + h.tel.RecordDriverSuccess(id) + + return transactions.NewTransactionEventResponse(), nil +} + +// ---- meter.CSMSHandler ---- + +func (h *handlerV201) OnMeterValues(id string, req *meter.MeterValuesRequest) (*meter.MeterValuesResponse, error) { + s := h.state(id) + powerW, energyWh, hasEnergy := sampledValuesV201(req.MeterValue) + + h.mu.Lock() + s.lastPowerW = powerW + if hasEnergy && s.transactionID >= 0 { + s.sessionMeterWh = energyWh - s.sessionStartMeterWh + } + h.mu.Unlock() + + h.pushReading(id, s) + h.tel.RecordDriverSuccess(id) + return meter.NewMeterValuesResponse(), nil +} + +// ---- authorization.CSMSHandler ---- + +// OnAuthorize accepts every token. FTW is a home energy manager, not an access +// control system: the charger is behind the operator's own front door, and +// refusing here would only stop them charging. Matches the 1.6 path. +func (h *handlerV201) OnAuthorize(id string, _ *authorization.AuthorizeRequest) (*authorization.AuthorizeResponse, error) { + h.tel.RecordDriverSuccess(id) + return authorization.NewAuthorizationResponse(types201.IdTokenInfo{ + Status: types201.AuthorizationStatusAccepted, + }), nil +} + +// sampledValuesV201 pulls active-import power and energy out of a 2.0.1 meter +// value set, normalising kW/kWh to W/Wh. +// +// 2.0.1 always states the measurand, so unlike 1.6 there is no default to +// assume. hasEnergy distinguishes "no energy sample in this batch" from a +// genuine zero reading, which matters because session energy is a difference +// against the transaction's starting register. +func sampledValuesV201(values []types201.MeterValue) (powerW, energyWh float64, hasEnergy bool) { + for _, mv := range values { + for _, sv := range mv.SampledValue { + val := sv.Value + switch sv.Measurand { + case types201.MeasurandPowerActiveImport: + if unitIsKilo(sv.UnitOfMeasure) { + val *= 1000 + } + powerW = val + case types201.MeasurandEnergyActiveImportRegister: + if unitIsKilo(sv.UnitOfMeasure) { + val *= 1000 + } + energyWh = val + hasEnergy = true + } + } + } + return powerW, energyWh, hasEnergy +} + +// unitIsKilo reports whether a sample is expressed in kW or kWh. An absent unit +// means the 2.0.1 default, which is already W/Wh. +func unitIsKilo(u *types201.UnitOfMeasure) bool { + if u == nil { + return false + } + switch u.Unit { + case "kW", "kWh": + return true + default: + return false + } +} + diff --git a/go/internal/ocpp/server.go b/go/internal/ocpp/server.go index 75a4dc06c..13447c4fa 100644 --- a/go/internal/ocpp/server.go +++ b/go/internal/ocpp/server.go @@ -1,130 +1,192 @@ -// Package ocpp is the OCPP 1.6J Central System for FTW. -// -// EV chargers connect to us via WebSocket. We translate every BootNotification, -// MeterValues, and StatusNotification into a DerEV reading in telemetry.Store, -// keyed by the chargePointId from the URL path. The dispatch layer -// (control/dispatch.go:199-216) sums DerEV readings and prevents home batteries -// from discharging into an active EV charge. -// -// Phase 1 is read-only — handlers below ack everything but do not push remote -// commands. Phase 2 will add RemoteStartTransaction / SetChargingProfile. -// -// The library backing this is github.com/lorenzodonini/ocpp-go (MIT, also used -// by SteVe) — it owns the WebSocket + JSON layer; we own the message handlers -// and the telemetry mapping. -package ocpp - -import ( - "context" - "errors" - "fmt" - "log/slog" - "sync" - "time" - - ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" - "github.com/lorenzodonini/ocpp-go/ws" - - "github.com/srcfl/ftw/go/internal/telemetry" -) - -// Server is a running OCPP 1.6J Central System. -type Server struct { - cfg *Config - cs ocpp16.CentralSystem - handler *Handler - done chan struct{} - stopOnce sync.Once -} - -// Start brings up the OCPP CS on the configured bind:port. Returns -// immediately once the listener is up; the WebSocket loop runs in its own -// goroutine until ctx is cancelled or Stop() is called. -// -// The returned Server is the handle for shutdown — main.go is expected to -// call Stop() during graceful drain. -func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, error) { - if cfg == nil { - return nil, errors.New("ocpp: nil config") - } - if tel == nil { - return nil, errors.New("ocpp: nil telemetry store") - } - cfg.Defaults() - - wsServer := ws.NewServer() - if cfg.Username != "" || cfg.Password != "" { - u, p := cfg.Username, cfg.Password - wsServer.SetBasicAuthHandler(func(user, pass string) bool { - return user == u && pass == p - }) - } - - cs := ocpp16.NewCentralSystem(nil, wsServer) - h := NewHandler(tel, cfg.HeartbeatIntervalS) - cs.SetCoreHandler(h) - cs.SetNewChargePointHandler(func(cp ocpp16.ChargePointConnection) { - h.OnConnect(cp.ID()) - }) - cs.SetChargePointDisconnectedHandler(func(cp ocpp16.ChargePointConnection) { - h.OnDisconnect(cp.ID()) - }) - - s := &Server{cfg: cfg, cs: cs, handler: h, done: make(chan struct{})} - go func() { - defer close(s.done) - slog.Info("OCPP central system listening", - "bind", cfg.Bind, "port", cfg.Port, "path", cfg.Path, - "basic_auth", cfg.Username != "") - // TODO: cfg.Bind is not honored here. The ocpp-go library's - // CentralSystem.Start(port, path) and ws.Server.Start(port, path) - // only accept a port — there is no SetAddr or bind-address parameter. - // To support bind-address natively we would need to either: - // (a) upstream a PR to ocpp-go adding a SetListenAddr method, or - // (b) create our own net.Listener bound to cfg.Bind:cfg.Port and - // serve the ws.Server's http.Handler on it. - // For now cfg.Bind is advisory-only (documented in Config). - // cs.Start blocks until cs.Stop is called. - s.cs.Start(cfg.Port, fmt.Sprintf("%s{ws}", cfg.Path)) - }() - go func() { - <-ctx.Done() - s.Stop() - }() - return s, nil -} - -// Stop closes the WebSocket server and waits for the listener goroutine to exit. -// A 5-second timeout prevents deadlock if the listener goroutine is stuck. -func (s *Server) Stop() { - if s == nil || s.cs == nil { - return - } - s.stopOnce.Do(func() { s.cs.Stop() }) - select { - case <-s.done: - case <-time.After(5 * time.Second): - slog.Warn("ocpp: shutdown timeout — forcing close") - } -} - -// Handler exposes per-charger state for tests + introspection. -func (s *Server) Handler() *Handler { return s.handler } - -// Port is the port the listener actually took, after defaults were applied. -// Callers configuring an unset port need this to log or display the real value. -func (s *Server) Port() int { - if s == nil || s.cfg == nil { - return 0 - } - return s.cfg.Port -} - -// Path is the URL prefix charge points connect to, after defaults were applied. -// A charger dials , and that identity becomes its device key. -func (s *Server) Path() string { - if s == nil || s.cfg == nil { - return "" - } - return s.cfg.Path -} +// Package ocpp is the OCPP 1.6J Central System for FTW. +// +// EV chargers connect to us via WebSocket. We translate every BootNotification, +// MeterValues, and StatusNotification into a DerEV reading in telemetry.Store, +// keyed by the chargePointId from the URL path. The dispatch layer +// (control/dispatch.go:199-216) sums DerEV readings and prevents home batteries +// from discharging into an active EV charge. +// +// Phase 1 is read-only — handlers below ack everything but do not push remote +// commands. Phase 2 will add RemoteStartTransaction / SetChargingProfile. +// +// The library backing this is github.com/lorenzodonini/ocpp-go (MIT, also used +// by SteVe) — it owns the WebSocket + JSON layer; we own the message handlers +// and the telemetry mapping. +package ocpp + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + "github.com/lorenzodonini/ocpp-go/ws" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Server is a running OCPP Central System, serving one listener per enabled +// protocol version. +// +// Each version needs its own port. The OCPP library's ws.Server keeps a single +// message handler, so one listener cannot dispatch both dialects, and a charger +// picks its dialect in the WebSocket handshake before any message is sent. +type Server struct { + cfg *Config + cs ocpp16.CentralSystem + csms ocpp201.CSMS + handler *Handler + // done closes when the 1.6 listener goroutine exits; doneV201 likewise for + // 2.0.1. A nil channel means that version was not enabled. + done chan struct{} + doneV201 chan struct{} + stopOnce sync.Once +} + +// Start brings up the OCPP CS on the configured bind:port. Returns +// immediately once the listener is up; the WebSocket loop runs in its own +// goroutine until ctx is cancelled or Stop() is called. +// +// The returned Server is the handle for shutdown — main.go is expected to +// call Stop() during graceful drain. +func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, error) { + if cfg == nil { + return nil, errors.New("ocpp: nil config") + } + if tel == nil { + return nil, errors.New("ocpp: nil telemetry store") + } + cfg.Defaults() + + wsServer := ws.NewServer() + if cfg.Username != "" || cfg.Password != "" { + u, p := cfg.Username, cfg.Password + wsServer.SetBasicAuthHandler(func(user, pass string) bool { + return user == u && pass == p + }) + } + + cs := ocpp16.NewCentralSystem(nil, wsServer) + h := NewHandler(tel, cfg.HeartbeatIntervalS) + cs.SetCoreHandler(h) + cs.SetNewChargePointHandler(func(cp ocpp16.ChargePointConnection) { + h.OnConnect(cp.ID()) + // Which listener a charger reached is what identifies its dialect, so + // record it here rather than inferring it from a later message. + h.setVersion(cp.ID(), Version16) + }) + cs.SetChargePointDisconnectedHandler(func(cp ocpp16.ChargePointConnection) { + h.OnDisconnect(cp.ID()) + }) + + s := &Server{cfg: cfg, cs: cs, handler: h, done: make(chan struct{})} + + // OCPP 2.0.1 on its own port, when configured. Same handler and therefore + // the same charger state and telemetry — only the message encoding differs. + if cfg.PortV201 > 0 { + wsServer201 := ws.NewServer() + if cfg.Username != "" || cfg.Password != "" { + u, p := cfg.Username, cfg.Password + wsServer201.SetBasicAuthHandler(func(user, pass string) bool { + return user == u && pass == p + }) + } + h201 := &handlerV201{Handler: h} + csms := ocpp201.NewCSMS(nil, wsServer201) + csms.SetProvisioningHandler(h201) + csms.SetAvailabilityHandler(h201) + csms.SetTransactionsHandler(h201) + csms.SetMeterHandler(h201) + csms.SetAuthorizationHandler(h201) + csms.SetNewChargingStationHandler(func(cs ocpp201.ChargingStationConnection) { + h.OnConnect(cs.ID()) + h.setVersion(cs.ID(), Version201) + }) + csms.SetChargingStationDisconnectedHandler(func(cs ocpp201.ChargingStationConnection) { + h.OnDisconnect(cs.ID()) + }) + + s.csms = csms + s.doneV201 = make(chan struct{}) + go func() { + defer close(s.doneV201) + slog.Info("OCPP central system listening", + "version", Version201, "port", cfg.PortV201, "path", cfg.Path, + "basic_auth", cfg.Username != "") + csms.Start(cfg.PortV201, fmt.Sprintf("%s{ws}", cfg.Path)) + }() + } + + go func() { + defer close(s.done) + slog.Info("OCPP central system listening", + "bind", cfg.Bind, "port", cfg.Port, "path", cfg.Path, + "basic_auth", cfg.Username != "") + // TODO: cfg.Bind is not honored here. The ocpp-go library's + // CentralSystem.Start(port, path) and ws.Server.Start(port, path) + // only accept a port — there is no SetAddr or bind-address parameter. + // To support bind-address natively we would need to either: + // (a) upstream a PR to ocpp-go adding a SetListenAddr method, or + // (b) create our own net.Listener bound to cfg.Bind:cfg.Port and + // serve the ws.Server's http.Handler on it. + // For now cfg.Bind is advisory-only (documented in Config). + // cs.Start blocks until cs.Stop is called. + s.cs.Start(cfg.Port, fmt.Sprintf("%s{ws}", cfg.Path)) + }() + go func() { + <-ctx.Done() + s.Stop() + }() + return s, nil +} + +// Stop closes the WebSocket server and waits for the listener goroutine to exit. +// A 5-second timeout prevents deadlock if the listener goroutine is stuck. +func (s *Server) Stop() { + if s == nil || s.cs == nil { + return + } + s.stopOnce.Do(func() { + s.cs.Stop() + if s.csms != nil { + s.csms.Stop() + } + }) + select { + case <-s.done: + case <-time.After(5 * time.Second): + slog.Warn("ocpp: shutdown timeout — forcing close", "version", Version16) + } + if s.doneV201 != nil { + select { + case <-s.doneV201: + case <-time.After(5 * time.Second): + slog.Warn("ocpp: shutdown timeout — forcing close", "version", Version201) + } + } +} + +// Handler exposes per-charger state for tests + introspection. +func (s *Server) Handler() *Handler { return s.handler } + +// Port is the port the listener actually took, after defaults were applied. +// Callers configuring an unset port need this to log or display the real value. +func (s *Server) Port() int { + if s == nil || s.cfg == nil { + return 0 + } + return s.cfg.Port +} + +// Path is the URL prefix charge points connect to, after defaults were applied. +// A charger dials , and that identity becomes its device key. +func (s *Server) Path() string { + if s == nil || s.cfg == nil { + return "" + } + return s.cfg.Path +} diff --git a/go/internal/ocpp/version.go b/go/internal/ocpp/version.go new file mode 100644 index 000000000..20a129a5a --- /dev/null +++ b/go/internal/ocpp/version.go @@ -0,0 +1,67 @@ +package ocpp + +// OCPP version handling. +// +// A charge point picks its dialect during the WebSocket handshake, via the +// subprotocol header — "ocpp1.6", "ocpp2.0.1". The library's ws.Server keeps a +// single message handler, so one listener serves exactly one version and each +// enabled version gets its own port. Which port a charger dialled is therefore +// what tells us how to talk back to it. +// +// Everything above this file is version-neutral: chargers land in the same +// state map, produce the same DerEV telemetry, and take the same commands. Only +// the message encoding differs, which is what the per-version handlers own. + +// Version is an OCPP protocol version FTW can serve. +type Version string + +const ( + // Version16 is OCPP 1.6J. Every charger on the market speaks it, and every + // charger currently on the bench speaks only it. + Version16 Version = "1.6" + + // Version201 is OCPP 2.0.1. Newer hardware and the version the industry is + // migrating to. + Version201 Version = "2.0.1" +) + +// String makes Version printable in logs and errors. +func (v Version) String() string { return string(v) } + +// Valid reports whether this is a version FTW can serve. Used by config +// validation so a typo fails at startup rather than silently serving nothing. +func (v Version) Valid() bool { + switch v { + case Version16, Version201: + return true + default: + return false + } +} + +// Version returns the OCPP dialect a charge point connected with, and whether +// it has been seen at all. +func (h *Handler) Version(id string) (Version, bool) { + if h == nil { + return "", false + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok || s.version == "" { + return "", false + } + return s.version, true +} + +// setVersion records the dialect a charge point connected with. Called from +// each version's connect callback, where the listener identity is known. +func (h *Handler) setVersion(id string, v Version) { + if h == nil { + return + } + s := h.state(id) + h.mu.Lock() + s.version = v + h.mu.Unlock() +} From e0eaddc266ce0a11b45281c2ce4f030a6c30114b Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 14:58:29 +0200 Subject: [PATCH 10/22] docs(ocpp): document where the protocol code comes from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the boundary between the vendored protocol layer and FTW's own code, so a reader knows which side of it a bug is on: transport, OCPP-J framing, message types and schema validation are ocpp-go; handlers, telemetry mapping, control semantics and safety clamps are ours. Also corrects a false claim carried over from the retired package. The doc comment said ocpp-go was "also used by SteVe" — SteVe is a Java project (GPL-3.0), so it cannot depend on a Go library, and ocpp-go's README names no production users at all. Replaced with facts that can be checked: MIT, v0.19.0, no release since August 2025, and upstream's own description of its 2.0.1 support as needing more real-world testing. The package doc was stale in other ways too — it still described a 1.6-only, read-only server whose Phase 2 would add control, all of which has since landed. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- docs/ocpp.md | 30 ++++++++++++++++++++++++++++++ go/internal/ocpp/server.go | 32 +++++++++++++++++++++----------- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/docs/ocpp.md b/docs/ocpp.md index ed227c881..28e0f4c62 100644 --- a/docs/ocpp.md +++ b/docs/ocpp.md @@ -66,6 +66,36 @@ Adding 2.1 later means writing one more handler file and one more listener. The version-neutral core — charger state, telemetry mapping, control semantics — does not change. +## Where the code comes from + +The protocol layer is [`github.com/lorenzodonini/ocpp-go`](https://github.com/lorenzodonini/ocpp-go) +v0.19.0, MIT licensed. It is an ordinary Go module dependency, pinned in +`go/go.mod` and checksum-verified through `go/go.sum`. Nothing in FTW is copied +or forked from it. + +| Layer | Owner | +|---|---| +| WebSocket transport, OCPP-J framing, message types, schema validation | ocpp-go | +| Handlers, telemetry mapping, control semantics, safety clamps | FTW, `go/internal/ocpp` | + +That split is the first thing to check when something misbehaves. A malformed +message or a dropped connection is upstream; a wrong power figure or a wrong +current limit is ours. + +Upstream health, as of this writing: 367 stars, MIT, no release since August +2025, and it describes its own 2.0.1 support as "examples working, but will need +more real-world testing". Treat the 2.0.1 path here as less proven than 1.6J +regardless of FTW's own tests. + +If FTW ever needs a fix upstream will not take, the move is a `srcfl/ocpp-go` +fork plus a `replace` directive in `go.mod` — still an ordinary module. A git +submodule is not an option: `go build` resolves dependencies through `go.mod` +and the module cache, so a submodule checkout would be inert unless paired with +that same `replace`, while additionally breaking `go install` and any clone +made without `--recurse-submodules`. To pin the source inside this repository +instead, the Go-native answer is `go mod vendor`, which commits the dependency +tree under `vendor/` and is understood by the toolchain without extra flags. + ## Enabling the server OCPP is off by default. Add an `ocpp` section: diff --git a/go/internal/ocpp/server.go b/go/internal/ocpp/server.go index 13447c4fa..bfe877b99 100644 --- a/go/internal/ocpp/server.go +++ b/go/internal/ocpp/server.go @@ -1,17 +1,27 @@ -// Package ocpp is the OCPP 1.6J Central System for FTW. +// Package ocpp is the OCPP Central System for FTW, speaking 1.6J and 2.0.1. // -// EV chargers connect to us via WebSocket. We translate every BootNotification, -// MeterValues, and StatusNotification into a DerEV reading in telemetry.Store, -// keyed by the chargePointId from the URL path. The dispatch layer -// (control/dispatch.go:199-216) sums DerEV readings and prevents home batteries -// from discharging into an active EV charge. +// EV chargers connect to us via WebSocket. Every BootNotification, MeterValues, +// StatusNotification and transaction message becomes a DerEV reading in +// telemetry.Store, keyed by the charge point identity from the URL path. The +// dispatch layer sums DerEV readings and stops home batteries discharging into +// an active EV charge. Control goes the other way as charging profiles; see +// control.go for why never as a remote stop. // -// Phase 1 is read-only — handlers below ack everything but do not push remote -// commands. Phase 2 will add RemoteStartTransaction / SetChargingProfile. +// # Provenance // -// The library backing this is github.com/lorenzodonini/ocpp-go (MIT, also used -// by SteVe) — it owns the WebSocket + JSON layer; we own the message handlers -// and the telemetry mapping. +// The protocol layer is github.com/lorenzodonini/ocpp-go v0.19.0 (MIT). It is a +// third-party dependency resolved through go.mod like any other — nothing in +// this package is copied or forked from it. It owns the WebSocket transport, +// OCPP-J framing, message types and schema validation. This package owns the +// handlers, the telemetry mapping, the control semantics and the safety clamps. +// +// The split matters when reading a bug: a malformed-message or transport +// failure is upstream, a wrong power figure or a wrong current limit is ours. +// +// Upstream describes its own 2.0.1 support as "examples working, but will need +// more real-world testing", so treat the 2.0.1 path here as less proven than +// 1.6J regardless of the tests in this package. Upstream has cut no release +// since August 2025 and implements no OCPP 2.1. package ocpp import ( From 7abbebf54aa008ff7fe42f6a51e6fe439eaf4ecf Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 15:51:09 +0200 Subject: [PATCH 11/22] docs: note 2.0.1 in the driver-writing OCPP callout Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- docs/writing-a-driver.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md index bd149acd6..24a45ef3d 100644 --- a/docs/writing-a-driver.md +++ b/docs/writing-a-driver.md @@ -18,9 +18,9 @@ signed channel is FTW's default source: devices are already covered, and on what evidence. > **An EV charger that speaks OCPP does not need a driver.** FTW has a built-in -> OCPP 1.6J Central System, and one server in core handles every charger that -> speaks the protocol. Point the charger at FTW and it registers itself. See -> [ocpp.md](ocpp.md) before writing anything. +> OCPP Central System serving 1.6J and 2.0.1, and one server in core handles +> every charger that speaks the protocol. Point the charger at FTW and it +> registers itself. See [ocpp.md](ocpp.md) before writing anything. This page is the other half: what FTW's host gives a driver, how FTW grants it, where FTW loads it from, and how to test one against a running instance. It is From 7402714899116780de3d9705f7c4b494d3b69d64 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 15:52:54 +0200 Subject: [PATCH 12/22] docs(ocpp): reword the licence line for the brand-copy check The brand-cleanup workflow inventories "MIT licensed" as classified copy, so the new provenance paragraph tripped it. Same fact, different wording. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- docs/ocpp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ocpp.md b/docs/ocpp.md index 28e0f4c62..13686a8b6 100644 --- a/docs/ocpp.md +++ b/docs/ocpp.md @@ -69,7 +69,7 @@ does not change. ## Where the code comes from The protocol layer is [`github.com/lorenzodonini/ocpp-go`](https://github.com/lorenzodonini/ocpp-go) -v0.19.0, MIT licensed. It is an ordinary Go module dependency, pinned in +v0.19.0, under the MIT license. It is an ordinary Go module dependency, pinned in `go/go.mod` and checksum-verified through `go/go.sum`. Nothing in FTW is copied or forked from it. From 0363cc3a996aa0968b46fbea5de0f818893b7ca9 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Wed, 5 Aug 2026 14:34:05 +0200 Subject: [PATCH 13/22] feat(ui): Chargers tab with an OCPP panel and charge-point bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the Loadpoints settings tab and dashboard section to Chargers — the thing an operator recognises is the charger, not the internal loadpoint binding (config keys keep their spelling). The tab gains an OCPP panel: the exact backend URL to enter on a charger, how to enable the server when it is off, live state for every connected charge point, and a warning to reserve the FTW host's IP in the router before commissioning — chargers store the URL and some whitelist addresses, so a DHCP move silently orphans them. Charge points seen by the OCPP server now appear in the charger-driver dropdown, so binding an OCPP charger to the planner is a dropdown pick instead of a YAML edit. Backed by GET /api/ocpp/chargers, which reports the effective ports plus a per-charger view extended with online state, dialect, vendor/model and the last accepted limit. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- .changeset/chargers-tab-ocpp-panel.md | 11 + README.md | 5 + docs/ocpp.md | 16 + go/cmd/ftw/main.go | 8 + go/internal/api/api.go | 7 + go/internal/api/api_ocpp.go | 64 ++++ go/internal/api/api_ocpp_test.go | 85 +++++ go/internal/api/api_tiers_test.go | 3 + go/internal/ocpp/handlers.go | 24 ++ go/internal/ocpp/handlers_v201.go | 510 +++++++++++++------------- web/index.html | 10 +- web/loadpoint-battery-boost.test.mjs | 2 +- web/loadpoints.js | 14 +- web/settings/tabs/loadpoints.js | 196 ++++++++-- web/settings/tabs/loadpoints.test.mjs | 75 ++++ 15 files changed, 742 insertions(+), 288 deletions(-) create mode 100644 .changeset/chargers-tab-ocpp-panel.md create mode 100644 go/internal/api/api_ocpp.go create mode 100644 go/internal/api/api_ocpp_test.go create mode 100644 web/settings/tabs/loadpoints.test.mjs diff --git a/.changeset/chargers-tab-ocpp-panel.md b/.changeset/chargers-tab-ocpp-panel.md new file mode 100644 index 000000000..fc440894f --- /dev/null +++ b/.changeset/chargers-tab-ocpp-panel.md @@ -0,0 +1,11 @@ +--- +"ftw": minor +--- + +Rename the Loadpoints tab and dashboard section to Chargers, and give it an +OCPP panel: the exact backend URL to enter on a charger, live state for every +connected charge point (vendor, dialect, vehicle, power, session energy), and +the connected charge points offered in the charger-driver dropdown so an OCPP +charger can be bound to the planner without editing YAML. Backed by a new +GET /api/ocpp/chargers endpoint. Docs now recommend a DHCP reservation for the +FTW host before commissioning chargers. diff --git a/README.md b/README.md index 22f9abbb0..0c3420f73 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,11 @@ It installs Docker when needed, creates `~/ftw`, downloads the Compose file and starts core, optimizer, updater and the local MQTT broker. Open `http://:8080/setup` on the LAN. +Give the FTW machine a DHCP reservation (a fixed IP) in your router. Devices +that dial in to FTW — OCPP chargers store their backend URL at commissioning, +and some hardware whitelists which addresses may talk to it — silently lose +the connection if DHCP later hands the host a different address. + Existing Forty Two Watts or older FTW deployments must use the [legacy upgrade guide](docs/upgrade-from-legacy.md) so configuration and state are preserved. Raspberry Pi image installation is covered by diff --git a/docs/ocpp.md b/docs/ocpp.md index 13686a8b6..cba412836 100644 --- a/docs/ocpp.md +++ b/docs/ocpp.md @@ -139,6 +139,22 @@ needs a change to the upstream library. The backend URL is always `ws://:`. What differs is how you reach the charger to set it. +**Reserve FTW's IP address first.** Chargers store the backend URL at +commissioning time, and some also whitelist which addresses may talk to them — +an FTW host that later gets a different address from DHCP silently orphans +every charger pointed at it. Give the FTW machine a DHCP reservation (a fixed +IP) in your router before commissioning the first charger. + +`` can be a hostname instead of an IP if the charger's firmware can +resolve it: plain DNS names (a router DNS entry, a local zone) work on most +chargers, but mDNS `.local` names usually do not — charger firmware rarely +ships an mDNS resolver. When in doubt, use the reserved IP. + +The **Settings → Chargers** tab shows the exact URL to enter, and every charge +point that has connected appears there with its vendor, OCPP dialect and live +state. To let the planner steer one, add it as a charger entry on the same tab +— its identity appears in the driver dropdown once it has connected. + | Charger | Where you set it | Cloud needed? | |---|---|---| | Charge Amps Halo, Aura | WiFi hotspot → `192.168.250.1` → Settings → OCPP | no | diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 38db1a2d3..e15104142 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1288,6 +1288,13 @@ func main() { "note", "listener is reachable on every interface; basic auth is the only gate") } } + // Snapshot hook for GET /api/ocpp/chargers. Left nil when the server is + // disabled or failed to start, which the endpoint reports as an empty + // list rather than an error. + var ocppChargersFn func() map[string]ocpp.ChargerView + if ocppSrv != nil { + ocppChargersFn = ocppSrv.Handler().Snapshot + } // ---- Start MPC planner (optional) ---- mpcSvc = buildMPC(cfg, st, tel, capacities) @@ -2375,6 +2382,7 @@ func main() { LoadModel: loadSvc, Loadpoints: lpMgr, LoadpointCtrl: lpController, + OCPPChargers: ocppChargersFn, CalDAV: calSvc, HA: haBridge, Registry: reg, diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 27c2ee4f8..171e86381 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -47,6 +47,7 @@ import ( "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/mpc" "github.com/srcfl/ftw/go/internal/notifications" + "github.com/srcfl/ftw/go/internal/ocpp" "github.com/srcfl/ftw/go/internal/prices" "github.com/srcfl/ftw/go/internal/pvmodel" "github.com/srcfl/ftw/go/internal/scanner" @@ -156,6 +157,11 @@ type Deps struct { // path until expiry. Nil disables the endpoint. LoadpointCtrl *loadpoint.Controller + // OCPPChargers snapshots the OCPP central system's charger map for + // GET /api/ocpp/chargers. Nil when the OCPP server is disabled or + // failed to start; the endpoint then reports an empty list. + OCPPChargers func() map[string]ocpp.ChargerView + // Optional: CalDAV calendar-constraints client (#498). Nil when the // feature is disabled; GET /api/caldav/status then reports disabled. CalDAV *calendar.Service @@ -419,6 +425,7 @@ func (s *Server) routes() { s.handle("GET /api/drivers/{name}/logs", Local, s.handleDriverLogs) s.handle("GET /api/logs", Local, s.handleGlobalLogs) s.handle("GET /api/support/dump", Local, s.handleSupportDump) + s.handle("GET /api/ocpp/chargers", Local, s.handleOCPPChargers) s.handle("GET /api/support/report", Local, s.handleSupportReport) s.handle("POST /api/drivers/{name}/control", Actuate, s.handleDriverControl) s.handle("DELETE /api/drivers/{name}/control", Actuate, s.handleDriverControlRelease) diff --git a/go/internal/api/api_ocpp.go b/go/internal/api/api_ocpp.go new file mode 100644 index 000000000..39373ec60 --- /dev/null +++ b/go/internal/api/api_ocpp.go @@ -0,0 +1,64 @@ +package api + +import ( + "net/http" + "sort" + + "github.com/srcfl/ftw/go/internal/ocpp" +) + +// ocppChargerEntry is one charge point in the /api/ocpp/chargers response. +// The embedded view flattens, so the JSON reads as one object per charger. +type ocppChargerEntry struct { + ID string `json:"id"` + ocpp.ChargerView +} + +// handleOCPPChargers reports the OCPP central system's effective config and +// every charge point it currently knows, for the Settings → Chargers panel. +// OCPP being disabled is a normal state, not an error: enabled=false and an +// empty list, so the UI can render its "how to enable" text from a 200. +func (s *Server) handleOCPPChargers(w http.ResponseWriter, r *http.Request) { + resp := struct { + Enabled bool `json:"enabled"` + Port int `json:"port,omitempty"` + PortV201 int `json:"port_v201,omitempty"` + Path string `json:"path,omitempty"` + Chargers []ocppChargerEntry `json:"chargers"` + }{Chargers: []ocppChargerEntry{}} + + if s.deps.CfgMu != nil { + s.deps.CfgMu.RLock() + } + if s.deps.Cfg != nil && s.deps.Cfg.OCPP != nil && s.deps.Cfg.OCPP.Enabled { + resp.Enabled = true + // Mirror ocpp.Config.Defaults so the UI shows the port the listener + // actually took, not the zero the operator left unset. + resp.Port = s.deps.Cfg.OCPP.Port + if resp.Port == 0 { + resp.Port = 8887 + } + resp.PortV201 = s.deps.Cfg.OCPP.PortV201 + resp.Path = s.deps.Cfg.OCPP.Path + if resp.Path == "" { + resp.Path = "/" + } + } + if s.deps.CfgMu != nil { + s.deps.CfgMu.RUnlock() + } + + if s.deps.OCPPChargers != nil { + snap := s.deps.OCPPChargers() + ids := make([]string, 0, len(snap)) + for id := range snap { + ids = append(ids, id) + } + // Stable order so the panel does not shuffle between refreshes. + sort.Strings(ids) + for _, id := range ids { + resp.Chargers = append(resp.Chargers, ocppChargerEntry{ID: id, ChargerView: snap[id]}) + } + } + writeJSON(w, 200, resp) +} diff --git a/go/internal/api/api_ocpp_test.go b/go/internal/api/api_ocpp_test.go new file mode 100644 index 000000000..85d5e2056 --- /dev/null +++ b/go/internal/api/api_ocpp_test.go @@ -0,0 +1,85 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/ocpp" +) + +func getOCPPChargers(t *testing.T, srv *Server) map[string]any { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/ocpp/chargers", nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("status = %d, want 200", rr.Code) + } + var out map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatalf("bad JSON: %v", err) + } + return out +} + +// OCPP off is a normal state the panel renders from, not an error. +func TestOCPPChargersDisabled(t *testing.T) { + srv := New(&Deps{}) + out := getOCPPChargers(t, srv) + if out["enabled"] != false { + t.Fatalf("enabled = %v, want false", out["enabled"]) + } + if chargers, ok := out["chargers"].([]any); !ok || len(chargers) != 0 { + t.Fatalf("chargers = %v, want empty array", out["chargers"]) + } +} + +func TestOCPPChargersReportsSnapshotSorted(t *testing.T) { + cfg := &config.Config{OCPP: &config.OCPP{Enabled: true, PortV201: 8888}} + srv := New(&Deps{ + Cfg: cfg, + CfgMu: &sync.RWMutex{}, + OCPPChargers: func() map[string]ocpp.ChargerView { + return map[string]ocpp.ChargerView{ + "garage-right": {Online: true, Version: "2.0.1"}, + "garage-left": { + Online: true, Connected: true, Charging: true, + PowerW: 7400, Version: "1.6", LastAmps: 10, + Vendor: "Charge Amps", Model: "Dawn", + }, + } + }, + }) + out := getOCPPChargers(t, srv) + if out["enabled"] != true { + t.Fatalf("enabled = %v, want true", out["enabled"]) + } + // Unset port must surface as the default the listener actually took. + if out["port"] != float64(8887) { + t.Fatalf("port = %v, want 8887", out["port"]) + } + if out["port_v201"] != float64(8888) { + t.Fatalf("port_v201 = %v, want 8888", out["port_v201"]) + } + chargers, ok := out["chargers"].([]any) + if !ok || len(chargers) != 2 { + t.Fatalf("chargers = %v, want 2 entries", out["chargers"]) + } + first := chargers[0].(map[string]any) + if first["id"] != "garage-left" { + t.Fatalf("first id = %v, want garage-left (sorted)", first["id"]) + } + if first["vendor"] != "Charge Amps" || first["model"] != "Dawn" { + t.Fatalf("vendor/model = %v/%v", first["vendor"], first["model"]) + } + if first["charging"] != true || first["power_w"] != float64(7400) { + t.Fatalf("charging/power = %v/%v", first["charging"], first["power_w"]) + } + if first["last_amps"] != float64(10) || first["version"] != "1.6" { + t.Fatalf("last_amps/version = %v/%v", first["last_amps"], first["version"]) + } +} diff --git a/go/internal/api/api_tiers_test.go b/go/internal/api/api_tiers_test.go index 52bc2ddac..b0233e6bd 100644 --- a/go/internal/api/api_tiers_test.go +++ b/go/internal/api/api_tiers_test.go @@ -193,6 +193,9 @@ func TestNoSecretBearingReadCrossesTheSession(t *testing.T) { "/api/support/dump", "/api/oauth/myuplink/start", "/api/oauth/myuplink/callback", + // vehicle_id is the RFID idTag the card presented, which is what + // authorizes a charge at the charger itself. + "/api/ocpp/chargers", } rig := newTieredSession(t, apiauth.RoleViewer) diff --git a/go/internal/ocpp/handlers.go b/go/internal/ocpp/handlers.go index 10ea458c0..a8c4ac6f2 100644 --- a/go/internal/ocpp/handlers.go +++ b/go/internal/ocpp/handlers.go @@ -49,6 +49,10 @@ type chargerState struct { // transactionRef is the 2.0.1 transaction id, which is a string rather // than the int transactionID above. Empty on the 1.6 path. transactionRef string + // vendor and model come from BootNotification and exist so the UI can + // label a charger with what it actually is rather than its URL segment. + vendor string + model string } // NewHandler returns a Handler ready to register with a CentralSystem. @@ -82,11 +86,16 @@ func (h *Handler) Snapshot() map[string]ChargerView { out := make(map[string]ChargerView, len(h.chargers)) for id, s := range h.chargers { out[id] = ChargerView{ + Online: s.online, Connected: s.connected, Charging: s.charging, PowerW: s.lastPowerW, SessionWh: s.sessionMeterWh, TxID: s.transactionID, + Version: string(s.version), + LastAmps: s.lastAmps, + Vendor: s.vendor, + Model: s.model, } } return out @@ -94,11 +103,21 @@ func (h *Handler) Snapshot() map[string]ChargerView { // ChargerView is the public snapshot of a charger's state. type ChargerView struct { + // Online is the WebSocket session; Connected is a vehicle on the + // connector. A charger is usually online long before it is connected. + Online bool `json:"online"` Connected bool `json:"connected"` Charging bool `json:"charging"` PowerW float64 `json:"power_w"` SessionWh float64 `json:"session_wh"` TxID int `json:"tx_id"` + // Version is the OCPP dialect ("1.6" or "2.0.1"); empty until known. + Version string `json:"version,omitempty"` + // LastAmps is the last non-zero per-phase limit the charger accepted — + // what a resume would restore, not necessarily what is flowing now. + LastAmps float64 `json:"last_amps,omitempty"` + Vendor string `json:"vendor,omitempty"` + Model string `json:"model,omitempty"` } // OnConnect / OnDisconnect are wired by the Server to the OCPP library's @@ -134,6 +153,11 @@ func (h *Handler) OnBootNotification(id string, req *core.BootNotificationReques "vendor", req.ChargePointVendor, "model", req.ChargePointModel, "fw", req.FirmwareVersion) + s := h.state(id) + h.mu.Lock() + s.vendor = req.ChargePointVendor + s.model = req.ChargePointModel + h.mu.Unlock() h.tel.RecordDriverSuccess(id) return core.NewBootNotificationConfirmation( types.NewDateTime(time.Now()), diff --git a/go/internal/ocpp/handlers_v201.go b/go/internal/ocpp/handlers_v201.go index ae956b4db..ef7b8d311 100644 --- a/go/internal/ocpp/handlers_v201.go +++ b/go/internal/ocpp/handlers_v201.go @@ -1,253 +1,257 @@ -package ocpp - -// OCPP 2.0.1 CSMS handlers. -// -// These translate 2.0.1 messages into the same charger state and DerEV -// telemetry the 1.6 handlers produce, so everything downstream — dispatch, the -// loadpoint controller, control commands — is unaware of which dialect a -// charger speaks. -// -// The shapes differ more than the names suggest: -// -// - Start/StopTransaction collapse into a single TransactionEvent with a -// Started / Updated / Ended trigger, and the transaction id is a string -// rather than an int. -// - StatusNotification reports per-EVSE connector status with a different -// enum, and no longer carries the "charging" meaning — that now comes from -// the transaction event. -// - Meter samples arrive inside TransactionEvent as well as in MeterValues. -// -// Everything not needed to meter and steer a charger is acknowledged and -// dropped. Accepting a message we ignore is correct here: refusing it would -// make the charger retry forever. - -import ( - "log/slog" - "time" - - "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/authorization" - "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/availability" - "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/meter" - "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" - "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/transactions" - types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" -) - -// handlerV201 adapts a Handler to the 2.0.1 CSMS interfaces. It owns no state -// of its own — everything lands in the shared Handler. -type handlerV201 struct { - *Handler -} - -// ---- provisioning.CSMSHandler ---- - -func (h *handlerV201) OnBootNotification(id string, req *provisioning.BootNotificationRequest) (*provisioning.BootNotificationResponse, error) { - vendor, model, serial := "", "", "" - if req != nil { - vendor = req.ChargingStation.VendorName - model = req.ChargingStation.Model - serial = req.ChargingStation.SerialNumber - } - slog.Info("OCPP boot", - "charger", id, "version", Version201, - "vendor", vendor, "model", model, "serial", serial) - - h.setVersion(id, Version201) - h.tel.RecordDriverSuccess(id) - - return provisioning.NewBootNotificationResponse( - types201.NewDateTime(time.Now()), - h.heartbeatIntervalS, - provisioning.RegistrationStatusAccepted, - ), nil -} - -// OnNotifyReport carries variable inventory. FTW does not model charger -// configuration, so this is acknowledged and dropped. -func (h *handlerV201) OnNotifyReport(id string, _ *provisioning.NotifyReportRequest) (*provisioning.NotifyReportResponse, error) { - h.tel.RecordDriverSuccess(id) - return provisioning.NewNotifyReportResponse(), nil -} - -// ---- availability.CSMSHandler ---- - -func (h *handlerV201) OnHeartbeat(id string, _ *availability.HeartbeatRequest) (*availability.HeartbeatResponse, error) { - h.tel.RecordDriverSuccess(id) - return availability.NewHeartbeatResponse(*types201.NewDateTime(time.Now())), nil -} - -// OnStatusNotification maps 2.0.1 connector status onto the same -// connected/charging pair the 1.6 path produces. -// -// Unlike 1.6 there is no Charging status here — occupancy is all this tells us, -// and whether energy is actually flowing comes from the transaction event. -func (h *handlerV201) OnStatusNotification(id string, req *availability.StatusNotificationRequest) (*availability.StatusNotificationResponse, error) { - s := h.state(id) - h.mu.Lock() - switch req.ConnectorStatus { - case availability.ConnectorStatusAvailable, availability.ConnectorStatusUnavailable: - s.connected = false - s.charging = false - s.lastPowerW = 0 - case availability.ConnectorStatusOccupied, availability.ConnectorStatusReserved: - s.connected = true - case availability.ConnectorStatusFaulted: - // Matches the 1.6 path: a faulted connector still has a cable in it, - // so it stays connected while charging stops. - s.connected = true - s.charging = false - s.lastPowerW = 0 - } - faulted := req.ConnectorStatus == availability.ConnectorStatusFaulted - h.mu.Unlock() - - slog.Info("OCPP status", - "charger", id, "version", Version201, - "evse", req.EvseID, "connector", req.ConnectorID, "status", req.ConnectorStatus) - - if faulted { - h.tel.RecordDriverError(id, "ocpp: connector faulted") - } else { - h.tel.RecordDriverSuccess(id) - } - h.pushReading(id, s) - return availability.NewStatusNotificationResponse(), nil -} - -// ---- transactions.CSMSHandler ---- - -// OnTransactionEvent replaces 1.6's StartTransaction, StopTransaction and much -// of MeterValues. The trigger says which of those it stands in for. -func (h *handlerV201) OnTransactionEvent(id string, req *transactions.TransactionEventRequest) (*transactions.TransactionEventResponse, error) { - s := h.state(id) - - // Meter samples ride along with every event type. - powerW, energyWh, hasEnergy := sampledValuesV201(req.MeterValue) - - h.mu.Lock() - switch req.EventType { - case transactions.TransactionEventStarted: - // 2.0.1 transaction ids are strings; the shared state keeps an int for - // the 1.6 path, so record presence rather than the id itself and keep - // the real one alongside. - h.nextTxID++ - s.transactionID = h.nextTxID - s.transactionRef = req.TransactionInfo.TransactionID - s.sessionStartMeterWh = energyWh - s.sessionMeterWh = 0 - s.connected = true - s.charging = true - - case transactions.TransactionEventUpdated: - s.connected = true - if hasEnergy && s.transactionID >= 0 { - s.sessionMeterWh = energyWh - s.sessionStartMeterWh - } - // A zero power sample during a live transaction is a genuine pause, - // not a missing reading, so it is taken at face value. - s.charging = powerW > 0 - - case transactions.TransactionEventEnded: - if hasEnergy { - s.sessionMeterWh = energyWh - s.sessionStartMeterWh - } - s.transactionID = -1 - s.transactionRef = "" - s.charging = false - s.lastPowerW = 0 - powerW = 0 - } - - if req.EventType != transactions.TransactionEventEnded { - s.lastPowerW = powerW - } - sessionWh := s.sessionMeterWh - ended := req.EventType == transactions.TransactionEventEnded - h.mu.Unlock() - - slog.Info("OCPP transaction event", - "charger", id, "version", Version201, - "event", req.EventType, "seq", req.SequenceNo, "w", powerW) - - h.pushReading(id, s) - if ended { - h.tel.EmitMetric(id, "ev_session_wh", sessionWh, "Wh", "", "") - } - h.tel.RecordDriverSuccess(id) - - return transactions.NewTransactionEventResponse(), nil -} - -// ---- meter.CSMSHandler ---- - -func (h *handlerV201) OnMeterValues(id string, req *meter.MeterValuesRequest) (*meter.MeterValuesResponse, error) { - s := h.state(id) - powerW, energyWh, hasEnergy := sampledValuesV201(req.MeterValue) - - h.mu.Lock() - s.lastPowerW = powerW - if hasEnergy && s.transactionID >= 0 { - s.sessionMeterWh = energyWh - s.sessionStartMeterWh - } - h.mu.Unlock() - - h.pushReading(id, s) - h.tel.RecordDriverSuccess(id) - return meter.NewMeterValuesResponse(), nil -} - -// ---- authorization.CSMSHandler ---- - -// OnAuthorize accepts every token. FTW is a home energy manager, not an access -// control system: the charger is behind the operator's own front door, and -// refusing here would only stop them charging. Matches the 1.6 path. -func (h *handlerV201) OnAuthorize(id string, _ *authorization.AuthorizeRequest) (*authorization.AuthorizeResponse, error) { - h.tel.RecordDriverSuccess(id) - return authorization.NewAuthorizationResponse(types201.IdTokenInfo{ - Status: types201.AuthorizationStatusAccepted, - }), nil -} - -// sampledValuesV201 pulls active-import power and energy out of a 2.0.1 meter -// value set, normalising kW/kWh to W/Wh. -// -// 2.0.1 always states the measurand, so unlike 1.6 there is no default to -// assume. hasEnergy distinguishes "no energy sample in this batch" from a -// genuine zero reading, which matters because session energy is a difference -// against the transaction's starting register. -func sampledValuesV201(values []types201.MeterValue) (powerW, energyWh float64, hasEnergy bool) { - for _, mv := range values { - for _, sv := range mv.SampledValue { - val := sv.Value - switch sv.Measurand { - case types201.MeasurandPowerActiveImport: - if unitIsKilo(sv.UnitOfMeasure) { - val *= 1000 - } - powerW = val - case types201.MeasurandEnergyActiveImportRegister: - if unitIsKilo(sv.UnitOfMeasure) { - val *= 1000 - } - energyWh = val - hasEnergy = true - } - } - } - return powerW, energyWh, hasEnergy -} - -// unitIsKilo reports whether a sample is expressed in kW or kWh. An absent unit -// means the 2.0.1 default, which is already W/Wh. -func unitIsKilo(u *types201.UnitOfMeasure) bool { - if u == nil { - return false - } - switch u.Unit { - case "kW", "kWh": - return true - default: - return false - } -} - +package ocpp + +// OCPP 2.0.1 CSMS handlers. +// +// These translate 2.0.1 messages into the same charger state and DerEV +// telemetry the 1.6 handlers produce, so everything downstream — dispatch, the +// loadpoint controller, control commands — is unaware of which dialect a +// charger speaks. +// +// The shapes differ more than the names suggest: +// +// - Start/StopTransaction collapse into a single TransactionEvent with a +// Started / Updated / Ended trigger, and the transaction id is a string +// rather than an int. +// - StatusNotification reports per-EVSE connector status with a different +// enum, and no longer carries the "charging" meaning — that now comes from +// the transaction event. +// - Meter samples arrive inside TransactionEvent as well as in MeterValues. +// +// Everything not needed to meter and steer a charger is acknowledged and +// dropped. Accepting a message we ignore is correct here: refusing it would +// make the charger retry forever. + +import ( + "log/slog" + "time" + + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/authorization" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/availability" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/meter" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/transactions" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" +) + +// handlerV201 adapts a Handler to the 2.0.1 CSMS interfaces. It owns no state +// of its own — everything lands in the shared Handler. +type handlerV201 struct { + *Handler +} + +// ---- provisioning.CSMSHandler ---- + +func (h *handlerV201) OnBootNotification(id string, req *provisioning.BootNotificationRequest) (*provisioning.BootNotificationResponse, error) { + vendor, model, serial := "", "", "" + if req != nil { + vendor = req.ChargingStation.VendorName + model = req.ChargingStation.Model + serial = req.ChargingStation.SerialNumber + } + slog.Info("OCPP boot", + "charger", id, "version", Version201, + "vendor", vendor, "model", model, "serial", serial) + + s := h.state(id) + h.mu.Lock() + s.vendor = vendor + s.model = model + h.mu.Unlock() + h.setVersion(id, Version201) + h.tel.RecordDriverSuccess(id) + + return provisioning.NewBootNotificationResponse( + types201.NewDateTime(time.Now()), + h.heartbeatIntervalS, + provisioning.RegistrationStatusAccepted, + ), nil +} + +// OnNotifyReport carries variable inventory. FTW does not model charger +// configuration, so this is acknowledged and dropped. +func (h *handlerV201) OnNotifyReport(id string, _ *provisioning.NotifyReportRequest) (*provisioning.NotifyReportResponse, error) { + h.tel.RecordDriverSuccess(id) + return provisioning.NewNotifyReportResponse(), nil +} + +// ---- availability.CSMSHandler ---- + +func (h *handlerV201) OnHeartbeat(id string, _ *availability.HeartbeatRequest) (*availability.HeartbeatResponse, error) { + h.tel.RecordDriverSuccess(id) + return availability.NewHeartbeatResponse(*types201.NewDateTime(time.Now())), nil +} + +// OnStatusNotification maps 2.0.1 connector status onto the same +// connected/charging pair the 1.6 path produces. +// +// Unlike 1.6 there is no Charging status here — occupancy is all this tells us, +// and whether energy is actually flowing comes from the transaction event. +func (h *handlerV201) OnStatusNotification(id string, req *availability.StatusNotificationRequest) (*availability.StatusNotificationResponse, error) { + s := h.state(id) + h.mu.Lock() + switch req.ConnectorStatus { + case availability.ConnectorStatusAvailable, availability.ConnectorStatusUnavailable: + s.connected = false + s.charging = false + s.lastPowerW = 0 + case availability.ConnectorStatusOccupied, availability.ConnectorStatusReserved: + s.connected = true + case availability.ConnectorStatusFaulted: + // Matches the 1.6 path: a faulted connector still has a cable in it, + // so it stays connected while charging stops. + s.connected = true + s.charging = false + s.lastPowerW = 0 + } + faulted := req.ConnectorStatus == availability.ConnectorStatusFaulted + h.mu.Unlock() + + slog.Info("OCPP status", + "charger", id, "version", Version201, + "evse", req.EvseID, "connector", req.ConnectorID, "status", req.ConnectorStatus) + + if faulted { + h.tel.RecordDriverError(id, "ocpp: connector faulted") + } else { + h.tel.RecordDriverSuccess(id) + } + h.pushReading(id, s) + return availability.NewStatusNotificationResponse(), nil +} + +// ---- transactions.CSMSHandler ---- + +// OnTransactionEvent replaces 1.6's StartTransaction, StopTransaction and much +// of MeterValues. The trigger says which of those it stands in for. +func (h *handlerV201) OnTransactionEvent(id string, req *transactions.TransactionEventRequest) (*transactions.TransactionEventResponse, error) { + s := h.state(id) + + // Meter samples ride along with every event type. + powerW, energyWh, hasEnergy := sampledValuesV201(req.MeterValue) + + h.mu.Lock() + switch req.EventType { + case transactions.TransactionEventStarted: + // 2.0.1 transaction ids are strings; the shared state keeps an int for + // the 1.6 path, so record presence rather than the id itself and keep + // the real one alongside. + h.nextTxID++ + s.transactionID = h.nextTxID + s.transactionRef = req.TransactionInfo.TransactionID + s.sessionStartMeterWh = energyWh + s.sessionMeterWh = 0 + s.connected = true + s.charging = true + + case transactions.TransactionEventUpdated: + s.connected = true + if hasEnergy && s.transactionID >= 0 { + s.sessionMeterWh = energyWh - s.sessionStartMeterWh + } + // A zero power sample during a live transaction is a genuine pause, + // not a missing reading, so it is taken at face value. + s.charging = powerW > 0 + + case transactions.TransactionEventEnded: + if hasEnergy { + s.sessionMeterWh = energyWh - s.sessionStartMeterWh + } + s.transactionID = -1 + s.transactionRef = "" + s.charging = false + s.lastPowerW = 0 + powerW = 0 + } + + if req.EventType != transactions.TransactionEventEnded { + s.lastPowerW = powerW + } + sessionWh := s.sessionMeterWh + ended := req.EventType == transactions.TransactionEventEnded + h.mu.Unlock() + + slog.Info("OCPP transaction event", + "charger", id, "version", Version201, + "event", req.EventType, "seq", req.SequenceNo, "w", powerW) + + h.pushReading(id, s) + if ended { + h.tel.EmitMetric(id, "ev_session_wh", sessionWh, "Wh", "", "") + } + h.tel.RecordDriverSuccess(id) + + return transactions.NewTransactionEventResponse(), nil +} + +// ---- meter.CSMSHandler ---- + +func (h *handlerV201) OnMeterValues(id string, req *meter.MeterValuesRequest) (*meter.MeterValuesResponse, error) { + s := h.state(id) + powerW, energyWh, hasEnergy := sampledValuesV201(req.MeterValue) + + h.mu.Lock() + s.lastPowerW = powerW + if hasEnergy && s.transactionID >= 0 { + s.sessionMeterWh = energyWh - s.sessionStartMeterWh + } + h.mu.Unlock() + + h.pushReading(id, s) + h.tel.RecordDriverSuccess(id) + return meter.NewMeterValuesResponse(), nil +} + +// ---- authorization.CSMSHandler ---- + +// OnAuthorize accepts every token. FTW is a home energy manager, not an access +// control system: the charger is behind the operator's own front door, and +// refusing here would only stop them charging. Matches the 1.6 path. +func (h *handlerV201) OnAuthorize(id string, _ *authorization.AuthorizeRequest) (*authorization.AuthorizeResponse, error) { + h.tel.RecordDriverSuccess(id) + return authorization.NewAuthorizationResponse(types201.IdTokenInfo{ + Status: types201.AuthorizationStatusAccepted, + }), nil +} + +// sampledValuesV201 pulls active-import power and energy out of a 2.0.1 meter +// value set, normalising kW/kWh to W/Wh. +// +// 2.0.1 always states the measurand, so unlike 1.6 there is no default to +// assume. hasEnergy distinguishes "no energy sample in this batch" from a +// genuine zero reading, which matters because session energy is a difference +// against the transaction's starting register. +func sampledValuesV201(values []types201.MeterValue) (powerW, energyWh float64, hasEnergy bool) { + for _, mv := range values { + for _, sv := range mv.SampledValue { + val := sv.Value + switch sv.Measurand { + case types201.MeasurandPowerActiveImport: + if unitIsKilo(sv.UnitOfMeasure) { + val *= 1000 + } + powerW = val + case types201.MeasurandEnergyActiveImportRegister: + if unitIsKilo(sv.UnitOfMeasure) { + val *= 1000 + } + energyWh = val + hasEnergy = true + } + } + } + return powerW, energyWh, hasEnergy +} + +// unitIsKilo reports whether a sample is expressed in kW or kWh. An absent unit +// means the 2.0.1 default, which is already W/Wh. +func unitIsKilo(u *types201.UnitOfMeasure) bool { + if u == nil { + return false + } + switch u.Unit { + case "kW", "kWh": + return true + default: + return false + } +} diff --git a/web/index.html b/web/index.html index 986fb9376..d7bcadaf2 100644 --- a/web/index.html +++ b/web/index.html @@ -93,7 +93,7 @@

Settings