mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-31 20:56:41 +00:00
feat: complete 15-minute optimization support
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user