From 98849cdeeed5595bf5d1ac82e7afcdda1ce0f36d Mon Sep 17 00:00:00 2001 From: Aleksei Larkov Date: Wed, 23 Sep 2026 22:27:01 +0300 Subject: [PATCH 1/2] fix: nightly charging --- mpc/mpc.go | 264 +++++++++++++++++++++++++++++++++++-------- mpc/mpc_test.go | 295 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 489 insertions(+), 70 deletions(-) diff --git a/mpc/mpc.go b/mpc/mpc.go index 67d1ad1..29efda8 100644 --- a/mpc/mpc.go +++ b/mpc/mpc.go @@ -123,14 +123,29 @@ func NewController(config SystemConfig, horizon int, initialSOC float64) *Contro return c } -// Optimize finds the optimal control strategy using dynamic programming. -// It runs two optimizations: one with solar forecast and one without (grid-only), -// then splits BatteryCharge into BatteryChargeFromPV and BatteryChargeFromGrid. +// Optimize finds the optimal control strategy using dynamic programming, then splits +// the resulting charge into BatteryChargeFromPV and BatteryChargeFromGrid. // -// Grid charging is suppressed entirely when the forecasted solar surplus over the -// horizon is sufficient to charge the battery from its current SOC to full. This -// prevents unnecessary grid imports during temporary cloud cover when overall daily -// solar production is more than enough to meet the full charge requirement. +// The split is derived from the single solar-aware DP pass: whatever the PV surplus +// can cover is attributed to PV, and the remainder is grid charge. Deriving it this +// way is what keeps overnight grid charging honest — the optimizer sizes the night's +// imports knowing how much free PV is arriving in the morning, so it buys only what +// still leaves headroom for that PV rather than filling the battery at 03:00 and +// forcing the next day's generation to be exported at midday's low prices. +// +// Grid charging is additionally suppressed *within the daylight window* when the +// forecasted solar surplus is sufficient to charge the battery from its current SOC +// to full. This prevents unnecessary grid imports during temporary cloud cover when +// overall daily solar production is more than enough to meet the full charge +// requirement. +// +// That suppression deliberately does NOT extend to slots outside the daylight window. +// Before sunrise and after sunset there is no PV to wait for, so "today's sun will +// cover it" is not an argument against buying cheap energy: overnight +// charge-low/discharge-high arbitrage is profitable independently of how sunny the +// following day turns out to be. Applying the gate across the whole horizon left the +// battery idle through the cheapest hours of the night purely because the next +// afternoon looked bright. func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision { if len(forecast) == 0 { return nil @@ -140,14 +155,9 @@ func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision { // incentivised during this optimisation run – at most once per calendar week. needsBalancing := mpc.needsWeeklyBalancing(forecast) - // Run optimization with full solar forecast. + // Run optimization with the full solar forecast. decisionsWithSolar := mpc.optimizeWithForecast(forecast, true, needsBalancing) - // Run optimization without solar (grid-only scenario). - // Used to identify grid charging that is profitable regardless of solar, so - // that the battery still charges from the grid on genuinely overcast days. - decisionsWithoutSolar := mpc.optimizeWithForecast(forecast, false, needsBalancing) - n := min(len(decisionsWithSolar), len(forecast)) // ── Solar-sufficiency check ────────────────────────────────────────────── @@ -165,24 +175,52 @@ func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision { // Energy (kWh at the AC input) needed to charge from CurrentSOC to // BatteryMaxSOC, accounting for charging efficiency. energyNeededToFull := math.Max(0, - (mpc.Config.BatteryMaxSOC-mpc.CurrentSOC)*mpc.Config.BatteryCapacity/mpc.Config.BatteryEfficiency) + (mpc.Config.BatteryMaxSOC-mpc.CurrentSOC)*mpc.Config.BatteryCapacity/mpc.batteryEfficiency()) // Only count solar surplus up to the nearest sunset. Summing across a // multi-day horizon would let solar production from a future day mask an // inability to charge fully today, causing grid charging to be suppressed - // even though today's solar alone can't cover it. Sunset is identified as - // the first slot where forecasted solar returns to zero after having been - // positive (i.e. the sun has set); if that never happens within the - // horizon (e.g. no daylight forecast at all), the full horizon is used. + // even though today's solar alone can't cover it. + // + // Sunset cannot simply be "the first zero-solar slot after the sun came up": + // a passing cloud also drives the forecast to zero, and treating it as + // sunset would cut the day short — collapsing the surplus total and + // defeating the very transient-cloud case this heuristic exists to handle. + // Instead a sunset is a zero-solar run long enough to be actual night + // (nightGapSlots ≈ 3 hours); shorter gaps are cloud cover and are absorbed + // into the day. If no such run occurs within the horizon (e.g. the horizon + // ends mid-afternoon, or there is no daylight forecast at all) the full + // horizon is used. + // + // sunriseIdx is the first slot with any forecasted solar. Together the two + // indices delimit the daylight window [sunriseIdx, sunsetIdx) in which the + // solar-sufficiency argument actually applies. + nightGapSlots := int(math.Ceil(3.0 / timeSlotDuration)) + if nightGapSlots < 1 { + nightGapSlots = 1 + } + + sunriseIdx := n sunsetIdx := n sawSun := false + zeroRunStart := -1 for i, slot := range forecast[:n] { if slot.SolarForecast > 0 { + if !sawSun { + sunriseIdx = i + } sawSun = true + zeroRunStart = -1 + continue + } + if !sawSun { continue } - if sawSun { - sunsetIdx = i + if zeroRunStart < 0 { + zeroRunStart = i + } + if i-zeroRunStart+1 >= nightGapSlots { + sunsetIdx = zeroRunStart break } } @@ -198,19 +236,17 @@ func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision { // avoid pulling from the grid during transient cloud cover. solarSufficient := totalSolarSurplus >= energyNeededToFull - // ── Combine results ────────────────────────────────────────────────────── - // The solar-scenario decision accounts for the full charge rate (PV surplus - // first, topped up by grid when profitable). We derive the PV portion as - // whatever charge the solar surplus can cover, and the grid portion from - // the without-solar scenario (unless solar is sufficient for the day). + // ── Split the optimized charge into PV and grid portions ──────────────── + // The DP decision accounts for the full charge rate; we attribute whatever + // the PV surplus can cover to PV and the remainder to the grid. // // Splitting can *reduce* the charge that will actually happen versus what - // the with-solar DP scenario assumed (e.g. grid top-up gets suppressed - // entirely when solarSufficient is true). Everything that scenario derived - // from its charge amount — GridImport/GridExport, battery preheating, the - // SOC trajectory, and Profit — is therefore recomputed below from the - // actual (possibly lower) charge so the returned decisions stay internally - // consistent with what will really be executed. + // the DP assumed (grid top-up gets suppressed when solarSufficient is true + // inside the daylight window). Everything the DP derived from its charge + // amount — GridImport/GridExport, battery preheating, the SOC trajectory, + // and Profit — is therefore recomputed below from the actual (possibly + // lower) charge so the returned decisions stay internally consistent with + // what will really be executed. finalDecisions := make([]ControlDecision, n) runningSOC := mpc.CurrentSOC for i, slot := range forecast[:n] { @@ -225,20 +261,48 @@ func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision { // (capped at the total charge being applied). pvPortion := math.Min(pvSurplus, totalCharge) - // Grid portion: use the without-solar scenario's recommendation, but - // suppress it entirely when daily solar production is sufficient to - // charge the battery in full. When allowed, cap so that - // pvPortion + gridPortion never exceeds the hardware-rated maximum - // charge power (exceeding it would cause the inverter to reject the - // register write with an illegal-data error). + // Grid portion: the part of the DP's charge that PV cannot cover, + // suppressed when daily solar production is sufficient to charge the + // battery in full *and* this slot lies inside the daylight window. + // Outside that window (before sunrise / after sunset) there is no PV + // on the way, so cheap-hour grid charging stays available and normal + // overnight arbitrage is preserved. + // + // Because totalCharge comes from the solar-aware DP, the grid portion + // is already sized in the knowledge of the coming day's generation: it + // buys only what still leaves room for that PV. + inDaylightWindow := i >= sunriseIdx && i < sunsetIdx gridPortion := 0.0 - if !solarSufficient { - gridPortion = math.Min(decisionsWithoutSolar[i].batteryCharge, mpc.Config.BatteryMaxCharge-pvPortion) - gridPortion = math.Max(0, gridPortion) + if !solarSufficient || !inDaylightWindow { + gridPortion = math.Max(0, totalCharge-pvPortion) } actualCharge := pvPortion + gridPortion + // Clamp charge and discharge to what the battery can actually accept or + // deliver from runningSOC. The DP's SOC trajectory differs from the + // reconciled one computed here whenever the grid top-up was suppressed + // above. Without this clamp calculateNewSOC silently saturates at the + // SOC limits while the grid import/export below is still sized for the + // unclamped power — producing plans that import into a full battery or + // export energy the battery does not hold (observed in production as a + // 20 kW discharge scheduled from a battery at 0.2% SOC). + discharge := finalDecisions[i].BatteryDischarge + + if maxCharge := mpc.maxChargePower(runningSOC, timeSlotDuration); actualCharge > maxCharge { + // Give up the grid top-up first: PV surplus is free and would + // otherwise have to be curtailed or exported, whereas grid charge + // is purely optional and costs money. + pvPortion = math.Min(pvPortion, maxCharge) + gridPortion = math.Max(0, maxCharge-pvPortion) + actualCharge = pvPortion + gridPortion + } + + if maxDischarge := mpc.maxDischargePower(runningSOC, timeSlotDuration); discharge > maxDischarge { + discharge = math.Max(0, maxDischarge) + finalDecisions[i].BatteryDischarge = discharge + } + finalDecisions[i].BatteryChargeFromPV = pvPortion finalDecisions[i].BatteryChargeFromGrid = gridPortion @@ -261,10 +325,10 @@ func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision { // included a grid top-up; when that top-up is suppressed above, the // import/export figures must be recalculated — otherwise the // decision would report a grid import that funds a charge which no - // longer happens. - discharge := finalDecisions[i].BatteryDischarge - netSupply := slot.SolarForecast + discharge*mpc.Config.BatteryEfficiency - netLoad := slot.LoadForecast + actualCharge/mpc.Config.BatteryEfficiency + extraLoad + // longer happens. `discharge` is the clamped value from above. + netSupply := slot.SolarForecast + discharge*mpc.batteryEfficiency() + netLoad := slot.LoadForecast + actualCharge/mpc.batteryEfficiency() + extraLoad + balance := netSupply - netLoad if balance > 0 { @@ -577,8 +641,8 @@ func (mpc *Controller) generateFeasibleDecisions(currentSOC float64, currentBatt extraLoad = preHeatPower } - netLoad := slot.LoadForecast + action.charge/mpc.Config.BatteryEfficiency + extraLoad - netSupply := netSolar + action.discharge*mpc.Config.BatteryEfficiency + netLoad := slot.LoadForecast + action.charge/mpc.batteryEfficiency() + extraLoad + netSupply := netSolar + action.discharge*mpc.batteryEfficiency() balance := netSupply - netLoad @@ -605,12 +669,12 @@ func (mpc *Controller) generateFeasibleDecisions(currentSOC float64, currentBatt // in netLoad), so `balance` is the PV power that still has nowhere // to go. Try to increase charging to absorb it — but only up to the // hardware maximum and SOC limits. - extraCharge := math.Min(balance*mpc.Config.BatteryEfficiency, mpc.Config.BatteryMaxCharge-action.charge) + extraCharge := math.Min(balance*mpc.batteryEfficiency(), mpc.Config.BatteryMaxCharge-action.charge) if extraCharge > 0 && mpc.canCharge(currentSOC, action.charge+extraCharge) { // Absorb as much surplus as possible into the battery. dec.batteryCharge += extraCharge // Recalculate balance after the extra charging. - balance -= extraCharge / mpc.Config.BatteryEfficiency + balance -= extraCharge / mpc.batteryEfficiency() } if balance > 0.001 { @@ -704,6 +768,13 @@ func (mpc *Controller) canCharge(soc, charge float64) bool { // multiply by time slot duration AND efficiency so that both functions agree on // how much the SOC actually rises. chargeEnergy := charge * timeSlotDuration * efficiency + + // With no usable capacity nothing can be stored; charging is never feasible. + // Guarding here keeps the ±Inf/NaN out of the comparison below. + if mpc.Config.BatteryCapacity <= 0 { + return false + } + newSOC := soc + (chargeEnergy / mpc.Config.BatteryCapacity) return newSOC <= mpc.Config.BatteryMaxSOC } @@ -715,12 +786,70 @@ func (mpc *Controller) canDischarge(soc, discharge float64) bool { timeSlotDuration = 1.0 } + // With no usable capacity there is nothing stored to discharge. + if mpc.Config.BatteryCapacity <= 0 { + return false + } + // Convert power (kW) to energy (kWh) by multiplying by time slot duration dischargeEnergy := discharge * timeSlotDuration newSOC := soc - (dischargeEnergy / mpc.Config.BatteryCapacity) return newSOC >= mpc.Config.BatteryMinSOC } +// batteryEfficiency returns the configured round-trip efficiency, defaulting to 1.0 +// (lossless) when it is unset or non-positive. +// +// Several power-balance expressions divide by this value. With an unpopulated config +// it is zero, and `0 / 0` is NaN — which silently propagated into GridImport, Profit +// and ultimately the SOC index, where it surfaced as an out-of-range panic. Defaulting +// mirrors how TimeSlotDuration is already handled throughout this file and keeps every +// returned figure finite. +func (mpc *Controller) batteryEfficiency() float64 { + if mpc.Config.BatteryEfficiency <= 0 { + return 1.0 + } + return mpc.Config.BatteryEfficiency +} + +// maxChargePower returns the largest charge power (kW) that can be applied for one +// time slot from soc without exceeding BatteryMaxSOC. It is the exact inverse of the +// SOC update in calculateNewSOC, including the CV/balancing derate, so that clamping +// to this value never saturates. The result is also capped at the hardware limit. +func (mpc *Controller) maxChargePower(soc, timeSlotDuration float64) float64 { + efficiency := 1.0 + if mpc.Config.BatteryBalancingSOCThreshold > 0 && + mpc.Config.BatteryBalancingEfficiencyFactor > 0 && + soc >= mpc.Config.BatteryBalancingSOCThreshold { + efficiency = mpc.Config.BatteryBalancingEfficiencyFactor + } + + headroom := mpc.Config.BatteryMaxSOC - soc + if headroom <= 0 { + return 0 + } + + return math.Min( + headroom*mpc.Config.BatteryCapacity/(efficiency*timeSlotDuration), + mpc.Config.BatteryMaxCharge, + ) +} + +// maxDischargePower returns the largest discharge power (kW) that can be sustained for +// one time slot from soc without dropping below BatteryMinSOC, capped at the hardware +// limit. Mirrors the SOC update in calculateNewSOC. +func (mpc *Controller) maxDischargePower(soc, timeSlotDuration float64) float64 { + available := soc - mpc.Config.BatteryMinSOC + if available <= 0 { + return 0 + } + + return math.Min( + available*mpc.Config.BatteryCapacity/timeSlotDuration, + mpc.Config.BatteryMaxDischarge, + ) +} + func (mpc *Controller) calculateNewSOC(currentSOC, charge, discharge float64) float64 { // Get time slot duration (default to 1 hour if not specified for backward compatibility) timeSlotDuration := mpc.Config.TimeSlotDuration @@ -754,13 +883,50 @@ func (mpc *Controller) calculateNewSOC(currentSOC, charge, discharge float64) fl // Convert power (kW) to energy (kWh) by multiplying by time slot duration chargeEnergy := charge * timeSlotDuration * efficiency dischargeEnergy := discharge * timeSlotDuration + + // A non-positive capacity means no energy can move in or out of the cells. + // Dividing by it would yield NaN (0/0) or ±Inf and poison the SOC for the + // rest of the horizon — and ultimately the DP index derived from it. + if mpc.Config.BatteryCapacity <= 0 { + return math.Max(mpc.Config.BatteryMinSOC, math.Min(mpc.Config.BatteryMaxSOC, currentSOC)) + } + socChange := (chargeEnergy - dischargeEnergy) / mpc.Config.BatteryCapacity newSOC := currentSOC + socChange return math.Max(mpc.Config.BatteryMinSOC, math.Min(mpc.Config.BatteryMaxSOC, newSOC)) } +// socToIndex maps an SOC value onto its DP table row. +// +// It must never return a value that cannot be used to index the table. Two +// degenerate configurations previously produced one: +// +// - BatteryMaxSOC == BatteryMinSOC makes socStep zero, so the division +// yields NaN (0/0) or ±Inf. Converting NaN to int is implementation +// defined and on amd64/arm64 gives the minimum int64, which panicked as +// soon as it was used as an index. +// - A non-finite soc (e.g. NaN propagated from a zero BatteryCapacity) +// produces the same result. +// +// A zero-width SOC range has exactly one reachable level, so index 0 is the +// correct answer there. A NaN SOC is not a real state and is reported as -1, +// which callers already treat as out of range and skip. func (mpc *Controller) socToIndex(soc float64, socStep float64) int { - return int(math.Floor((soc - mpc.Config.BatteryMinSOC) / socStep)) + if !(socStep > 0) { + return 0 + } + + idx := math.Floor((soc - mpc.Config.BatteryMinSOC) / socStep) + switch { + case math.IsNaN(idx): + return -1 + case math.IsInf(idx, -1): + return -1 + case math.IsInf(idx, 1): + return math.MaxInt + } + + return int(idx) } func (mpc *Controller) indexToSOC(index int, socStep float64) float64 { diff --git a/mpc/mpc_test.go b/mpc/mpc_test.go index 26f9101..7c37fca 100644 --- a/mpc/mpc_test.go +++ b/mpc/mpc_test.go @@ -1559,9 +1559,10 @@ func TestNegativeExportPriceTransition(t *testing.T) { } // TestOptimize_SolarSufficientSuppressesGridCharging verifies that BatteryChargeFromGrid -// is zero for every slot when the total forecasted solar surplus over the horizon is +// is zero throughout the daylight window when the total forecasted solar surplus is // enough to charge the battery from its current SOC to BatteryMaxSOC. Temporary cloud -// cover (a slot with SolarForecast = 0 inside a sunny day) must NOT trigger grid imports. +// cover (a slot with SolarForecast = 0 *inside* a sunny day) must NOT trigger grid +// imports, even when that slot happens to be the cheapest of the day. func TestOptimize_SolarSufficientSuppressesGridCharging(t *testing.T) { config := SystemConfig{ BatteryCapacity: 10.0, // kWh @@ -1579,16 +1580,19 @@ func TestOptimize_SolarSufficientSuppressesGridCharging(t *testing.T) { // Battery starts at 10% SOC and needs to reach 90%. // energyNeededToFull = (0.9 - 0.1) * 10 / 0.9 ≈ 8.89 kWh // - // Solar surplus per slot (solar - load, floored at 0) × 1 h: - // slot 0: cloud → max(0, 0 - 0.5) * 1 = 0 kWh - // slot 1: sunny → max(0, 5 - 0.5) * 1 = 4.5 kWh + // The horizon opens at sunrise, so the daylight window is [0, 4): + // slot 0: sunny → max(0, 5 - 0.5) * 1 = 4.5 kWh + // slot 1: CLOUD → max(0, 0 - 0.5) * 1 = 0 kWh (cheapest slot of the day) // slot 2: sunny → max(0, 5 - 0.5) * 1 = 4.5 kWh // slot 3: sunny → max(0, 5 - 0.5) * 1 = 4.5 kWh - // slot 4: cloud → max(0, 0 - 0.5) * 1 = 0 kWh + // slot 4: after sunset — outside the window, governed by arbitrage // Total solar surplus = 13.5 kWh ≥ 8.89 kWh → solarSufficient = true + // + // Slot 1 is deliberately the cheapest import price in the horizon: without + // the gate the optimizer would grid-charge straight through the cloud. forecast := []TimeSlot{ - {Hour: 0, Timestamp: 1704326400, ImportPrice: 0.05, ExportPrice: 0.02, SolarForecast: 0.0, LoadForecast: 0.5}, - {Hour: 1, Timestamp: 1704330000, ImportPrice: 0.10, ExportPrice: 0.05, SolarForecast: 5.0, LoadForecast: 0.5}, + {Hour: 0, Timestamp: 1704326400, ImportPrice: 0.10, ExportPrice: 0.05, SolarForecast: 5.0, LoadForecast: 0.5}, + {Hour: 1, Timestamp: 1704330000, ImportPrice: 0.05, ExportPrice: 0.02, SolarForecast: 0.0, LoadForecast: 0.5}, {Hour: 2, Timestamp: 1704333600, ImportPrice: 0.10, ExportPrice: 0.05, SolarForecast: 5.0, LoadForecast: 0.5}, {Hour: 3, Timestamp: 1704337200, ImportPrice: 0.10, ExportPrice: 0.05, SolarForecast: 5.0, LoadForecast: 0.5}, {Hour: 4, Timestamp: 1704340800, ImportPrice: 0.30, ExportPrice: 0.15, SolarForecast: 0.0, LoadForecast: 0.5}, @@ -1601,9 +1605,11 @@ func TestOptimize_SolarSufficientSuppressesGridCharging(t *testing.T) { t.Fatalf("expected %d decisions, got %d", len(forecast), len(decisions)) } + // Daylight window is [0, 4) — sunset is slot 4. + const daylightEnd = 4 for i, d := range decisions { - if d.BatteryChargeFromGrid > 1e-9 { - t.Errorf("slot %d: BatteryChargeFromGrid = %.4f kW, want 0 (solar sufficient)", + if i < daylightEnd && d.BatteryChargeFromGrid > 1e-9 { + t.Errorf("slot %d: BatteryChargeFromGrid = %.4f kW, want 0 (inside daylight window, solar sufficient)", i, d.BatteryChargeFromGrid) } t.Logf("slot %d: ChargeFromPV=%.3f kW, ChargeFromGrid=%.3f kW, SOC=%.3f, Solar=%.1f kW", @@ -1632,8 +1638,8 @@ func TestOptimize_DecisionsAreInternallyConsistentAfterSuppression(t *testing.T) } forecast := []TimeSlot{ - {Hour: 0, Timestamp: 1704326400, ImportPrice: 0.05, ExportPrice: 0.02, SolarForecast: 0.0, LoadForecast: 0.5}, - {Hour: 1, Timestamp: 1704330000, ImportPrice: 0.10, ExportPrice: 0.05, SolarForecast: 5.0, LoadForecast: 0.5}, + {Hour: 0, Timestamp: 1704326400, ImportPrice: 0.10, ExportPrice: 0.05, SolarForecast: 5.0, LoadForecast: 0.5}, + {Hour: 1, Timestamp: 1704330000, ImportPrice: 0.05, ExportPrice: 0.02, SolarForecast: 0.0, LoadForecast: 0.5}, {Hour: 2, Timestamp: 1704333600, ImportPrice: 0.10, ExportPrice: 0.05, SolarForecast: 5.0, LoadForecast: 0.5}, {Hour: 3, Timestamp: 1704337200, ImportPrice: 0.10, ExportPrice: 0.05, SolarForecast: 5.0, LoadForecast: 0.5}, {Hour: 4, Timestamp: 1704340800, ImportPrice: 0.30, ExportPrice: 0.15, SolarForecast: 0.0, LoadForecast: 0.5}, @@ -1673,18 +1679,18 @@ func TestOptimize_DecisionsAreInternallyConsistentAfterSuppression(t *testing.T) } } - // Slot 0 specifically mirrors the reported bug: no PV surplus (solar < load) - // and grid charging suppressed for the whole horizon (solar sufficient overall). + // Slot 1 specifically mirrors the reported bug: a cloud inside the sunny day + // with no PV surplus (solar < load) and grid charging suppressed by the gate. // The actual charge must be 0, and GridImport must reflect only the load // deficit — not a stale value sized for a charge that never happens. - if decisions[0].BatteryChargeFromPV != 0 || decisions[0].BatteryChargeFromGrid != 0 { - t.Fatalf("expected no charge in slot 0, got PV=%.4f kW Grid=%.4f kW", - decisions[0].BatteryChargeFromPV, decisions[0].BatteryChargeFromGrid) + if decisions[1].BatteryChargeFromPV != 0 || decisions[1].BatteryChargeFromGrid != 0 { + t.Fatalf("expected no charge in slot 1, got PV=%.4f kW Grid=%.4f kW", + decisions[1].BatteryChargeFromPV, decisions[1].BatteryChargeFromGrid) } - wantImport := forecast[0].LoadForecast - forecast[0].SolarForecast - if math.Abs(decisions[0].GridImport-wantImport) > 1e-9 { - t.Errorf("slot 0: GridImport = %.4f kW, want %.4f kW (load only, no stale charge-driven import)", - decisions[0].GridImport, wantImport) + wantImport := forecast[1].LoadForecast - forecast[1].SolarForecast + if decisions[1].BatteryDischarge == 0 && math.Abs(decisions[1].GridImport-wantImport) > 1e-9 { + t.Errorf("slot 1: GridImport = %.4f kW, want %.4f kW (load only, no stale charge-driven import)", + decisions[1].GridImport, wantImport) } } @@ -1742,3 +1748,250 @@ func TestOptimize_SolarInsufficientAllowsGridCharging(t *testing.T) { i, d.BatteryChargeFromPV, d.BatteryChargeFromGrid, d.BatterySOC, d.ImportPrice) } } + +// TestOptimize_OvernightArbitrageNotSuppressedBySunnyTomorrow is a regression test for +// the reported fault: the battery sat idle and empty all night, through the cheapest +// prices of the horizon, because the *next day's* forecast solar was enough to fill it. +// +// The solar-sufficiency heuristic used to be applied across the whole horizon, so a +// bright afternoon 10+ hours away suppressed grid charging during the pre-dawn price +// trough. Sunrise is hours off at that point, so PV is no argument against buying +// cheap: charge-low/discharge-high arbitrage stands on its own. +func TestOptimize_OvernightArbitrageNotSuppressedBySunnyTomorrow(t *testing.T) { + config := SystemConfig{ + BatteryCapacity: 10.0, // kWh + BatteryMaxCharge: 5.0, // kW + BatteryMaxDischarge: 5.0, // kW + BatteryMinSOC: 0.1, + BatteryMaxSOC: 0.9, + BatteryEfficiency: 0.9, + BatteryDegradationCost: 0.01, + MaxGridImport: 10.0, + MaxGridExport: 10.0, + TimeSlotDuration: 1.0, + } + + // Slots 0-5: night, no solar, cheap (0.05). Slots 6-11: sunny day, ample + // surplus (6 x 4.5 = 27 kWh, far above the ~8.9 kWh needed to fill the + // battery) so solarSufficient is true. Slots 12-14: evening peak, expensive + // import and a high export price to sell into. + forecast := []TimeSlot{} + ts := int64(1704326400) + for i := range 6 { // night + forecast = append(forecast, TimeSlot{ + Hour: i, Timestamp: ts + int64(i)*3600, + ImportPrice: 0.05, ExportPrice: 0.02, + SolarForecast: 0.0, LoadForecast: 0.5, + }) + } + for i := 6; i < 12; i++ { // sunny day + forecast = append(forecast, TimeSlot{ + Hour: i, Timestamp: ts + int64(i)*3600, + ImportPrice: 0.10, ExportPrice: 0.05, + SolarForecast: 5.0, LoadForecast: 0.5, + }) + } + for i := 12; i < 15; i++ { // evening peak + forecast = append(forecast, TimeSlot{ + Hour: i, Timestamp: ts + int64(i)*3600, + ImportPrice: 0.30, ExportPrice: 0.25, + SolarForecast: 0.0, LoadForecast: 0.5, + }) + } + + ctrl := NewController(config, len(forecast), 0.1) + decisions := ctrl.Optimize(forecast) + + if len(decisions) != len(forecast) { + t.Fatalf("expected %d decisions, got %d", len(forecast), len(decisions)) + } + + var nightGridCharge float64 + for i := range 6 { + nightGridCharge += decisions[i].BatteryChargeFromGrid + } + if nightGridCharge <= 0.01 { + t.Errorf("expected grid charging during the cheap pre-dawn slots, got %.4f kW total; "+ + "a sunny tomorrow must not suppress overnight arbitrage", nightGridCharge) + } + + // The daytime cloud-cover guarantee must still hold: no grid charging once + // the sun is up, because that day's PV alone covers a full charge. + for i := 6; i < 12; i++ { + if decisions[i].BatteryChargeFromGrid > 1e-9 { + t.Errorf("slot %d: BatteryChargeFromGrid = %.4f kW, want 0 (daylight, solar sufficient)", + i, decisions[i].BatteryChargeFromGrid) + } + } + + for i, d := range decisions { + t.Logf("slot %d: solar=%.1f imp=%.2f exp=%.2f | fromPV=%.3f fromGrid=%.3f dis=%.3f SOC=%.3f", + i, forecast[i].SolarForecast, forecast[i].ImportPrice, forecast[i].ExportPrice, + d.BatteryChargeFromPV, d.BatteryChargeFromGrid, d.BatteryDischarge, d.BatterySOC) + } +} + +// TestOptimize_DischargeNeverExceedsStoredEnergy guards the second half of the same +// fault: BatteryDischarge is carried over from a DP pass whose SOC trajectory assumed +// a grid top-up that later got suppressed. calculateNewSOC silently saturates at +// BatteryMinSOC, so without an explicit clamp the plan exported energy the battery +// did not hold (observed live: 20 kW discharge from a battery at 0.2% SOC). +func TestOptimize_DischargeNeverExceedsStoredEnergy(t *testing.T) { + config := SystemConfig{ + BatteryCapacity: 41.28, + BatteryMaxCharge: 20.0, + BatteryMaxDischarge: 20.0, + BatteryMinSOC: 0.0, + BatteryMaxSOC: 1.0, + BatteryEfficiency: 0.92, + BatteryDegradationCost: 0.01, + MaxGridImport: 30.0, + MaxGridExport: 30.0, + TimeSlotDuration: 0.25, + } + + // Mirrors the live forecast shape: an empty battery overnight, a bright day + // that more than fills it, and expensive evening slots to discharge into. + forecast := []TimeSlot{} + ts := int64(1790190000) + add := func(n int, solar, imp, exp float64) { + for range n { + forecast = append(forecast, TimeSlot{ + Hour: len(forecast), Timestamp: ts + int64(len(forecast))*900, + ImportPrice: imp, ExportPrice: exp, + SolarForecast: solar, LoadForecast: 0.0, + }) + } + } + add(38, 0.0, 0.16, 0.10) // night + add(48, 9.0, 0.19, 0.13) // sunny day + add(10, 0.0, 0.30, 0.24) // evening peak + add(12, 0.0, 0.25, 0.19) // night again + + ctrl := NewController(config, len(forecast), 0.001) + decisions := ctrl.Optimize(forecast) + + runningSOC := 0.001 + for i, d := range decisions { + available := (runningSOC - config.BatteryMinSOC) * config.BatteryCapacity / config.TimeSlotDuration + if d.BatteryDischarge > available+1e-6 { + t.Errorf("slot %d: BatteryDischarge = %.3f kW exceeds %.3f kW available at SOC %.4f", + i, d.BatteryDischarge, available, runningSOC) + } + + totalCharge := d.BatteryChargeFromPV + d.BatteryChargeFromGrid + headroom := (config.BatteryMaxSOC - runningSOC) * config.BatteryCapacity / config.TimeSlotDuration + if totalCharge > headroom+1e-6 { + t.Errorf("slot %d: total charge = %.3f kW exceeds %.3f kW headroom at SOC %.4f", + i, totalCharge, headroom, runningSOC) + } + + runningSOC = ctrl.calculateNewSOC(runningSOC, totalCharge, d.BatteryDischarge) + if math.Abs(d.BatterySOC-runningSOC) > 1e-6 { + t.Errorf("slot %d: BatterySOC %.6f != forward-simulated %.6f", i, d.BatterySOC, runningSOC) + } + } +} + +// TestOptimize_DegenerateBatteryConfigDoesNotPanic is a regression test for a panic +// reachable from any caller that ran the optimizer with an unpopulated battery +// config (observed via scheduler.RunMPCOptimize): +// +// panic: runtime error: index out of range [-9223372036854775808] +// +// BatteryMaxSOC == BatteryMinSOC makes socStep zero, so socToIndex computed 0/0 = NaN. +// Converting NaN to int yields the minimum int64 on amd64/arm64, which panicked the +// moment it was used to index the DP table. A zero BatteryCapacity reached the same +// state by a different route, poisoning the SOC trajectory with NaN. +// +// Such a config describes a system with no usable battery, so the optimizer must +// return a well-formed idle plan rather than crash the process. +func TestOptimize_DegenerateBatteryConfigDoesNotPanic(t *testing.T) { + forecast := []TimeSlot{ + {Hour: 0, Timestamp: 1704326400, ImportPrice: 0.10, ExportPrice: 0.05, SolarForecast: 0.0, LoadForecast: 1.0}, + {Hour: 1, Timestamp: 1704330000, ImportPrice: 0.30, ExportPrice: 0.15, SolarForecast: 2.0, LoadForecast: 1.0}, + {Hour: 2, Timestamp: 1704333600, ImportPrice: 0.20, ExportPrice: 0.10, SolarForecast: 0.0, LoadForecast: 1.0}, + } + + cases := []struct { + name string + config SystemConfig + }{ + { + // Exactly what scheduler.RunMPCOptimize built from a Config that + // set no battery fields at all. + name: "all battery fields zero", + config: SystemConfig{MaxGridImport: 10, MaxGridExport: 10, TimeSlotDuration: 0.25}, + }, + { + name: "zero-width SOC range", + config: SystemConfig{ + BatteryCapacity: 10, BatteryMaxCharge: 5, BatteryMaxDischarge: 5, + BatteryMinSOC: 0.5, BatteryMaxSOC: 0.5, BatteryEfficiency: 0.9, + MaxGridImport: 10, MaxGridExport: 10, TimeSlotDuration: 1.0, + }, + }, + { + name: "zero capacity", + config: SystemConfig{ + BatteryCapacity: 0, BatteryMaxCharge: 5, BatteryMaxDischarge: 5, + BatteryMinSOC: 0.0, BatteryMaxSOC: 1.0, BatteryEfficiency: 0.9, + MaxGridImport: 10, MaxGridExport: 10, TimeSlotDuration: 1.0, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctrl := NewController(tc.config, len(forecast), tc.config.BatteryMinSOC) + decisions := ctrl.Optimize(forecast) // must not panic + + if len(decisions) != len(forecast) { + t.Fatalf("expected %d decisions, got %d", len(forecast), len(decisions)) + } + + for i, d := range decisions { + // No battery movement is possible with any of these configs. + if d.BatteryChargeFromPV != 0 || d.BatteryChargeFromGrid != 0 || d.BatteryDischarge != 0 { + t.Errorf("slot %d: expected an idle battery, got fromPV=%.4f fromGrid=%.4f dis=%.4f", + i, d.BatteryChargeFromPV, d.BatteryChargeFromGrid, d.BatteryDischarge) + } + // Every reported figure must be a real number. + for name, v := range map[string]float64{ + "BatterySOC": d.BatterySOC, "GridImport": d.GridImport, + "GridExport": d.GridExport, "Profit": d.Profit, + } { + if math.IsNaN(v) || math.IsInf(v, 0) { + t.Errorf("slot %d: %s is non-finite (%v)", i, name, v) + } + } + } + }) + } +} + +// TestSOCToIndex_DegenerateInputs pins the contract socToIndex must satisfy so that +// its result is always safe to use as a DP table index. +func TestSOCToIndex_DegenerateInputs(t *testing.T) { + ctrl := NewController(SystemConfig{BatteryMinSOC: 0.0, BatteryMaxSOC: 1.0}, 1, 0.5) + + if got := ctrl.socToIndex(0.5, 0); got != 0 { + t.Errorf("socToIndex with zero socStep = %d, want 0 (single reachable level)", got) + } + if got := ctrl.socToIndex(0.5, -0.1); got != 0 { + t.Errorf("socToIndex with negative socStep = %d, want 0", got) + } + if got := ctrl.socToIndex(math.NaN(), 0.002); got != -1 { + t.Errorf("socToIndex with NaN soc = %d, want -1 (skipped as out of range)", got) + } + if got := ctrl.socToIndex(math.Inf(-1), 0.002); got != -1 { + t.Errorf("socToIndex with -Inf soc = %d, want -1", got) + } + if got := ctrl.socToIndex(math.Inf(1), 0.002); got != math.MaxInt { + t.Errorf("socToIndex with +Inf soc = %d, want MaxInt (skipped as out of range)", got) + } + // Sanity: normal inputs still map as before. + if got := ctrl.socToIndex(0.5, 0.002); got != 250 { + t.Errorf("socToIndex(0.5, 0.002) = %d, want 250", got) + } +} From b5b6613d1466539ed03e3108bb31b106262b7580 Mon Sep 17 00:00:00 2001 From: Aleksei Larkov Date: Wed, 23 Sep 2026 22:56:19 +0300 Subject: [PATCH 2/2] fix: ci fixes --- data-service/main.go | 18 ++++++++++++++---- scheduler/mpc_test.go | 2 +- scheduler/pricing_test.go | 35 ++++++++++++++++++++++++++++++++--- scheduler/server.go | 15 ++++++++++++++- 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/data-service/main.go b/data-service/main.go index 6f52e6b..9127a0e 100644 --- a/data-service/main.go +++ b/data-service/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "os/signal" + "strconv" "sync" "syscall" "time" @@ -16,16 +17,25 @@ import ( "github.com/devskill-org/ems/mpc" ) +// defaultPort is the port used when PORT is not set in the environment. +const defaultPort = 8081 + var ( decisions []mpc.ControlDecision decisionsMu sync.RWMutex ) func main() { - port := os.Getenv("PORT") - if port == "" { - port = "8081" + // Validate the port from the environment to avoid propagating untrusted input. + portNum := defaultPort + if raw := os.Getenv("PORT"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 1 || parsed > 65535 { + log.Fatalf("invalid PORT value") + } + portNum = parsed } + port := strconv.Itoa(portNum) mux := http.NewServeMux() mux.HandleFunc("/mpc/save", handleMPCSave) @@ -38,7 +48,7 @@ func main() { } go func() { - log.Printf("data-service HTTP server listening on :%s", port) + log.Printf("data-service HTTP server listening on :%d", portNum) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("HTTP server error: %v", err) } diff --git a/scheduler/mpc_test.go b/scheduler/mpc_test.go index e232a38..def6af6 100644 --- a/scheduler/mpc_test.go +++ b/scheduler/mpc_test.go @@ -657,7 +657,7 @@ func TestGetSolarForecast_NilWeatherForecast_NilOpenMeteo(t *testing.T) { // Point the scheduler at a fake Open-Meteo server that always returns 503 so // the fetch fails deterministically regardless of internet connectivity. - fakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "service unavailable", http.StatusServiceUnavailable) })) defer fakeServer.Close() diff --git a/scheduler/pricing_test.go b/scheduler/pricing_test.go index 7231269..61300a6 100644 --- a/scheduler/pricing_test.go +++ b/scheduler/pricing_test.go @@ -12,6 +12,10 @@ import ( "strings" "testing" "time" + + "github.com/devskill-org/ems/meteo" + "github.com/devskill-org/ems/mpc" + "github.com/devskill-org/ems/openmeteo" ) // TestGetCurrentPrice_UsesConfiguredTimezone validates that getCurrentPrice uses the configured timezone @@ -210,6 +214,13 @@ func TestStoreMarketDataXML_RefreshesMPCAndNextDayPrices(t *testing.T) { logger := log.New(os.Stdout, "[TEST] ", log.LstdFlags) scheduler := NewMinerScheduler(config, logger) + // Pre-seed the weather and solar irradiance caches so buildMPCForecast never + // reaches out to MET Norway or Open-Meteo. Without this the asynchronous MPC + // run started by StoreMarketDataXML blocks on real network I/O, which makes + // the test both non-hermetic and dependent on how fast those APIs respond. + scheduler.weatherCache.Set(&meteo.METJSONForecast{}) + scheduler.solarForecastCache.Set(&openmeteo.SolarForecast{}) + ctx := context.Background() // The XML cache is keyed on the current date in the configured location @@ -221,8 +232,6 @@ func TestStoreMarketDataXML_RefreshesMPCAndNextDayPrices(t *testing.T) { t.Fatalf("StoreMarketDataXML failed: %v", err) } - time.Sleep(500 * time.Millisecond) - marketData, err := scheduler.GetMarketData(ctx) if err != nil { t.Fatalf("GetMarketData failed: %v", err) @@ -238,12 +247,32 @@ func TestStoreMarketDataXML_RefreshesMPCAndNextDayPrices(t *testing.T) { t.Fatal("generated market data does not cover the current time") } - decisions := scheduler.GetMPCDecisions() + // StoreMarketDataXML refreshes the MPC asynchronously, so poll for the + // result instead of sleeping for a fixed duration. + decisions := waitForMPCDecisions(t, scheduler, 10*time.Second) if len(decisions) == 0 { t.Errorf("Expected MPC decisions to be generated after uploading XML, got 0") } } +// waitForMPCDecisions polls the scheduler until it reports at least one MPC +// decision or the timeout elapses, returning whatever it saw last. +func waitForMPCDecisions(t *testing.T, s *MinerScheduler, timeout time.Duration) []mpc.ControlDecision { + t.Helper() + + deadline := time.Now().Add(timeout) + for { + decisions := s.GetMPCDecisions() + if len(decisions) > 0 { + return decisions + } + if time.Now().After(deadline) { + return decisions + } + time.Sleep(20 * time.Millisecond) + } +} + // dayAheadPricesXML builds a minimal but valid ENTSO-E A44 day-ahead price // document covering [start, start+duration) at 15-minute resolution. // diff --git a/scheduler/server.go b/scheduler/server.go index e01661c..06699b1 100644 --- a/scheduler/server.go +++ b/scheduler/server.go @@ -752,7 +752,8 @@ func (hs *WebServer) marketDataUploadHandler(w http.ResponseWriter, r *http.Requ // Limit request body to 10 MB to prevent memory exhaustion. r.Body = http.MaxBytesReader(w, r.Body, 10<<20) - // Parse multipart form – limit body to 10 MB. + // Parse multipart form – the body size is already capped by MaxBytesReader above. + //nolint:gosec // G120: request body is bounded by http.MaxBytesReader (10 MB) if err := r.ParseMultipartForm(10 << 20); err != nil { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]string{"error": "failed to parse form: " + err.Error()}) @@ -863,6 +864,16 @@ func (hs *WebServer) marketDataDownloadHandler(w http.ResponseWriter, r *http.Re date = time.Now().In(location).Format("2006-01-02") } + // Validate the date so it is safe to embed in response headers. + parsedDate, err := time.Parse("2006-01-02", date) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid date format, expected YYYY-MM-DD"}) + return + } + date = parsedDate.Format("2006-01-02") + rawXML, ok := hs.scheduler.xmlCache.GetRaw(date) if !ok { w.Header().Set("Content-Type", "application/json") @@ -872,8 +883,10 @@ func (hs *WebServer) marketDataDownloadHandler(w http.ResponseWriter, r *http.Re } w.Header().Set("Content-Type", "application/xml") + w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"Energy_Prices_%s.xml\"", date)) w.WriteHeader(http.StatusOK) + //nolint:gosec // G705: served as a non-sniffable XML attachment, never rendered inline _, _ = w.Write(rawXML) }