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
+3
View File
@@ -0,0 +1,3 @@
http://192.168.1.175:8503/v1/measurement/keys -> Keys auslesen
grid_import_mr
grid_export_mr
+6
View File
@@ -20,6 +20,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
default 3600 s interval keeps the previous hourly behaviour. The new sub-hourly PV default 3600 s interval keeps the previous hourly behaviour. The new sub-hourly PV
providers (pvnode, Forecast.Solar, Solcast) feed their native resolution straight providers (pvnode, Forecast.Solar, Solcast) feed their native resolution straight
into the quarter-hour grid. into the quarter-hour grid.
- Legacy hourly API inputs are normalized onto the quarter-hour grid: PV/load energy
is distributed across four slots, prices are held constant, and hourly warm-start
solutions are expanded to slot controls. Native slot arrays are preserved and
ambiguous lengths are rejected.
- Home-appliance scheduling remains hourly and is therefore rejected for sub-hourly
optimization instead of being simulated with incorrect slot indices.
- The Tibber electricity price provider now requests native 15-minute exchange prices - The Tibber electricity price provider now requests native 15-minute exchange prices
(`priceInfoRange(resolution: QUARTER_HOURLY)`) and stores them at their native (`priceInfoRange(resolution: QUARTER_HOURLY)`) and stores them at their native
resolution, so both the hourly and the 15-minute optimizer are fed the correct resolution, so both the hourly and the 15-minute optimizer are fed the correct
+7
View File
@@ -149,6 +149,13 @@ The energy management can be run in three modes:
Use `900` together with a 15-minute electricity price source (for example a dynamic or Use `900` together with a 15-minute electricity price source (for example a dynamic or
exchange-priced tariff) to let the optimizer schedule on a quarter-hour grid. Keeping the exchange-priced tariff) to let the optimizer schedule on a quarter-hour grid. Keeping the
default `3600` preserves the previous hourly behaviour. default `3600` preserves the previous hourly behaviour.
Legacy hourly API input is accepted at `900`: energy values are distributed over four slots,
while electricity prices and feed-in tariffs are held constant. Native quarter-hour arrays are
used unchanged. Other input lengths are rejected to prevent a shortened simulation horizon.
Home-appliance scheduling currently remains hourly and is not supported with a sub-hourly
optimization interval.
::: :::
#### Genetic Algorithm Parameters #### Genetic Algorithm Parameters
File diff suppressed because one or more lines are too long
+8 -3
View File
@@ -96,8 +96,8 @@ class EnergyManagement(
If no datetime is provided, the current datetime is used. If no datetime is provided, the current datetime is used.
The start datetime is always rounded down to the nearest hour The start datetime is rounded down to the configured optimization
(i.e., setting minutes, seconds, and microseconds to zero). interval. For a 15-minute grid this yields :00, :15, :30 or :45.
Args: Args:
start_datetime (Optional[DateTime]): The datetime to set as the start. start_datetime (Optional[DateTime]): The datetime to set as the start.
@@ -108,7 +108,12 @@ class EnergyManagement(
""" """
if start_datetime is None: if start_datetime is None:
start_datetime = to_datetime() 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 return cls._start_datetime
@classmethod @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.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.discharge_array = np.full(self.prediction_hours, 0)
self.charge_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.soc_wh = (self.initial_soc_percentage / 100) * self.capacity_wh
self.min_soc_wh = (self.min_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 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.soc_wh = min(self.soc_wh, self.max_soc_wh) # Only clamp to max
self.discharge_array = np.full(self.prediction_hours, 0) self.discharge_array = np.full(self.prediction_hours, 0)
self.charge_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: def set_discharge_per_hour(self, discharge_array: np.ndarray) -> None:
"""Sets the discharge values for each hour.""" """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. # 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 # max_charge_power_w is a power [W]; energy movable in one slot is
# power x slot_duration_h. # power x slot_duration_h.
max_raw_wh = ( max_raw_wh = max(
self.max_charge_power_w * self.slot_duration_h 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 ) # TODO rename to max_discharge_power_w
# Actual raw withdrawal (internal) # Actual raw withdrawal (internal)
@@ -170,6 +184,7 @@ class Battery:
# Update SoC # Update SoC
self.soc_wh -= raw_used_wh self.soc_wh -= raw_used_wh
self.soc_wh = max(self.soc_wh, self.min_soc_wh) self.soc_wh = max(self.soc_wh, self.min_soc_wh)
self._discharged_raw_wh_per_slot[hour] += raw_used_wh
# Losses # Losses
losses_wh = raw_used_wh - delivered_wh losses_wh = raw_used_wh - delivered_wh
@@ -246,7 +261,10 @@ class Battery:
soc_wh_fast = self.soc_wh soc_wh_fast = self.soc_wh
# Scale the power cap [W] to a per-slot energy cap [Wh] (W x slot hours). # 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. # 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 charging_efficiency_fast = self.charging_efficiency
# Decide mode & determine raw_request_wh and raw_charge_wh # Decide mode & determine raw_request_wh and raw_charge_wh
@@ -290,6 +308,7 @@ class Battery:
) )
self.soc_wh = new_soc self.soc_wh = new_soc
self._charged_raw_wh_per_slot[hour] += raw_input_wh
losses_wh = raw_input_wh - stored_wh losses_wh = raw_input_wh - stored_wh
return stored_wh, losses_wh return stored_wh, losses_wh
+9 -10
View File
@@ -74,9 +74,12 @@ class Inverter:
grid_import = -remaining_power # Negative indicates feeding into the grid grid_import = -remaining_power # Negative indicates feeding into the grid
self_consumption = self.max_power_wh self_consumption = self.max_power_wh
else: 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( scr = self.self_consumption_predictor.calculate_self_consumption(
consumption, generation consumption / self.slot_duration_h, generation / self.slot_duration_h
) )
# Remaining power after consumption # Remaining power after consumption
@@ -133,10 +136,8 @@ class Inverter:
if allow_battery_grid_export and self.battery: if allow_battery_grid_export and self.battery:
export_capacity = max(self.max_power_wh - consumption - grid_export, 0.0) export_capacity = max(self.max_power_wh - consumption - grid_export, 0.0)
max_discharge_dc = getattr(self.battery, "max_charge_power_w", None) remaining_battery_ac = (
if max_discharge_dc is not None: self.battery.remaining_discharge_energy_wh(hour) * dc_to_ac_eff
remaining_battery_ac = max(
(max_discharge_dc - from_battery_dc) * dc_to_ac_eff, 0.0
) )
export_capacity = min(export_capacity, remaining_battery_ac) export_capacity = min(export_capacity, remaining_battery_ac)
battery_export_ac, battery_export_losses = self._discharge_battery_to_ac( battery_export_ac, battery_export_losses = self._discharge_battery_to_ac(
@@ -171,10 +172,8 @@ class Inverter:
if allow_battery_grid_export and self.battery and grid_import <= 0.0: if allow_battery_grid_export and self.battery and grid_import <= 0.0:
export_capacity = max(self.max_power_wh - consumption, 0.0) export_capacity = max(self.max_power_wh - consumption, 0.0)
max_discharge_dc = getattr(self.battery, "max_charge_power_w", None) remaining_battery_ac = (
if max_discharge_dc is not None: self.battery.remaining_discharge_energy_wh(hour) * dc_to_ac_eff
remaining_battery_ac = max(
(max_discharge_dc - battery_discharge_dc) * dc_to_ac_eff, 0.0
) )
export_capacity = min(export_capacity, remaining_battery_ac) export_capacity = min(export_capacity, remaining_battery_ac)
battery_export_ac, battery_export_losses = self._discharge_battery_to_ac( battery_export_ac, battery_export_losses = self._discharge_battery_to_ac(
+153 -24
View File
@@ -100,9 +100,7 @@ class GeneticSimulation(PydanticBaseModel):
) )
bat_grid_export_hours: Optional[NDArray[Shape["*"], float]] = Field( bat_grid_export_hours: Optional[NDArray[Shape["*"], float]] = Field(
default=None, default=None,
json_schema_extra={ json_schema_extra={"description": "Hourly permission for battery discharge into the grid."},
"description": "Hourly permission for battery discharge into the grid."
},
) )
ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field( ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field(
default=None, json_schema_extra={"description": "TBD"} default=None, json_schema_extra={"description": "TBD"}
@@ -453,9 +451,7 @@ class GeneticSimulation(PydanticBaseModel):
# Financial calculations # Financial calculations
costs_per_hour[hour_idx] = energy_consumption_grid_actual * hourly_electricity_price costs_per_hour[hour_idx] = energy_consumption_grid_actual * hourly_electricity_price
revenue_per_hour[hour_idx] = ( revenue_per_hour[hour_idx] = energy_feedin_grid_actual * hourly_feed_in_tariff
energy_feedin_grid_actual * hourly_feed_in_tariff
)
total_cost = np.nansum(costs_per_hour) total_cost = np.nansum(costs_per_hour)
total_losses = np.nansum(losses_wh_per_hour) total_losses = np.nansum(losses_wh_per_hour)
@@ -529,12 +525,23 @@ class GeneticOptimization(OptimizationBase):
fixed_seed: Optional[int] = None, fixed_seed: Optional[int] = None,
): ):
"""Initialize the optimization problem with the required parameters.""" """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] = {} self.opti_param: dict[str, Any] = {}
# Number of slots at the tail of the optimization window where EV # 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 # 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). # tail length (at interval=3600 s this equals prediction.hours - horizon).
self.fixed_eauto_hours = self.total_slots - ( self.fixed_eauto_hours = max(
self.config.optimization.horizon_hours * self.slots_per_hour 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] self.ev_possible_charge_values: list[float] = [1.0]
# Separate charge-level list for battery AC charging (independent of EV rates). # Separate charge-level list for battery AC charging (independent of EV rates).
@@ -580,15 +587,111 @@ class GeneticOptimization(OptimizationBase):
return parameters return parameters
ems_parameters = parameters.ems.model_copy( ems_parameters = parameters.ems.model_copy(
update={ update={"einspeiseverguetung_euro_pro_wh": list(parameters.ems.strompreis_euro_pro_wh)},
"einspeiseverguetung_euro_pro_wh": list(
parameters.ems.strompreis_euro_pro_wh
)
},
deep=True, deep=True,
) )
return parameters.model_copy(update={"ems": ems_parameters}, 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( def decode_charge_discharge(
self, discharge_hours_bin: np.ndarray self, discharge_hours_bin: np.ndarray
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
@@ -813,8 +916,15 @@ class GeneticOptimization(OptimizationBase):
self.toolbox.register("mate", tools.cxTwoPoint) self.toolbox.register("mate", tools.cxTwoPoint)
# Mutation operator for battery charge/discharge states # 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( 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) # Mutation operator for EV states (separate index space)
@@ -823,7 +933,7 @@ class GeneticOptimization(OptimizationBase):
tools.mutUniformInt, tools.mutUniformInt,
low=0, low=0,
up=len_ev - 1, up=len_ev - 1,
indpb=0.2, indpb=mutation_probability,
) )
# Mutation for household appliance # Mutation for household appliance
@@ -1106,8 +1216,10 @@ class GeneticOptimization(OptimizationBase):
if best_uncovered_price < break_even_price: if best_uncovered_price < break_even_price:
# AC charging at this hour is economically unjustified. # AC charging at this hour is economically unjustified.
# Penalty = excess cost per Wh × DC energy requested this hour. # Penalty = excess cost per Wh × DC energy requested this slot.
dc_wh = bat.max_charge_power_w * ac_factor # 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) ac_wh = dc_wh / max(inv.ac_to_dc_efficiency, 1e-9)
excess_cost_per_wh = break_even_price - best_uncovered_price excess_cost_per_wh = break_even_price - best_uncovered_price
gesamtbilanz += ac_wh * excess_cost_per_wh * ac_penalty_factor 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). @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 # Set the number of inviduals in a generation
try: try:
individuals = self.config.optimization.genetic.individuals individuals = self.config.optimization.genetic.individuals
@@ -1157,14 +1275,16 @@ class GeneticOptimization(OptimizationBase):
logger.debug("Start optimize: {}", start_solution) logger.debug("Start optimize: {}", start_solution)
# Insert the start solution into the population if provided and compatible with the # 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. # so a cached solution from a previous run without EV optimization must not be reused.
if start_solution is not None: if start_solution is not None:
expected_length = self.config.prediction.hours has_appliance = self.opti_param.get("home_appliance", 0) > 0
if self.optimize_ev: expected_length = self.total_slots * (2 if self.optimize_ev else 1)
expected_length += self.config.prediction.hours if has_appliance:
if self.opti_param.get("home_appliance", 0) > 0:
expected_length += 1 expected_length += 1
start_solution = self._start_solution_for_slot_grid(
start_solution, has_appliance=has_appliance
)
if len(start_solution) == expected_length: if len(start_solution) == expected_length:
for _ in range(10): for _ in range(10):
@@ -1218,6 +1338,12 @@ class GeneticOptimization(OptimizationBase):
"""Perform EMS (Energy Management System) optimization and visualize results.""" """Perform EMS (Energy Management System) optimization and visualize results."""
direct_marketing_enabled = self._direct_marketing_enabled() direct_marketing_enabled = self._direct_marketing_enabled()
parameters = self._parameters_for_config(parameters) 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_dc_charge = direct_marketing_enabled
self.optimize_battery_grid_export = direct_marketing_enabled self.optimize_battery_grid_export = direct_marketing_enabled
@@ -1399,7 +1525,10 @@ class GeneticOptimization(OptimizationBase):
else: else:
battery_grid_export = battery_grid_export.tolist() battery_grid_export = battery_grid_export.tolist()
# Visualize the results in PDF # 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: try:
from akkudoktoreos.utils.visualize import prepare_visualize from akkudoktoreos.utils.visualize import prepare_visualize
@@ -1418,7 +1547,7 @@ class GeneticOptimization(OptimizationBase):
"fixed_seed": self.fix_seed, "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: except Exception as ex:
error_msg = f"Visualization failed: {ex}" error_msg = f"Visualization failed: {ex}"
@@ -227,7 +227,7 @@ class GeneticOptimizationParameters(
# Add forecast and device data # Add forecast and device data
interval = to_duration(cls.config.optimization.interval) interval = to_duration(cls.config.optimization.interval)
power_to_energy_per_interval_factor = cls.config.optimization.interval / 3600 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) parameter_end_datetime = parameter_start_datetime.add(hours=cls.config.prediction.hours)
max_retries = 10 max_retries = 10
@@ -248,7 +248,11 @@ class GeneticOptimizationParameters(
start_datetime=parameter_start_datetime, start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime, end_datetime=parameter_end_datetime,
interval=interval, 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 * power_to_energy_per_interval_factor
).tolist() ).tolist()
@@ -585,8 +589,12 @@ class GeneticOptimizationParameters(
# Home Appliances # Home Appliances
# --------------- # ---------------
if cls.config.devices.max_home_appliances is None: if cls.config.devices.max_home_appliances is None:
logger.info("Number of home appliance devices not configured - defaulting to 1.") default_home_appliances = 0 if cls.config.optimization.interval < 3600 else 1
cls.config.devices.max_home_appliances = 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: if cls.config.devices.max_home_appliances == 0:
home_appliance_params = None home_appliance_params = None
else: else:
@@ -177,7 +177,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
default_factory=list, default_factory=list,
json_schema_extra={ json_schema_extra={
"description": "Array with battery-to-grid export values (1 for export discharge, 0 otherwise)." "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"}) eautocharge_hours_float: Optional[list[float]] = Field(json_schema_extra={"description": "TBD"})
result: GeneticSimulationResult result: GeneticSimulationResult
@@ -331,7 +331,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
Clamping rules: Clamping rules:
- AC charge factor: scaled down proportionally when the battery - AC charge factor: scaled down proportionally when the battery
headroom (max_soc current_soc) is smaller than what the 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 - DC charge factor (PV): zeroed when battery is at or above max SOC
(the inverter curtails automatically, but this makes intent clear). (the inverter curtails automatically, but this makes intent clear).
- Discharge: blocked when SOC is at or below min SOC. - 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 if inv_list and inv_list[0].max_ac_charge_power_w is not None
else float(bat.max_charge_power_w) else float(bat.max_charge_power_w)
) )
max_dc_per_h_wh = effective_ac * max_ac_cp_w * ac_to_dc_eff * ch_eff # Energy storable in one optimization slot, not per hour: scale the
if max_dc_per_h_wh > headroom_wh: # power [W] by the slot duration (1.0 hourly, 0.25 at 15 min).
effective_ac = effective_ac * (headroom_wh / max_dc_per_h_wh) 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 --- # --- DC charge (PV): zero when battery is full ---
effective_dc = dc_charge 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 [ for pred_key, pred_fill_method, pred_solution_key, pred_solution_factor in [
( (
"pvforecast_ac_power", "pvforecast_ac_power",
"linear", "ffill",
"pvforecast_ac_energy_wh", "pvforecast_ac_energy_wh",
power_to_energy_per_interval_factor, power_to_energy_per_interval_factor,
), ),
( (
"pvforecast_dc_power", "pvforecast_dc_power",
"linear", "ffill",
"pvforecast_dc_energy_wh", "pvforecast_dc_energy_wh",
power_to_energy_per_interval_factor, power_to_energy_per_interval_factor,
), ),
@@ -637,7 +642,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
), ),
( (
"feed_in_tariff_wh", "feed_in_tariff_wh",
"linear", "ffill",
"feed_in_tariff_amt_kwh", "feed_in_tariff_amt_kwh",
1000.0, 1000.0,
), ),
@@ -649,19 +654,19 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
), ),
( (
"loadforecast_power_w", "loadforecast_power_w",
"linear", "ffill",
"loadforecast_energy_wh", "loadforecast_energy_wh",
power_to_energy_per_interval_factor, power_to_energy_per_interval_factor,
), ),
( (
"loadakkudoktor_std_power_w", "loadakkudoktor_std_power_w",
"linear", "ffill",
"loadakkudoktor_std_energy_wh", "loadakkudoktor_std_energy_wh",
power_to_energy_per_interval_factor, power_to_energy_per_interval_factor,
), ),
( (
"loadakkudoktor_mean_power_w", "loadakkudoktor_mean_power_w",
"linear", "ffill",
"loadakkudoktor_mean_energy_wh", "loadakkudoktor_mean_energy_wh",
power_to_energy_per_interval_factor, 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( genetic: GeneticCommonSettings = Field(
default_factory=GeneticCommonSettings, default_factory=GeneticCommonSettings,
json_schema_extra={ json_schema_extra={
@@ -176,6 +176,19 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
return series_data 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: def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
mean = data.mean() mean = data.mean()
std = data.std() std = data.std()
@@ -250,37 +263,64 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
f"No Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}" 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 if not self.highest_orig_datetime: # mypy fix
error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}" error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}"
logger.error(error_msg) logger.error(error_msg)
raise ValueError(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 raw_series = self.key_to_series(
needed_hours = int( key="elecprice_marketprice_wh",
self.config.prediction.hours end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1),
- ((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600) )
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( 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 ) # this might keep data longer than self.ems_start_datetime + self.config.prediction.hours in the records
return return
if amount_datasets > 800: # we do the full ets with seasons of 1 week weekly_history_slots = 800 * slots_per_hour
prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours) daily_history_slots = 168 * slots_per_hour
elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day if len(history) > weekly_history_slots:
prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours) prediction = self._predict_ets(
elif amount_datasets > 0: # not enough data for ets, do median history,
prediction = self._predict_median(history, hours=needed_hours) 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: else:
logger.error("No data available for prediction") logger.error("No data available for prediction")
raise ValueError("No data available") raise ValueError("No data available")
@@ -289,7 +329,7 @@ class ElecPriceEnergyCharts(ElecPriceProvider):
prediction_series = pd.Series( prediction_series = pd.Series(
data=prediction, data=prediction,
index=[ 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)) for i in range(len(prediction))
], ],
) )
@@ -34,7 +34,40 @@ query TibberPriceInfo {
total 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 { nodes {
startsAt startsAt
total total
@@ -185,9 +218,17 @@ class ElecPriceTibber(ElecPriceProvider):
if not access_token: if not access_token:
raise ValueError("Tibber access_token is required") raise ValueError("Tibber access_token is required")
# 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( response = requests.post(
TIBBER_GRAPHQL_URL, TIBBER_GRAPHQL_URL,
json={"query": TIBBER_PRICE_QUERY}, json={"query": query},
headers={ headers={
"Authorization": f"Bearer {access_token}", "Authorization": f"Bearer {access_token}",
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -195,7 +236,20 @@ class ElecPriceTibber(ElecPriceProvider):
timeout=30, timeout=30,
) )
logger.debug(f"Response from Tibber GraphQL API: {response}") 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() 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) tibber_data = self._validate_data(response.content)
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return tibber_data return tibber_data
@@ -85,15 +85,18 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
series_data.at[orig_datetime] = price_eur_per_mwh / 1_000_000 series_data.at[orig_datetime] = price_eur_per_mwh / 1_000_000
return series_data return series_data
def _predict_prices(self, history, hours: int): def _predict_prices(self, history, slots: int, slots_per_hour: int):
energycharts = ElecPriceEnergyCharts() energycharts = ElecPriceEnergyCharts()
amount_datasets = len(self.records) if len(history) > 800 * slots_per_hour:
if amount_datasets > 800: return energycharts._predict_ets(
return energycharts._predict_ets(history, seasonal_periods=168, hours=hours) history, seasonal_periods=168 * slots_per_hour, hours=slots
if amount_datasets > 168: )
return energycharts._predict_ets(history, seasonal_periods=24, hours=hours) if len(history) > 168 * slots_per_hour:
if amount_datasets > 0: return energycharts._predict_ets(
return energycharts._predict_median(history, hours=hours) 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") logger.error("No feed-in tariff data available for Energy-Charts prediction")
raise ValueError("No data available") raise ValueError("No data available")
@@ -141,38 +144,52 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
self.highest_orig_datetime, 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: if not self.highest_orig_datetime:
error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}" error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}"
logger.error(error_msg) logger.error(error_msg)
raise ValueError(error_msg) raise ValueError(error_msg)
needed_hours = int( raw_series = self.key_to_series(
self.config.prediction.hours key="feed_in_tariff_wh",
- ((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600) 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( logger.warning(
"No feed-in tariff prediction needed. needed_hours={}, hours={}, " "No feed-in tariff prediction needed. needed_slots={}, hours={}, "
"highest_orig_datetime={}, start_datetime={}", "resolution_seconds={}, highest_orig_datetime={}, start_datetime={}",
needed_hours, needed_slots,
self.config.prediction.hours, self.config.prediction.hours,
resolution_seconds,
self.highest_orig_datetime, self.highest_orig_datetime,
self.ems_start_datetime, self.ems_start_datetime,
) )
return return
prediction = self._predict_prices(history, needed_hours) prediction = self._predict_prices(history, needed_slots, slots_per_hour)
prediction_series = pd.Series( prediction_series = pd.Series(
data=prediction, data=prediction,
index=[ 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)) for i in range(len(prediction))
], ],
) )
+22 -9
View File
@@ -15,31 +15,44 @@ class SelfConsumptionProbabilityInterpolator:
# Load the RegularGridInterpolator # Load the RegularGridInterpolator
with open(self.filepath, "rb") as file: with open(self.filepath, "rb") as file:
self.interpolator: RegularGridInterpolator = pickle.load(file) # noqa: S301 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( 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]: ) -> tuple[np.ndarray, np.ndarray]:
"""Generate the grid points for interpolation.""" """Generate in-bounds 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 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 return points, partial_loads
@cache_energy_management @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. """Calculate the PV self-consumption rate using RegularGridInterpolator.
The results are cached until the start of the next energy management run/ optimization. The results are cached until the start of the next energy management run/ optimization.
Args: Args:
- last_1h_power: 1h power levels (W). - mean_load_power_w: Mean load power for the current forecast interval (W).
- pv_power: Current PV power output (W). - pv_power_w: Current PV power output (W).
Returns: Returns:
- Self-consumption rate as a float. - 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) 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: # def calculate_self_consumption(self, load_1h_power: float, pv_power: float) -> float:
# """Calculate the PV self-consumption rate using RegularGridInterpolator. # """Calculate the PV self-consumption rate using RegularGridInterpolator.
+4 -7
View File
@@ -82,7 +82,6 @@ elecprice_energy_charts = ElecPriceEnergyCharts()
elecprice_tibber = ElecPriceTibber() elecprice_tibber = ElecPriceTibber()
elecprice_fixed = ElecPriceFixed() elecprice_fixed = ElecPriceFixed()
elecprice_import = ElecPriceImport() elecprice_import = ElecPriceImport()
elecprice_tibber = ElecPriceTibber()
feedintariff_energy_charts = FeedInTariffEnergyCharts() feedintariff_energy_charts = FeedInTariffEnergyCharts()
feedintariff_fixed = FeedInTariffFixed() feedintariff_fixed = FeedInTariffFixed()
feedintariff_import = FeedInTariffImport() feedintariff_import = FeedInTariffImport()
@@ -102,14 +101,14 @@ weather_openmeteo = WeatherOpenMeteo()
weather_import = WeatherImport() weather_import = WeatherImport()
def prediction_providers() -> list[ def prediction_providers() -> (
list[
Union[ Union[
ElecPriceAkkudoktor, ElecPriceAkkudoktor,
ElecPriceEnergyCharts, ElecPriceEnergyCharts,
ElecPriceTibber, ElecPriceTibber,
ElecPriceFixed, ElecPriceFixed,
ElecPriceImport, ElecPriceImport,
ElecPriceTibber,
FeedInTariffEnergyCharts, FeedInTariffEnergyCharts,
FeedInTariffFixed, FeedInTariffFixed,
FeedInTariffImport, FeedInTariffImport,
@@ -128,7 +127,8 @@ def prediction_providers() -> list[
WeatherOpenMeteo, WeatherOpenMeteo,
WeatherImport, WeatherImport,
] ]
]: ]
):
"""Return list of prediction providers. """Return list of prediction providers.
Factory for prediction container. Factory for prediction container.
@@ -139,7 +139,6 @@ def prediction_providers() -> list[
elecprice_tibber, \ elecprice_tibber, \
elecprice_fixed, \ elecprice_fixed, \
elecprice_import, \ elecprice_import, \
elecprice_tibber, \
feedintariff_energy_charts, \ feedintariff_energy_charts, \
feedintariff_fixed, \ feedintariff_fixed, \
feedintariff_import, \ feedintariff_import, \
@@ -165,7 +164,6 @@ def prediction_providers() -> list[
elecprice_tibber, elecprice_tibber,
elecprice_fixed, elecprice_fixed,
elecprice_import, elecprice_import,
elecprice_tibber,
feedintariff_energy_charts, feedintariff_energy_charts,
feedintariff_fixed, feedintariff_fixed,
feedintariff_import, feedintariff_import,
@@ -196,7 +194,6 @@ class Prediction(PredictionContainer):
ElecPriceTibber, ElecPriceTibber,
ElecPriceFixed, ElecPriceFixed,
ElecPriceImport, ElecPriceImport,
ElecPriceTibber,
FeedInTariffEnergyCharts, FeedInTariffEnergyCharts,
FeedInTariffFixed, FeedInTariffFixed,
FeedInTariffImport, FeedInTariffImport,
+32 -35
View File
@@ -149,9 +149,8 @@ class VisualizationReport(ConfigMixin):
"""Create a line chart and add it to the current group.""" """Create a line chart and add it to the current group."""
def chart() -> None: def chart() -> None:
timestamps = [ interval_s = int(self.config.optimization.interval or 3600)
start_date.add(hours=i) for i in range(len(y_list[0])) timestamps = [start_date.add(seconds=i * interval_s) for i in range(len(y_list[0]))]
] # 840 timestamps at 1-hour intervals
for idx, y_data in enumerate(y_list): for idx, y_data in enumerate(y_list):
label = labels[idx] if labels else None # Chart label 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_xticks(timestamps[::48]) # Set ticks every 12 hours
# ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[::48]]) # 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) // 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) // 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: if x2label:
ax2.set_xlabel(x2label) ax2.set_xlabel(x2label)
@@ -443,12 +443,14 @@ def prepare_visualize(
global debug_visualize global debug_visualize
report = VisualizationReport(filename) 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: # Group 1:
report.create_line_chart_date( report.create_line_chart_date(
next_full_hour_date, start_datetime,
[ [
parameters.ems.gesamtlast[start_hour:], parameters.ems.gesamtlast[start_slot:],
], ],
title="Load Profile", title="Load Profile",
# xlabel="Hours", # not enough space # xlabel="Hours", # not enough space
@@ -456,9 +458,9 @@ def prepare_visualize(
labels=["Total Load (Wh)"], labels=["Total Load (Wh)"],
) )
report.create_line_chart_date( 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", title="PV Forecast",
# xlabel="Hours", # not enough space # xlabel="Hours", # not enough space
@@ -466,11 +468,11 @@ def prepare_visualize(
) )
report.create_line_chart_date( report.create_line_chart_date(
next_full_hour_date, start_datetime,
[ [
np.full( np.full(
len(parameters.ems.gesamtlast) - start_hour, len(parameters.ems.gesamtlast) - start_slot,
parameters.ems.einspeiseverguetung_euro_pro_wh[start_hour:] parameters.ems.einspeiseverguetung_euro_pro_wh[start_slot:]
if isinstance(parameters.ems.einspeiseverguetung_euro_pro_wh, list) if isinstance(parameters.ems.einspeiseverguetung_euro_pro_wh, list)
else parameters.ems.einspeiseverguetung_euro_pro_wh, else parameters.ems.einspeiseverguetung_euro_pro_wh,
) )
@@ -482,9 +484,9 @@ def prepare_visualize(
) )
if parameters.temperature_forecast: if parameters.temperature_forecast:
report.create_line_chart_date( report.create_line_chart_date(
next_full_hour_date, start_datetime,
[ [
parameters.temperature_forecast[start_hour:], parameters.temperature_forecast[start_slot:],
], ],
title="Temperature Forecast", title="Temperature Forecast",
# xlabel="Hours", # not enough space # xlabel="Hours", # not enough space
@@ -495,7 +497,7 @@ def prepare_visualize(
# Group 2: # Group 2:
report.create_line_chart_date( report.create_line_chart_date(
next_full_hour_date, # start_date start_datetime,
[ [
results["result"]["Last_Wh_pro_Stunde"], results["result"]["Last_Wh_pro_Stunde"],
results["result"]["Home_appliance_wh_per_hour"], results["result"]["Home_appliance_wh_per_hour"],
@@ -503,7 +505,7 @@ def prepare_visualize(
results["result"]["Netzbezug_Wh_pro_Stunde"], results["result"]["Netzbezug_Wh_pro_Stunde"],
results["result"]["Verluste_Pro_Stunde"], results["result"]["Verluste_Pro_Stunde"],
], ],
title="Energy Flow per Hour", title="Energy Flow per Interval",
# xlabel="Date", # not enough space # xlabel="Date", # not enough space
ylabel="Energy (Wh)", ylabel="Energy (Wh)",
labels=[ labels=[
@@ -520,7 +522,7 @@ def prepare_visualize(
# Group 3: # Group 3:
report.create_line_chart_date( 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"]], [results["result"]["akku_soc_pro_stunde"], results["result"]["EAuto_SoC_pro_Stunde"]],
title="Battery SOC", title="Battery SOC",
# xlabel="Date", # not enough space # xlabel="Date", # not enough space
@@ -532,27 +534,22 @@ def prepare_visualize(
markers=["o", "x"], markers=["o", "x"],
) )
report.create_line_chart_date( report.create_line_chart_date(
next_full_hour_date, # start_date start_datetime,
[parameters.ems.strompreis_euro_pro_wh[start_hour:]], [parameters.ems.strompreis_euro_pro_wh[start_slot:]],
# title="Electricity Price", # not enough space # title="Electricity Price", # not enough space
# xlabel="Date", # not enough space # xlabel="Date", # not enough space
ylabel="Electricity Price (€/Wh)", ylabel="Electricity Price (€/Wh)",
x2label=None, # not enough space 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 = [ charge_discharge_series = [
results["ac_charge"][start_hour:], results["ac_charge"][start_slot:],
results["dc_charge"][start_hour:], results["dc_charge"][start_slot:],
results["discharge_allowed"][start_hour:], 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 = [ charge_discharge_labels = [
"AC Charging (relative)", "AC Charging (relative)",
@@ -561,7 +558,7 @@ def prepare_visualize(
] ]
charge_discharge_colors = ["blue", "green", "red"] charge_discharge_colors = ["blue", "green", "red"]
if results.get("battery_grid_export_allowed"): 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_labels.append("Battery Grid Export Allowed")
charge_discharge_colors.append("purple") charge_discharge_colors.append("purple")
@@ -580,12 +577,12 @@ def prepare_visualize(
# Group 4: # Group 4:
report.create_line_chart_date( report.create_line_chart_date(
next_full_hour_date, # start_date start_datetime,
[ [
results["result"]["Kosten_Euro_pro_Stunde"], results["result"]["Kosten_Euro_pro_Stunde"],
results["result"]["Einnahmen_Euro_pro_Stunde"], results["result"]["Einnahmen_Euro_pro_Stunde"],
], ],
title="Financial Balance per Hour", title="Financial Balance per Interval",
# xlabel="Date", # not enough space # xlabel="Date", # not enough space
ylabel="Euro", ylabel="Euro",
labels=["Costs", "Revenue"], labels=["Costs", "Revenue"],
+44
View File
@@ -294,3 +294,47 @@ def test_car_and_pv_battery_discharge_and_max_charge_power(setup_pv_battery, set
assert car_battery.parameters.max_charge_power_w == 7000, ( assert car_battery.parameters.max_charge_power_w == 7000, (
"Car battery max charge power should remain as defined" "Car battery max charge power should remain as defined"
) )
def test_quarter_hour_charge_calls_share_one_power_budget():
params = SolarPanelBatteryParameters(
device_id="battery1",
capacity_wh=10_000,
initial_soc_percentage=0,
min_soc_percentage=0,
max_soc_percentage=100,
max_charge_power_w=1_000,
charging_efficiency=1.0,
discharging_efficiency=1.0,
)
battery = Battery(params, prediction_hours=4, slot_duration_h=0.25)
battery.set_charge_per_hour(np.ones(4))
first_stored, _ = battery.charge_energy(200.0, 0)
second_stored, _ = battery.charge_energy(200.0, 0)
assert first_stored == pytest.approx(200.0)
assert second_stored == pytest.approx(50.0)
assert battery.soc_wh == pytest.approx(250.0)
def test_quarter_hour_discharge_calls_share_one_power_budget():
params = SolarPanelBatteryParameters(
device_id="battery1",
capacity_wh=10_000,
initial_soc_percentage=100,
min_soc_percentage=0,
max_soc_percentage=100,
max_charge_power_w=1_000,
charging_efficiency=1.0,
discharging_efficiency=1.0,
)
battery = Battery(params, prediction_hours=4, slot_duration_h=0.25)
battery.set_discharge_per_hour(np.ones(4))
first_delivered, _ = battery.discharge_energy(200.0, 0)
second_delivered, _ = battery.discharge_energy(200.0, 0)
assert first_delivered == pytest.approx(200.0)
assert second_delivered == pytest.approx(50.0)
assert battery.soc_wh == pytest.approx(9_750.0)
+38 -4
View File
@@ -118,9 +118,9 @@ def test_update_data(mock_get, provider, sample_energycharts_json, cache_store):
# Assert: Verify the result is as expected # Assert: Verify the result is as expected
mock_get.assert_called_once() mock_get.assert_called_once()
assert ( assert len(provider) == 72
len(provider) == 73 # The final raw timestamp already represents its complete interval. Thus the
) # we have 48 datasets in the api response, we want to know 48h into the future. The data we get has already 23h into the future so we need only 25h more. 48+25=73 # 48 API values need 24, rather than 25, additional hourly forecasts.
# Assert we get hours prioce values by resampling # Assert we get hours prioce values by resampling
np_price_array = provider.key_to_array( np_price_array = provider.key_to_array(
@@ -131,10 +131,43 @@ def test_update_data(mock_get, provider, sample_energycharts_json, cache_store):
assert len(np_price_array) == provider.total_hours assert len(np_price_array) == provider.total_hours
def test_update_data_keeps_quarter_hour_resolution(provider):
# Use a range that does not overlap the hourly fixture data used by the
# neighbouring tests; the provider is a singleton by design.
start = to_datetime("2025-01-15 00:00:00", in_timezone="Europe/Berlin")
get_ems().set_start_datetime(start)
provider.highest_orig_datetime = None
raw_slots = provider.config.prediction.hours * 2
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)],
price=[100.0] * raw_slots,
unit="EUR/MWh",
deprecated=False,
)
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
provider._update_data(force_update=True)
result = provider.key_to_series(
key="elecprice_marketprice_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
)
assert len(result) == provider.config.prediction.hours * 4
assert result.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0]
@patch("requests.get") @patch("requests.get")
def test_update_data_with_incomplete_forecast(mock_get, provider): def test_update_data_with_incomplete_forecast(mock_get, provider):
"""Test `_update_data` with incomplete or missing forecast data.""" """Test `_update_data` with incomplete or missing forecast data."""
incomplete_data: dict = {"license_info": "", "unix_seconds": [], "price": [], "unit": "", "deprecated": False} incomplete_data: dict = {
"license_info": "",
"unix_seconds": [],
"price": [],
"unit": "",
"deprecated": False,
}
mock_response = Mock() mock_response = Mock()
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.content = json.dumps(incomplete_data) mock_response.content = json.dumps(incomplete_data)
@@ -218,6 +251,7 @@ def test_request_forecast_url_bidding_zone_is_value(mock_get, provider, sample_e
# Extract the bzn= query parameter value from the URL # Extract the bzn= query parameter value from the URL
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
parsed = urlparse(actual_url) parsed = urlparse(actual_url)
query_params = parse_qs(parsed.query) query_params = parse_qs(parsed.query)
+11
View File
@@ -12,6 +12,7 @@ from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.elecpricetibber import ( from akkudoktoreos.prediction.elecpricetibber import (
ElecPriceTibber, ElecPriceTibber,
ElecPriceTibberCommonSettings, ElecPriceTibberCommonSettings,
TIBBER_PRICE_QUERY_QUARTER_HOURLY,
TibberGraphQLResponse, TibberGraphQLResponse,
) )
from akkudoktoreos.utils.datetimeutil import to_datetime from akkudoktoreos.utils.datetimeutil import to_datetime
@@ -255,6 +256,16 @@ def test_request_forecast_uses_tibber_graphql_api(
assert kwargs["timeout"] == 30 assert kwargs["timeout"] == 30
def test_quarter_hour_query_sets_resolution_on_price_info():
"""Tibber defines resolution on priceInfo, not on today or tomorrow."""
compact_query = " ".join(TIBBER_PRICE_QUERY_QUARTER_HOURLY.split())
assert "priceInfo(resolution: QUARTER_HOURLY)" in compact_query
assert "today(resolution:" not in compact_query
assert "tomorrow(resolution:" not in compact_query
assert "priceInfoRange(resolution: QUARTER_HOURLY, last: 672)" in compact_query
def test_tibber_update_extrapolates_missing_hours_with_seasonal_history( def test_tibber_update_extrapolates_missing_hours_with_seasonal_history(
tibber_provider, monkeypatch tibber_provider, monkeypatch
): ):
+24
View File
@@ -75,3 +75,27 @@ def test_request_forecast_uses_feedintariff_bidding_zone(
actual_url = mock_get.call_args[0][0] actual_url = mock_get.call_args[0][0]
assert "bzn=AT" in actual_url assert "bzn=AT" in actual_url
def test_update_data_keeps_quarter_hour_resolution(provider):
start = to_datetime("2025-01-15 00:00:00", in_timezone="Europe/Berlin")
get_ems().set_start_datetime(start)
raw_slots = provider.config.prediction.hours * 2
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)],
price=[100.0] * raw_slots,
unit="EUR/MWh",
deprecated=False,
)
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
provider._update_data(force_update=True)
result = provider.key_to_series(
key="feed_in_tariff_wh",
start_datetime=start,
end_datetime=start.add(hours=provider.config.prediction.hours),
)
assert len(result) == provider.config.prediction.hours * 4
assert result.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0]
+55 -51
View File
@@ -48,7 +48,9 @@ def genetic_simulation(config_eos) -> GeneticSimulation:
akku.reset() akku.reset()
inverter = Inverter( inverter = Inverter(
InverterParameters(device_id="inverter1", max_power_wh=10000, battery_id=akku.parameters.device_id), InverterParameters(
device_id="inverter1", max_power_wh=10000, battery_id=akku.parameters.device_id
),
battery=akku, battery=akku,
) )
@@ -301,69 +303,67 @@ def test_simulation(genetic_simulation):
assert GeneticSimulationResult(**result) is not None assert GeneticSimulationResult(**result) is not None
# Check the length of the main arrays # Check the length of the main arrays
assert len(result["Last_Wh_pro_Stunde"]) == 47, ( assert (
"The length of 'Last_Wh_pro_Stunde' should be 48." len(result["Last_Wh_pro_Stunde"]) == 47
) ), "The length of 'Last_Wh_pro_Stunde' should be 48."
assert len(result["Netzeinspeisung_Wh_pro_Stunde"]) == 47, ( assert (
"The length of 'Netzeinspeisung_Wh_pro_Stunde' should be 48." len(result["Netzeinspeisung_Wh_pro_Stunde"]) == 47
) ), "The length of 'Netzeinspeisung_Wh_pro_Stunde' should be 48."
assert len(result["Netzbezug_Wh_pro_Stunde"]) == 47, ( assert (
"The length of 'Netzbezug_Wh_pro_Stunde' should be 48." len(result["Netzbezug_Wh_pro_Stunde"]) == 47
) ), "The length of 'Netzbezug_Wh_pro_Stunde' should be 48."
assert len(result["Kosten_Euro_pro_Stunde"]) == 47, ( assert (
"The length of 'Kosten_Euro_pro_Stunde' should be 48." len(result["Kosten_Euro_pro_Stunde"]) == 47
) ), "The length of 'Kosten_Euro_pro_Stunde' should be 48."
assert len(result["akku_soc_pro_stunde"]) == 47, ( assert (
"The length of 'akku_soc_pro_stunde' should be 48." len(result["akku_soc_pro_stunde"]) == 47
) ), "The length of 'akku_soc_pro_stunde' should be 48."
# Verify specific values in the 'Last_Wh_pro_Stunde' array # Verify specific values in the 'Last_Wh_pro_Stunde' array
assert result["Last_Wh_pro_Stunde"][1] == 1527.13, ( assert (
"The value at index 1 of 'Last_Wh_pro_Stunde' should be 1527.13." result["Last_Wh_pro_Stunde"][1] == 1527.13
) ), "The value at index 1 of 'Last_Wh_pro_Stunde' should be 1527.13."
assert result["Last_Wh_pro_Stunde"][2] == 1468.88, ( assert (
"The value at index 2 of 'Last_Wh_pro_Stunde' should be 1468.88." result["Last_Wh_pro_Stunde"][2] == 1468.88
) ), "The value at index 2 of 'Last_Wh_pro_Stunde' should be 1468.88."
assert result["Last_Wh_pro_Stunde"][12] == 1132.03, ( assert (
"The value at index 12 of 'Last_Wh_pro_Stunde' should be 1132.03." result["Last_Wh_pro_Stunde"][12] == 1132.03
) ), "The value at index 12 of 'Last_Wh_pro_Stunde' should be 1132.03."
# Verify that the value at index 0 is 'None' # Verify that the value at index 0 is 'None'
# Check that 'Netzeinspeisung_Wh_pro_Stunde' and 'Netzbezug_Wh_pro_Stunde' are consistent # Check that 'Netzeinspeisung_Wh_pro_Stunde' and 'Netzbezug_Wh_pro_Stunde' are consistent
assert result["Netzbezug_Wh_pro_Stunde"][1] == 1527.13, ( assert (
"The value at index 1 of 'Netzbezug_Wh_pro_Stunde' should be 1527.13." result["Netzbezug_Wh_pro_Stunde"][1] == 1527.13
) ), "The value at index 1 of 'Netzbezug_Wh_pro_Stunde' should be 1527.13."
# Verify the total balance # Verify the total balance
assert abs(result["Gesamtbilanz_Euro"] - 6.612835813556755) < 1e-5, ( assert (
"Total balance should be 6.612835813556755." abs(result["Gesamtbilanz_Euro"] - 6.62818441758576) < 1e-5
) ), "Total balance should reflect the shared per-slot battery power limit."
# Check total revenue and total costs # Check total revenue and total costs
assert abs(result["Gesamteinnahmen_Euro"] - 1.964301131937134) < 1e-5, ( assert (
"Total revenue should be 1.964301131937134." abs(result["Gesamteinnahmen_Euro"] - 1.9606946615517515) < 1e-5
) ), "Total revenue should respect the shared per-slot battery power limit."
assert abs(result["Gesamtkosten_Euro"] - 8.577136945493889) < 1e-5, ( assert (
"Total costs should be 8.577136945493889 ." abs(result["Gesamtkosten_Euro"] - 8.588879079137512) < 1e-5
) ), "Total costs should respect the shared per-slot battery power limit."
# Check the losses # Check the losses
assert abs(result["Gesamt_Verluste"] - 1620.0) < 1e-5, ( assert abs(result["Gesamt_Verluste"] - 1620.0) < 1e-5, "Total losses should be 1620.0 ."
"Total losses should be 1620.0 ."
)
# Check the values in 'akku_soc_pro_stunde' # Check the values in 'akku_soc_pro_stunde'
assert result["akku_soc_pro_stunde"][-1] == 98.0, ( assert (
"The value at index -1 of 'akku_soc_pro_stunde' should be 98.0." result["akku_soc_pro_stunde"][-1] == 98.0
) ), "The value at index -1 of 'akku_soc_pro_stunde' should be 98.0."
assert result["akku_soc_pro_stunde"][1] == 98.0, ( assert (
"The value at index 1 of 'akku_soc_pro_stunde' should be 98.0." result["akku_soc_pro_stunde"][1] == 98.0
) ), "The value at index 1 of 'akku_soc_pro_stunde' should be 98.0."
# Check home appliances # Check home appliances
assert sum(simulation.home_appliance.get_load_curve()) == 2000, ( assert (
"The sum of 'simulation.home_appliance.get_load_curve()' should be 2000." sum(simulation.home_appliance.get_load_curve()) == 2000
) ), "The sum of 'simulation.home_appliance.get_load_curve()' should be 2000."
assert ( assert (
np.nansum( np.nansum(
@@ -379,13 +379,17 @@ def test_simulation(genetic_simulation):
print("All tests passed successfully.") print("All tests passed successfully.")
def test_direct_marketing_curtails_negative_feed_in(config_eos): def test_direct_marketing_curtails_negative_feed_in(config_eos, monkeypatch):
config_eos.merge_settings_from_dict( config_eos.merge_settings_from_dict(
{"prediction": {"hours": 2}, "optimization": {"horizon_hours": 2}} {"prediction": {"hours": 2}, "optimization": {"horizon_hours": 2}}
) )
inverter = Inverter(InverterParameters(device_id="inverter1", max_power_wh=1000.0)) inverter = Inverter(InverterParameters(device_id="inverter1", max_power_wh=1000.0))
inverter.self_consumption_predictor.calculate_self_consumption = Mock(return_value=1.0) monkeypatch.setattr(
inverter.self_consumption_predictor,
"calculate_self_consumption",
Mock(return_value=1.0),
)
simulation = GeneticSimulation() simulation = GeneticSimulation()
simulation.prepare( simulation.prepare(
+30
View File
@@ -0,0 +1,30 @@
import pytest
from akkudoktoreos.prediction.interpolator import get_eos_load_interpolator
def test_quarter_hour_energy_is_converted_back_to_same_mean_power():
"""Splitting hourly energy must not change the minute-load probability lookup."""
interpolator = get_eos_load_interpolator()
hourly_load_wh = 800.0
hourly_pv_wh = 1200.0
slot_duration_h = 0.25
hourly = interpolator.calculate_self_consumption(hourly_load_wh, hourly_pv_wh)
quarter_hour = interpolator.calculate_self_consumption(
(hourly_load_wh / 4) / slot_duration_h,
(hourly_pv_wh / 4) / slot_duration_h,
)
assert quarter_hour == pytest.approx(hourly)
def test_load_above_probability_grid_uses_highest_supported_distribution():
"""Out-of-range household load must not make self-consumption jump to zero."""
interpolator = get_eos_load_interpolator()
at_boundary = interpolator.calculate_self_consumption(3450.0, 5000.0)
above_boundary = interpolator.calculate_self_consumption(4000.0, 5000.0)
assert above_boundary == pytest.approx(at_boundary)
assert above_boundary > 0.99
+45 -1
View File
@@ -1,8 +1,11 @@
from unittest.mock import Mock, call, patch from unittest.mock import Mock, call, patch
import numpy as np
import pytest import pytest
from akkudoktoreos.devices.genetic.battery import Battery
from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters
from akkudoktoreos.optimization.genetic.geneticdevices import SolarPanelBatteryParameters
@pytest.fixture @pytest.fixture
@@ -26,11 +29,51 @@ def inverter(mock_battery) -> Inverter:
InverterParameters( InverterParameters(
device_id="iv1", max_power_wh=500.0, battery_id=mock_battery.parameters.device_id device_id="iv1", max_power_wh=500.0, battery_id=mock_battery.parameters.device_id
), ),
battery = mock_battery battery=mock_battery,
) )
return iv return iv
def test_quarter_hour_load_and_grid_export_share_discharge_power_limit():
"""Local supply plus direct export may not exceed one slot's battery budget."""
battery = Battery(
SolarPanelBatteryParameters(
device_id="battery",
capacity_wh=10000,
charging_efficiency=1.0,
discharging_efficiency=1.0,
max_charge_power_w=7000,
initial_soc_percentage=100,
),
prediction_hours=1,
slot_duration_h=0.25,
)
battery.set_discharge_per_hour(np.array([1]))
quarter_hour_inverter = Inverter(
InverterParameters(
device_id="inverter",
max_power_wh=10000,
battery_id="battery",
dc_to_ac_efficiency=1.0,
ac_to_dc_efficiency=1.0,
),
battery=battery,
slot_duration_h=0.25,
)
initial_soc_wh = battery.soc_wh
grid_export, grid_import, _, _ = quarter_hour_inverter.process_energy(
generation=0.0,
consumption=1000.0,
hour=0,
allow_battery_grid_export=True,
)
assert grid_import == 0.0
assert grid_export == pytest.approx(750.0)
assert initial_soc_wh - battery.soc_wh == pytest.approx(1750.0)
def test_process_energy_excess_generation(inverter, mock_battery): def test_process_energy_excess_generation(inverter, mock_battery):
# Battery charges 100 Wh with 10 Wh loss # Battery charges 100 Wh with 10 Wh loss
mock_battery.charge_energy.return_value = (100.0, 10.0) mock_battery.charge_energy.return_value = (100.0, 10.0)
@@ -125,6 +168,7 @@ def test_process_energy_battery_discharges(inverter, mock_battery):
def test_process_energy_allows_battery_grid_export(inverter, mock_battery): def test_process_energy_allows_battery_grid_export(inverter, mock_battery):
mock_battery.max_charge_power_w = 300.0 mock_battery.max_charge_power_w = 300.0
mock_battery.remaining_discharge_energy_wh.return_value = 200.0
mock_battery.discharge_energy.side_effect = [(100.0, 0.0), (200.0, 0.0)] mock_battery.discharge_energy.side_effect = [(100.0, 0.0), (200.0, 0.0)]
grid_export, grid_import, losses, self_consumption = inverter.process_energy( grid_export, grid_import, losses, self_consumption = inverter.process_energy(
+195 -5
View File
@@ -16,6 +16,7 @@ from akkudoktoreos.config.config import ConfigEOS
from akkudoktoreos.core.cache import CacheEnergyManagementStore from akkudoktoreos.core.cache import CacheEnergyManagementStore
from akkudoktoreos.core.coreabc import get_ems from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
from akkudoktoreos.optimization.genetic.geneticdevices import HomeApplianceParameters
from akkudoktoreos.optimization.genetic.geneticparams import ( from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticOptimizationParameters, GeneticOptimizationParameters,
) )
@@ -27,6 +28,12 @@ ems_eos = get_ems(init=True) # init once
DIR_TESTDATA = Path(__file__).parent / "testdata" DIR_TESTDATA = Path(__file__).parent / "testdata"
def load_hourly_parameters() -> GeneticOptimizationParameters:
"""Load the legacy 48-value API example used by hourly clients."""
with (DIR_TESTDATA / "optimize_input_1.json").open("r") as f_in:
return GeneticOptimizationParameters(**json.load(f_in))
@pytest.mark.parametrize( @pytest.mark.parametrize(
"interval, exp_slots_per_hour, exp_slot_duration_h", "interval, exp_slots_per_hour, exp_slot_duration_h",
[ [
@@ -76,6 +83,189 @@ def test_start_day_slot_includes_minute_offset(config_eos: ConfigEOS):
assert opt._start_day_slot() == sd.hour * 4 + sd.minute // 15 assert opt._start_day_slot() == sd.hour * 4 + sd.minute // 15
def test_ems_start_is_floored_to_quarter_hour(config_eos: ConfigEOS):
"""Rolling optimization starts at the current slot, not the previous full hour."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"horizon_hours": 48, "interval": 900},
}
)
aligned = ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=38, second=42))
assert aligned.hour == 10
assert aligned.minute == 30
assert aligned.second == 0
def test_unsupported_interval_falls_back_to_hourly(config_eos: ConfigEOS):
"""The genetic optimizer falls back without restricting interval-aware providers."""
config_eos.merge_settings_from_dict({"optimization": {"interval": 1800}})
assert config_eos.optimization.interval == 1800
GeneticOptimization(fixed_seed=42)
assert config_eos.optimization.interval == 3600
def test_hourly_api_input_is_normalized_to_quarter_hour_slots(config_eos: ConfigEOS):
"""Legacy API energy is split while prices are held over four slots."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"horizon_hours": 48, "interval": 900},
}
)
parameters = load_hourly_parameters()
opt = GeneticOptimization(fixed_seed=42)
normalized = opt._parameters_for_slot_grid(parameters)
assert len(normalized.ems.pv_prognose_wh) == 192
assert len(normalized.ems.gesamtlast) == 192
assert len(normalized.ems.strompreis_euro_pro_wh) == 192
assert len(normalized.ems.einspeiseverguetung_euro_pro_wh) == 192
assert sum(normalized.ems.pv_prognose_wh[:4]) == pytest.approx(parameters.ems.pv_prognose_wh[0])
assert sum(normalized.ems.gesamtlast[:4]) == pytest.approx(parameters.ems.gesamtlast[0])
assert (
normalized.ems.strompreis_euro_pro_wh[:4] == [parameters.ems.strompreis_euro_pro_wh[0]] * 4
)
assert (
normalized.ems.einspeiseverguetung_euro_pro_wh[:4]
== [parameters.ems.einspeiseverguetung_euro_pro_wh[0]] * 4
)
def test_native_quarter_hour_input_is_not_resampled(config_eos: ConfigEOS):
"""Native 192-value input survives normalization without repetition or scaling."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"horizon_hours": 48, "interval": 900},
}
)
parameters = load_hourly_parameters()
native_values = [float(i) for i in range(192)]
native_ems = parameters.ems.model_copy(
update={
"pv_prognose_wh": native_values,
"gesamtlast": native_values,
"strompreis_euro_pro_wh": native_values,
"einspeiseverguetung_euro_pro_wh": native_values,
},
deep=True,
)
native_parameters = parameters.model_copy(update={"ems": native_ems}, deep=True)
normalized = GeneticOptimization(fixed_seed=42)._parameters_for_slot_grid(native_parameters)
assert normalized.ems.pv_prognose_wh == native_values
assert normalized.ems.gesamtlast == native_values
assert normalized.ems.strompreis_euro_pro_wh == native_values
assert normalized.ems.einspeiseverguetung_euro_pro_wh == native_values
def test_scalar_feed_in_tariff_fills_quarter_hour_grid(config_eos: ConfigEOS):
"""A fixed feed-in tariff becomes one value per optimization slot."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"horizon_hours": 48, "interval": 900},
}
)
parameters = load_hourly_parameters()
fixed_tariff = 0.00008
scalar_ems = parameters.ems.model_copy(
update={"einspeiseverguetung_euro_pro_wh": fixed_tariff}, deep=True
)
scalar_parameters = parameters.model_copy(update={"ems": scalar_ems}, deep=True)
normalized = GeneticOptimization(fixed_seed=42)._parameters_for_slot_grid(scalar_parameters)
assert normalized.ems.einspeiseverguetung_euro_pro_wh == [fixed_tariff] * 192
def test_ambiguous_input_length_is_rejected(config_eos: ConfigEOS):
"""Unexpected input lengths fail instead of silently shortening the simulation."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"horizon_hours": 48, "interval": 900},
}
)
parameters = load_hourly_parameters()
invalid_ems = parameters.ems.model_copy(
update={
"pv_prognose_wh": [0.0] * 96,
"gesamtlast": [0.0] * 96,
"strompreis_euro_pro_wh": [0.0] * 96,
"einspeiseverguetung_euro_pro_wh": [0.0] * 96,
},
deep=True,
)
invalid_parameters = parameters.model_copy(update={"ems": invalid_ems}, deep=True)
with pytest.raises(ValueError, match="expected either 48 hourly values or 192"):
GeneticOptimization(fixed_seed=42)._parameters_for_slot_grid(invalid_parameters)
def test_hourly_start_solution_is_expanded_to_slots(config_eos: ConfigEOS):
"""A cached hourly genome becomes a valid quarter-hour warm start."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"horizon_hours": 48, "interval": 900},
}
)
opt = GeneticOptimization(fixed_seed=42)
opt.optimize_ev = False
hourly = list(range(48))
migrated = opt._start_solution_for_slot_grid(hourly, has_appliance=False)
assert len(migrated) == 192
assert migrated[:8] == [0, 0, 0, 0, 1, 1, 1, 1]
def test_quarter_hour_mutation_probability_preserves_hourly_rate(config_eos: ConfigEOS):
"""A finer genome does not mutate four times as many controls per hour."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"horizon_hours": 48, "interval": 900},
}
)
opt = GeneticOptimization(fixed_seed=42)
opt.optimize_ev = False
opt.setup_deap_environment({"home_appliance": 0}, start_hour=0)
assert opt.toolbox.mutate_charge_discharge.keywords["indpb"] == pytest.approx(0.05)
def test_sub_hourly_home_appliance_is_rejected(config_eos: ConfigEOS):
"""An hourly appliance model must not silently run on slot indices."""
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"horizon_hours": 48, "interval": 900},
}
)
parameters = load_hourly_parameters().model_copy(
update={
"dishwasher": HomeApplianceParameters(
device_id="dishwasher", consumption_wh=1200, duration_h=2
)
},
deep=True,
)
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
with pytest.raises(ValueError, match="Home-appliance scheduling"):
GeneticOptimization(fixed_seed=42).optimierung_ems(
parameters=parameters, start_hour=10, ngen=1
)
def test_optimize_15min_slot_grid(config_eos: ConfigEOS): def test_optimize_15min_slot_grid(config_eos: ConfigEOS):
"""An end-to-end optimization at interval=900 runs on a 192-slot day grid. """An end-to-end optimization at interval=900 runs on a 192-slot day grid.
@@ -110,8 +300,7 @@ def test_optimize_15min_slot_grid(config_eos: ConfigEOS):
} }
) )
with (DIR_TESTDATA / "optimize_input_1.json").open("r") as f_in: input_data = load_hourly_parameters()
input_data = GeneticOptimizationParameters(**json.load(f_in))
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0)) ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
CacheEnergyManagementStore().clear() CacheEnergyManagementStore().clear()
@@ -127,14 +316,15 @@ def test_optimize_15min_slot_grid(config_eos: ConfigEOS):
parameters, results, filename=visualize_filename, **kwargs parameters, results, filename=visualize_filename, **kwargs
), ),
): ):
genetic_solution = opt.optimierung_ems( genetic_solution = opt.optimierung_ems(parameters=input_data, start_hour=10, ngen=3)
parameters=input_data, start_hour=10, ngen=3
)
# The genetic core emitted a full-day grid at 15-min resolution. # The genetic core emitted a full-day grid at 15-min resolution.
assert len(genetic_solution.ac_charge) == 192 assert len(genetic_solution.ac_charge) == 192
assert len(genetic_solution.dc_charge) == 192 assert len(genetic_solution.dc_charge) == 192
assert len(genetic_solution.discharge_allowed) == 192 assert len(genetic_solution.discharge_allowed) == 192
expected_result_slots = 192 - opt._start_day_slot()
assert len(genetic_solution.result.Last_Wh_pro_Stunde) == expected_result_slots
assert len(genetic_solution.result.Electricity_price) == expected_result_slots
# The serializers consume the 15-min grid without error and emit a 900 s # The serializers consume the 15-min grid without error and emit a 900 s
# spaced solution index. # spaced solution index.
+23 -17
View File
@@ -7,6 +7,7 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
from akkudoktoreos.prediction.loadakkudoktor import ( from akkudoktoreos.prediction.loadakkudoktor import (
@@ -46,6 +47,7 @@ def forecast_providers():
ElecPriceTibber(), ElecPriceTibber(),
ElecPriceFixed(), ElecPriceFixed(),
ElecPriceImport(), ElecPriceImport(),
FeedInTariffEnergyCharts(),
FeedInTariffFixed(), FeedInTariffFixed(),
FeedInTariffImport(), FeedInTariffImport(),
LoadAkkudoktor(), LoadAkkudoktor(),
@@ -99,28 +101,32 @@ def test_provider_sequence(prediction):
assert isinstance(prediction.providers[2], ElecPriceTibber) assert isinstance(prediction.providers[2], ElecPriceTibber)
assert isinstance(prediction.providers[3], ElecPriceFixed) assert isinstance(prediction.providers[3], ElecPriceFixed)
assert isinstance(prediction.providers[4], ElecPriceImport) assert isinstance(prediction.providers[4], ElecPriceImport)
assert isinstance(prediction.providers[5], FeedInTariffFixed) assert isinstance(prediction.providers[5], FeedInTariffEnergyCharts)
assert isinstance(prediction.providers[6], FeedInTariffImport) assert isinstance(prediction.providers[6], FeedInTariffFixed)
assert isinstance(prediction.providers[7], LoadAkkudoktor) assert isinstance(prediction.providers[7], FeedInTariffImport)
assert isinstance(prediction.providers[8], LoadAkkudoktorAdjusted) assert isinstance(prediction.providers[8], LoadAkkudoktor)
assert isinstance(prediction.providers[9], LoadVrm) assert isinstance(prediction.providers[9], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[10], LoadImport) assert isinstance(prediction.providers[10], LoadVrm)
assert isinstance(prediction.providers[11], PVForecastAkkudoktor) assert isinstance(prediction.providers[11], LoadImport)
assert isinstance(prediction.providers[12], PVForecastVrm) assert isinstance(prediction.providers[12], PVForecastAkkudoktor)
assert isinstance(prediction.providers[13], PVForecastPVNode) assert isinstance(prediction.providers[13], PVForecastVrm)
assert isinstance(prediction.providers[14], PVForecastForecastSolar) assert isinstance(prediction.providers[14], PVForecastPVNode)
assert isinstance(prediction.providers[15], PVForecastSolcast) assert isinstance(prediction.providers[15], PVForecastForecastSolar)
assert isinstance(prediction.providers[16], PVForecastImport) assert isinstance(prediction.providers[16], PVForecastSolcast)
assert isinstance(prediction.providers[17], WeatherBrightSky) assert isinstance(prediction.providers[17], PVForecastImport)
assert isinstance(prediction.providers[18], WeatherClearOutside) assert isinstance(prediction.providers[18], WeatherBrightSky)
assert isinstance(prediction.providers[19], WeatherOpenMeteo) assert isinstance(prediction.providers[19], WeatherClearOutside)
assert isinstance(prediction.providers[20], WeatherImport) assert isinstance(prediction.providers[20], WeatherOpenMeteo)
assert isinstance(prediction.providers[21], WeatherImport)
def test_provider_by_id(prediction, forecast_providers): def test_provider_by_id(prediction, forecast_providers):
"""Test that provider_by_id method returns the correct provider.""" """Test that provider_by_id method returns the correct provider."""
for provider in forecast_providers: for provider in forecast_providers:
assert prediction.provider_by_id(provider.provider_id()).provider_id() == provider.provider_id() assert (
prediction.provider_by_id(provider.provider_id()).provider_id()
== provider.provider_id()
)
def test_prediction_repr(prediction): def test_prediction_repr(prediction):
Binary file not shown.
+170 -181
View File
@@ -14,10 +14,11 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
1.0,
1.0,
0.0, 0.0,
0.0, 1.0,
0.0, 1.0,
0.0,
0.0, 0.0,
1.0, 1.0,
0.0, 0.0,
@@ -39,12 +40,11 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
1.0,
1.0,
0.0, 0.0,
0.0, 1.0,
0.0, 1.0,
0.0,
0.0,
0.0,
0.0, 0.0,
0.0, 0.0,
0.0 0.0
@@ -111,44 +111,45 @@
0, 0,
0, 0,
1, 1,
0,
1,
1, 1,
1, 1,
0, 0,
1, 0,
0,
0,
0,
0,
0,
0, 0,
1, 1,
0, 0,
1,
0, 0,
1, 1,
1, 1,
1, 1,
1, 1,
0, 0,
1,
0, 0,
0, 0,
1, 1,
0,
1,
1, 1,
1, 1,
1, 1,
1, 1,
0, 0,
1, 1,
0,
1,
0,
1, 1,
0, 0,
0, 0,
0, 0,
1, 1,
0,
0,
1,
1,
0 0
], ],
"battery_grid_export_allowed": [],
"eautocharge_hours_float": null, "eautocharge_hours_float": null,
"result": { "result": {
"Last_Wh_pro_Stunde": [ "Last_Wh_pro_Stunde": [
@@ -156,7 +157,7 @@
1063.91, 1063.91,
1320.56, 1320.56,
1132.03, 1132.03,
1163.67, 1308.5200000002487,
1176.82, 1176.82,
1216.22, 1216.22,
1103.78, 1103.78,
@@ -237,10 +238,10 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.19391086083906173, 0.20303854854278033,
0.18681973047764083, 0.1899652543898917,
0.12880892587597292, 0.12833851757957424,
0.02404700392596282, 0.04233866391809883,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -261,20 +262,20 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.12500582038027028, 0.16357655037574115,
0.14608958812480227, 0.14900060381217387,
0.09047346757070289, 0.08949503544160814,
0.010404817872487553, 0.010110585812541543,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0 0.0
], ],
"Gesamt_Verluste": 2878.660271824896, "Gesamt_Verluste": 2807.7292841655817,
"Gesamtbilanz_Euro": 1.053854835771092, "Gesamtbilanz_Euro": 0.8879905947253857,
"Gesamteinnahmen_Euro": 0.9055602150669013, "Gesamteinnahmen_Euro": 0.9758637598724098,
"Gesamtkosten_Euro": 1.9594150508379933, "Gesamtkosten_Euro": 1.8638543545977955,
"Home_appliance_wh_per_hour": [ "Home_appliance_wh_per_hour": [
0.0, 0.0,
0.0, 0.0,
@@ -316,82 +317,82 @@
0.0 0.0
], ],
"Kosten_Euro_pro_Stunde": [ "Kosten_Euro_pro_Stunde": [
0.0,
0.004569992000000018,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.001880612819166666,
0.026623430000091274,
4.482711108977355e-14, 4.482711108977355e-14,
0.0, 0.008763569215739961,
0.016809914659344942, 0.018335381563380656,
0.0, 0.06267084962493971,
0.05480703000000003, 0.05480703000000003,
0.225316611,
0.0, 0.0,
0.291163892, 0.26650619799999997,
0.19588158,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.22802125600000003,
0.0,
0.182970359, 0.182970359,
0.162995926, 0.162995926,
0.0, 0.16677339,
0.26411047,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.010682755832597498, 0.02844719513362201,
0.0, 0.0,
0.0003442778967139274,
0.0, 0.0,
0.028137079449292023, 0.0004412281431084693,
0.008372170029774037,
0.03130999506792791,
0.0, 0.0,
0.08231598, 0.08231598,
0.174597189, 0.174597189,
0.293043269, 0.0,
0.0, 0.0,
0.16484566 0.16484566
], ],
"Netzbezug_Wh_pro_Stunde": [ "Netzbezug_Wh_pro_Stunde": [
0.0,
20.660000000000082,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
10.008583390988111,
144.8500000004966,
2.236881790906864e-10, 2.236881790906864e-10,
0.0, 39.870651572975255,
74.05248748610107, 80.77260600608219,
0.0, 209.11194402715952,
171.54000000000008, 171.54000000000008,
731.31,
0.0, 0.0,
980.68, 912.38,
704.61,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
694.34,
0.0,
556.31, 556.31,
488.89, 488.89,
0.0, 506.91,
799.85,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
56.853410498124, 135.91588692604878,
0.0, 0.0,
1.7179535764168035,
0.0, 0.0,
123.95189184710142, 2.201737241060226,
38.089945540373236,
137.92949369131236,
0.0, 0.0,
257.64, 257.64,
566.69, 566.69,
987.01, 0.0,
0.0, 0.0,
592.97 592.97
], ],
@@ -401,10 +402,10 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
2770.155154843739, 2900.550693468291,
2668.8532925377262, 2713.7893484270244,
1840.127512513899, 1833.4073939939178,
343.5286275137546, 604.8380559728405,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -425,10 +426,10 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
1785.797434003861, 2336.807862510588,
2086.994116068604, 2128.580054459627,
1292.4781081528986, 1278.5005063086878,
148.64025532125078, 144.4369401791649,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -437,83 +438,83 @@
], ],
"Verluste_Pro_Stunde": [ "Verluste_Pro_Stunde": [
16.744090909090914, 16.744090909090914,
0.0, 2.817272727272737,
29.157272727272726, 29.157272727272726,
3.7179773678125034, 2.358169993081436,
582.6180000000041, 600.0000000000002,
188.65138141872444, 173.00391678377832,
10.782457846871594,
0.0, 0.0,
59.810111380126784,
0.0, 0.0,
99.72409090909093,
0.0, 0.0,
124.41545454545451, 0.0,
96.08318181818186, 0.0,
133.72909090909081,
0.0,
0.0,
70.41409090909087, 70.41409090909087,
118.37045454545455, 118.37045454545455,
0.0, 94.68272727272722,
83.01681818181817, 83.01681818181817,
0.0, 0.0,
0.0, 0.0,
69.12409090909085,
0.0, 0.0,
109.0704545454546,
109.96227272727276, 109.96227272727276,
47.952272727272714, 47.952272727272714,
16.01263877609336, 16.031648664443644,
55.946263193163475, 55.9754990365584,
161.62968357967037, 143.33449356887422,
14.209990740225123, 21.973794868109557,
538.2984000000038, 538.2984000000038,
227.42215349036655, 161.2428480298022,
10.13011689299087,
0.0,
44.377460775206174,
0.0, 0.0,
0.0, 0.0,
44.91187685729284,
0.0, 0.0,
0.0,
134.59227272727276,
100.08954545454549, 100.08954545454549,
0.0 0.0
], ],
"akku_soc_pro_stunde": [ "akku_soc_pro_stunde": [
80.0, 80.0,
79.4714617768595, 79.4714617768595,
79.4714617768595, 79.38253271349862,
78.55109331955923, 78.46216425619835,
78.57585051614844, 78.52766897822838,
94.75968384947988, 95.19433564489505,
100.0,
100.0,
100.0,
100.0,
100.0,
96.85214359504131,
96.85214359504131,
92.92488808539943,
89.89195936639118,
87.6692923553719,
83.93285123966942,
83.93285123966942,
81.31237086776858,
81.31237086776858,
81.31237086776858,
79.13042355371898,
79.13042355371898,
75.65939221763084,
74.14574724517905,
74.30696088041155,
74.82732877552169,
78.33526266026307,
78.72998462526934,
93.68271795860093,
100.0, 100.0,
100.0, 100.0,
100.0, 100.0,
100.0, 100.0,
100.0, 100.0,
100.0, 100.0,
95.77875344352617,
95.77875344352617,
95.77875344352617,
93.55608643250687,
89.8196453168044,
86.83092286501376,
84.21044249311292,
84.21044249311292,
84.21044249311292,
84.21044249311292,
80.76756198347105,
77.2965306473829,
75.78288567493111,
75.93522642877453,
76.44194846937079,
80.42346217961729,
80.56829866584056,
95.52103199917215,
100.0, 100.0,
96.84060778236915 100.0,
100.0,
100.0,
100.0,
100.0,
95.75150654269973,
92.59211432506888
], ],
"Electricity_price": [ "Electricity_price": [
0.000228, 0.000228,
@@ -554,6 +555,46 @@
0.0002969, 0.0002969,
0.0002921, 0.0002921,
0.000278 0.000278
],
"Feed_in_tariff": [
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05
] ]
}, },
"eauto_obj": { "eauto_obj": {
@@ -667,14 +708,12 @@
"initial_soc_percentage": 54 "initial_soc_percentage": 54
}, },
"start_solution": [ "start_solution": [
1.0, 0.0,
1.0,
1.0,
1.0,
0.0, 0.0,
1.0, 1.0,
2.0,
0.0, 0.0,
1.0, 2.0,
2.0, 2.0,
1.0, 1.0,
1.0, 1.0,
@@ -683,90 +722,40 @@
1.0, 1.0,
1.0, 1.0,
0.0, 0.0,
1.0, 2.0,
2.0,
0.0,
2.0,
2.0,
0.0, 0.0,
1.0,
2.0, 2.0,
1.0, 1.0,
0.0, 0.0,
0.0,
1.0, 1.0,
1.0, 1.0,
1.0, 1.0,
1.0, 1.0,
0.0, 0.0,
1.0,
0.0, 0.0,
0.0, 0.0,
1.0, 1.0,
0.0,
1.0,
1.0, 1.0,
1.0, 1.0,
1.0, 1.0,
1.0, 1.0,
0.0, 0.0,
1.0, 1.0,
0.0,
1.0, 1.0,
0.0, 0.0,
2.0,
2.0,
1.0, 1.0,
0.0, 2.0,
0.0, 2.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0, 1.0,
1.0, 1.0,
1.0, 0.0
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0
], ],
"washingstart": null "washingstart": null
} }
+184 -195
View File
@@ -14,6 +14,13 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
1.0,
1.0,
0.0,
1.0,
1.0,
0.0,
1.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -34,14 +41,7 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 1.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -111,16 +111,15 @@
0, 0,
0, 0,
1, 1,
0, 1,
1, 1,
0, 0,
1,
0, 0,
1,
0, 0,
1,
0, 0,
1, 0,
0,
0,
0, 0,
1, 1,
0, 0,
@@ -129,6 +128,16 @@
1, 1,
1, 1,
1, 1,
0,
0,
1,
1,
1,
0,
0,
0,
0,
1,
1, 1,
0, 0,
1, 1,
@@ -136,19 +145,11 @@
0, 0,
0, 0,
0, 0,
1,
0, 0,
1, 1,
0, 1
1,
0,
1,
0,
1,
0,
1,
0
], ],
"battery_grid_export_allowed": [],
"eautocharge_hours_float": null, "eautocharge_hours_float": null,
"result": { "result": {
"Last_Wh_pro_Stunde": [ "Last_Wh_pro_Stunde": [
@@ -156,7 +157,7 @@
1063.91, 1063.91,
1320.56, 1320.56,
1132.03, 1132.03,
1163.67, 1308.5200000002487,
1176.82, 1176.82,
1216.22, 1216.22,
1103.78, 1103.78,
@@ -237,10 +238,10 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.19478794541504615, 0.20303854854278033,
0.18681973047764083, 0.1899652543898917,
0.12880892587597292, 0.12833851757957424,
0.02404700392596282, 0.04233866391809883,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -261,20 +262,20 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.17104472158211628, 0.16452297290911175,
0.14608958812480227, 0.1455575560489687,
0.09047346757070289, 0.08949503544160814,
0.010404817872487553, 0.024046008596276005,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0 0.0
], ],
"Gesamt_Verluste": 2804.7326610375394, "Gesamt_Verluste": 2717.1309464905708,
"Gesamtbilanz_Euro": 0.9004618481798127, "Gesamtbilanz_Euro": 0.998163925609791,
"Gesamteinnahmen_Euro": 0.9524762008447317, "Gesamteinnahmen_Euro": 0.9873025574263097,
"Gesamtkosten_Euro": 1.8529380490245444, "Gesamtkosten_Euro": 1.9854664830361006,
"Home_appliance_wh_per_hour": [ "Home_appliance_wh_per_hour": [
0.0, 0.0,
0.0, 0.0,
@@ -317,83 +318,83 @@
], ],
"Kosten_Euro_pro_Stunde": [ "Kosten_Euro_pro_Stunde": [
0.0, 0.0,
0.004569992000000018,
0.0, 0.0,
0.0018232052307313393,
0.0, 0.0,
0.001880612819166666,
0.026623430000091274,
4.482711108977355e-14, 4.482711108977355e-14,
0.0, 0.008763569215739961,
0.016809914659344942, 0.018335381563380656,
0.0, 0.06267084962493971,
0.05480703000000003, 0.05480703000000003,
0.225316611,
0.0, 0.0,
0.291163892, 0.26650619799999997,
0.0,
0.19588158, 0.19588158,
0.174739608,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.182970359,
0.162995926,
0.0,
0.0, 0.0,
0.16677339,
0.0, 0.0,
0.24530383800000005,
0.08545095, 0.08545095,
0.007989913613567745, 0.008254784724581705,
0.028255713342252034, 0.028650916976410694,
0.02844719513362201,
0.0, 0.0,
0.010682755832597498,
0.0, 0.0,
0.0003442778967139274, 0.0004412281431084693,
0.0,
0.028137079449292023,
0.0, 0.0,
0.03130999506792791,
0.04620342776708689,
0.08231598, 0.08231598,
0.0, 0.174597189,
0.293043269, 0.293043269,
0.0, 0.0,
0.16484566 0.0
], ],
"Netzbezug_Wh_pro_Stunde": [ "Netzbezug_Wh_pro_Stunde": [
0.0, 0.0,
20.660000000000082,
0.0, 0.0,
9.703061366318996,
0.0, 0.0,
10.008583390988111,
144.8500000004966,
2.236881790906864e-10, 2.236881790906864e-10,
0.0, 39.870651572975255,
74.05248748610107, 80.77260600608219,
0.0, 209.11194402715952,
171.54000000000008, 171.54000000000008,
731.31,
0.0, 0.0,
980.68, 912.38,
0.0,
704.61, 704.61,
516.37,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
556.31,
488.89,
0.0,
0.0, 0.0,
506.91,
0.0, 0.0,
806.3900000000001,
351.65, 351.65,
35.04348076126204, 36.20519616044607,
127.73830624887898, 129.52494112301397,
135.91588692604878,
0.0, 0.0,
56.853410498124,
0.0, 0.0,
1.7179535764168035, 2.201737241060226,
0.0,
123.95189184710142,
0.0, 0.0,
137.92949369131236,
154.1655914817714,
257.64, 257.64,
0.0, 566.69,
987.01, 987.01,
0.0, 0.0,
592.97 0.0
], ],
"Netzeinspeisung_Wh_pro_Stunde": [ "Netzeinspeisung_Wh_pro_Stunde": [
0.0, 0.0,
@@ -401,10 +402,10 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
2782.6849345006594, 2900.550693468291,
2668.8532925377262, 2713.7893484270244,
1840.127512513899, 1833.4073939939178,
343.5286275137546, 604.8380559728405,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -425,10 +426,10 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
2443.4960226016615, 2350.3281844158823,
2086.994116068604, 2079.39365784241,
1292.4781081528986, 1278.5005063086878,
148.64025532125078, 343.51440851822866,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -437,83 +438,83 @@
], ],
"Verluste_Pro_Stunde": [ "Verluste_Pro_Stunde": [
16.744090909090914, 16.744090909090914,
0.0, 2.817272727272737,
29.157272727272726, 29.157272727272726,
2.3948326360417305, 2.358169993081436,
582.6180000000041, 600.0000000000002,
187.1478078598941, 173.00391678377832,
10.782457846871594,
0.0,
59.810111380126784,
0.0,
99.72409090909093,
0.0,
124.41545454545451,
0.0, 0.0,
0.0, 0.0,
0.0,
0.0,
0.0,
133.72909090909081,
0.0,
0.0,
70.41409090909087,
118.37045454545455, 118.37045454545455,
94.68272727272722, 94.68272727272722,
83.01681818181817, 83.01681818181817,
75.86045454545456,
66.66681818181814,
0.0, 0.0,
0.0,
69.12409090909085,
109.0704545454546, 109.0704545454546,
109.96227272727276,
0.0, 0.0,
0.0, 11.094576460746453,
11.233982308648535, 38.31300706523831,
38.52740325013451, 143.33449356887422,
161.62968357967037, 21.973794868109557,
14.209990740225123,
538.2984000000038, 538.2984000000038,
148.49832285863067, 159.62040940116685,
10.13011689299087, 11.096451076844168,
0.0,
0.0,
0.0, 0.0,
44.377460775206174,
0.0, 0.0,
77.27590909090907,
0.0, 0.0,
100.08954545454549, 100.08954545454549,
0.0 80.85954545454547
], ],
"akku_soc_pro_stunde": [ "akku_soc_pro_stunde": [
80.0, 80.0,
79.4714617768595, 79.4714617768595,
79.4714617768595, 79.38253271349862,
78.55109331955923, 78.46216425619835,
78.61761644833817, 78.52766897822838,
94.80144978166962, 95.19433564489505,
100.0, 100.0,
100.0, 100.0,
100.0, 100.0,
100.0, 100.0,
100.0, 100.0,
96.85214359504131, 100.0,
96.85214359504131, 95.77875344352617,
92.92488808539943, 95.77875344352617,
92.92488808539943, 95.77875344352617,
92.92488808539943, 93.55608643250687,
89.18844696969695, 89.8196453168044,
86.19972451790632, 86.83092286501376,
83.57924414600548, 84.21044249311292,
81.18465909090907, 84.21044249311292,
79.08027720385672, 84.21044249311292,
79.08027720385672, 82.02849517906333,
75.63739669421484, 78.58561466942146,
75.63739669421484, 75.1145833333333,
75.63739669421484, 75.1145833333333,
75.94945175834397, 75.42276601279848,
77.01965740418103, 76.4870162090551,
80.52759128892241, 80.4685299193016,
80.92231325392866, 80.61336640552487,
95.87504658726026, 95.56609973885648,
100.0, 100.0,
100.0, 100.0,
100.0, 100.0,
100.0, 100.0,
100.0, 100.0,
97.56073519283747, 100.0,
97.56073519283747, 100.0,
94.40134297520663 96.84060778236915
], ],
"Electricity_price": [ "Electricity_price": [
0.000228, 0.000228,
@@ -554,6 +555,46 @@
0.0002969, 0.0002969,
0.0002921, 0.0002921,
0.000278 0.000278
],
"Feed_in_tariff": [
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05
] ]
}, },
"eauto_obj": { "eauto_obj": {
@@ -667,28 +708,27 @@
"initial_soc_percentage": 54 "initial_soc_percentage": 54
}, },
"start_solution": [ "start_solution": [
1.0,
1.0,
1.0, 1.0,
1.0, 1.0,
0.0, 0.0,
1.0, 1.0,
0.0, 1.0,
2.0,
2.0,
1.0,
1.0,
0.0, 0.0,
1.0, 1.0,
1.0, 1.0,
1.0, 1.0,
0.0, 0.0,
1.0, 2.0,
2.0,
0.0, 0.0,
1.0, 2.0,
0.0, 2.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0, 0.0,
2.0,
1.0, 1.0,
0.0, 0.0,
0.0, 0.0,
@@ -696,76 +736,25 @@
1.0, 1.0,
1.0, 1.0,
1.0, 1.0,
1.0,
0.0, 0.0,
0.0,
1.0,
1.0,
1.0, 1.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
1.0, 1.0,
0.0,
1.0, 1.0,
0.0, 0.0,
1.0, 1.0,
2.0,
0.0, 0.0,
1.0,
0.0, 0.0,
1.0,
0.0, 0.0,
1.0,
0.0, 0.0,
1.0, 1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
0.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0 1.0
], ],
"washingstart": null "washingstart": null
+107 -66
View File
@@ -149,17 +149,18 @@
1, 1,
1 1
], ],
"battery_grid_export_allowed": [],
"eautocharge_hours_float": [ "eautocharge_hours_float": [
1.0, 0.0,
0.5, 0.0,
0.375, 0.0,
0.375, 0.0,
1.0, 0.0,
0.75, 0.0,
1.0, 0.0,
0.75, 0.0,
0.5, 0.0,
0.875, 0.0,
0.0, 0.0,
0.875, 0.875,
0.75, 0.75,
@@ -168,36 +169,36 @@
0.375, 0.375,
0.75, 0.75,
0.875, 0.875,
0.625, 0.1,
1.0,
0.75,
0.375,
0.75,
0.5,
0.5,
0.625,
0.875,
0.5,
0.0, 0.0,
0.0, 0.0,
0.75,
0.875,
0.0, 0.0,
0.0, 0.0,
1.0,
0.375,
0.375,
1.0,
0.0, 0.0,
0.875, 0.0,
0.5, 0.0,
1.0, 0.0,
0.625, 0.0,
0.75, 0.0,
0.875, 0.0,
1.0, 0.0,
0.5, 0.0,
0.5 0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0
], ],
"result": { "result": {
"Last_Wh_pro_Stunde": [ "Last_Wh_pro_Stunde": [
@@ -309,21 +310,21 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.010924611164505103, 0.00826914812545985,
0.25751345302450973, 0.25743585772309185,
0.14608958812480227, 0.1455575560489687,
0.07926913850394514, 0.07702723513376727,
0.010404817872487553, 0.010110585812541543,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0 0.0
], ],
"Gesamt_Verluste": 5766.286846118221, "Gesamt_Verluste": 5776.448801897527,
"Gesamtbilanz_Euro": 12.78299149726557, "Gesamtbilanz_Euro": 12.789452797857164,
"Gesamteinnahmen_Euro": 0.5042016086902498, "Gesamteinnahmen_Euro": 0.49840038284382926,
"Gesamtkosten_Euro": 13.28719310595582, "Gesamtkosten_Euro": 13.287853180700994,
"Home_appliance_wh_per_hour": [ "Home_appliance_wh_per_hour": [
0.0, 0.0,
0.0, 0.0,
@@ -389,8 +390,8 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.007989913613567745, 0.008254784724581705,
0.028255713342252034, 0.028650916976410694,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -429,8 +430,8 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
35.04348076126204, 36.20519616044607,
127.73830624887898, 129.52494112301397,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -473,11 +474,11 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
156.06587377864435, 118.1306875065693,
3678.7636146358536, 3677.655110329884,
2086.994116068604, 2079.39365784241,
1132.4162643420734, 1100.3890733395326,
148.64025532125078, 144.4369401791649,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -509,15 +510,15 @@
109.0704545454546, 109.0704545454546,
109.96227272727276, 109.96227272727276,
47.952272727272714, 47.952272727272714,
11.233982308648535, 11.094576460746453,
38.52740325013451, 38.31300706523831,
161.62968357967037, 161.86847814969906,
21.962728535423857, 21.973794868109557,
519.5704951465664, 524.1227174992155,
0.5004782113116364, 0.6414151879949013,
10.13011689299087, 11.096451076844168,
36.109951963721926, 40.181939277841224,
44.377460775206174, 44.91187685729284,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -550,10 +551,10 @@
85.51196625344349, 85.51196625344349,
82.04093491735533, 82.04093491735533,
80.52728994490354, 80.52728994490354,
80.83934500903268, 80.83547262436872,
81.90955065486975, 81.89972282062534,
85.41748453961112, 85.29619913880036,
85.56748624593055, 85.44103562502363,
100.0, 100.0,
100.0, 100.0,
100.0, 100.0,
@@ -603,6 +604,46 @@
0.0002969, 0.0002969,
0.0002921, 0.0002921,
0.000278 0.000278
],
"Feed_in_tariff": [
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05
] ]
}, },
"eauto_obj": { "eauto_obj": {
+334 -293
View File
@@ -10,11 +10,19 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
1.0,
0.0,
1.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
1.0,
0.0, 0.0,
1.0,
0.0,
0.0,
1.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -26,23 +34,15 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
1.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
1.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
1.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -110,32 +110,6 @@
0, 0,
0, 0,
0, 0,
1,
1,
1,
1,
1,
1,
1,
0,
0,
0,
1,
1,
0,
0,
1,
1,
0,
0,
1,
1,
0,
1,
0,
0,
0,
0,
0, 0,
0, 0,
0, 0,
@@ -143,90 +117,117 @@
1, 1,
1, 1,
0, 0,
0,
0,
0,
1,
0,
0,
1,
0,
0,
1,
0,
1,
1,
0,
1,
1, 1,
1, 1,
0, 0,
0, 0,
1 1,
1,
1,
1,
1,
1,
0,
1,
0,
1,
1,
0
], ],
"battery_grid_export_allowed": [],
"eautocharge_hours_float": [ "eautocharge_hours_float": [
0.625,
0.375,
0.5,
1.0,
1.0,
0.75,
0.5,
0.375,
0.375,
0.625,
0.75,
0.875,
0.625,
0.625,
0.625,
1.0,
0.375,
0.625,
0.875,
0.0,
0.5,
0.0, 0.0,
0.0, 0.0,
0.5,
0.875,
0.625,
0.75,
0.5,
0.375,
0.375,
0.75,
0.625,
1.0,
0.375,
0.625,
0.625,
0.0, 0.0,
0.5, 0.0,
0.875, 0.0,
0.375, 0.0,
0.5, 0.0,
0.875, 0.0,
0.5, 0.0,
0.875, 0.0,
1.0,
0.75, 0.75,
0.625, 0.625,
0.5,
0.375,
0.875,
0.5,
0.75, 0.75,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0 0.0
], ],
"result": { "result": {
"Last_Wh_pro_Stunde": [ "Last_Wh_pro_Stunde": [
8919.07, 16541.07,
10240.91, 8929.91,
7875.5599999999995, 8875.56,
7687.03, 6376.03,
7718.67, 5096.67,
11664.82, 12853.82,
5149.22, 8960.220000000001,
6347.78, 13969.78,
1129.12, 1129.12,
8678.71, 1178.71,
3550.98, 1050.98,
988.56, 988.56,
912.38, 1912.38,
704.61, 704.61,
516.37, 516.37,
868.05, 868.05,
5694.34, 694.34,
608.79, 2108.79,
556.31, 556.31,
488.89, 488.89,
506.91, 506.91,
804.89, 804.89,
6141.98, 1141.98,
1056.97, 1056.97,
992.46, 992.46,
6155.99, 1155.99,
827.01, 827.01,
1257.98, 1257.98,
1232.67, 1232.67,
@@ -242,13 +243,13 @@
], ],
"EAuto_SoC_pro_Stunde": [ "EAuto_SoC_pro_Stunde": [
5.0, 5.0,
18.11, 22.48,
33.405, 35.589999999999996,
44.330000000000005, 46.515,
55.254999999999995, 55.254999999999995,
66.18, 61.809999999999995,
83.66, 77.105,
90.215, 85.845,
98.955, 98.955,
98.955, 98.955,
98.955, 98.955,
@@ -285,6 +286,7 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.06455049999999336,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -308,27 +310,22 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.12279079241545988,
0.0, 0.25743585772309185,
0.0, 0.1455575560489687,
0.0, 0.07702723513376727,
0.0, 0.024046008596276005,
0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0 0.0
], ],
"Gesamt_Verluste": 10674.660531928814, "Gesamt_Verluste": 6842.366945701403,
"Gesamtbilanz_Euro": 14.366070195145795, "Gesamtbilanz_Euro": 14.10088133917546,
"Gesamteinnahmen_Euro": 0.0, "Gesamteinnahmen_Euro": 0.6914079499175572,
"Gesamtkosten_Euro": 14.366070195145795, "Gesamtkosten_Euro": 14.792289289093018,
"Home_appliance_wh_per_hour": [ "Home_appliance_wh_per_hour": [
0.0,
0.0,
0.0,
0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -362,93 +359,98 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0,
0.0,
0.0,
0.0,
0.0 0.0
], ],
"Kosten_Euro_pro_Stunde": [ "Kosten_Euro_pro_Stunde": [
0.81824412, 3.55926012,
1.061242392, 1.7445291920000001,
0.49579402599999994, 1.6260140259999998,
0.399351386, 0.979774486,
0.13127915000000007,
1.2316083,
0.259218932,
0.7558691399999999,
0.061530097476751734,
2.4510570300000003,
0.0, 0.0,
0.5881238999999999,
1.0968767320000004,
2.4860631399999997,
0.06267084962493971,
0.05480703000000003,
0.0, 0.0,
0.26650619799999997, 0.291163892,
0.19588158, 0.558606198,
0.13029273082161758, 0.0,
0.174739608,
0.28801899, 0.28801899,
1.870021256, 0.0,
0.199865757, 0.692315757,
0.0, 0.0,
0.0, 0.0,
0.16677339, 0.16677339,
0.0, 0.0,
1.7663038380000002,
0.08545095,
0.007989913613567745,
1.134255713342252,
0.025392879919306634,
0.010682755832597498,
4.174095896658514e-14,
0.0003442778967139274,
0.0, 0.0,
0.0, 0.0,
0.04565364324294593, 0.008254784724581705,
0.028650916976410694,
0.0, 0.0,
0.0, 0.0,
0.293043269, 0.0,
0.214398479, 0.0,
0.0 0.0,
0.0,
0.04620342776708689,
0.0,
0.174597189,
0.0,
0.0,
0.16484566
], ],
"Netzbezug_Wh_pro_Stunde": [ "Netzbezug_Wh_pro_Stunde": [
3588.79, 15610.789999999999,
4797.66, 7886.66,
2368.8199999999997, 7768.82,
2125.34, 5214.34,
714.2500000000003,
6145.75,
1179.3400000000001,
3329.8199999999997,
205.30563055305882,
7671.54,
0.0, 0.0,
2934.75,
4990.340000000001,
10951.82,
209.11194402715952,
171.54000000000008,
0.0, 0.0,
912.38, 980.68,
704.61, 1912.38,
385.0258003002884, 0.0,
516.37,
868.05, 868.05,
5694.34, 0.0,
608.79, 2108.79,
0.0, 0.0,
0.0, 0.0,
506.91, 506.91,
0.0, 0.0,
5806.39,
351.65,
35.04348076126204,
5127.738306248879,
121.32288542430308,
56.853410498124,
2.270998855635753e-10,
1.7179535764168035,
0.0, 0.0,
0.0, 0.0,
152.3311419517715, 36.20519616044607,
129.52494112301397,
0.0, 0.0,
0.0, 0.0,
987.01, 0.0,
733.99, 0.0,
0.0 0.0,
0.0,
154.1655914817714,
0.0,
566.69,
0.0,
0.0,
592.97
], ],
"Netzeinspeisung_Wh_pro_Stunde": [ "Netzeinspeisung_Wh_pro_Stunde": [
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
922.1499999999053,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -472,12 +474,11 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 1754.1541773637127,
0.0, 3677.655110329884,
0.0, 2079.39365784241,
0.0, 1100.3890733395326,
0.0, 343.51440851822866,
0.0,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -485,84 +486,84 @@
0.0 0.0
], ],
"Verluste_Pro_Stunde": [ "Verluste_Pro_Stunde": [
1014.0, 1152.0,
1083.0, 414.0,
945.0, 465.0,
945.0,
479.4,
552.0,
207.0,
276.0, 276.0,
73.03732433363291, 207.00000000001197,
600.0, 1083.0,
440.6331818181816, 276.0,
133.72909090909081, 1014.0,
72.5805667167408,
0.0,
99.72409090909093,
0.0,
120.0,
96.08318181818186,
0.0, 0.0,
0.0, 0.0,
17.910572686324315, 94.68272727272722,
0.0, 180.0,
600.0,
0.0,
75.86045454545456, 75.86045454545456,
66.66681818181814, 66.66681818181814,
0.0, 0.0,
109.0704545454546, 109.0704545454546,
600.0, 109.96227272727276,
47.952272727272714,
11.094576460746453,
38.31300706523831,
161.86847814969906,
21.973794868109557,
327.79989871635826,
0.6414151879949013,
11.096451076844168,
40.181939277841224,
0.0, 0.0,
11.233982308648535,
638.5274032501345,
145.08565374908358,
14.209990740225123,
538.2983999999728,
441.7178455708299,
260.56941082122324,
171.99990368477063,
41.441862965787436,
35.132727272727266, 35.132727272727266,
77.27590909090907,
0.0, 0.0,
0.0, 134.59227272727276,
80.85954545454547 100.08954545454549,
0.0
], ],
"akku_soc_pro_stunde": [ "akku_soc_pro_stunde": [
80.0, 80.0,
61.06060606060606, 96.66666666666667,
42.12121212121212, 96.66666666666667,
23.18181818181818, 100.0,
4.242424242424243, 100.0,
0.0, 100.0,
0.0, 81.06060606060606,
0.0, 81.06060606060606,
0.0, 97.72727272727273,
2.0288145648231377, 99.74339958051553,
18.695481231489804, 99.74339958051553,
4.786605542784571, 96.59554317555684,
0.5653589863107422, 96.59554317555684,
0.5653589863107422, 99.92887650889017,
0.5653589863107422, 96.89594778988192,
0.0, 96.89594778988192,
0.0, 96.89594778988192,
16.666666666666664, 93.90722533809128,
16.666666666666664, 98.90722533809128,
14.272081611570247, 96.51264028299485,
12.167699724517906, 94.40825839594251,
12.167699724517906, 94.40825839594251,
8.724819214876034, 90.96537788630063,
25.391485881542703, 87.49434655021247,
25.391485881542703, 85.9807015777607,
25.703540945671826, 86.28888425722586,
43.44041325817556, 87.35313445348248,
47.47057030676122, 90.7496107716575,
47.86529227176747, 90.89444725788077,
62.81802560510005, 100.0,
75.08796575984533, 100.0,
82.04461281340734, 100.0,
85.81933369454761, 100.0,
86.97049655470836, 100.0,
85.86150895140257, 98.89101239669421,
83.42224414424004, 98.89101239669421,
83.42224414424004, 94.64251893939394,
83.42224414424004 91.48312672176309
], ],
"Electricity_price": [ "Electricity_price": [
0.000228, 0.000228,
@@ -603,6 +604,46 @@
0.0002969, 0.0002969,
0.0002921, 0.0002921,
0.000278 0.000278
],
"Feed_in_tariff": [
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05,
7e-05
] ]
}, },
"eauto_obj": { "eauto_obj": {
@@ -619,14 +660,14 @@
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
0.75,
0.875,
0.625,
0.625,
0.625,
1.0, 1.0,
0.375, 0.75,
0.625,
0.5, 0.5,
0.375,
0.875,
0.5,
0.75,
0.0, 0.0,
0.0, 0.0,
0.0, 0.0,
@@ -716,103 +757,103 @@
"initial_soc_percentage": 5 "initial_soc_percentage": 5
}, },
"start_solution": [ "start_solution": [
2.0,
2.0,
0.0, 0.0,
0.0, 0.0,
2.0, 2.0,
0.0, 0.0,
0.0, 1.0,
2.0, 2.0,
1.0, 1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
0.0,
0.0, 0.0,
2.0, 2.0,
1.0, 2.0,
1.0, 2.0,
0.0, 0.0,
0.0,
1.0,
1.0,
2.0, 2.0,
0.0, 0.0,
1.0, 1.0,
1.0, 1.0,
0.0, 0.0,
1.0,
2.0, 2.0,
0.0, 0.0,
2.0,
1.0,
0.0, 0.0,
2.0, 2.0,
1.0,
0.0, 0.0,
0.0, 0.0,
0.0, 1.0,
0.0, 2.0,
1.0, 1.0,
1.0, 1.0,
0.0, 0.0,
1.0, 1.0,
1.0, 1.0,
1.0,
0.0, 0.0,
0.0, 0.0,
1.0, 1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
2.0,
1.0,
0.0,
1.0,
1.0,
0.0,
1.0,
6.0,
5.0,
3.0,
0.0,
0.0,
4.0,
6.0,
3.0, 3.0,
1.0,
2.0, 2.0,
6.0, 6.0,
6.0,
4.0, 4.0,
3.0,
2.0, 2.0,
1.0, 1.0,
5.0,
2.0,
4.0,
4.0,
6.0,
5.0,
0.0,
2.0,
2.0,
2.0,
4.0,
0.0,
1.0, 1.0,
3.0,
4.0, 4.0,
5.0, 5.0,
3.0, 3.0,
3.0,
3.0,
6.0, 6.0,
1.0,
3.0, 3.0,
5.0, 5.0,
0.0,
2.0,
0.0,
0.0,
2.0,
5.0,
3.0, 3.0,
4.0,
2.0,
1.0, 1.0,
1.0,
4.0,
3.0,
6.0, 6.0,
1.0, 5.0,
3.0, 2.0,
3.0,
0.0, 0.0,
2.0, 6.0,
5.0, 4.0,
1.0,
2.0,
5.0,
2.0,
5.0, 5.0,
4.0, 4.0,
3.0, 3.0,
4.0, 3.0,
0.0, 1.0,
19.0 3.0,
15.0
], ],
"washingstart": 19 "washingstart": 15
} }