perf: cache AC charge break-even prices

This commit is contained in:
Andreas
2026-07-14 17:54:15 +02:00
parent 92a8a093e8
commit a1b469fa38
2 changed files with 110 additions and 34 deletions
+44 -6
View File
@@ -208,21 +208,59 @@ data and rely on file/JSON imports only for initial setup.
Prediction keys: Prediction keys:
- `feed_in_tarif_wh`: Feed in tarif per Wh (€/Wh). - `feed_in_tariff_wh`: Feed-in tariff per Wh (€/Wh).
- `feed_in_tarif_kwh`: Feed in tarif per kWh (€/kWh) - `feed_in_tariff_kwh`: Feed-in tariff per kWh (€/kWh).
Configuration options: Configuration options:
- `feedintarif`: Feed in tariff configuration. - `feedintariff`: Feed-in tariff configuration.
- `provider`: Feed in tariff provider id of provider to be used. - `provider`: Feed in tariff provider id of provider to be used.
- `FeedInTariffFixed`: Provides fixed feed in tariff values. - `FeedInTariffFixed`: Provides fixed feed in tariff values.
- `FeedInTariffEnergyCharts`: Retrieves Energy-Charts day-ahead market prices and extends
them to the configured prediction horizon when necessary.
- `FeedInTariffImport`: Imports from a file or JSON string or by endpoint data provision. - `FeedInTariffImport`: Imports from a file or JSON string or by endpoint data provision.
- `provider_settings.feed_in_tariff_kwh`: Fixed feed in tariff (€/kWh). - `provider_settings.FeedInTariffFixed.feed_in_tariff_kwh`: Fixed feed-in tariff (€/kWh).
- `provider_settings.import_file_path`: Path to the file to import feed in tariff forecast data from. - `provider_settings.FeedInTariffEnergyCharts.bidding_zone`: Energy-Charts bidding zone.
- `provider_settings.import_json`: JSON string, dictionary of feed in tariff value lists. - `provider_settings.FeedInTariffImport.import_file_path`: Path to the file containing imported
feed-in tariff prediction data.
- `provider_settings.FeedInTariffImport.import_json`: JSON string containing feed-in tariff
prediction data.
### FeedInTariffEnergyCharts Provider
The `FeedInTariffEnergyCharts` provider uses the raw Energy-Charts day-ahead market price as the
feed-in tariff. It stores prices in `feed_in_tariff_wh` without adding electricity import charges
or VAT. The native Energy-Charts resolution, including quarter-hour data, is retained.
Energy-Charts usually supplies prices only for the published day-ahead period. If that data does
not cover the complete configured prediction horizon, the provider extends it as follows:
- With more than 800 hours of history, an ETS (Holt-Winters exponential smoothing) forecast with
weekly seasonality is used.
- With more than 168 hours of history, an ETS forecast with daily seasonality is used.
- With less history, the median of the available values is used as a constant fallback.
The seasonal periods are adjusted to the source resolution. For example, quarter-hour data uses
four values per hour. Values already supplied by Energy-Charts are kept unchanged; only missing
future slots after the last published price are forecast.
Example configuration:
```json
{
"feedintariff": {
"provider": "FeedInTariffEnergyCharts",
"provider_settings": {
"FeedInTariffEnergyCharts": {
"bidding_zone": "DE-LU"
}
}
}
}
```
### FeedInTariffImport Provider ### FeedInTariffImport Provider
@@ -561,6 +561,9 @@ class GeneticOptimization(OptimizationBase):
self.fix_seed = random.randint(1, 100000000000) # noqa: S311 self.fix_seed = random.randint(1, 100000000000) # noqa: S311
random.seed(self.fix_seed) random.seed(self.fix_seed)
# Per-run cache for the AC-charge break-even penalty (see evaluate()).
self._ac_break_even_best_prices: Optional[list[float]] = None
# Create Simulation # Create Simulation
self.simulation = GeneticSimulation() self.simulation = GeneticSimulation()
@@ -571,6 +574,47 @@ class GeneticOptimization(OptimizationBase):
except Exception: except Exception:
return False return False
def _ac_break_even_prices(
self,
prices_arr: Any,
load_arr: Any,
free_ac_wh: float,
) -> list[float]:
"""Best still-uncovered future price per potential AC-charge slot.
The AC-charge break-even penalty needs, for every potential charge slot,
the highest future price whose load is not already covered by the energy
that is in the battery at simulation start. Prices, loads and the free
battery energy are constant within one optimization run, so this table
is computed once per run and looked up in every fitness evaluation.
(Previously the future list was rebuilt and sorted per slot per
individual, which dominated the fitness runtime.) The loops replicate
the former inline computation exactly, keeping results bit-identical.
"""
n = len(prices_arr)
best_prices = [0.0] * n
for hour in range(n):
# Build list of (price, load_wh) for all future hours in the horizon
future = [(float(prices_arr[h]), float(load_arr[h])) for h in range(hour + 1, n)]
# Sort descending by price so we "use" the most expensive hours first
future.sort(key=lambda x: -x[0])
# Consume free PV energy against the highest-price future hours.
# The first uncovered (partially or fully) hour defines the best
# price still available for the new AC charge.
remaining_free = free_ac_wh
best_uncovered_price = 0.0
for fp, fl in future:
if remaining_free >= fl:
# Entire expensive hour is already covered by free PV energy
remaining_free -= fl
else:
# First hour not (fully) covered: this is where new charge goes
best_uncovered_price = fp
break
best_prices[hour] = best_uncovered_price
return best_prices
def _parameters_for_config( def _parameters_for_config(
self, parameters: GeneticOptimizationParameters self, parameters: GeneticOptimizationParameters
) -> GeneticOptimizationParameters: ) -> GeneticOptimizationParameters:
@@ -1157,7 +1201,17 @@ class GeneticOptimization(OptimizationBase):
* inv.dc_to_ac_efficiency * inv.dc_to_ac_efficiency
) )
if round_trip_eff > 0: # Configurable penalty multiplier (default 1 = economic loss in €)
try:
ac_penalty_factor = float(
self.config.optimization.genetic.penalties["ac_charge_break_even"]
)
except Exception:
ac_penalty_factor = 1.0
# A factor of 0 multiplies every penalty term to zero - skip the
# whole computation in that case.
if round_trip_eff > 0 and ac_penalty_factor != 0.0:
ac_charge_arr = self.simulation.ac_charge_hours ac_charge_arr = self.simulation.ac_charge_hours
prices_arr = self.simulation.elect_price_hourly prices_arr = self.simulation.elect_price_hourly
load_arr = self.simulation.load_energy_array load_arr = self.simulation.load_energy_array
@@ -1173,13 +1227,13 @@ class GeneticOptimization(OptimizationBase):
* inv.dc_to_ac_efficiency * inv.dc_to_ac_efficiency
) )
# Configurable penalty multiplier (default 1 = economic loss in €) # Prices/loads/free energy are constant within one optimization
try: # run - compute the break-even lookup once, reuse it for every
ac_penalty_factor = float( # individual (cache is reset per run in optimierung_ems()).
self.config.optimization.genetic.penalties["ac_charge_break_even"] best_prices = getattr(self, "_ac_break_even_best_prices", None)
) if best_prices is None:
except Exception: best_prices = self._ac_break_even_prices(prices_arr, load_arr, free_ac_wh)
ac_penalty_factor = 1.0 self._ac_break_even_best_prices = best_prices
for hour in range(start_hour, min(len(ac_charge_arr), n)): for hour in range(start_hour, min(len(ac_charge_arr), n)):
ac_factor = ac_charge_arr[hour] ac_factor = ac_charge_arr[hour]
@@ -1193,26 +1247,7 @@ class GeneticOptimization(OptimizationBase):
# Price that a future discharge hour must reach to break even # Price that a future discharge hour must reach to break even
break_even_price = charge_price / round_trip_eff break_even_price = charge_price / round_trip_eff
# Build list of (price, load_wh) for all future hours in the horizon best_uncovered_price = best_prices[hour]
future = [
(float(prices_arr[h]), float(load_arr[h])) for h in range(hour + 1, n)
]
# Sort descending by price so we "use" the most expensive hours first
future.sort(key=lambda x: -x[0])
# Consume free PV energy against the highest-price future hours.
# The first uncovered (partially or fully) hour defines the best
# price still available for the new AC charge.
remaining_free = free_ac_wh
best_uncovered_price = 0.0
for fp, fl in future:
if remaining_free >= fl:
# Entire expensive hour is already covered by free PV energy
remaining_free -= fl
else:
# First hour not (fully) covered: this is where new charge goes
best_uncovered_price = fp
break
if best_uncovered_price < break_even_price: if best_uncovered_price < break_even_price:
# AC charging at this hour is economically unjustified. # AC charging at this hour is economically unjustified.
@@ -1369,6 +1404,9 @@ class GeneticOptimization(OptimizationBase):
logger.error("Generations not configured. Using {}.", generations) logger.error("Generations not configured. Using {}.", generations)
self.simulation.reset() self.simulation.reset()
# Prices/loads/initial SoC may differ from the previous run - the
# break-even lookup must be rebuilt lazily on first evaluation.
self._ac_break_even_best_prices = None
# Initialize PV and EV batteries. slot_duration_h lets the Battery scale # Initialize PV and EV batteries. slot_duration_h lets the Battery scale
# its power caps (max_charge_power_w) to a per-slot energy cap. # its power caps (max_charge_power_w) to a per-slot energy cap.