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:
- `feed_in_tarif_wh`: Feed in tarif per Wh (€/Wh).
- `feed_in_tarif_kwh`: Feed in tarif per kWh (€/kWh)
- `feed_in_tariff_wh`: Feed-in tariff per Wh (€/Wh).
- `feed_in_tariff_kwh`: Feed-in tariff per kWh (€/kWh).
Configuration options:
- `feedintarif`: Feed in tariff configuration.
- `feedintariff`: Feed-in tariff configuration.
- `provider`: Feed in tariff provider id of provider to be used.
- `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.
- `provider_settings.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.import_json`: JSON string, dictionary of feed in tariff value lists.
- `provider_settings.FeedInTariffFixed.feed_in_tariff_kwh`: Fixed feed-in tariff (€/kWh).
- `provider_settings.FeedInTariffEnergyCharts.bidding_zone`: Energy-Charts bidding zone.
- `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
@@ -561,6 +561,9 @@ class GeneticOptimization(OptimizationBase):
self.fix_seed = random.randint(1, 100000000000) # noqa: S311
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
self.simulation = GeneticSimulation()
@@ -571,6 +574,47 @@ class GeneticOptimization(OptimizationBase):
except Exception:
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(
self, parameters: GeneticOptimizationParameters
) -> GeneticOptimizationParameters:
@@ -1157,7 +1201,17 @@ class GeneticOptimization(OptimizationBase):
* 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
prices_arr = self.simulation.elect_price_hourly
load_arr = self.simulation.load_energy_array
@@ -1173,13 +1227,13 @@ class GeneticOptimization(OptimizationBase):
* inv.dc_to_ac_efficiency
)
# 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
# Prices/loads/free energy are constant within one optimization
# run - compute the break-even lookup once, reuse it for every
# individual (cache is reset per run in optimierung_ems()).
best_prices = getattr(self, "_ac_break_even_best_prices", None)
if best_prices is None:
best_prices = self._ac_break_even_prices(prices_arr, load_arr, free_ac_wh)
self._ac_break_even_best_prices = best_prices
for hour in range(start_hour, min(len(ac_charge_arr), n)):
ac_factor = ac_charge_arr[hour]
@@ -1193,26 +1247,7 @@ class GeneticOptimization(OptimizationBase):
# Price that a future discharge hour must reach to break even
break_even_price = charge_price / round_trip_eff
# 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_uncovered_price = best_prices[hour]
if best_uncovered_price < break_even_price:
# AC charging at this hour is economically unjustified.
@@ -1369,6 +1404,9 @@ class GeneticOptimization(OptimizationBase):
logger.error("Generations not configured. Using {}.", generations)
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
# its power caps (max_charge_power_w) to a per-slot energy cap.