diff --git a/.changeset/si-core-units.md b/.changeset/si-core-units.md new file mode 100644 index 000000000..fb54bf612 --- /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, 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/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/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/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..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.PVSurplusAbsorbSoCCapPct + 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 235c2791b..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: 42, Strategy: mpc.ModePassiveArbitrage, - PVLimitW: 7000, GridW: -6400, LivePVSurplusSoCCapPct: 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 a53aeb6d5..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, } } @@ -1071,7 +1071,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 +1150,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() @@ -1320,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 @@ -1342,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 } @@ -1366,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 @@ -1396,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 @@ -1409,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 @@ -1454,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", @@ -1503,11 +1503,11 @@ func main() { ID: st.ID, CapacityWh: capWh, Levels: 11, - MinPct: 0, - MaxPct: maxPct, - InitialSoCPct: initSoC, + SoCMin: 0, + SoCMax: maxSoC, + InitialSoC: initSoC, PluggedIn: true, - TargetSoCPct: targetPct, + TargetSoC: targetSoC, TargetSlotIdx: targetSlot, MaxChargeW: st.MaxChargeW, AllowedStepsW: st.AllowedStepsW, @@ -1759,7 +1759,7 @@ func main() { return loadpoint.BatteryBoostStoppedBatteryUnavailable } usable++ - floor := lease.MinBatterySoCPct / 100 + floor := lease.MinBatterySoC if staticFloor[name] > floor { floor = staticFloor[name] } @@ -2773,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() { @@ -3557,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.SoCPct, + SoC: a.SoC, Confidence: a.Confidence, }) } @@ -3587,7 +3587,7 @@ func buildLoadpointConfigs(src []config.Loadpoint) []loadpoint.Config { MaxChargeW: lp.MaxChargeW, AllowedStepsW: lp.AllowedStepsW, VehicleCapacityWh: lp.VehicleCapacityWh, - PluginSoCPct: lp.PluginSoCPct, + PluginSoC: lp.PluginSoC, PhaseMode: lp.PhaseMode, PhaseSplitW: lp.PhaseSplitW, MinPhaseHoldS: lp.MinPhaseHoldS, @@ -3716,13 +3716,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 +3743,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 +4199,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.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 bc4c9ad2e..8d0f2fdf5 100644 --- a/go/internal/api/api_loadpoint_battery_boost.go +++ b/go/internal/api/api_loadpoint_battery_boost.go @@ -6,12 +6,15 @@ 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,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.EVTargetSoCPct, + 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 02588a8a2..9ccfd1364 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, }) @@ -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_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..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", - MinReserveSoCPct: 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 6a4f3ca4a..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,17 +71,17 @@ 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.SoCPct < min { - min = a.SoCPct + if a.SoC < min { + min = a.SoC } } if math.IsInf(min, 1) { @@ -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.SoCPct <= minSoCPct+0.5 { + if priceAbove && a.SoC <= minSoC+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/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 f955f5d50..c0a578858 100644 --- a/go/internal/calendar/service.go +++ b/go/internal/calendar/service.go @@ -171,7 +171,7 @@ func (s *Service) applyConfig(cfg config.CalDAV, firstLoadpointID string) { if horizonDays <= 0 { horizonDays = config.DefaultCalDAVHorizonDays } - targetSoC := cfg.EVDefaultTargetSoCPct + targetSoC := cfg.EVDefaultTargetSoC if targetSoC <= 0 { targetSoC = config.DefaultCalDAVEVTargetSoC } @@ -505,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 @@ -513,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 628e654a3..6ed18af55 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"` @@ -722,8 +725,9 @@ 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"` // PVSurplusAbsorbThresholdW is the trigger threshold for the @@ -1107,10 +1111,12 @@ 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"` - 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 +1125,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 +1136,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 +1696,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 +2025,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 +2048,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..783209b5e --- /dev/null +++ b/go/internal/config/units.go @@ -0,0 +1,65 @@ +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 { + return units.DecodeJSONFraction(canonical, legacyPercent) +} + +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..09e186778 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.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 70879b503..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 = 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: 80, // 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 = 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 = 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: 80, + 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: 80, + 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 30654f904..3d0bf5d87 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. @@ -122,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 @@ -157,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 @@ -503,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 @@ -512,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 @@ -1738,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 @@ -1919,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 { @@ -3589,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 := state.PVSurplusAbsorbSoCCapPct - plannerCap := 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 > 100 { - return 100 - } - return capPct + return cap } // pvSurplusAbsorbHeadroomW converts the fleet's quantified SoC headroom into @@ -3624,7 +3622,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..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 = roundW(60 + rng.Float64()*35) + slot.LivePVSurplusSoCCap = 0.60 + rng.Float64()*0.35 } in.Slot = slot } if rng.Float64() < 0.10 { - in.State.PVSurplusAbsorbSoCCapPct = roundW(70 + rng.Float64()*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/drivers/host.go b/go/internal/drivers/host.go index 3daebb793..274535a26 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) } @@ -684,10 +692,13 @@ func (h *HostEnv) emitTelemetry(rawJSON []byte) error { // Driver authors call this for anything beyond the standard pv/battery/meter // shape — temperatures, voltages, frequencies, MPPT currents, etc. unit is an // optional display unit (e.g. "°C", "Hz") used by the UI to group + label. +// Vendor kW/kWh are folded to W/Wh here so a pinned Lua driver that still +// speaks kilo-units cannot store the wrong scale. func (h *HostEnv) emitMetric(name string, value float64, unit, register, title string) error { if math.IsNaN(value) || math.IsInf(value, 0) { return fmt.Errorf("emit_metric: %s is non-finite: %v", name, value) } + value, unit = units.CanonicalPowerEnergy(value, unit) if h.bufferPollMetric(pollMetric{name: name, value: value, unit: unit, register: register, title: title}) { return nil } 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/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..acefbc773 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,55 +434,28 @@ 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 { 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 { - 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: @@ -475,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) { @@ -508,7 +499,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,20 +515,17 @@ 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 } -// 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 { @@ -547,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/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/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..d4ae1b9ec 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.InitialSoC, + // so InitialSoC == 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 } @@ -765,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). // @@ -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/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..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, - 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}, @@ -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") } } @@ -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..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,30 +474,30 @@ 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.SoCPct, - 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.LoadpointSoCPctByID[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{ - lpID: a.LoadpointSoCPct, + d.LoadpointSoCTarget = map[string]float64{ + lpID: a.LoadpointSoC, } } return d, true @@ -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,13 +518,13 @@ 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 } 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..16ce90880 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.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,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.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, 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}, @@ -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/mpc/shadow_evaluator.go b/go/internal/mpc/shadow_evaluator.go index c743405db..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"` - ChampionVirtualSoCPct float64 `json:"champion_virtual_soc_pct"` - ChallengerVirtualSoCPct 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"` @@ -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 + 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 415203d9b..0b49ed071 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-0.55) > 1e-6 || math.Abs(score.ChallengerVirtualSoC-0.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-0.10) > 1e-6 || math.Abs(score.ChallengerVirtualSoC-0.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 != 0.43 || score.ChallengerVirtualSoC != 0.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 != 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/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/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 new file mode 100644 index 000000000..54ce42874 --- /dev/null +++ b/go/internal/units/consistency_test.go @@ -0,0 +1,255 @@ +package units_test + +import ( + "encoding/json" + "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 +// 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) + } +} + +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(mpc.SlotDirective{}), + reflect.TypeOf(forecast.Array{}), + } + 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 { + 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 new file mode 100644 index 000000000..05e455d83 --- /dev/null +++ b/go/internal/units/units.go @@ -0,0 +1,147 @@ +// 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 +} + +// CanonicalPowerEnergy is the emit_metric door for vendor kilo-units. +// kW/kWh become W/Wh. Other units pass through, including W and Wh +// already converted in a Lua driver. +func CanonicalPowerEnergy(value float64, unit string) (float64, string) { + switch unit { + case "kW": + return value * 1000.0, "W" + case "kWh": + return value * 1000.0, "Wh" + default: + return value, unit + } +} + +// 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 +} + +// 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 +} + +// 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..e77525f9a --- /dev/null +++ b/go/internal/units/units_test.go @@ -0,0 +1,118 @@ +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 TestCanonicalPowerEnergy(t *testing.T) { + w, u := CanonicalPowerEnergy(2.5, "kW") + if w != 2500 || u != "W" { + t.Fatalf("2.5 kW → %v %q, want 2500 W", w, u) + } + wh, u := CanonicalPowerEnergy(12.5, "kWh") + if wh != 12500 || u != "Wh" { + t.Fatalf("12.5 kWh → %v %q, want 12500 Wh", wh, u) + } + w, u = CanonicalPowerEnergy(1500, "W") + if w != 1500 || u != "W" { + t.Fatalf("already watts: %v %q", w, u) + } + wh, u = CanonicalPowerEnergy(5399.9, "Wh") + if wh != 5399.9 || u != "Wh" { + t.Fatalf("already watt-hours: %v %q", wh, u) + } + c, u := CanonicalPowerEnergy(22.6, "°C") + if c != 22.6 || u != "°C" { + t.Fatalf("other units pass through: %v %q", c, u) + } +} + +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 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") + } + 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..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.MinReserveSoCPct + env.MinReserveSoC = policy.MinReserveSoC env.VehicleCapacityWh = firstPositive(policy.VehicleCapacityWh, snap.CapacityWh) - env.DepartureTargetSoCPct = policy.DepartureTargetSoCPct + env.DepartureTargetSoC = 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..53947f62a 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..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; } @@ -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 }, ]; @@ -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 31579be24..130d45ccc 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,13 @@ 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). +function arrayKwpForScale(a) { + const ratedW = Number(a && a.rated_w); + if (ratedW > 0) return ratedW / 1000; + return 0; +} + // 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 +158,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 +375,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..054cfacb5 100644 --- a/web/diagnose.js +++ b/web/diagnose.js @@ -30,6 +30,23 @@ return u.formatPrice(minor, cur, u.unitFor(cur).scale === 1 ? 0 : 2); } + // Plan/diagnose JSON SoC is a 0–1 fraction. Older snapshots stored 0–100 + // as soc_pct / loadpoint_soc_pct. Multiply only when showing "%". + function socValue(sl) { + if (!sl) return null; + if (sl.soc != null) return sl.soc; + return sl.soc_pct; + } + function loadpointSocValue(sl) { + if (!sl) return null; + if (sl.loadpoint_soc != null) return sl.loadpoint_soc; + return sl.loadpoint_soc_pct; + } + function socPercent(frac) { + if (frac == null || !Number.isFinite(frac)) return null; + return frac > 1 ? frac : frac * 100; + } + function canvasColors() { return window.ftwThemeColors ? window.ftwThemeColors.palette() @@ -288,7 +305,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 || x.loadpoint_soc_pct); const lpBadges = lpActive ? [ 'EV in plan', params.loadpoint_surplus_only ? 'surplus only' : '', @@ -296,6 +313,7 @@ ? 'battery to EV blocked' : 'battery may cover EV' ].join('') : ''; + const startSoCPct = socPercent(params.initial_soc != null ? params.initial_soc : params.initial_soc_pct); const header = `
@@ -310,7 +328,7 @@
${escapeHtml(params.mode || '—')} - SoC start ${params.initial_soc_pct != null ? params.initial_soc_pct.toFixed(1) : '—'}% + SoC start ${startSoCPct == null ? '—' : startSoCPct.toFixed(1)}% ${params.capacity_wh ? (params.capacity_wh/1000).toFixed(1)+' kWh' : ''} ${lpBadges}
@@ -429,6 +447,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(socValue(sl)); + const lpSocPct = socPercent(loadpointSocValue(sl)); return ` ${sl.idx} ${fmtHHMM(sl.slot_start_ms)} @@ -439,8 +459,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 +593,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(socValue(s)) || 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..e87ebdf57 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,12 +320,18 @@ '
'; } - // ── 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) { if (!points || points.length < 2) return {}; var last = points[points.length - 1].v; + // Pre-upgrade samples under these metric names are kWh; new ones are Wh. + function asWh(v) { + if (last >= 1e6 && v < last / 100) return v * 1000; + return v; + } + last = asWh(last); var earliest = points[0].ts; var now = new Date(); var startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); @@ -333,8 +341,8 @@ var startYear = new Date(now.getFullYear(), 0, 1).getTime(); function delta(b) { if (earliest > b) return null; - var base = points[0].v; - for (var i = 0; i < points.length; i++) { if (points[i].ts <= b) base = points[i].v; else break; } + var base = asWh(points[0].v); + for (var i = 0; i < points.length; i++) { if (points[i].ts <= b) base = asWh(points[i].v); else break; } return Math.max(0, last - base); } return { today: delta(startToday), week: delta(startWeek), month: delta(startMonth), year: delta(startYear) }; @@ -342,7 +350,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/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 a2a2cbce0..40c961675 100644 --- a/web/loadpoints.js +++ b/web/loadpoints.js @@ -5,10 +5,10 @@ // 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_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. @@ -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} `; } @@ -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 || '')}` + ''; @@ -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, }), }) 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/); + }); +});