From a1b469fa388da4106fca81aecf572977db566108 Mon Sep 17 00:00:00 2001 From: Andreas Date: Tue, 14 Jul 2026 17:54:15 +0200 Subject: [PATCH] perf: cache AC charge break-even prices --- docs/akkudoktoreos/prediction.md | 50 ++++++++-- .../optimization/genetic/genetic.py | 94 +++++++++++++------ 2 files changed, 110 insertions(+), 34 deletions(-) diff --git a/docs/akkudoktoreos/prediction.md b/docs/akkudoktoreos/prediction.md index d1a0e7cb..3fb6befc 100644 --- a/docs/akkudoktoreos/prediction.md +++ b/docs/akkudoktoreos/prediction.md @@ -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 diff --git a/src/akkudoktoreos/optimization/genetic/genetic.py b/src/akkudoktoreos/optimization/genetic/genetic.py index 005dda6b..2bbe2479 100644 --- a/src/akkudoktoreos/optimization/genetic/genetic.py +++ b/src/akkudoktoreos/optimization/genetic/genetic.py @@ -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.