mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-31 12:46:38 +00:00
perf: cache AC charge break-even prices
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user