feat: complete 15-minute optimization support

This commit is contained in:
Andreas
2026-07-14 17:00:07 +02:00
parent 81a36cf355
commit 92a8a093e8
31 changed files with 1812 additions and 1032 deletions
+8 -3
View File
@@ -96,8 +96,8 @@ class EnergyManagement(
If no datetime is provided, the current datetime is used.
The start datetime is always rounded down to the nearest hour
(i.e., setting minutes, seconds, and microseconds to zero).
The start datetime is rounded down to the configured optimization
interval. For a 15-minute grid this yields :00, :15, :30 or :45.
Args:
start_datetime (Optional[DateTime]): The datetime to set as the start.
@@ -108,7 +108,12 @@ class EnergyManagement(
"""
if start_datetime is None:
start_datetime = to_datetime()
cls._start_datetime = start_datetime.set(minute=0, second=0, microsecond=0)
interval_s = int(cls.config.optimization.interval or 3600)
wall_clock_s = (
start_datetime.hour * 3600 + start_datetime.minute * 60 + start_datetime.second
)
remainder_s = wall_clock_s % interval_s
cls._start_datetime = start_datetime.subtract(seconds=remainder_s).set(microsecond=0)
return cls._start_datetime
@classmethod
+22 -3
View File
@@ -58,6 +58,8 @@ class Battery:
self.max_charge_power_w = self.capacity_wh # TODO this should not be equal capacity_wh
self.discharge_array = np.full(self.prediction_hours, 0)
self.charge_array = np.full(self.prediction_hours, 0)
self._discharged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
self._charged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
self.soc_wh = (self.initial_soc_percentage / 100) * self.capacity_wh
self.min_soc_wh = (self.min_soc_percentage / 100) * self.capacity_wh
self.max_soc_wh = (self.max_soc_percentage / 100) * self.capacity_wh
@@ -101,6 +103,17 @@ class Battery:
self.soc_wh = min(self.soc_wh, self.max_soc_wh) # Only clamp to max
self.discharge_array = np.full(self.prediction_hours, 0)
self.charge_array = np.full(self.prediction_hours, 0)
self._discharged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
self._charged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
def remaining_discharge_energy_wh(self, hour: int) -> float:
"""Return DC energy still deliverable within one optimization slot."""
raw_power_budget_wh = self.max_charge_power_w * self.slot_duration_h
raw_power_remaining_wh = max(
raw_power_budget_wh - self._discharged_raw_wh_per_slot[hour], 0.0
)
raw_soc_available_wh = max(self.soc_wh - self.min_soc_wh, 0.0)
return min(raw_power_remaining_wh, raw_soc_available_wh) * self.discharging_efficiency
def set_discharge_per_hour(self, discharge_array: np.ndarray) -> None:
"""Sets the discharge values for each hour."""
@@ -151,8 +164,9 @@ class Battery:
# Maximum raw discharge due to power limit, scaled to the slot duration.
# max_charge_power_w is a power [W]; energy movable in one slot is
# power x slot_duration_h.
max_raw_wh = (
self.max_charge_power_w * self.slot_duration_h
max_raw_wh = max(
self.max_charge_power_w * self.slot_duration_h - self._discharged_raw_wh_per_slot[hour],
0.0,
) # TODO rename to max_discharge_power_w
# Actual raw withdrawal (internal)
@@ -170,6 +184,7 @@ class Battery:
# Update SoC
self.soc_wh -= raw_used_wh
self.soc_wh = max(self.soc_wh, self.min_soc_wh)
self._discharged_raw_wh_per_slot[hour] += raw_used_wh
# Losses
losses_wh = raw_used_wh - delivered_wh
@@ -246,7 +261,10 @@ class Battery:
soc_wh_fast = self.soc_wh
# Scale the power cap [W] to a per-slot energy cap [Wh] (W x slot hours).
# At slot_duration_h=1.0 (hourly) this equals the legacy power value.
max_charge_per_slot_wh_fast = self.max_charge_power_w * self.slot_duration_h
max_charge_per_slot_wh_fast = max(
self.max_charge_power_w * self.slot_duration_h - self._charged_raw_wh_per_slot[hour],
0.0,
)
charging_efficiency_fast = self.charging_efficiency
# Decide mode & determine raw_request_wh and raw_charge_wh
@@ -290,6 +308,7 @@ class Battery:
)
self.soc_wh = new_soc
self._charged_raw_wh_per_slot[hour] += raw_input_wh
losses_wh = raw_input_wh - stored_wh
return stored_wh, losses_wh
+13 -14
View File
@@ -74,9 +74,12 @@ class Inverter:
grid_import = -remaining_power # Negative indicates feeding into the grid
self_consumption = self.max_power_wh
else:
# Calculate scr using cached results per energy management/optimization run
# Calculate scr using cached results per energy management/optimization run.
# The interpolator expects power levels [W]; consumption/generation are
# energy per slot [Wh], so convert via the slot duration (identical at
# the hourly default, ×4 on the 15-minute grid).
scr = self.self_consumption_predictor.calculate_self_consumption(
consumption, generation
consumption / self.slot_duration_h, generation / self.slot_duration_h
)
# Remaining power after consumption
@@ -133,12 +136,10 @@ class Inverter:
if allow_battery_grid_export and self.battery:
export_capacity = max(self.max_power_wh - consumption - grid_export, 0.0)
max_discharge_dc = getattr(self.battery, "max_charge_power_w", None)
if max_discharge_dc is not None:
remaining_battery_ac = max(
(max_discharge_dc - from_battery_dc) * dc_to_ac_eff, 0.0
)
export_capacity = min(export_capacity, remaining_battery_ac)
remaining_battery_ac = (
self.battery.remaining_discharge_energy_wh(hour) * dc_to_ac_eff
)
export_capacity = min(export_capacity, remaining_battery_ac)
battery_export_ac, battery_export_losses = self._discharge_battery_to_ac(
export_capacity, hour
)
@@ -171,12 +172,10 @@ class Inverter:
if allow_battery_grid_export and self.battery and grid_import <= 0.0:
export_capacity = max(self.max_power_wh - consumption, 0.0)
max_discharge_dc = getattr(self.battery, "max_charge_power_w", None)
if max_discharge_dc is not None:
remaining_battery_ac = max(
(max_discharge_dc - battery_discharge_dc) * dc_to_ac_eff, 0.0
)
export_capacity = min(export_capacity, remaining_battery_ac)
remaining_battery_ac = (
self.battery.remaining_discharge_energy_wh(hour) * dc_to_ac_eff
)
export_capacity = min(export_capacity, remaining_battery_ac)
battery_export_ac, battery_export_losses = self._discharge_battery_to_ac(
export_capacity, hour
)
+172 -43
View File
@@ -100,9 +100,7 @@ class GeneticSimulation(PydanticBaseModel):
)
bat_grid_export_hours: Optional[NDArray[Shape["*"], float]] = Field(
default=None,
json_schema_extra={
"description": "Hourly permission for battery discharge into the grid."
},
json_schema_extra={"description": "Hourly permission for battery discharge into the grid."},
)
ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(
default=None, json_schema_extra={"description": "TBD"}
@@ -453,9 +451,7 @@ class GeneticSimulation(PydanticBaseModel):
# Financial calculations
costs_per_hour[hour_idx] = energy_consumption_grid_actual * hourly_electricity_price
revenue_per_hour[hour_idx] = (
energy_feedin_grid_actual * hourly_feed_in_tariff
)
revenue_per_hour[hour_idx] = energy_feedin_grid_actual * hourly_feed_in_tariff
total_cost = np.nansum(costs_per_hour)
total_losses = np.nansum(losses_wh_per_hour)
@@ -529,12 +525,23 @@ class GeneticOptimization(OptimizationBase):
fixed_seed: Optional[int] = None,
):
"""Initialize the optimization problem with the required parameters."""
if self.config.optimization.interval not in (900, 3600):
logger.warning(
"Genetic optimization interval {} seconds is unsupported; using 3600 seconds.",
self.config.optimization.interval,
)
self.config.optimization.interval = 3600
self.opti_param: dict[str, Any] = {}
# Number of slots at the tail of the optimization window where EV
# charging is fixed to 0. Slot-counted so 15-min runs reserve the right
# tail length (at interval=3600 s this equals prediction.hours - horizon).
self.fixed_eauto_hours = self.total_slots - (
self.config.optimization.horizon_hours * self.slots_per_hour
self.fixed_eauto_hours = max(
self.total_slots
- (
self._start_day_slot()
+ self.config.optimization.horizon_hours * self.slots_per_hour
),
0,
)
self.ev_possible_charge_values: list[float] = [1.0]
# Separate charge-level list for battery AC charging (independent of EV rates).
@@ -580,15 +587,111 @@ class GeneticOptimization(OptimizationBase):
return parameters
ems_parameters = parameters.ems.model_copy(
update={
"einspeiseverguetung_euro_pro_wh": list(
parameters.ems.strompreis_euro_pro_wh
)
},
update={"einspeiseverguetung_euro_pro_wh": list(parameters.ems.strompreis_euro_pro_wh)},
deep=True,
)
return parameters.model_copy(update={"ems": ems_parameters}, deep=True)
def _parameters_for_slot_grid(
self, parameters: GeneticOptimizationParameters
) -> GeneticOptimizationParameters:
"""Normalize hourly or native-slot EMS input onto the optimization grid.
API clients historically provide one value per prediction hour. At a
sub-hourly interval, energy quantities are distributed across the slots
while price quantities are held constant. Inputs already matching the
native slot grid are preserved exactly. Any other length is ambiguous and
rejected instead of silently shortening the simulation horizon.
"""
def normalize(values: list[float], name: str, *, energy: bool) -> list[float]:
value_count = len(values)
if value_count == self.total_slots:
return list(values)
if value_count != self.config.prediction.hours:
raise ValueError(
f"{name} has {value_count} values; expected either "
f"{self.config.prediction.hours} hourly values or "
f"{self.total_slots} optimization-slot values."
)
normalized = np.repeat(np.asarray(values, dtype=float), self.slots_per_hour)
if energy:
normalized /= self.slots_per_hour
return normalized.tolist()
ems = parameters.ems
feed_in_tariff = ems.einspeiseverguetung_euro_pro_wh
if isinstance(feed_in_tariff, list):
normalized_feed_in_tariff: list[float] | float = normalize(
feed_in_tariff,
"einspeiseverguetung_euro_pro_wh",
energy=False,
)
else:
normalized_feed_in_tariff = [float(feed_in_tariff)] * self.total_slots
normalized_ems = ems.model_copy(
update={
"pv_prognose_wh": normalize(ems.pv_prognose_wh, "pv_prognose_wh", energy=True),
"gesamtlast": normalize(ems.gesamtlast, "gesamtlast", energy=True),
"strompreis_euro_pro_wh": normalize(
ems.strompreis_euro_pro_wh,
"strompreis_euro_pro_wh",
energy=False,
),
"einspeiseverguetung_euro_pro_wh": normalized_feed_in_tariff,
},
deep=True,
)
temperature_forecast = parameters.temperature_forecast
if temperature_forecast is not None:
if len(temperature_forecast) == self.config.prediction.hours:
temperature_forecast = [
value for value in temperature_forecast for _ in range(self.slots_per_hour)
]
elif len(temperature_forecast) != self.total_slots:
raise ValueError(
f"temperature_forecast has {len(temperature_forecast)} values; expected "
f"either {self.config.prediction.hours} hourly values or "
f"{self.total_slots} optimization-slot values."
)
return parameters.model_copy(
update={"ems": normalized_ems, "temperature_forecast": temperature_forecast},
deep=True,
)
def _start_solution_for_slot_grid(
self, start_solution: list[float], *, has_appliance: bool
) -> list[float]:
"""Expand a legacy hourly genome to the configured slot grid when possible."""
expected_length = self.total_slots * (2 if self.optimize_ev else 1)
hourly_length = self.config.prediction.hours * (2 if self.optimize_ev else 1)
if has_appliance:
expected_length += 1
hourly_length += 1
if len(start_solution) == expected_length or self.slots_per_hour == 1:
return list(start_solution)
if len(start_solution) != hourly_length:
return list(start_solution)
battery_end = self.config.prediction.hours
migrated = np.repeat(start_solution[:battery_end], self.slots_per_hour).tolist()
if self.optimize_ev:
ev_end = battery_end + self.config.prediction.hours
migrated.extend(
np.repeat(start_solution[battery_end:ev_end], self.slots_per_hour).tolist()
)
if has_appliance:
migrated.append(start_solution[-1])
logger.info(
"Expanded hourly start_solution from {} to {} slot values.",
hourly_length,
expected_length,
)
return migrated
def decode_charge_discharge(
self, discharge_hours_bin: np.ndarray
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
@@ -813,8 +916,15 @@ class GeneticOptimization(OptimizationBase):
self.toolbox.register("mate", tools.cxTwoPoint)
# Mutation operator for battery charge/discharge states
# Keep the expected number of mutated genes per hour stable when the
# interval becomes finer (0.2 hourly -> 0.05 on a quarter-hour grid).
mutation_probability = 0.2 / self.slots_per_hour
self.toolbox.register(
"mutate_charge_discharge", tools.mutUniformInt, low=0, up=total_states - 1, indpb=0.2
"mutate_charge_discharge",
tools.mutUniformInt,
low=0,
up=total_states - 1,
indpb=mutation_probability,
)
# Mutation operator for EV states (separate index space)
@@ -823,7 +933,7 @@ class GeneticOptimization(OptimizationBase):
tools.mutUniformInt,
low=0,
up=len_ev - 1,
indpb=0.2,
indpb=mutation_probability,
)
# Mutation for household appliance
@@ -1106,8 +1216,10 @@ class GeneticOptimization(OptimizationBase):
if best_uncovered_price < break_even_price:
# AC charging at this hour is economically unjustified.
# Penalty = excess cost per Wh × DC energy requested this hour.
dc_wh = bat.max_charge_power_w * ac_factor
# Penalty = excess cost per Wh × DC energy requested this slot.
# max_charge_power_w is a power [W]; the energy movable in
# one slot is power × slot_duration_h (¼ at 15 min).
dc_wh = bat.max_charge_power_w * self.slot_duration_h * ac_factor
ac_wh = dc_wh / max(inv.ac_to_dc_efficiency, 1e-9)
excess_cost_per_wh = break_even_price - best_uncovered_price
gesamtbilanz += ac_wh * excess_cost_per_wh * ac_penalty_factor
@@ -1138,6 +1250,12 @@ class GeneticOptimization(OptimizationBase):
@TODO: optimize() ngen default (200) is different from optimierung_ems() ngen default (400).
"""
# Re-seed at the actual optimization boundary. Setup and validation may
# consume random values elsewhere in a long-running process; a fixed seed
# must nevertheless produce the same population and result.
if self.fix_seed is not None:
random.seed(self.fix_seed)
# Set the number of inviduals in a generation
try:
individuals = self.config.optimization.genetic.individuals
@@ -1157,14 +1275,16 @@ class GeneticOptimization(OptimizationBase):
logger.debug("Start optimize: {}", start_solution)
# Insert the start solution into the population if provided and compatible with the
# currently active genome layout. EV optimization adds one gene per prediction hour,
# currently active genome layout. EV optimization adds one gene per prediction slot,
# so a cached solution from a previous run without EV optimization must not be reused.
if start_solution is not None:
expected_length = self.config.prediction.hours
if self.optimize_ev:
expected_length += self.config.prediction.hours
if self.opti_param.get("home_appliance", 0) > 0:
has_appliance = self.opti_param.get("home_appliance", 0) > 0
expected_length = self.total_slots * (2 if self.optimize_ev else 1)
if has_appliance:
expected_length += 1
start_solution = self._start_solution_for_slot_grid(
start_solution, has_appliance=has_appliance
)
if len(start_solution) == expected_length:
for _ in range(10):
@@ -1218,6 +1338,12 @@ class GeneticOptimization(OptimizationBase):
"""Perform EMS (Energy Management System) optimization and visualize results."""
direct_marketing_enabled = self._direct_marketing_enabled()
parameters = self._parameters_for_config(parameters)
parameters = self._parameters_for_slot_grid(parameters)
if self.slots_per_hour > 1 and parameters.dishwasher is not None:
raise ValueError(
"Home-appliance scheduling is not yet supported for sub-hourly "
"optimization intervals."
)
self.optimize_dc_charge = direct_marketing_enabled
self.optimize_battery_grid_export = direct_marketing_enabled
@@ -1399,30 +1525,33 @@ class GeneticOptimization(OptimizationBase):
else:
battery_grid_export = battery_grid_export.tolist()
# Visualize the results in PDF
try:
from akkudoktoreos.utils.visualize import prepare_visualize
# Visualize the results in PDF. Skippable via config — matplotlib PDF
# generation costs several seconds per run, which headless setups
# (API/Node-RED polling) never look at.
if getattr(self.config.optimization, "visualize_pdf", True):
try:
from akkudoktoreos.utils.visualize import prepare_visualize
visualize = {
"ac_charge": ac_charge_hours,
"dc_charge": dc_charge_hours,
"discharge_allowed": discharge,
"battery_grid_export_allowed": battery_grid_export,
"eautocharge_hours_float": eautocharge_hours_float,
"result": simulation_result,
"eauto_obj": self.simulation.ev.to_dict() if self.simulation.ev else None,
"start_solution": start_solution,
"spuelstart": washingstart_int,
"extra_data": extra_data,
"fitness_history": self.fitness_history,
"fixed_seed": self.fix_seed,
}
visualize = {
"ac_charge": ac_charge_hours,
"dc_charge": dc_charge_hours,
"discharge_allowed": discharge,
"battery_grid_export_allowed": battery_grid_export,
"eautocharge_hours_float": eautocharge_hours_float,
"result": simulation_result,
"eauto_obj": self.simulation.ev.to_dict() if self.simulation.ev else None,
"start_solution": start_solution,
"spuelstart": washingstart_int,
"extra_data": extra_data,
"fitness_history": self.fitness_history,
"fixed_seed": self.fix_seed,
}
prepare_visualize(parameters, visualize, start_hour=start_hour)
prepare_visualize(parameters, visualize, start_hour=start_slot)
except Exception as ex:
error_msg = f"Visualization failed: {ex}"
logger.error(error_msg)
except Exception as ex:
error_msg = f"Visualization failed: {ex}"
logger.error(error_msg)
return GeneticSolution(
**{
@@ -227,7 +227,7 @@ class GeneticOptimizationParameters(
# Add forecast and device data
interval = to_duration(cls.config.optimization.interval)
power_to_energy_per_interval_factor = cls.config.optimization.interval / 3600
parameter_start_datetime = ems.start_datetime.set(hour=0, second=0, microsecond=0)
parameter_start_datetime = ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0)
parameter_end_datetime = parameter_start_datetime.add(hours=cls.config.prediction.hours)
max_retries = 10
@@ -248,7 +248,11 @@ class GeneticOptimizationParameters(
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="linear",
# Forecast power values represent the mean of their source
# period. Hold them over smaller optimization slots so
# resampling preserves energy (especially hourly and
# Solcast 30-minute forecasts).
fill_method="ffill",
)
* power_to_energy_per_interval_factor
).tolist()
@@ -585,8 +589,12 @@ class GeneticOptimizationParameters(
# Home Appliances
# ---------------
if cls.config.devices.max_home_appliances is None:
logger.info("Number of home appliance devices not configured - defaulting to 1.")
cls.config.devices.max_home_appliances = 1
default_home_appliances = 0 if cls.config.optimization.interval < 3600 else 1
logger.info(
"Number of home appliance devices not configured - defaulting to {}.",
default_home_appliances,
)
cls.config.devices.max_home_appliances = default_home_appliances
if cls.config.devices.max_home_appliances == 0:
home_appliance_params = None
else:
@@ -177,7 +177,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
default_factory=list,
json_schema_extra={
"description": "Array with battery-to-grid export values (1 for export discharge, 0 otherwise)."
}
},
)
eautocharge_hours_float: Optional[list[float]] = Field(json_schema_extra={"description": "TBD"})
result: GeneticSimulationResult
@@ -331,7 +331,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
Clamping rules:
- AC charge factor: scaled down proportionally when the battery
headroom (max_soc current_soc) is smaller than what the
commanded factor would store in one hour. Set to 0 when full.
commanded factor would store in one optimization slot. Set to 0 when full.
- DC charge factor (PV): zeroed when battery is at or above max SOC
(the inverter curtails automatically, but this makes intent clear).
- Discharge: blocked when SOC is at or below min SOC.
@@ -361,9 +361,14 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
if inv_list and inv_list[0].max_ac_charge_power_w is not None
else float(bat.max_charge_power_w)
)
max_dc_per_h_wh = effective_ac * max_ac_cp_w * ac_to_dc_eff * ch_eff
if max_dc_per_h_wh > headroom_wh:
effective_ac = effective_ac * (headroom_wh / max_dc_per_h_wh)
# Energy storable in one optimization slot, not per hour: scale the
# power [W] by the slot duration (1.0 hourly, 0.25 at 15 min).
slot_duration_h = float(self.config.optimization.interval or 3600) / 3600.0
max_dc_per_slot_wh = (
effective_ac * max_ac_cp_w * slot_duration_h * ac_to_dc_eff * ch_eff
)
if max_dc_per_slot_wh > headroom_wh:
effective_ac = effective_ac * (headroom_wh / max_dc_per_slot_wh)
# --- DC charge (PV): zero when battery is full ---
effective_dc = dc_charge
@@ -619,13 +624,13 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
for pred_key, pred_fill_method, pred_solution_key, pred_solution_factor in [
(
"pvforecast_ac_power",
"linear",
"ffill",
"pvforecast_ac_energy_wh",
power_to_energy_per_interval_factor,
),
(
"pvforecast_dc_power",
"linear",
"ffill",
"pvforecast_dc_energy_wh",
power_to_energy_per_interval_factor,
),
@@ -637,7 +642,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
),
(
"feed_in_tariff_wh",
"linear",
"ffill",
"feed_in_tariff_amt_kwh",
1000.0,
),
@@ -649,19 +654,19 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
),
(
"loadforecast_power_w",
"linear",
"ffill",
"loadforecast_energy_wh",
power_to_energy_per_interval_factor,
),
(
"loadakkudoktor_std_power_w",
"linear",
"ffill",
"loadakkudoktor_std_energy_wh",
power_to_energy_per_interval_factor,
),
(
"loadakkudoktor_mean_power_w",
"linear",
"ffill",
"loadakkudoktor_mean_energy_wh",
power_to_energy_per_interval_factor,
),
@@ -89,6 +89,18 @@ class OptimizationCommonSettings(SettingsBaseModel):
},
)
visualize_pdf: bool = Field(
default=True,
json_schema_extra={
"description": (
"Generate the PDF visualization after each optimization run. "
"Disable for headless setups (e.g. Node-RED integration) to save "
"several seconds per run. Defaults to True."
),
"examples": [True, False],
},
)
genetic: GeneticCommonSettings = Field(
default_factory=GeneticCommonSettings,
json_schema_extra={
@@ -176,6 +176,19 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
return series_data
@staticmethod
def _resolution_seconds(series: pd.Series) -> int:
"""Infer the current native market interval from recent timestamps."""
if len(series) < 2:
return 3600
index = pd.DatetimeIndex(series.sort_index().index).drop_duplicates()
deltas = index.to_series().diff().dropna().dt.total_seconds()
deltas = deltas[deltas > 0].tail(96)
if deltas.empty:
return 3600
resolution = int(round(float(deltas.median())))
return resolution if resolution > 0 and 3600 % resolution == 0 else 3600
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
mean = data.mean()
std = data.std()
@@ -250,37 +263,64 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
f"No Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}"
)
# Generate history array for prediction
history = self.key_to_array(
key="elecprice_marketprice_wh",
end_datetime=self.highest_orig_datetime,
fill_method="linear",
)
amount_datasets = len(self.records)
if not self.highest_orig_datetime: # mypy fix
error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}"
logger.error(error_msg)
raise ValueError(error_msg)
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
needed_hours = int(
self.config.prediction.hours
- ((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600)
raw_series = self.key_to_series(
key="elecprice_marketprice_wh",
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
)
resolution_seconds = self._resolution_seconds(raw_series)
slots_per_hour = 3600 // resolution_seconds
history = self.key_to_array(
key="elecprice_marketprice_wh",
end_datetime=self.highest_orig_datetime,
interval=to_duration(f"{resolution_seconds} seconds"),
fill_method="linear",
)
if needed_hours <= 0:
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
covered_slots = 0
if self.highest_orig_datetime >= self.ems_start_datetime:
covered_slots = (
int(
(self.highest_orig_datetime - self.ems_start_datetime).total_seconds()
// resolution_seconds
)
+ 1
)
needed_slots = self.config.prediction.hours * slots_per_hour - covered_slots
if needed_slots <= 0:
logger.warning(
f"No prediction needed. needed_hours={needed_hours}, hours={self.config.prediction.hours},highest_orig_datetime {self.highest_orig_datetime}, start_datetime {self.ems_start_datetime}"
"No prediction needed. needed_slots={}, hours={}, resolution_seconds={}, "
"highest_orig_datetime={}, start_datetime={}",
needed_slots,
self.config.prediction.hours,
resolution_seconds,
self.highest_orig_datetime,
self.ems_start_datetime,
) # this might keep data longer than self.ems_start_datetime + self.config.prediction.hours in the records
return
if amount_datasets > 800: # we do the full ets with seasons of 1 week
prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours)
elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day
prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours)
elif amount_datasets > 0: # not enough data for ets, do median
prediction = self._predict_median(history, hours=needed_hours)
weekly_history_slots = 800 * slots_per_hour
daily_history_slots = 168 * slots_per_hour
if len(history) > weekly_history_slots:
prediction = self._predict_ets(
history,
seasonal_periods=168 * slots_per_hour,
hours=needed_slots,
)
elif len(history) > daily_history_slots:
prediction = self._predict_ets(
history,
seasonal_periods=24 * slots_per_hour,
hours=needed_slots,
)
elif len(history) > 0:
prediction = self._predict_median(history, hours=needed_slots)
else:
logger.error("No data available for prediction")
raise ValueError("No data available")
@@ -289,7 +329,7 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
prediction_series = pd.Series(
data=prediction,
index=[
self.highest_orig_datetime + to_duration(f"{i + 1} hours")
self.highest_orig_datetime + to_duration(f"{(i + 1) * resolution_seconds} seconds")
for i in range(len(prediction))
],
)
+66 -12
View File
@@ -34,7 +34,40 @@ query TibberPriceInfo {
total
}
}
priceInfoRange(resolution: QUARTER_HOURLY, last: 960) {
priceInfoRange(resolution: QUARTER_HOURLY, last: 672) {
nodes {
startsAt
total
}
}
}
}
}
}
"""
# Same query, but requesting priceInfo (and therefore its today/tomorrow
# fields) at quarter-hourly resolution. Tibber defines ``resolution`` on
# Subscription.priceInfo, not on the nested PriceInfo.today/tomorrow fields.
# Tried first; on a GraphQL schema error from an older API the provider falls
# back to TIBBER_PRICE_QUERY.
TIBBER_PRICE_QUERY_QUARTER_HOURLY = """
query TibberPriceInfo {
viewer {
homes {
id
currentSubscription {
priceInfo(resolution: QUARTER_HOURLY) {
today {
startsAt
total
}
tomorrow {
startsAt
total
}
}
priceInfoRange(resolution: QUARTER_HOURLY, last: 672) {
nodes {
startsAt
total
@@ -185,17 +218,38 @@ class ElecPriceTibber(ElecPriceProvider):
if not access_token:
raise ValueError("Tibber access_token is required")
response = requests.post(
TIBBER_GRAPHQL_URL,
json={"query": TIBBER_PRICE_QUERY},
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
timeout=30,
)
logger.debug(f"Response from Tibber GraphQL API: {response}")
response.raise_for_status()
# Prefer quarter-hourly today/tomorrow prices; fall back to the hourly
# query when the Tibber API rejects the resolution argument. Tibber
# signals schema errors either as HTTP 400 or as HTTP 200 with an
# "errors" array, so both must route to the fallback (raise_for_status
# must NOT run before the fallback check).
response = None
queries = (TIBBER_PRICE_QUERY_QUARTER_HOURLY, TIBBER_PRICE_QUERY)
for attempt, query in enumerate(queries, start=1):
response = requests.post(
TIBBER_GRAPHQL_URL,
json={"query": query},
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
timeout=30,
)
logger.debug(f"Response from Tibber GraphQL API: {response}")
if response.ok and b'"errors"' not in response.content:
break
if attempt < len(queries):
logger.info(
"Tibber rejected the quarter-hourly priceInfo query "
"(HTTP {}): {} - falling back to hourly today/tomorrow prices.",
response.status_code,
response.text[:300],
)
else:
# Final (hourly) attempt failed for real - surface the error.
response.raise_for_status()
if response is None: # pragma: no cover - the query tuple is never empty
raise RuntimeError("No Tibber GraphQL query was attempted")
tibber_data = self._validate_data(response.content)
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return tibber_data
@@ -85,15 +85,18 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
series_data.at[orig_datetime] = price_eur_per_mwh / 1_000_000
return series_data
def _predict_prices(self, history, hours: int):
def _predict_prices(self, history, slots: int, slots_per_hour: int):
energycharts = ElecPriceEnergyCharts()
amount_datasets = len(self.records)
if amount_datasets > 800:
return energycharts._predict_ets(history, seasonal_periods=168, hours=hours)
if amount_datasets > 168:
return energycharts._predict_ets(history, seasonal_periods=24, hours=hours)
if amount_datasets > 0:
return energycharts._predict_median(history, hours=hours)
if len(history) > 800 * slots_per_hour:
return energycharts._predict_ets(
history, seasonal_periods=168 * slots_per_hour, hours=slots
)
if len(history) > 168 * slots_per_hour:
return energycharts._predict_ets(
history, seasonal_periods=24 * slots_per_hour, hours=slots
)
if len(history) > 0:
return energycharts._predict_median(history, hours=slots)
logger.error("No feed-in tariff data available for Energy-Charts prediction")
raise ValueError("No data available")
@@ -141,38 +144,52 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
self.highest_orig_datetime,
)
history = self.key_to_array(
key="feed_in_tariff_wh",
end_datetime=self.highest_orig_datetime,
fill_method="linear",
)
if not self.highest_orig_datetime:
error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}"
logger.error(error_msg)
raise ValueError(error_msg)
needed_hours = int(
self.config.prediction.hours
- ((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600)
raw_series = self.key_to_series(
key="feed_in_tariff_wh",
end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
)
resolution_seconds = ElecPriceEnergyCharts._resolution_seconds(raw_series)
slots_per_hour = 3600 // resolution_seconds
history = self.key_to_array(
key="feed_in_tariff_wh",
end_datetime=self.highest_orig_datetime,
interval=to_duration(f"{resolution_seconds} seconds"),
fill_method="linear",
)
if needed_hours <= 0:
covered_slots = 0
if self.highest_orig_datetime >= self.ems_start_datetime:
covered_slots = (
int(
(self.highest_orig_datetime - self.ems_start_datetime).total_seconds()
// resolution_seconds
)
+ 1
)
needed_slots = self.config.prediction.hours * slots_per_hour - covered_slots
if needed_slots <= 0:
logger.warning(
"No feed-in tariff prediction needed. needed_hours={}, hours={}, "
"highest_orig_datetime={}, start_datetime={}",
needed_hours,
"No feed-in tariff prediction needed. needed_slots={}, hours={}, "
"resolution_seconds={}, highest_orig_datetime={}, start_datetime={}",
needed_slots,
self.config.prediction.hours,
resolution_seconds,
self.highest_orig_datetime,
self.ems_start_datetime,
)
return
prediction = self._predict_prices(history, needed_hours)
prediction = self._predict_prices(history, needed_slots, slots_per_hour)
prediction_series = pd.Series(
data=prediction,
index=[
self.highest_orig_datetime + to_duration(f"{i + 1} hours")
self.highest_orig_datetime + to_duration(f"{(i + 1) * resolution_seconds} seconds")
for i in range(len(prediction))
],
)
+22 -9
View File
@@ -15,31 +15,44 @@ class SelfConsumptionProbabilityInterpolator:
# Load the RegularGridInterpolator
with open(self.filepath, "rb") as file:
self.interpolator: RegularGridInterpolator = pickle.load(file) # noqa: S301
self.load_power_min_w = float(self.interpolator.grid[0][0])
self.load_power_max_w = float(self.interpolator.grid[0][-1])
self.minute_load_max_w = float(self.interpolator.grid[1][-1])
def _generate_points(
self, load_1h_power: float, pv_power: float
self, mean_load_power_w: float, pv_power_w: float
) -> tuple[np.ndarray, np.ndarray]:
"""Generate the grid points for interpolation."""
partial_loads = np.arange(0, pv_power + 50, 50)
points = np.array([np.full_like(partial_loads, load_1h_power), partial_loads]).T
"""Generate in-bounds grid points for interpolation.
The bundled probability table was calibrated from a one-hour mean load
and one-minute samples. Sub-hourly optimization still passes *power* in
watts here; a native 15-minute mean is therefore a documented
approximation until a separately calibrated table is available.
"""
bounded_mean_load_w = float(
np.clip(mean_load_power_w, self.load_power_min_w, self.load_power_max_w)
)
bounded_pv_power_w = float(np.clip(pv_power_w, 0.0, self.minute_load_max_w))
partial_loads = np.arange(0.0, bounded_pv_power_w + 1.0, 50.0)
points = np.column_stack((np.full(partial_loads.shape, bounded_mean_load_w), partial_loads))
return points, partial_loads
@cache_energy_management
def calculate_self_consumption(self, load_1h_power: float, pv_power: float) -> float:
def calculate_self_consumption(self, mean_load_power_w: float, pv_power_w: float) -> float:
"""Calculate the PV self-consumption rate using RegularGridInterpolator.
The results are cached until the start of the next energy management run/ optimization.
Args:
- last_1h_power: 1h power levels (W).
- pv_power: Current PV power output (W).
- mean_load_power_w: Mean load power for the current forecast interval (W).
- pv_power_w: Current PV power output (W).
Returns:
- Self-consumption rate as a float.
"""
points, partial_loads = self._generate_points(load_1h_power, pv_power)
points, _ = self._generate_points(mean_load_power_w, pv_power_w)
probabilities = self.interpolator(points)
return probabilities.sum()
return float(np.clip(probabilities.sum(), 0.0, 1.0))
# def calculate_self_consumption(self, load_1h_power: float, pv_power: float) -> float:
# """Calculate the PV self-consumption rate using RegularGridInterpolator.
+27 -30
View File
@@ -82,7 +82,6 @@ elecprice_energy_charts = ElecPriceEnergyCharts()
elecprice_tibber = ElecPriceTibber()
elecprice_fixed = ElecPriceFixed()
elecprice_import = ElecPriceImport()
elecprice_tibber = ElecPriceTibber()
feedintariff_energy_charts = FeedInTariffEnergyCharts()
feedintariff_fixed = FeedInTariffFixed()
feedintariff_import = FeedInTariffImport()
@@ -102,33 +101,34 @@ weather_openmeteo = WeatherOpenMeteo()
weather_import = WeatherImport()
def prediction_providers() -> list[
Union[
ElecPriceAkkudoktor,
ElecPriceEnergyCharts,
ElecPriceTibber,
ElecPriceFixed,
ElecPriceImport,
ElecPriceTibber,
FeedInTariffEnergyCharts,
FeedInTariffFixed,
FeedInTariffImport,
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
LoadVrm,
LoadImport,
PVForecastAkkudoktor,
PVForecastVrm,
PVForecastPVNode,
PVForecastForecastSolar,
PVForecastSolcast,
PVForecastImport,
WeatherBrightSky,
WeatherClearOutside,
WeatherOpenMeteo,
WeatherImport,
def prediction_providers() -> (
list[
Union[
ElecPriceAkkudoktor,
ElecPriceEnergyCharts,
ElecPriceTibber,
ElecPriceFixed,
ElecPriceImport,
FeedInTariffEnergyCharts,
FeedInTariffFixed,
FeedInTariffImport,
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
LoadVrm,
LoadImport,
PVForecastAkkudoktor,
PVForecastVrm,
PVForecastPVNode,
PVForecastForecastSolar,
PVForecastSolcast,
PVForecastImport,
WeatherBrightSky,
WeatherClearOutside,
WeatherOpenMeteo,
WeatherImport,
]
]
]:
):
"""Return list of prediction providers.
Factory for prediction container.
@@ -139,7 +139,6 @@ def prediction_providers() -> list[
elecprice_tibber, \
elecprice_fixed, \
elecprice_import, \
elecprice_tibber, \
feedintariff_energy_charts, \
feedintariff_fixed, \
feedintariff_import, \
@@ -165,7 +164,6 @@ def prediction_providers() -> list[
elecprice_tibber,
elecprice_fixed,
elecprice_import,
elecprice_tibber,
feedintariff_energy_charts,
feedintariff_fixed,
feedintariff_import,
@@ -196,7 +194,6 @@ class Prediction(PredictionContainer):
ElecPriceTibber,
ElecPriceFixed,
ElecPriceImport,
ElecPriceTibber,
FeedInTariffEnergyCharts,
FeedInTariffFixed,
FeedInTariffImport,
+32 -35
View File
@@ -149,9 +149,8 @@ class VisualizationReport(ConfigMixin):
"""Create a line chart and add it to the current group."""
def chart() -> None:
timestamps = [
start_date.add(hours=i) for i in range(len(y_list[0]))
] # 840 timestamps at 1-hour intervals
interval_s = int(self.config.optimization.interval or 3600)
timestamps = [start_date.add(seconds=i * interval_s) for i in range(len(y_list[0]))]
for idx, y_data in enumerate(y_list):
label = labels[idx] if labels else None # Chart label
@@ -208,9 +207,10 @@ class VisualizationReport(ConfigMixin):
# ax2.set_xticks(timestamps[::48]) # Set ticks every 12 hours
# ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[::48]])
# ax2.set_xticks(timestamps[:: len(timestamps) // 24]) # Select 10 evenly spaced ticks
ax2.set_xticks(timestamps[:: len(timestamps) // 12]) # Select 10 evenly spaced ticks
tick_step = max(1, len(timestamps) // 12)
ax2.set_xticks(timestamps[::tick_step])
# ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[:: len(timestamps) // 24]])
ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[:: len(timestamps) // 12]])
ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[::tick_step]])
if x2label:
ax2.set_xlabel(x2label)
@@ -443,12 +443,14 @@ def prepare_visualize(
global debug_visualize
report = VisualizationReport(filename)
next_full_hour_date = get_ems().start_datetime
start_datetime = get_ems().start_datetime
start_slot = start_hour # Backwards-compatible argument name; value is a slot index.
interval_s = int(report.config.optimization.interval or 3600)
# Group 1:
report.create_line_chart_date(
next_full_hour_date,
start_datetime,
[
parameters.ems.gesamtlast[start_hour:],
parameters.ems.gesamtlast[start_slot:],
],
title="Load Profile",
# xlabel="Hours", # not enough space
@@ -456,9 +458,9 @@ def prepare_visualize(
labels=["Total Load (Wh)"],
)
report.create_line_chart_date(
next_full_hour_date,
start_datetime,
[
parameters.ems.pv_prognose_wh[start_hour:],
parameters.ems.pv_prognose_wh[start_slot:],
],
title="PV Forecast",
# xlabel="Hours", # not enough space
@@ -466,11 +468,11 @@ def prepare_visualize(
)
report.create_line_chart_date(
next_full_hour_date,
start_datetime,
[
np.full(
len(parameters.ems.gesamtlast) - start_hour,
parameters.ems.einspeiseverguetung_euro_pro_wh[start_hour:]
len(parameters.ems.gesamtlast) - start_slot,
parameters.ems.einspeiseverguetung_euro_pro_wh[start_slot:]
if isinstance(parameters.ems.einspeiseverguetung_euro_pro_wh, list)
else parameters.ems.einspeiseverguetung_euro_pro_wh,
)
@@ -482,9 +484,9 @@ def prepare_visualize(
)
if parameters.temperature_forecast:
report.create_line_chart_date(
next_full_hour_date,
start_datetime,
[
parameters.temperature_forecast[start_hour:],
parameters.temperature_forecast[start_slot:],
],
title="Temperature Forecast",
# xlabel="Hours", # not enough space
@@ -495,7 +497,7 @@ def prepare_visualize(
# Group 2:
report.create_line_chart_date(
next_full_hour_date, # start_date
start_datetime,
[
results["result"]["Last_Wh_pro_Stunde"],
results["result"]["Home_appliance_wh_per_hour"],
@@ -503,7 +505,7 @@ def prepare_visualize(
results["result"]["Netzbezug_Wh_pro_Stunde"],
results["result"]["Verluste_Pro_Stunde"],
],
title="Energy Flow per Hour",
title="Energy Flow per Interval",
# xlabel="Date", # not enough space
ylabel="Energy (Wh)",
labels=[
@@ -520,7 +522,7 @@ def prepare_visualize(
# Group 3:
report.create_line_chart_date(
next_full_hour_date, # start_date
start_datetime,
[results["result"]["akku_soc_pro_stunde"], results["result"]["EAuto_SoC_pro_Stunde"]],
title="Battery SOC",
# xlabel="Date", # not enough space
@@ -532,27 +534,22 @@ def prepare_visualize(
markers=["o", "x"],
)
report.create_line_chart_date(
next_full_hour_date, # start_date
[parameters.ems.strompreis_euro_pro_wh[start_hour:]],
start_datetime,
[parameters.ems.strompreis_euro_pro_wh[start_slot:]],
# title="Electricity Price", # not enough space
# xlabel="Date", # not enough space
ylabel="Electricity Price (€/Wh)",
x2label=None, # not enough space
)
labels = list(
item
for sublist in zip(
list(str(i) for i in range(0, 23, 2)), list(str(" ") for i in range(0, 23, 2))
)
for item in sublist
)
labels = labels[start_hour:] + labels
charge_discharge_series = [
results["ac_charge"][start_hour:],
results["dc_charge"][start_hour:],
results["discharge_allowed"][start_hour:],
results["ac_charge"][start_slot:],
results["dc_charge"][start_slot:],
results["discharge_allowed"][start_slot:],
]
labels = [
start_datetime.add(seconds=i * interval_s).format("HH:mm")
for i in range(len(charge_discharge_series[0]))
]
charge_discharge_labels = [
"AC Charging (relative)",
@@ -561,7 +558,7 @@ def prepare_visualize(
]
charge_discharge_colors = ["blue", "green", "red"]
if results.get("battery_grid_export_allowed"):
charge_discharge_series.append(results["battery_grid_export_allowed"][start_hour:])
charge_discharge_series.append(results["battery_grid_export_allowed"][start_slot:])
charge_discharge_labels.append("Battery Grid Export Allowed")
charge_discharge_colors.append("purple")
@@ -580,12 +577,12 @@ def prepare_visualize(
# Group 4:
report.create_line_chart_date(
next_full_hour_date, # start_date
start_datetime,
[
results["result"]["Kosten_Euro_pro_Stunde"],
results["result"]["Einnahmen_Euro_pro_Stunde"],
],
title="Financial Balance per Hour",
title="Financial Balance per Interval",
# xlabel="Date", # not enough space
ylabel="Euro",
labels=["Costs", "Revenue"],