@@ -188,25 +188,9 @@ def _state_transition(
188188 the economically correct baseline: free solar energy is more valuable stored
189189 for later use than exported at the (typically lower) sell price.
190190 """
191- if power > POWER_TOLERANCE_KW : # STORE disposition (+ optional grid charge)
192- surplus = max (0.0 , solar_production - home_consumption )
193- room_throughput = (
194- battery_settings .max_soe_kwh - soe
195- ) / battery_settings .efficiency_charge
196- rate_throughput = battery_settings .max_charge_power_kw * dt
197- solar_to_battery = min (surplus , rate_throughput , room_throughput )
198- remaining_rate = max (
199- 0.0 , min (rate_throughput , room_throughput ) - solar_to_battery
200- )
201- if surplus > POWER_TOLERANCE_KW :
202- grid_to_battery = 0.0 # SOLAR_STORAGE: solar only, no grid top-up
203- else :
204- grid_to_battery = (
205- remaining_rate # GRID_CHARGING / battery_first: charge at max rate
206- )
207- charge_energy = (
208- solar_to_battery + grid_to_battery
209- ) * battery_settings .efficiency_charge
191+ if power > POWER_TOLERANCE_KW : # Charging
192+ # Energy stored = power throughput x charging efficiency
193+ charge_energy = power * dt * battery_settings .efficiency_charge
210194 next_soe = min (battery_settings .max_soe_kwh , soe + charge_energy )
211195
212196 elif power < - POWER_TOLERANCE_KW : # Discharging
@@ -216,8 +200,15 @@ def _state_transition(
216200 actual_discharge = min (discharge_energy , available_energy )
217201 next_soe = soe - actual_discharge
218202
219- else : # Hold / IDLE — EXPORT disposition: surplus is exported, battery holds
220- next_soe = soe
203+ else : # Hold / IDLE — passive solar charging
204+ excess_solar = max (0.0 , solar_production - home_consumption )
205+ # Clamp to inverter max charge rate (excess_solar is kWh, limit is kW * dt = kWh)
206+ max_passive_energy = battery_settings .max_charge_power_kw * dt
207+ clamped_solar = min (excess_solar , max_passive_energy )
208+ passive_charge = clamped_solar * battery_settings .efficiency_charge
209+ available_capacity = battery_settings .max_soe_kwh - soe
210+ actual_passive = min (passive_charge , available_capacity )
211+ next_soe = soe + actual_passive
221212
222213 # Ensure SOE stays within physical bounds
223214 next_soe = min (
@@ -291,36 +282,17 @@ def _compute_reward(
291282 # ============================================================================
292283 new_cost_basis = cost_basis
293284
294- if power > POWER_TOLERANCE_KW : # STORE disposition
295- surplus = max (0.0 , solar_production - home_consumption )
296- room_throughput = (
297- battery_settings .max_soe_kwh - soe
298- ) / battery_settings .efficiency_charge
299- rate_throughput = battery_settings .max_charge_power_kw * dt
300- solar_to_battery = min (surplus , rate_throughput , room_throughput )
301- remaining_rate = max (
302- 0.0 , min (rate_throughput , room_throughput ) - solar_to_battery
303- )
304- if surplus > POWER_TOLERANCE_KW :
305- grid_to_battery = 0.0 # SOLAR_STORAGE: solar only, no grid top-up
306- else :
307- grid_to_battery = (
308- remaining_rate # GRID_CHARGING / battery_first: charge at max rate
309- )
310-
311- energy_stored = (
312- solar_to_battery + grid_to_battery
313- ) * battery_settings .efficiency_charge
285+ if power > POWER_TOLERANCE_KW : # Active charging
286+ energy_stored = power * dt * battery_settings .efficiency_charge
314287 battery_wear_cost = energy_stored * battery_settings .cycle_cost_per_kwh
315288
316- # genuine excess solar (above rate/room) is exported; deliberate grid top-up imported
317- surplus_exported = max (0.0 , surplus - solar_to_battery )
318- grid_imported = grid_to_battery + max (0.0 , home_consumption - solar_production )
319- grid_exported = surplus_exported
320-
321- solar_opportunity_cost = solar_to_battery * current_sell_price
289+ solar_available = max (0 , solar_production - home_consumption )
290+ solar_to_battery = min (solar_available , power * dt )
291+ grid_to_battery = max (0 , (power * dt ) - solar_to_battery )
322292 grid_energy_cost = grid_to_battery * current_buy_price
293+ solar_opportunity_cost = solar_to_battery * current_sell_price
323294 total_new_cost = grid_energy_cost + solar_opportunity_cost + battery_wear_cost
295+
324296 if next_soe > battery_settings .min_soe_kwh :
325297 existing_cost = soe * cost_basis
326298 new_cost_basis = (existing_cost + total_new_cost ) / next_soe
@@ -329,13 +301,6 @@ def _compute_reward(
329301 (total_new_cost / energy_stored ) if energy_stored > 0 else cost_basis
330302 )
331303
332- total_cost = (
333- grid_imported * current_buy_price
334- - grid_exported * current_sell_price
335- + battery_wear_cost
336- )
337- return - total_cost , new_cost_basis
338-
339304 elif power < - POWER_TOLERANCE_KW : # Discharging
340305 battery_wear_cost = 0.0
341306
@@ -374,11 +339,22 @@ def _compute_reward(
374339 if effective_value_per_kwh_stored <= effective_cost_basis :
375340 return float ("-inf" ), cost_basis
376341
377- else : # IDLE — EXPORT disposition: battery holds, surplus exported
378- battery_wear_cost = 0.0
379- # battery_charged/battery_discharged already 0 from _idle_battery_flows;
380- # with next_soe == soe, _idle_battery_flows returns (0.0, 0.0), so the
381- # energy_balance below exports the full surplus and credits it.
342+ else : # IDLE — passive solar charging
343+ passive_energy_stored = next_soe - soe
344+ battery_wear_cost = passive_energy_stored * battery_settings .cycle_cost_per_kwh
345+ # Solar opportunity cost: stored solar could have been exported at sell price
346+ passive_throughput = (
347+ passive_energy_stored / battery_settings .efficiency_charge
348+ if passive_energy_stored > 0
349+ else 0.0
350+ )
351+ solar_opportunity_cost = passive_throughput * current_sell_price
352+
353+ if passive_energy_stored > 0 and next_soe > battery_settings .min_soe_kwh :
354+ existing_cost = soe * cost_basis
355+ new_cost_basis = (
356+ existing_cost + solar_opportunity_cost + battery_wear_cost
357+ ) / next_soe
382358
383359 # ============================================================================
384360 # REWARD CALCULATION
@@ -413,28 +389,13 @@ def _build_period_data(
413389 current_buy_price = buy_price [period ]
414390 current_sell_price = sell_price [period ]
415391
416- if power > POWER_TOLERANCE_KW : # STORE disposition (+ optional grid charge)
417- surplus = max (0.0 , solar_production - home_consumption )
418- room_throughput = (
419- battery_settings .max_soe_kwh - soe
420- ) / battery_settings .efficiency_charge
421- rate_throughput = battery_settings .max_charge_power_kw * dt
422- solar_to_battery = min (surplus , rate_throughput , room_throughput )
423- remaining_rate = max (
424- 0.0 , min (rate_throughput , room_throughput ) - solar_to_battery
425- )
426- if surplus > POWER_TOLERANCE_KW :
427- grid_to_battery = 0.0 # SOLAR_STORAGE: solar only, no grid top-up
428- else :
429- grid_to_battery = (
430- remaining_rate # GRID_CHARGING / battery_first: charge at max rate
431- )
432- battery_charged = solar_to_battery + grid_to_battery
392+ if power > POWER_TOLERANCE_KW : # Active charging
393+ battery_charged = power * dt
433394 battery_discharged = 0.0
434395 elif power < - POWER_TOLERANCE_KW : # Active discharging
435396 battery_charged = 0.0
436397 battery_discharged = abs (power ) * dt
437- else : # IDLE — EXPORT disposition: battery holds, surplus exported
398+ else : # IDLE — passive solar charging
438399 battery_charged , battery_discharged = _idle_battery_flows (
439400 soe , next_soe , battery_settings
440401 )
@@ -456,9 +417,19 @@ def _build_period_data(
456417 battery_soe_end = next_soe ,
457418 )
458419
459- if power > POWER_TOLERANCE_KW : # STORE disposition
460- energy_stored = next_soe - soe
420+ if power > POWER_TOLERANCE_KW : # Active charging
421+ energy_stored = power * dt * battery_settings . efficiency_charge
461422 battery_wear_cost = energy_stored * battery_settings .cycle_cost_per_kwh
423+
424+ expected_stored = next_soe - soe
425+ if abs (energy_stored - expected_stored ) > 0.01 :
426+ logger .warning (
427+ f"Energy stored mismatch: calculated={ energy_stored :.3f} , "
428+ f"SOE delta={ expected_stored :.3f} "
429+ )
430+ elif abs (power ) <= POWER_TOLERANCE_KW and next_soe > soe : # Passive solar charging
431+ passive_energy_stored = next_soe - soe
432+ battery_wear_cost = passive_energy_stored * battery_settings .cycle_cost_per_kwh
462433 else :
463434 battery_wear_cost = 0.0
464435
@@ -1067,8 +1038,8 @@ def optimize_battery_schedule(
10671038 f"Starting direct optimization: horizon={ horizon } , initial_soe={ initial_soe :.1f} , initial_cost_basis={ initial_cost_basis :.3f} "
10681039 )
10691040
1070- # Step 1: Run DP — capture policy for continuous reconstruction
1071- _ , policy , _ , _ = _run_dynamic_programming (
1041+ # Step 1: Run DP with PeriodData storage
1042+ _ , _ , _ , stored_period_data = _run_dynamic_programming (
10721043 horizon = horizon ,
10731044 buy_price = buy_price ,
10741045 sell_price = sell_price ,
@@ -1083,72 +1054,30 @@ def optimize_battery_schedule(
10831054 max_charge_power_per_period = max_charge_power_per_period ,
10841055 )
10851056
1086- # Step 2: Reconstruct the optimal path with continuous SoE propagation.
1087- # The old approach read period_data from stored_period_data[(t, i)], which
1088- # reported grid-snapped SoE values (battery_soe_end = soe_levels[next_i]).
1089- # Here we carry the exact floating-point SoE forward each period so the
1090- # reported trajectory matches what the simulator will produce (R == P).
1057+ # Step 2: Extract optimal path results directly from stored DP data
10911058 hourly_results = []
10921059 current_soe = initial_soe
1093- current_cost_basis = initial_cost_basis
10941060 soe_levels = np .arange (
10951061 battery_settings .min_soe_kwh ,
10961062 battery_settings .max_soe_kwh + SOE_STEP_KWH ,
10971063 SOE_STEP_KWH ,
10981064 )
10991065
11001066 for t in range (horizon ):
1101- # Map continuous SoE to the nearest grid index to look up the policy action
1067+ # Find current state index (same logic as simulation)
11021068 i = round ((current_soe - battery_settings .min_soe_kwh ) / SOE_STEP_KWH )
11031069 i = min (max (0 , i ), len (soe_levels ) - 1 )
1104- action = float (policy [t , i ])
11051070
1106- # Propagate SoE continuously using the exact same physics as the simulator
1107- next_soe = _state_transition (
1108- current_soe ,
1109- action ,
1110- battery_settings ,
1111- dt ,
1112- solar_production = solar_production [t ],
1113- home_consumption = home_consumption [t ],
1114- )
1071+ # Get the PeriodData from DP results - should always exist with valid inputs
1072+ if (t , i ) not in stored_period_data :
1073+ raise RuntimeError (
1074+ f"Missing DP result for hour { t } , state { i } (SOE={ current_soe :.1f} ). "
1075+ f"This indicates a bug in the DP algorithm or invalid inputs."
1076+ )
11151077
1116- # Thread cost_basis continuously forward
1117- reward , new_cost_basis = _compute_reward (
1118- power = action ,
1119- soe = current_soe ,
1120- next_soe = next_soe ,
1121- period = t ,
1122- home_consumption = home_consumption [t ],
1123- battery_settings = battery_settings ,
1124- dt = dt ,
1125- buy_price = buy_price ,
1126- sell_price = sell_price ,
1127- solar_production = solar_production [t ],
1128- cost_basis = current_cost_basis ,
1129- )
1130- # _compute_reward returns (-inf, cost_basis) for a blocked discharge; when
1131- # replaying the already-chosen policy action just keep the current basis.
1132- if reward == float ("-inf" ):
1133- new_cost_basis = current_cost_basis
1134-
1135- period_data = _build_period_data (
1136- power = action ,
1137- soe = current_soe ,
1138- next_soe = next_soe ,
1139- period = t ,
1140- home_consumption = home_consumption [t ],
1141- battery_settings = battery_settings ,
1142- dt = dt ,
1143- buy_price = buy_price ,
1144- sell_price = sell_price ,
1145- solar_production = solar_production [t ],
1146- new_cost_basis = new_cost_basis ,
1147- currency = currency ,
1148- )
1078+ period_data = stored_period_data [(t , i )]
11491079 hourly_results .append (period_data )
1150- current_soe = next_soe
1151- current_cost_basis = new_cost_basis
1080+ current_soe = period_data .energy .battery_soe_end
11521081
11531082 # Step 3: Calculate economic summary directly from PeriodData
11541083 total_base_cost = sum (
0 commit comments