Skip to content

Commit b8ae0a6

Browse files
authored
Revert "Fix solar-export over-crediting + faithful control vocabulary (binary store/export, continuous reconstruction) (#145) (#146)" (#151)
This reverts commit 71413a1.
1 parent fcb1d3b commit b8ae0a6

38 files changed

Lines changed: 257 additions & 1499 deletions

core/bess/decision_intelligence.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -440,8 +440,6 @@ def classify_strategic_intent(power: float, energy_data: EnergyData) -> str:
440440
return "SOLAR_STORAGE"
441441
elif energy_data.battery_discharged > 0.01:
442442
return "LOAD_SUPPORT"
443-
elif energy_data.grid_exported > 0.01 and energy_data.solar_to_grid > 0.01:
444-
return "EXPORT_ARBITRAGE"
445443
return "IDLE"
446444

447445

core/bess/dp_battery_algorithm.py

Lines changed: 62 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -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(

core/bess/simulation/inverter_simulator.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,8 @@ def mode_to_power(
9898
)
9999
return -delivered_kwh / dt
100100

101-
# SOLAR_STORAGE (load_first + no discharge): STORE disposition — charge all surplus.
102-
# _state_transition's STORE branch caps at rate/room; no grid top-up since power*dt==surplus.
103-
surplus = max(0.0, solar - home)
104-
return surplus / dt
101+
# SOLAR_STORAGE / IDLE: power 0 -> _state_transition stores surplus solar passively
102+
return 0.0
105103

106104

107105
@dataclass

core/bess/simulation/verification.py

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -72,43 +72,3 @@ def ab_compare(
7272
)
7373
# cost delta; savings delta is the negation. Positive cost delta = modified costs more.
7474
return mod.realized_cost - base.realized_cost
75-
76-
77-
def realized_under_solar_error(
78-
forecast_solar,
79-
actual_solar,
80-
buy_price,
81-
sell_price,
82-
home,
83-
initial_soe: float,
84-
settings: BatterySettings,
85-
dt: float,
86-
) -> tuple[float, float]:
87-
"""Forecast-robustness harness: optimize on the *forecast* solar, then execute
88-
the derived commands against the *actual* solar.
89-
90-
Returns ``(planned_cost_on_forecast, realized_cost_on_actual)``. With the binary
91-
store/export model, bonus solar (actual > forecast) is captured or exported with
92-
no phantom export booked, so realized should never be worse than the
93-
forecast plan would have been on the actual day. This is the simulator-verified
94-
answer to "is the schedule robust to solar forecast error?".
95-
"""
96-
result = optimize_battery_schedule(
97-
buy_price=buy_price,
98-
sell_price=sell_price,
99-
home_consumption=home,
100-
solar_production=forecast_solar,
101-
initial_soe=initial_soe,
102-
battery_settings=settings,
103-
period_duration_hours=dt,
104-
)
105-
commands = [
106-
derive_control_command(
107-
pd.decision.strategic_intent, pd.decision.battery_action / dt, settings
108-
)
109-
for pd in result.period_data
110-
]
111-
sim = simulate(
112-
commands, actual_solar, home, buy_price, sell_price, initial_soe, settings, dt
113-
)
114-
return result.economic_summary.battery_solar_cost, sim.realized_cost

0 commit comments

Comments
 (0)