You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
_calculate_terminal_value (core/bess/battery_system_manager.py:1860-1925) computes a cap on the value of battery energy left at the end of the optimization horizon as:
sell_cap = max(sell_prices in price_entries[optimization_period:]) * efficiency_discharge - cycle_cost_per_kwh
The sell_prices window used for max() spans the entire remaining horizon (battery_system_manager.py:2052-2059), including near-term periods the current plan has already committed to exporting through. When that near-term peak (e.g. today's still-upcoming best sell slot) is higher than the actual sell prices available near the terminal boundary (e.g. tomorrow evening), the cap becomes inflated relative to what's actually achievable at the horizon end. This makes holding charge look more valuable than exporting for the entire tail of the plan, even though that near-term peak price is not actually available to the terminal-boundary energy — it's being consumed by the plan's own near-term action.
18:40/18:45 run (b27 and b28, both show the same shape): tomorrow's plan (30 Jul) shows SOLAR_STORAGE to 95% SOC, then LOAD_SUPPORT only through the evening down to ~80% at midnight — no BATTERY_EXPORT at all, despite tomorrow's evening sell prices (~0.185-0.187 €/kWh) being only ~5-6 cents below tonight's (~0.243-0.184 €/kWh), which did get BATTERY_EXPORT.
Root cause traced: sell_cap for that run = 0.24261922 (today's still-upcoming 20:00 sell price) * 0.95 - 0.035 = 0.19549, which exceeds every one of tomorrow evening's own sell prices (~0.185-0.187), so the DP correctly (given that flawed cap) chose to hold rather than export through all of tomorrow evening.
By ~23:00 the same day, once today's 20:00 slot rolled out of the optimization window on a later re-run, max_sell_price fell to tomorrow's own peak (~0.187), the cap dropped to ~0.1427, tomorrow's own prices cleared it, and the plan flipped back to BATTERY_EXPORT with midnight target ~12% (Min SOC) — confirmed in Frank's follow-up comment (21:17:19Z, same thread).
Frank's min_profit hypothesis was investigated and ruled out — min_profit (core/bess/settings.py:32,100) is a dead/legacy setting, not read anywhere in the DP or cost functions.
Why this is a bug, not just DP sensitivity
Using an already-committed near-term price (one the plan's own schedule is about to consume) as the ceiling for a distant terminal value double-counts that price opportunity. It doesn't reflect what's actually achievable for energy still in the battery at the horizon boundary, so the terminal value estimate is inflated for reasons unrelated to the true economics of the far end of the horizon. This produces large, visible swings in the displayed/predicted plan (dozens of periods flipping between LOAD_SUPPORT and BATTERY_EXPORT) purely as re-optimization runs cross a near-term price slot's boundary — undermining user trust in the schedule even though the underlying DP math, given its (flawed) inputs, is internally consistent.
Hardware/economic impact — platform-dependent, not universally cosmetic
Near-term decision impact is unproven either way, not ruled out._run_dynamic_programming (core/bess/dp_battery_algorithm.py:932-987) is a plain backward Bellman recursion — value = reward + V[t+1][next_i] for every period down to 0, seeded at the (possibly inflated) terminal value V[horizon,:] (lines 897-903). There is no firewall preventing that bias from reaching V[0,:]; it's a direct recursive dependency. Whether it actually flips a near-term optimal action depends on the local reward gradient vs. the terminal-value gradient, which is data-dependent. Three conditions make a near-term flip plausible:
Short/truncated horizon (early in a run, or near midnight rollover) — fewer intervening periods dilute the bias.
Binding SOC constraints (tight headroom narrows the feasible action set to corner solutions driven by the terminal gradient).
Near-indifferent local price spreads (shadow price near threshold) — a modest terminal-value shift is enough to flip the argmax.
In the two bundles examined for #126, near-term decisions (today's period 80-87 export) happened not to be affected — an empirical observation from one case, not proof it can't happen with a shorter horizon or tighter SOC headroom.
Hardware write behavior is platform-specific, not universally a no-op:
Growatt TOU (growatt_min_controller.py:246): LOAD_SUPPORT maps to load_first, and _groups_to_tou_intervals() explicitly skips load_first groups when building the physical TOU table — no hardware write at all, defaults to the inverter's normal load-following behavior. Re-optimization runs every 15 minutes (backend/app.py:337-341), and only 9 TOU slots are ever programmed (growatt_min_controller.py:300-356), so a corrected BATTERY_EXPORT segment is written long before affected hours arrive in practice. Confirmed safe/no-op.
Real SolaX hardware (solax_controller.py:19-24,56,104-151): discharge_rate_is_load_following = False, so LOAD_SUPPORT maps to an active forced-discharge command (discharge_rate=100 via set_solax_active_power_control()), re-issued every period. A wrongly-suppressed export/charge decision here is not a no-op — it's a real command difference. (Real SolaX hardware is currently experimental/not real-world validated per project maturity notes, which lowers likelihood of reports but doesn't make the mechanism safe.)
So this is confirmed a plan-display stability bug with no hardware impact on Growatt TOU and solax_modbus-VPP, but the safety claim does not extend to real SolaX hardware, and near-term/actionable-decision impact should be treated as an open question, not ruled out, until tested under short-horizon/tight-SOC conditions.
Suggested fix direction
Exclude periods the plan has already committed to exporting through (or otherwise restrict the price window used for max_sell_price to periods contemporaneous with/after the terminal boundary) when computing the cap in _calculate_terminal_value, so a near-term price the schedule is already consuming can't inflate the value ceiling for a much later, unrelated pool of terminal energy.
Summary
_calculate_terminal_value(core/bess/battery_system_manager.py:1860-1925) computes a cap on the value of battery energy left at the end of the optimization horizon as:The
sell_priceswindow used formax()spans the entire remaining horizon (battery_system_manager.py:2052-2059), including near-term periods the current plan has already committed to exporting through. When that near-term peak (e.g. today's still-upcoming best sell slot) is higher than the actual sell prices available near the terminal boundary (e.g. tomorrow evening), the cap becomes inflated relative to what's actually achievable at the horizon end. This makes holding charge look more valuable than exporting for the entire tail of the plan, even though that near-term peak price is not actually available to the terminal-boundary energy — it's being consumed by the plan's own near-term action.Observed symptom (issue #126)
Reported by @Frank-Leysen in #126 (comment 2026-07-29T16:48:56Z):
SOLAR_STORAGEto 95% SOC, thenLOAD_SUPPORTonly through the evening down to ~80% at midnight — noBATTERY_EXPORTat all, despite tomorrow's evening sell prices (~0.185-0.187 €/kWh) being only ~5-6 cents below tonight's (~0.243-0.184 €/kWh), which did getBATTERY_EXPORT.sell_capfor that run =0.24261922 (today's still-upcoming 20:00 sell price) * 0.95 - 0.035 = 0.19549, which exceeds every one of tomorrow evening's own sell prices (~0.185-0.187), so the DP correctly (given that flawed cap) chose to hold rather than export through all of tomorrow evening.max_sell_pricefell to tomorrow's own peak (~0.187), the cap dropped to ~0.1427, tomorrow's own prices cleared it, and the plan flipped back toBATTERY_EXPORTwith midnight target ~12% (Min SOC) — confirmed in Frank's follow-up comment (21:17:19Z, same thread).Frank's
min_profithypothesis was investigated and ruled out —min_profit(core/bess/settings.py:32,100) is a dead/legacy setting, not read anywhere in the DP or cost functions.Why this is a bug, not just DP sensitivity
Using an already-committed near-term price (one the plan's own schedule is about to consume) as the ceiling for a distant terminal value double-counts that price opportunity. It doesn't reflect what's actually achievable for energy still in the battery at the horizon boundary, so the terminal value estimate is inflated for reasons unrelated to the true economics of the far end of the horizon. This produces large, visible swings in the displayed/predicted plan (dozens of periods flipping between
LOAD_SUPPORTandBATTERY_EXPORT) purely as re-optimization runs cross a near-term price slot's boundary — undermining user trust in the schedule even though the underlying DP math, given its (flawed) inputs, is internally consistent.Hardware/economic impact — platform-dependent, not universally cosmetic
Near-term decision impact is unproven either way, not ruled out.
_run_dynamic_programming(core/bess/dp_battery_algorithm.py:932-987) is a plain backward Bellman recursion —value = reward + V[t+1][next_i]for every period down to 0, seeded at the (possibly inflated) terminal valueV[horizon,:](lines 897-903). There is no firewall preventing that bias from reachingV[0,:]; it's a direct recursive dependency. Whether it actually flips a near-term optimal action depends on the local reward gradient vs. the terminal-value gradient, which is data-dependent. Three conditions make a near-term flip plausible:In the two bundles examined for #126, near-term decisions (today's period 80-87 export) happened not to be affected — an empirical observation from one case, not proof it can't happen with a shorter horizon or tighter SOC headroom.
Hardware write behavior is platform-specific, not universally a no-op:
growatt_min_controller.py:246):LOAD_SUPPORTmaps toload_first, and_groups_to_tou_intervals()explicitly skipsload_firstgroups when building the physical TOU table — no hardware write at all, defaults to the inverter's normal load-following behavior. Re-optimization runs every 15 minutes (backend/app.py:337-341), and only 9 TOU slots are ever programmed (growatt_min_controller.py:300-356), so a correctedBATTERY_EXPORTsegment is written long before affected hours arrive in practice. Confirmed safe/no-op.solax_modbus_growatt_controller.py:135-139, post-VPP mode: LOAD_SUPPORT forces fixed grid_first discharge rate instead of releasing control #413): also a genuine no-op (power=0, remote control disabled), matching Growatt TOU behavior.solax_controller.py:19-24,56,104-151):discharge_rate_is_load_following = False, soLOAD_SUPPORTmaps to an active forced-discharge command (discharge_rate=100viaset_solax_active_power_control()), re-issued every period. A wrongly-suppressed export/charge decision here is not a no-op — it's a real command difference. (Real SolaX hardware is currently experimental/not real-world validated per project maturity notes, which lowers likelihood of reports but doesn't make the mechanism safe.)So this is confirmed a plan-display stability bug with no hardware impact on Growatt TOU and solax_modbus-VPP, but the safety claim does not extend to real SolaX hardware, and near-term/actionable-decision impact should be treated as an open question, not ruled out, until tested under short-horizon/tight-SOC conditions.
Suggested fix direction
Exclude periods the plan has already committed to exporting through (or otherwise restrict the price window used for
max_sell_priceto periods contemporaneous with/after the terminal boundary) when computing the cap in_calculate_terminal_value, so a near-term price the schedule is already consuming can't inflate the value ceiling for a much later, unrelated pool of terminal energy.References
core/bess/battery_system_manager.py:1860-1925(_calculate_terminal_value)core/bess/battery_system_manager.py:2052-2059(price window slicing)core/bess/settings.py:32,100(min_profit, confirmed unused)