From 92a8a093e87f767f8ab258069142e95a7897dbea Mon Sep 17 00:00:00 2001 From: Andreas Date: Tue, 14 Jul 2026 17:00:07 +0200 Subject: [PATCH] feat: complete 15-minute optimization support --- Anleitung.txt | 3 + CHANGELOG.md | 6 + docs/akkudoktoreos/optimauto.md | 7 + nodered_simulation_data_interval_ready.json | 22 + src/akkudoktoreos/core/ems.py | 11 +- src/akkudoktoreos/devices/genetic/battery.py | 25 +- src/akkudoktoreos/devices/genetic/inverter.py | 27 +- .../optimization/genetic/genetic.py | 215 ++++-- .../optimization/genetic/geneticparams.py | 16 +- .../optimization/genetic/geneticsolution.py | 27 +- .../optimization/optimization.py | 12 + .../prediction/elecpriceenergycharts.py | 82 ++- .../prediction/elecpricetibber.py | 78 ++- .../prediction/feedintariffenergycharts.py | 63 +- src/akkudoktoreos/prediction/interpolator.py | 31 +- src/akkudoktoreos/prediction/prediction.py | 57 +- src/akkudoktoreos/utils/visualize.py | 67 +- tests/test_battery.py | 44 ++ tests/test_elecpriceenergycharts.py | 42 +- tests/test_elecpricetibber.py | 11 + tests/test_feedintariffenergycharts.py | 24 + tests/test_geneticsimulation.py | 120 ++-- tests/test_interpolator.py | 30 + tests/test_inverter.py | 46 +- tests/test_optimization_interval.py | 200 +++++- tests/test_prediction.py | 40 +- tests/testdata/new_optimize_15min.pdf | Bin 0 -> 84717 bytes tests/testdata/optimize_result_1.json | 353 +++++----- tests/testdata/optimize_result_1_be.json | 381 ++++++----- tests/testdata/optimize_result_2.json | 175 +++-- tests/testdata/optimize_result_2_be.json | 629 ++++++++++-------- 31 files changed, 1812 insertions(+), 1032 deletions(-) create mode 100644 Anleitung.txt create mode 100644 nodered_simulation_data_interval_ready.json create mode 100644 tests/test_interpolator.py create mode 100644 tests/testdata/new_optimize_15min.pdf diff --git a/Anleitung.txt b/Anleitung.txt new file mode 100644 index 00000000..64f35a51 --- /dev/null +++ b/Anleitung.txt @@ -0,0 +1,3 @@ +http://192.168.1.175:8503/v1/measurement/keys -> Keys auslesen +grid_import_mr +grid_export_mr \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4448f1..b74af9e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 providers (pvnode, Forecast.Solar, Solcast) feed their native resolution straight 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 (`priceInfoRange(resolution: QUARTER_HOURLY)`) and stores them at their native resolution, so both the hourly and the 15-minute optimizer are fed the correct diff --git a/docs/akkudoktoreos/optimauto.md b/docs/akkudoktoreos/optimauto.md index 659e1964..a96fa664 100644 --- a/docs/akkudoktoreos/optimauto.md +++ b/docs/akkudoktoreos/optimauto.md @@ -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 exchange-priced tariff) to let the optimizer schedule on a quarter-hour grid. Keeping the 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 diff --git a/nodered_simulation_data_interval_ready.json b/nodered_simulation_data_interval_ready.json new file mode 100644 index 00000000..b50cd46f --- /dev/null +++ b/nodered_simulation_data_interval_ready.json @@ -0,0 +1,22 @@ +[ + { + "id": "2f258cb68a2ff471", + "type": "function", + "z": "e8cb99e55189e3aa", + "name": "simulation_data_interval_ready", + "func": "// JSON-Daten aus dem Payload (für das Beispiel, ersetzen mit deinen Daten)\nlet data = msg.payload.result;\n\n// Ermittlung der maximalen Länge der Arrays\nlet maxLength = Math.max(data.Netzeinspeisung_Wh_pro_Stunde.length, data.Netzbezug_Wh_pro_Stunde.length, data.akku_soc_pro_stunde.length, data.Last_Wh_pro_Stunde.length);\n\n// Startzeitpunkt ab der jetzigen Stunde in der lokalen Zeitzone\nconst intervalSeconds = Number(\n msg.optimization_interval_seconds ??\n flow.get(\"eos_optimization_interval_seconds\") ??\n 900\n);\nif (![900, 3600].includes(intervalSeconds)) {\n node.error(`Unsupported EOS interval: ${intervalSeconds} seconds`, msg);\n return null;\n}\nconst intervalMinutes = intervalSeconds / 60;\nconst intervalMs = intervalSeconds * 1000;\n\n// Align to the same local slot as EOS (:00/:15/:30/:45 at 900 seconds).\nlet now = msg.eos_start_datetime ? new Date(msg.eos_start_datetime) : new Date();\nif (Number.isNaN(now.getTime())) {\n node.error(\"Invalid msg.eos_start_datetime\", msg);\n return null;\n}\nnow.setMinutes(Math.floor(now.getMinutes() / intervalMinutes) * intervalMinutes, 0, 0);\n\n// Initialisierung des SQL-Statements\nlet sqlStatements = \"\";\n\n// Schleife über die Anzahl der Stunden\nfor (let i = 0; i < maxLength; i++) {\n // Berechnung des Zeitstempels für jede Stunde in der lokalen Zeit\n let localTimestamp = new Date(now.getTime() + i * intervalMs);\n //let todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // 0:00 Uhr heute\n\n // Optional: Falls du i nutzen möchtest, um Stunden hinzuzufügen:\n //let localTimestamp = new Date(todayMidnight.getTime() + i * intervalMs);\n\n\n // Manuelle Formatierung in das MariaDB-Format YYYY-MM-DD HH:MM:SS\n let year = localTimestamp.getFullYear();\n let month = ('0' + (localTimestamp.getMonth() + 1)).slice(-2);\n let day = ('0' + localTimestamp.getDate()).slice(-2);\n let hours = ('0' + localTimestamp.getHours()).slice(-2);\n let minutes = ('0' + localTimestamp.getMinutes()).slice(-2);\n let seconds = ('0' + localTimestamp.getSeconds()).slice(-2);\n\n // Formatierung für MariaDB\n let timestamp = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\n // Prüfen, ob es für diese Stunde überhaupt Daten gibt\n let hasData = false;\n let hourSqlStatements = \"\";\n //node.warn(String(i) + \" - \"+String(data.Last_Wh_pro_Stunde[i]) + \" \"+ String(data.Netzeinspeisung_Wh_pro_Stunde[i]) + \" \"+ String(data.Netzbezug_Wh_pro_Stunde[i]))\n // Einfügen der Netzeinspeisung, wenn der Wert nicht null oder undefined ist\n if (i < data.Netzeinspeisung_Wh_pro_Stunde.length && data.Netzeinspeisung_Wh_pro_Stunde[i] != null ) {\n hourSqlStatements += `INSERT INTO eos_simulation_data (topic, data, timestamp) VALUES ('Netzeinspeisung_Wh_pro_Stunde', ROUND(${data.Netzeinspeisung_Wh_pro_Stunde[i]},8), '${timestamp}');\\n`;\n hasData = true;\n }\n if (i < data.Electricity_price.length && data.Electricity_price[i] != null) {\n hourSqlStatements += `INSERT INTO eos_simulation_data (topic, data, timestamp) VALUES ('Electricity_price', ROUND(${data.Electricity_price[i]},8), '${timestamp}');\\n`;\n hasData = true;\n }\n\n // Einfügen des Netzbezugs, wenn der Wert nicht null oder undefined ist\n if (i < data.Netzbezug_Wh_pro_Stunde.length && data.Netzbezug_Wh_pro_Stunde[i] != null ) {\n hourSqlStatements += `INSERT INTO eos_simulation_data (topic, data, timestamp) VALUES ('Netzbezug_Wh_pro_Stunde', ROUND(${data.Netzbezug_Wh_pro_Stunde[i]},8), '${timestamp}');\\n`;\n hasData = true;\n }\n\n // Einfügen des Akkustands (SoC), wenn der Wert nicht null oder undefined ist nicht bei hasData berücksichtigen!\n if (i < data.akku_soc_pro_stunde.length && data.akku_soc_pro_stunde[i] != null ) {\n hourSqlStatements += `INSERT INTO eos_simulation_data (topic, data, timestamp) VALUES ('akku_soc_pro_stunde', ROUND(${data.akku_soc_pro_stunde[i]},8), '${timestamp}');\\n`;\n //hasData = true;\n }\n\n // Einfügen der Last, wenn der Wert nicht null oder undefined ist\n if (i < data.Last_Wh_pro_Stunde.length && data.Last_Wh_pro_Stunde[i] != null ) {\n hourSqlStatements += `INSERT INTO eos_simulation_data (topic, data, timestamp) VALUES ('last_pro_stunde', ROUND(${data.Last_Wh_pro_Stunde[i]},8), '${timestamp}');\\n`;\n //node.warn(`INSERT INTO eos_simulation_data (topic, data, timestamp) VALUES ('last_pro_stunde', ROUND(${data.Last_Wh_pro_Stunde[i]},8), '${timestamp}');`);\n hasData = true;\n }\n \n // --- Neu: DV-relevante Werte ---\n if (i < data.Einnahmen_Euro_pro_Stunde.length && data.Einnahmen_Euro_pro_Stunde[i] != null) {\n hourSqlStatements += `INSERT INTO eos_simulation_data (topic, data, timestamp) VALUES ('Einnahmen_Euro_pro_Stunde', ROUND(${data.Einnahmen_Euro_pro_Stunde[i]},8), '${timestamp}');\\n`;\n }\n if (i < data.Kosten_Euro_pro_Stunde.length && data.Kosten_Euro_pro_Stunde[i] != null) {\n hourSqlStatements += `INSERT INTO eos_simulation_data (topic, data, timestamp) VALUES ('Kosten_Euro_pro_Stunde', ROUND(${data.Kosten_Euro_pro_Stunde[i]},8), '${timestamp}');\\n`;\n }\n if (i < data.Verluste_Pro_Stunde.length && data.Verluste_Pro_Stunde[i] != null) {\n hourSqlStatements += `INSERT INTO eos_simulation_data (topic, data, timestamp) VALUES ('Verluste_Pro_Stunde', ROUND(${data.Verluste_Pro_Stunde[i]},8), '${timestamp}');\\n`;\n }\n if (i < data.Feed_in_tariff.length && data.Feed_in_tariff[i] != null) {\n hourSqlStatements += `INSERT INTO eos_simulation_data (topic, data, timestamp) VALUES ('Feed_in_tariff', ROUND(${data.Feed_in_tariff[i]},10), '${timestamp}');\\n`;\n }\n\n // Nur wenn für diese Stunde Daten vorhanden sind, die Anweisungen generieren\n if (hasData) {\n \n sqlStatements += `DELETE FROM eos_simulation_data WHERE timestamp = '${timestamp}';\\n`;\n sqlStatements += hourSqlStatements;\n \n }\n}\n\n// Das generierte SQL-Statement in msg.topic einfügen\nmsg.topic = sqlStatements;\nmsg.payload = sqlStatements;\nreturn msg;\n", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1100, + "y": 220, + "wires": [ + [ + "b67e8f2dfd038358" + ] + ] + } +] diff --git a/src/akkudoktoreos/core/ems.py b/src/akkudoktoreos/core/ems.py index d2aafd6e..c6589a38 100644 --- a/src/akkudoktoreos/core/ems.py +++ b/src/akkudoktoreos/core/ems.py @@ -96,8 +96,8 @@ class EnergyManagement( If no datetime is provided, the current datetime is used. - The start datetime is always rounded down to the nearest hour - (i.e., setting minutes, seconds, and microseconds to zero). + The start datetime is rounded down to the configured optimization + interval. For a 15-minute grid this yields :00, :15, :30 or :45. Args: start_datetime (Optional[DateTime]): The datetime to set as the start. @@ -108,7 +108,12 @@ class EnergyManagement( """ if start_datetime is None: start_datetime = to_datetime() - cls._start_datetime = start_datetime.set(minute=0, second=0, microsecond=0) + interval_s = int(cls.config.optimization.interval or 3600) + wall_clock_s = ( + start_datetime.hour * 3600 + start_datetime.minute * 60 + start_datetime.second + ) + remainder_s = wall_clock_s % interval_s + cls._start_datetime = start_datetime.subtract(seconds=remainder_s).set(microsecond=0) return cls._start_datetime @classmethod diff --git a/src/akkudoktoreos/devices/genetic/battery.py b/src/akkudoktoreos/devices/genetic/battery.py index 24b914f9..e6aff926 100644 --- a/src/akkudoktoreos/devices/genetic/battery.py +++ b/src/akkudoktoreos/devices/genetic/battery.py @@ -58,6 +58,8 @@ class Battery: self.max_charge_power_w = self.capacity_wh # TODO this should not be equal capacity_wh self.discharge_array = np.full(self.prediction_hours, 0) self.charge_array = np.full(self.prediction_hours, 0) + self._discharged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float) + self._charged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float) self.soc_wh = (self.initial_soc_percentage / 100) * self.capacity_wh self.min_soc_wh = (self.min_soc_percentage / 100) * self.capacity_wh self.max_soc_wh = (self.max_soc_percentage / 100) * self.capacity_wh @@ -101,6 +103,17 @@ class Battery: self.soc_wh = min(self.soc_wh, self.max_soc_wh) # Only clamp to max self.discharge_array = np.full(self.prediction_hours, 0) self.charge_array = np.full(self.prediction_hours, 0) + self._discharged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float) + self._charged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float) + + def remaining_discharge_energy_wh(self, hour: int) -> float: + """Return DC energy still deliverable within one optimization slot.""" + raw_power_budget_wh = self.max_charge_power_w * self.slot_duration_h + raw_power_remaining_wh = max( + raw_power_budget_wh - self._discharged_raw_wh_per_slot[hour], 0.0 + ) + raw_soc_available_wh = max(self.soc_wh - self.min_soc_wh, 0.0) + return min(raw_power_remaining_wh, raw_soc_available_wh) * self.discharging_efficiency def set_discharge_per_hour(self, discharge_array: np.ndarray) -> None: """Sets the discharge values for each hour.""" @@ -151,8 +164,9 @@ class Battery: # Maximum raw discharge due to power limit, scaled to the slot duration. # max_charge_power_w is a power [W]; energy movable in one slot is # power x slot_duration_h. - max_raw_wh = ( - self.max_charge_power_w * self.slot_duration_h + max_raw_wh = max( + self.max_charge_power_w * self.slot_duration_h - self._discharged_raw_wh_per_slot[hour], + 0.0, ) # TODO rename to max_discharge_power_w # Actual raw withdrawal (internal) @@ -170,6 +184,7 @@ class Battery: # Update SoC self.soc_wh -= raw_used_wh self.soc_wh = max(self.soc_wh, self.min_soc_wh) + self._discharged_raw_wh_per_slot[hour] += raw_used_wh # Losses losses_wh = raw_used_wh - delivered_wh @@ -246,7 +261,10 @@ class Battery: soc_wh_fast = self.soc_wh # Scale the power cap [W] to a per-slot energy cap [Wh] (W x slot hours). # At slot_duration_h=1.0 (hourly) this equals the legacy power value. - max_charge_per_slot_wh_fast = self.max_charge_power_w * self.slot_duration_h + max_charge_per_slot_wh_fast = max( + self.max_charge_power_w * self.slot_duration_h - self._charged_raw_wh_per_slot[hour], + 0.0, + ) charging_efficiency_fast = self.charging_efficiency # Decide mode & determine raw_request_wh and raw_charge_wh @@ -290,6 +308,7 @@ class Battery: ) self.soc_wh = new_soc + self._charged_raw_wh_per_slot[hour] += raw_input_wh losses_wh = raw_input_wh - stored_wh return stored_wh, losses_wh diff --git a/src/akkudoktoreos/devices/genetic/inverter.py b/src/akkudoktoreos/devices/genetic/inverter.py index fe8f56c1..da098583 100644 --- a/src/akkudoktoreos/devices/genetic/inverter.py +++ b/src/akkudoktoreos/devices/genetic/inverter.py @@ -74,9 +74,12 @@ class Inverter: grid_import = -remaining_power # Negative indicates feeding into the grid self_consumption = self.max_power_wh else: - # Calculate scr using cached results per energy management/optimization run + # Calculate scr using cached results per energy management/optimization run. + # The interpolator expects power levels [W]; consumption/generation are + # energy per slot [Wh], so convert via the slot duration (identical at + # the hourly default, ×4 on the 15-minute grid). scr = self.self_consumption_predictor.calculate_self_consumption( - consumption, generation + consumption / self.slot_duration_h, generation / self.slot_duration_h ) # Remaining power after consumption @@ -133,12 +136,10 @@ class Inverter: if allow_battery_grid_export and self.battery: export_capacity = max(self.max_power_wh - consumption - grid_export, 0.0) - max_discharge_dc = getattr(self.battery, "max_charge_power_w", None) - if max_discharge_dc is not None: - remaining_battery_ac = max( - (max_discharge_dc - from_battery_dc) * dc_to_ac_eff, 0.0 - ) - export_capacity = min(export_capacity, remaining_battery_ac) + remaining_battery_ac = ( + self.battery.remaining_discharge_energy_wh(hour) * dc_to_ac_eff + ) + export_capacity = min(export_capacity, remaining_battery_ac) battery_export_ac, battery_export_losses = self._discharge_battery_to_ac( export_capacity, hour ) @@ -171,12 +172,10 @@ class Inverter: if allow_battery_grid_export and self.battery and grid_import <= 0.0: export_capacity = max(self.max_power_wh - consumption, 0.0) - max_discharge_dc = getattr(self.battery, "max_charge_power_w", None) - if max_discharge_dc is not None: - remaining_battery_ac = max( - (max_discharge_dc - battery_discharge_dc) * dc_to_ac_eff, 0.0 - ) - export_capacity = min(export_capacity, remaining_battery_ac) + remaining_battery_ac = ( + self.battery.remaining_discharge_energy_wh(hour) * dc_to_ac_eff + ) + export_capacity = min(export_capacity, remaining_battery_ac) battery_export_ac, battery_export_losses = self._discharge_battery_to_ac( export_capacity, hour ) diff --git a/src/akkudoktoreos/optimization/genetic/genetic.py b/src/akkudoktoreos/optimization/genetic/genetic.py index c8fc46e1..005dda6b 100644 --- a/src/akkudoktoreos/optimization/genetic/genetic.py +++ b/src/akkudoktoreos/optimization/genetic/genetic.py @@ -100,9 +100,7 @@ class GeneticSimulation(PydanticBaseModel): ) bat_grid_export_hours: Optional[NDArray[Shape["*"], float]] = Field( default=None, - json_schema_extra={ - "description": "Hourly permission for battery discharge into the grid." - }, + json_schema_extra={"description": "Hourly permission for battery discharge into the grid."}, ) ev_charge_hours: Optional[NDArray[Shape["*"], float]] = Field( default=None, json_schema_extra={"description": "TBD"} @@ -453,9 +451,7 @@ class GeneticSimulation(PydanticBaseModel): # Financial calculations costs_per_hour[hour_idx] = energy_consumption_grid_actual * hourly_electricity_price - revenue_per_hour[hour_idx] = ( - energy_feedin_grid_actual * hourly_feed_in_tariff - ) + revenue_per_hour[hour_idx] = energy_feedin_grid_actual * hourly_feed_in_tariff total_cost = np.nansum(costs_per_hour) total_losses = np.nansum(losses_wh_per_hour) @@ -529,12 +525,23 @@ class GeneticOptimization(OptimizationBase): fixed_seed: Optional[int] = None, ): """Initialize the optimization problem with the required parameters.""" + if self.config.optimization.interval not in (900, 3600): + logger.warning( + "Genetic optimization interval {} seconds is unsupported; using 3600 seconds.", + self.config.optimization.interval, + ) + self.config.optimization.interval = 3600 self.opti_param: dict[str, Any] = {} # Number of slots at the tail of the optimization window where EV # charging is fixed to 0. Slot-counted so 15-min runs reserve the right # tail length (at interval=3600 s this equals prediction.hours - horizon). - self.fixed_eauto_hours = self.total_slots - ( - self.config.optimization.horizon_hours * self.slots_per_hour + self.fixed_eauto_hours = max( + self.total_slots + - ( + self._start_day_slot() + + self.config.optimization.horizon_hours * self.slots_per_hour + ), + 0, ) self.ev_possible_charge_values: list[float] = [1.0] # Separate charge-level list for battery AC charging (independent of EV rates). @@ -580,15 +587,111 @@ class GeneticOptimization(OptimizationBase): return parameters ems_parameters = parameters.ems.model_copy( - update={ - "einspeiseverguetung_euro_pro_wh": list( - parameters.ems.strompreis_euro_pro_wh - ) - }, + update={"einspeiseverguetung_euro_pro_wh": list(parameters.ems.strompreis_euro_pro_wh)}, deep=True, ) return parameters.model_copy(update={"ems": ems_parameters}, deep=True) + def _parameters_for_slot_grid( + self, parameters: GeneticOptimizationParameters + ) -> GeneticOptimizationParameters: + """Normalize hourly or native-slot EMS input onto the optimization grid. + + API clients historically provide one value per prediction hour. At a + sub-hourly interval, energy quantities are distributed across the slots + while price quantities are held constant. Inputs already matching the + native slot grid are preserved exactly. Any other length is ambiguous and + rejected instead of silently shortening the simulation horizon. + """ + + def normalize(values: list[float], name: str, *, energy: bool) -> list[float]: + value_count = len(values) + if value_count == self.total_slots: + return list(values) + if value_count != self.config.prediction.hours: + raise ValueError( + f"{name} has {value_count} values; expected either " + f"{self.config.prediction.hours} hourly values or " + f"{self.total_slots} optimization-slot values." + ) + + normalized = np.repeat(np.asarray(values, dtype=float), self.slots_per_hour) + if energy: + normalized /= self.slots_per_hour + return normalized.tolist() + + ems = parameters.ems + feed_in_tariff = ems.einspeiseverguetung_euro_pro_wh + if isinstance(feed_in_tariff, list): + normalized_feed_in_tariff: list[float] | float = normalize( + feed_in_tariff, + "einspeiseverguetung_euro_pro_wh", + energy=False, + ) + else: + normalized_feed_in_tariff = [float(feed_in_tariff)] * self.total_slots + + normalized_ems = ems.model_copy( + update={ + "pv_prognose_wh": normalize(ems.pv_prognose_wh, "pv_prognose_wh", energy=True), + "gesamtlast": normalize(ems.gesamtlast, "gesamtlast", energy=True), + "strompreis_euro_pro_wh": normalize( + ems.strompreis_euro_pro_wh, + "strompreis_euro_pro_wh", + energy=False, + ), + "einspeiseverguetung_euro_pro_wh": normalized_feed_in_tariff, + }, + deep=True, + ) + temperature_forecast = parameters.temperature_forecast + if temperature_forecast is not None: + if len(temperature_forecast) == self.config.prediction.hours: + temperature_forecast = [ + value for value in temperature_forecast for _ in range(self.slots_per_hour) + ] + elif len(temperature_forecast) != self.total_slots: + raise ValueError( + f"temperature_forecast has {len(temperature_forecast)} values; expected " + f"either {self.config.prediction.hours} hourly values or " + f"{self.total_slots} optimization-slot values." + ) + return parameters.model_copy( + update={"ems": normalized_ems, "temperature_forecast": temperature_forecast}, + deep=True, + ) + + def _start_solution_for_slot_grid( + self, start_solution: list[float], *, has_appliance: bool + ) -> list[float]: + """Expand a legacy hourly genome to the configured slot grid when possible.""" + expected_length = self.total_slots * (2 if self.optimize_ev else 1) + hourly_length = self.config.prediction.hours * (2 if self.optimize_ev else 1) + if has_appliance: + expected_length += 1 + hourly_length += 1 + + if len(start_solution) == expected_length or self.slots_per_hour == 1: + return list(start_solution) + if len(start_solution) != hourly_length: + return list(start_solution) + + battery_end = self.config.prediction.hours + migrated = np.repeat(start_solution[:battery_end], self.slots_per_hour).tolist() + if self.optimize_ev: + ev_end = battery_end + self.config.prediction.hours + migrated.extend( + np.repeat(start_solution[battery_end:ev_end], self.slots_per_hour).tolist() + ) + if has_appliance: + migrated.append(start_solution[-1]) + logger.info( + "Expanded hourly start_solution from {} to {} slot values.", + hourly_length, + expected_length, + ) + return migrated + def decode_charge_discharge( self, discharge_hours_bin: np.ndarray ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: @@ -813,8 +916,15 @@ class GeneticOptimization(OptimizationBase): self.toolbox.register("mate", tools.cxTwoPoint) # Mutation operator for battery charge/discharge states + # Keep the expected number of mutated genes per hour stable when the + # interval becomes finer (0.2 hourly -> 0.05 on a quarter-hour grid). + mutation_probability = 0.2 / self.slots_per_hour self.toolbox.register( - "mutate_charge_discharge", tools.mutUniformInt, low=0, up=total_states - 1, indpb=0.2 + "mutate_charge_discharge", + tools.mutUniformInt, + low=0, + up=total_states - 1, + indpb=mutation_probability, ) # Mutation operator for EV states (separate index space) @@ -823,7 +933,7 @@ class GeneticOptimization(OptimizationBase): tools.mutUniformInt, low=0, up=len_ev - 1, - indpb=0.2, + indpb=mutation_probability, ) # Mutation for household appliance @@ -1106,8 +1216,10 @@ class GeneticOptimization(OptimizationBase): if best_uncovered_price < break_even_price: # AC charging at this hour is economically unjustified. - # Penalty = excess cost per Wh × DC energy requested this hour. - dc_wh = bat.max_charge_power_w * ac_factor + # Penalty = excess cost per Wh × DC energy requested this slot. + # max_charge_power_w is a power [W]; the energy movable in + # one slot is power × slot_duration_h (¼ at 15 min). + dc_wh = bat.max_charge_power_w * self.slot_duration_h * ac_factor ac_wh = dc_wh / max(inv.ac_to_dc_efficiency, 1e-9) excess_cost_per_wh = break_even_price - best_uncovered_price gesamtbilanz += ac_wh * excess_cost_per_wh * ac_penalty_factor @@ -1138,6 +1250,12 @@ class GeneticOptimization(OptimizationBase): @TODO: optimize() ngen default (200) is different from optimierung_ems() ngen default (400). """ + # Re-seed at the actual optimization boundary. Setup and validation may + # consume random values elsewhere in a long-running process; a fixed seed + # must nevertheless produce the same population and result. + if self.fix_seed is not None: + random.seed(self.fix_seed) + # Set the number of inviduals in a generation try: individuals = self.config.optimization.genetic.individuals @@ -1157,14 +1275,16 @@ class GeneticOptimization(OptimizationBase): logger.debug("Start optimize: {}", start_solution) # Insert the start solution into the population if provided and compatible with the - # currently active genome layout. EV optimization adds one gene per prediction hour, + # currently active genome layout. EV optimization adds one gene per prediction slot, # so a cached solution from a previous run without EV optimization must not be reused. if start_solution is not None: - expected_length = self.config.prediction.hours - if self.optimize_ev: - expected_length += self.config.prediction.hours - if self.opti_param.get("home_appliance", 0) > 0: + has_appliance = self.opti_param.get("home_appliance", 0) > 0 + expected_length = self.total_slots * (2 if self.optimize_ev else 1) + if has_appliance: expected_length += 1 + start_solution = self._start_solution_for_slot_grid( + start_solution, has_appliance=has_appliance + ) if len(start_solution) == expected_length: for _ in range(10): @@ -1218,6 +1338,12 @@ class GeneticOptimization(OptimizationBase): """Perform EMS (Energy Management System) optimization and visualize results.""" direct_marketing_enabled = self._direct_marketing_enabled() parameters = self._parameters_for_config(parameters) + parameters = self._parameters_for_slot_grid(parameters) + if self.slots_per_hour > 1 and parameters.dishwasher is not None: + raise ValueError( + "Home-appliance scheduling is not yet supported for sub-hourly " + "optimization intervals." + ) self.optimize_dc_charge = direct_marketing_enabled self.optimize_battery_grid_export = direct_marketing_enabled @@ -1399,30 +1525,33 @@ class GeneticOptimization(OptimizationBase): else: battery_grid_export = battery_grid_export.tolist() - # Visualize the results in PDF - try: - from akkudoktoreos.utils.visualize import prepare_visualize + # Visualize the results in PDF. Skippable via config — matplotlib PDF + # generation costs several seconds per run, which headless setups + # (API/Node-RED polling) never look at. + if getattr(self.config.optimization, "visualize_pdf", True): + try: + from akkudoktoreos.utils.visualize import prepare_visualize - visualize = { - "ac_charge": ac_charge_hours, - "dc_charge": dc_charge_hours, - "discharge_allowed": discharge, - "battery_grid_export_allowed": battery_grid_export, - "eautocharge_hours_float": eautocharge_hours_float, - "result": simulation_result, - "eauto_obj": self.simulation.ev.to_dict() if self.simulation.ev else None, - "start_solution": start_solution, - "spuelstart": washingstart_int, - "extra_data": extra_data, - "fitness_history": self.fitness_history, - "fixed_seed": self.fix_seed, - } + visualize = { + "ac_charge": ac_charge_hours, + "dc_charge": dc_charge_hours, + "discharge_allowed": discharge, + "battery_grid_export_allowed": battery_grid_export, + "eautocharge_hours_float": eautocharge_hours_float, + "result": simulation_result, + "eauto_obj": self.simulation.ev.to_dict() if self.simulation.ev else None, + "start_solution": start_solution, + "spuelstart": washingstart_int, + "extra_data": extra_data, + "fitness_history": self.fitness_history, + "fixed_seed": self.fix_seed, + } - prepare_visualize(parameters, visualize, start_hour=start_hour) + prepare_visualize(parameters, visualize, start_hour=start_slot) - except Exception as ex: - error_msg = f"Visualization failed: {ex}" - logger.error(error_msg) + except Exception as ex: + error_msg = f"Visualization failed: {ex}" + logger.error(error_msg) return GeneticSolution( **{ diff --git a/src/akkudoktoreos/optimization/genetic/geneticparams.py b/src/akkudoktoreos/optimization/genetic/geneticparams.py index f6d3cd8e..edc6d2d6 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticparams.py +++ b/src/akkudoktoreos/optimization/genetic/geneticparams.py @@ -227,7 +227,7 @@ class GeneticOptimizationParameters( # Add forecast and device data interval = to_duration(cls.config.optimization.interval) power_to_energy_per_interval_factor = cls.config.optimization.interval / 3600 - parameter_start_datetime = ems.start_datetime.set(hour=0, second=0, microsecond=0) + parameter_start_datetime = ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0) parameter_end_datetime = parameter_start_datetime.add(hours=cls.config.prediction.hours) max_retries = 10 @@ -248,7 +248,11 @@ class GeneticOptimizationParameters( start_datetime=parameter_start_datetime, end_datetime=parameter_end_datetime, interval=interval, - fill_method="linear", + # Forecast power values represent the mean of their source + # period. Hold them over smaller optimization slots so + # resampling preserves energy (especially hourly and + # Solcast 30-minute forecasts). + fill_method="ffill", ) * power_to_energy_per_interval_factor ).tolist() @@ -585,8 +589,12 @@ class GeneticOptimizationParameters( # Home Appliances # --------------- if cls.config.devices.max_home_appliances is None: - logger.info("Number of home appliance devices not configured - defaulting to 1.") - cls.config.devices.max_home_appliances = 1 + default_home_appliances = 0 if cls.config.optimization.interval < 3600 else 1 + logger.info( + "Number of home appliance devices not configured - defaulting to {}.", + default_home_appliances, + ) + cls.config.devices.max_home_appliances = default_home_appliances if cls.config.devices.max_home_appliances == 0: home_appliance_params = None else: diff --git a/src/akkudoktoreos/optimization/genetic/geneticsolution.py b/src/akkudoktoreos/optimization/genetic/geneticsolution.py index ff3d4b21..ed5a633e 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticsolution.py +++ b/src/akkudoktoreos/optimization/genetic/geneticsolution.py @@ -177,7 +177,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): default_factory=list, json_schema_extra={ "description": "Array with battery-to-grid export values (1 for export discharge, 0 otherwise)." - } + }, ) eautocharge_hours_float: Optional[list[float]] = Field(json_schema_extra={"description": "TBD"}) result: GeneticSimulationResult @@ -331,7 +331,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): Clamping rules: - AC charge factor: scaled down proportionally when the battery headroom (max_soc − current_soc) is smaller than what the - commanded factor would store in one hour. Set to 0 when full. + commanded factor would store in one optimization slot. Set to 0 when full. - DC charge factor (PV): zeroed when battery is at or above max SOC (the inverter curtails automatically, but this makes intent clear). - Discharge: blocked when SOC is at or below min SOC. @@ -361,9 +361,14 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): if inv_list and inv_list[0].max_ac_charge_power_w is not None else float(bat.max_charge_power_w) ) - max_dc_per_h_wh = effective_ac * max_ac_cp_w * ac_to_dc_eff * ch_eff - if max_dc_per_h_wh > headroom_wh: - effective_ac = effective_ac * (headroom_wh / max_dc_per_h_wh) + # Energy storable in one optimization slot, not per hour: scale the + # power [W] by the slot duration (1.0 hourly, 0.25 at 15 min). + slot_duration_h = float(self.config.optimization.interval or 3600) / 3600.0 + max_dc_per_slot_wh = ( + effective_ac * max_ac_cp_w * slot_duration_h * ac_to_dc_eff * ch_eff + ) + if max_dc_per_slot_wh > headroom_wh: + effective_ac = effective_ac * (headroom_wh / max_dc_per_slot_wh) # --- DC charge (PV): zero when battery is full --- effective_dc = dc_charge @@ -619,13 +624,13 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): for pred_key, pred_fill_method, pred_solution_key, pred_solution_factor in [ ( "pvforecast_ac_power", - "linear", + "ffill", "pvforecast_ac_energy_wh", power_to_energy_per_interval_factor, ), ( "pvforecast_dc_power", - "linear", + "ffill", "pvforecast_dc_energy_wh", power_to_energy_per_interval_factor, ), @@ -637,7 +642,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): ), ( "feed_in_tariff_wh", - "linear", + "ffill", "feed_in_tariff_amt_kwh", 1000.0, ), @@ -649,19 +654,19 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): ), ( "loadforecast_power_w", - "linear", + "ffill", "loadforecast_energy_wh", power_to_energy_per_interval_factor, ), ( "loadakkudoktor_std_power_w", - "linear", + "ffill", "loadakkudoktor_std_energy_wh", power_to_energy_per_interval_factor, ), ( "loadakkudoktor_mean_power_w", - "linear", + "ffill", "loadakkudoktor_mean_energy_wh", power_to_energy_per_interval_factor, ), diff --git a/src/akkudoktoreos/optimization/optimization.py b/src/akkudoktoreos/optimization/optimization.py index 57b840e4..3b0a5a98 100644 --- a/src/akkudoktoreos/optimization/optimization.py +++ b/src/akkudoktoreos/optimization/optimization.py @@ -89,6 +89,18 @@ class OptimizationCommonSettings(SettingsBaseModel): }, ) + visualize_pdf: bool = Field( + default=True, + json_schema_extra={ + "description": ( + "Generate the PDF visualization after each optimization run. " + "Disable for headless setups (e.g. Node-RED integration) to save " + "several seconds per run. Defaults to True." + ), + "examples": [True, False], + }, + ) + genetic: GeneticCommonSettings = Field( default_factory=GeneticCommonSettings, json_schema_extra={ diff --git a/src/akkudoktoreos/prediction/elecpriceenergycharts.py b/src/akkudoktoreos/prediction/elecpriceenergycharts.py index e33f2835..48f2e56f 100644 --- a/src/akkudoktoreos/prediction/elecpriceenergycharts.py +++ b/src/akkudoktoreos/prediction/elecpriceenergycharts.py @@ -176,6 +176,19 @@ class ElecPriceEnergyCharts(ElecPriceProvider): return series_data + @staticmethod + def _resolution_seconds(series: pd.Series) -> int: + """Infer the current native market interval from recent timestamps.""" + if len(series) < 2: + return 3600 + index = pd.DatetimeIndex(series.sort_index().index).drop_duplicates() + deltas = index.to_series().diff().dropna().dt.total_seconds() + deltas = deltas[deltas > 0].tail(96) + if deltas.empty: + return 3600 + resolution = int(round(float(deltas.median()))) + return resolution if resolution > 0 and 3600 % resolution == 0 else 3600 + def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray: mean = data.mean() std = data.std() @@ -250,37 +263,64 @@ class ElecPriceEnergyCharts(ElecPriceProvider): f"No Update ElecPriceEnergyCharts is needed, last in history: {self.highest_orig_datetime}" ) - # Generate history array for prediction - history = self.key_to_array( - key="elecprice_marketprice_wh", - end_datetime=self.highest_orig_datetime, - fill_method="linear", - ) - - amount_datasets = len(self.records) if not self.highest_orig_datetime: # mypy fix error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}" logger.error(error_msg) raise ValueError(error_msg) - # some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours - needed_hours = int( - self.config.prediction.hours - - ((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600) + raw_series = self.key_to_series( + key="elecprice_marketprice_wh", + end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1), + ) + resolution_seconds = self._resolution_seconds(raw_series) + slots_per_hour = 3600 // resolution_seconds + history = self.key_to_array( + key="elecprice_marketprice_wh", + end_datetime=self.highest_orig_datetime, + interval=to_duration(f"{resolution_seconds} seconds"), + fill_method="linear", ) - if needed_hours <= 0: + # some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours + covered_slots = 0 + if self.highest_orig_datetime >= self.ems_start_datetime: + covered_slots = ( + int( + (self.highest_orig_datetime - self.ems_start_datetime).total_seconds() + // resolution_seconds + ) + + 1 + ) + needed_slots = self.config.prediction.hours * slots_per_hour - covered_slots + + if needed_slots <= 0: logger.warning( - f"No prediction needed. needed_hours={needed_hours}, hours={self.config.prediction.hours},highest_orig_datetime {self.highest_orig_datetime}, start_datetime {self.ems_start_datetime}" + "No prediction needed. needed_slots={}, hours={}, resolution_seconds={}, " + "highest_orig_datetime={}, start_datetime={}", + needed_slots, + self.config.prediction.hours, + resolution_seconds, + self.highest_orig_datetime, + self.ems_start_datetime, ) # this might keep data longer than self.ems_start_datetime + self.config.prediction.hours in the records return - if amount_datasets > 800: # we do the full ets with seasons of 1 week - prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours) - elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day - prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours) - elif amount_datasets > 0: # not enough data for ets, do median - prediction = self._predict_median(history, hours=needed_hours) + weekly_history_slots = 800 * slots_per_hour + daily_history_slots = 168 * slots_per_hour + if len(history) > weekly_history_slots: + prediction = self._predict_ets( + history, + seasonal_periods=168 * slots_per_hour, + hours=needed_slots, + ) + elif len(history) > daily_history_slots: + prediction = self._predict_ets( + history, + seasonal_periods=24 * slots_per_hour, + hours=needed_slots, + ) + elif len(history) > 0: + prediction = self._predict_median(history, hours=needed_slots) else: logger.error("No data available for prediction") raise ValueError("No data available") @@ -289,7 +329,7 @@ class ElecPriceEnergyCharts(ElecPriceProvider): prediction_series = pd.Series( data=prediction, index=[ - self.highest_orig_datetime + to_duration(f"{i + 1} hours") + self.highest_orig_datetime + to_duration(f"{(i + 1) * resolution_seconds} seconds") for i in range(len(prediction)) ], ) diff --git a/src/akkudoktoreos/prediction/elecpricetibber.py b/src/akkudoktoreos/prediction/elecpricetibber.py index 3489822b..3357be9f 100644 --- a/src/akkudoktoreos/prediction/elecpricetibber.py +++ b/src/akkudoktoreos/prediction/elecpricetibber.py @@ -34,7 +34,40 @@ query TibberPriceInfo { total } } - priceInfoRange(resolution: QUARTER_HOURLY, last: 960) { + priceInfoRange(resolution: QUARTER_HOURLY, last: 672) { + nodes { + startsAt + total + } + } + } + } + } +} +""" + +# Same query, but requesting priceInfo (and therefore its today/tomorrow +# fields) at quarter-hourly resolution. Tibber defines ``resolution`` on +# Subscription.priceInfo, not on the nested PriceInfo.today/tomorrow fields. +# Tried first; on a GraphQL schema error from an older API the provider falls +# back to TIBBER_PRICE_QUERY. +TIBBER_PRICE_QUERY_QUARTER_HOURLY = """ +query TibberPriceInfo { + viewer { + homes { + id + currentSubscription { + priceInfo(resolution: QUARTER_HOURLY) { + today { + startsAt + total + } + tomorrow { + startsAt + total + } + } + priceInfoRange(resolution: QUARTER_HOURLY, last: 672) { nodes { startsAt total @@ -185,17 +218,38 @@ class ElecPriceTibber(ElecPriceProvider): if not access_token: raise ValueError("Tibber access_token is required") - response = requests.post( - TIBBER_GRAPHQL_URL, - json={"query": TIBBER_PRICE_QUERY}, - headers={ - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - }, - timeout=30, - ) - logger.debug(f"Response from Tibber GraphQL API: {response}") - response.raise_for_status() + # Prefer quarter-hourly today/tomorrow prices; fall back to the hourly + # query when the Tibber API rejects the resolution argument. Tibber + # signals schema errors either as HTTP 400 or as HTTP 200 with an + # "errors" array, so both must route to the fallback (raise_for_status + # must NOT run before the fallback check). + response = None + queries = (TIBBER_PRICE_QUERY_QUARTER_HOURLY, TIBBER_PRICE_QUERY) + for attempt, query in enumerate(queries, start=1): + response = requests.post( + TIBBER_GRAPHQL_URL, + json={"query": query}, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + timeout=30, + ) + logger.debug(f"Response from Tibber GraphQL API: {response}") + if response.ok and b'"errors"' not in response.content: + break + if attempt < len(queries): + logger.info( + "Tibber rejected the quarter-hourly priceInfo query " + "(HTTP {}): {} - falling back to hourly today/tomorrow prices.", + response.status_code, + response.text[:300], + ) + else: + # Final (hourly) attempt failed for real - surface the error. + response.raise_for_status() + if response is None: # pragma: no cover - the query tuple is never empty + raise RuntimeError("No Tibber GraphQL query was attempted") tibber_data = self._validate_data(response.content) self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) return tibber_data diff --git a/src/akkudoktoreos/prediction/feedintariffenergycharts.py b/src/akkudoktoreos/prediction/feedintariffenergycharts.py index 4513e754..cd286e6f 100644 --- a/src/akkudoktoreos/prediction/feedintariffenergycharts.py +++ b/src/akkudoktoreos/prediction/feedintariffenergycharts.py @@ -85,15 +85,18 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider): series_data.at[orig_datetime] = price_eur_per_mwh / 1_000_000 return series_data - def _predict_prices(self, history, hours: int): + def _predict_prices(self, history, slots: int, slots_per_hour: int): energycharts = ElecPriceEnergyCharts() - amount_datasets = len(self.records) - if amount_datasets > 800: - return energycharts._predict_ets(history, seasonal_periods=168, hours=hours) - if amount_datasets > 168: - return energycharts._predict_ets(history, seasonal_periods=24, hours=hours) - if amount_datasets > 0: - return energycharts._predict_median(history, hours=hours) + if len(history) > 800 * slots_per_hour: + return energycharts._predict_ets( + history, seasonal_periods=168 * slots_per_hour, hours=slots + ) + if len(history) > 168 * slots_per_hour: + return energycharts._predict_ets( + history, seasonal_periods=24 * slots_per_hour, hours=slots + ) + if len(history) > 0: + return energycharts._predict_median(history, hours=slots) logger.error("No feed-in tariff data available for Energy-Charts prediction") raise ValueError("No data available") @@ -141,38 +144,52 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider): self.highest_orig_datetime, ) - history = self.key_to_array( - key="feed_in_tariff_wh", - end_datetime=self.highest_orig_datetime, - fill_method="linear", - ) - if not self.highest_orig_datetime: error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}" logger.error(error_msg) raise ValueError(error_msg) - needed_hours = int( - self.config.prediction.hours - - ((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600) + raw_series = self.key_to_series( + key="feed_in_tariff_wh", + end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1), + ) + resolution_seconds = ElecPriceEnergyCharts._resolution_seconds(raw_series) + slots_per_hour = 3600 // resolution_seconds + history = self.key_to_array( + key="feed_in_tariff_wh", + end_datetime=self.highest_orig_datetime, + interval=to_duration(f"{resolution_seconds} seconds"), + fill_method="linear", ) - if needed_hours <= 0: + covered_slots = 0 + if self.highest_orig_datetime >= self.ems_start_datetime: + covered_slots = ( + int( + (self.highest_orig_datetime - self.ems_start_datetime).total_seconds() + // resolution_seconds + ) + + 1 + ) + needed_slots = self.config.prediction.hours * slots_per_hour - covered_slots + + if needed_slots <= 0: logger.warning( - "No feed-in tariff prediction needed. needed_hours={}, hours={}, " - "highest_orig_datetime={}, start_datetime={}", - needed_hours, + "No feed-in tariff prediction needed. needed_slots={}, hours={}, " + "resolution_seconds={}, highest_orig_datetime={}, start_datetime={}", + needed_slots, self.config.prediction.hours, + resolution_seconds, self.highest_orig_datetime, self.ems_start_datetime, ) return - prediction = self._predict_prices(history, needed_hours) + prediction = self._predict_prices(history, needed_slots, slots_per_hour) prediction_series = pd.Series( data=prediction, index=[ - self.highest_orig_datetime + to_duration(f"{i + 1} hours") + self.highest_orig_datetime + to_duration(f"{(i + 1) * resolution_seconds} seconds") for i in range(len(prediction)) ], ) diff --git a/src/akkudoktoreos/prediction/interpolator.py b/src/akkudoktoreos/prediction/interpolator.py index ae7818d6..ff7b256a 100644 --- a/src/akkudoktoreos/prediction/interpolator.py +++ b/src/akkudoktoreos/prediction/interpolator.py @@ -15,31 +15,44 @@ class SelfConsumptionProbabilityInterpolator: # Load the RegularGridInterpolator with open(self.filepath, "rb") as file: self.interpolator: RegularGridInterpolator = pickle.load(file) # noqa: S301 + self.load_power_min_w = float(self.interpolator.grid[0][0]) + self.load_power_max_w = float(self.interpolator.grid[0][-1]) + self.minute_load_max_w = float(self.interpolator.grid[1][-1]) def _generate_points( - self, load_1h_power: float, pv_power: float + self, mean_load_power_w: float, pv_power_w: float ) -> tuple[np.ndarray, np.ndarray]: - """Generate the grid points for interpolation.""" - partial_loads = np.arange(0, pv_power + 50, 50) - points = np.array([np.full_like(partial_loads, load_1h_power), partial_loads]).T + """Generate in-bounds grid points for interpolation. + + The bundled probability table was calibrated from a one-hour mean load + and one-minute samples. Sub-hourly optimization still passes *power* in + watts here; a native 15-minute mean is therefore a documented + approximation until a separately calibrated table is available. + """ + bounded_mean_load_w = float( + np.clip(mean_load_power_w, self.load_power_min_w, self.load_power_max_w) + ) + bounded_pv_power_w = float(np.clip(pv_power_w, 0.0, self.minute_load_max_w)) + partial_loads = np.arange(0.0, bounded_pv_power_w + 1.0, 50.0) + points = np.column_stack((np.full(partial_loads.shape, bounded_mean_load_w), partial_loads)) return points, partial_loads @cache_energy_management - def calculate_self_consumption(self, load_1h_power: float, pv_power: float) -> float: + def calculate_self_consumption(self, mean_load_power_w: float, pv_power_w: float) -> float: """Calculate the PV self-consumption rate using RegularGridInterpolator. The results are cached until the start of the next energy management run/ optimization. Args: - - last_1h_power: 1h power levels (W). - - pv_power: Current PV power output (W). + - mean_load_power_w: Mean load power for the current forecast interval (W). + - pv_power_w: Current PV power output (W). Returns: - Self-consumption rate as a float. """ - points, partial_loads = self._generate_points(load_1h_power, pv_power) + points, _ = self._generate_points(mean_load_power_w, pv_power_w) probabilities = self.interpolator(points) - return probabilities.sum() + return float(np.clip(probabilities.sum(), 0.0, 1.0)) # def calculate_self_consumption(self, load_1h_power: float, pv_power: float) -> float: # """Calculate the PV self-consumption rate using RegularGridInterpolator. diff --git a/src/akkudoktoreos/prediction/prediction.py b/src/akkudoktoreos/prediction/prediction.py index be4a1e9b..1907c536 100644 --- a/src/akkudoktoreos/prediction/prediction.py +++ b/src/akkudoktoreos/prediction/prediction.py @@ -82,7 +82,6 @@ elecprice_energy_charts = ElecPriceEnergyCharts() elecprice_tibber = ElecPriceTibber() elecprice_fixed = ElecPriceFixed() elecprice_import = ElecPriceImport() -elecprice_tibber = ElecPriceTibber() feedintariff_energy_charts = FeedInTariffEnergyCharts() feedintariff_fixed = FeedInTariffFixed() feedintariff_import = FeedInTariffImport() @@ -102,33 +101,34 @@ weather_openmeteo = WeatherOpenMeteo() weather_import = WeatherImport() -def prediction_providers() -> list[ - Union[ - ElecPriceAkkudoktor, - ElecPriceEnergyCharts, - ElecPriceTibber, - ElecPriceFixed, - ElecPriceImport, - ElecPriceTibber, - FeedInTariffEnergyCharts, - FeedInTariffFixed, - FeedInTariffImport, - LoadAkkudoktor, - LoadAkkudoktorAdjusted, - LoadVrm, - LoadImport, - PVForecastAkkudoktor, - PVForecastVrm, - PVForecastPVNode, - PVForecastForecastSolar, - PVForecastSolcast, - PVForecastImport, - WeatherBrightSky, - WeatherClearOutside, - WeatherOpenMeteo, - WeatherImport, +def prediction_providers() -> ( + list[ + Union[ + ElecPriceAkkudoktor, + ElecPriceEnergyCharts, + ElecPriceTibber, + ElecPriceFixed, + ElecPriceImport, + FeedInTariffEnergyCharts, + FeedInTariffFixed, + FeedInTariffImport, + LoadAkkudoktor, + LoadAkkudoktorAdjusted, + LoadVrm, + LoadImport, + PVForecastAkkudoktor, + PVForecastVrm, + PVForecastPVNode, + PVForecastForecastSolar, + PVForecastSolcast, + PVForecastImport, + WeatherBrightSky, + WeatherClearOutside, + WeatherOpenMeteo, + WeatherImport, + ] ] -]: +): """Return list of prediction providers. Factory for prediction container. @@ -139,7 +139,6 @@ def prediction_providers() -> list[ elecprice_tibber, \ elecprice_fixed, \ elecprice_import, \ - elecprice_tibber, \ feedintariff_energy_charts, \ feedintariff_fixed, \ feedintariff_import, \ @@ -165,7 +164,6 @@ def prediction_providers() -> list[ elecprice_tibber, elecprice_fixed, elecprice_import, - elecprice_tibber, feedintariff_energy_charts, feedintariff_fixed, feedintariff_import, @@ -196,7 +194,6 @@ class Prediction(PredictionContainer): ElecPriceTibber, ElecPriceFixed, ElecPriceImport, - ElecPriceTibber, FeedInTariffEnergyCharts, FeedInTariffFixed, FeedInTariffImport, diff --git a/src/akkudoktoreos/utils/visualize.py b/src/akkudoktoreos/utils/visualize.py index 4bffa0db..bf180bd9 100644 --- a/src/akkudoktoreos/utils/visualize.py +++ b/src/akkudoktoreos/utils/visualize.py @@ -149,9 +149,8 @@ class VisualizationReport(ConfigMixin): """Create a line chart and add it to the current group.""" def chart() -> None: - timestamps = [ - start_date.add(hours=i) for i in range(len(y_list[0])) - ] # 840 timestamps at 1-hour intervals + interval_s = int(self.config.optimization.interval or 3600) + timestamps = [start_date.add(seconds=i * interval_s) for i in range(len(y_list[0]))] for idx, y_data in enumerate(y_list): label = labels[idx] if labels else None # Chart label @@ -208,9 +207,10 @@ class VisualizationReport(ConfigMixin): # ax2.set_xticks(timestamps[::48]) # Set ticks every 12 hours # ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[::48]]) # ax2.set_xticks(timestamps[:: len(timestamps) // 24]) # Select 10 evenly spaced ticks - ax2.set_xticks(timestamps[:: len(timestamps) // 12]) # Select 10 evenly spaced ticks + tick_step = max(1, len(timestamps) // 12) + ax2.set_xticks(timestamps[::tick_step]) # ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[:: len(timestamps) // 24]]) - ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[:: len(timestamps) // 12]]) + ax2.set_xticklabels([f"{int(h)}" for h in hours_since_start[::tick_step]]) if x2label: ax2.set_xlabel(x2label) @@ -443,12 +443,14 @@ def prepare_visualize( global debug_visualize report = VisualizationReport(filename) - next_full_hour_date = get_ems().start_datetime + start_datetime = get_ems().start_datetime + start_slot = start_hour # Backwards-compatible argument name; value is a slot index. + interval_s = int(report.config.optimization.interval or 3600) # Group 1: report.create_line_chart_date( - next_full_hour_date, + start_datetime, [ - parameters.ems.gesamtlast[start_hour:], + parameters.ems.gesamtlast[start_slot:], ], title="Load Profile", # xlabel="Hours", # not enough space @@ -456,9 +458,9 @@ def prepare_visualize( labels=["Total Load (Wh)"], ) report.create_line_chart_date( - next_full_hour_date, + start_datetime, [ - parameters.ems.pv_prognose_wh[start_hour:], + parameters.ems.pv_prognose_wh[start_slot:], ], title="PV Forecast", # xlabel="Hours", # not enough space @@ -466,11 +468,11 @@ def prepare_visualize( ) report.create_line_chart_date( - next_full_hour_date, + start_datetime, [ np.full( - len(parameters.ems.gesamtlast) - start_hour, - parameters.ems.einspeiseverguetung_euro_pro_wh[start_hour:] + len(parameters.ems.gesamtlast) - start_slot, + parameters.ems.einspeiseverguetung_euro_pro_wh[start_slot:] if isinstance(parameters.ems.einspeiseverguetung_euro_pro_wh, list) else parameters.ems.einspeiseverguetung_euro_pro_wh, ) @@ -482,9 +484,9 @@ def prepare_visualize( ) if parameters.temperature_forecast: report.create_line_chart_date( - next_full_hour_date, + start_datetime, [ - parameters.temperature_forecast[start_hour:], + parameters.temperature_forecast[start_slot:], ], title="Temperature Forecast", # xlabel="Hours", # not enough space @@ -495,7 +497,7 @@ def prepare_visualize( # Group 2: report.create_line_chart_date( - next_full_hour_date, # start_date + start_datetime, [ results["result"]["Last_Wh_pro_Stunde"], results["result"]["Home_appliance_wh_per_hour"], @@ -503,7 +505,7 @@ def prepare_visualize( results["result"]["Netzbezug_Wh_pro_Stunde"], results["result"]["Verluste_Pro_Stunde"], ], - title="Energy Flow per Hour", + title="Energy Flow per Interval", # xlabel="Date", # not enough space ylabel="Energy (Wh)", labels=[ @@ -520,7 +522,7 @@ def prepare_visualize( # Group 3: report.create_line_chart_date( - next_full_hour_date, # start_date + start_datetime, [results["result"]["akku_soc_pro_stunde"], results["result"]["EAuto_SoC_pro_Stunde"]], title="Battery SOC", # xlabel="Date", # not enough space @@ -532,27 +534,22 @@ def prepare_visualize( markers=["o", "x"], ) report.create_line_chart_date( - next_full_hour_date, # start_date - [parameters.ems.strompreis_euro_pro_wh[start_hour:]], + start_datetime, + [parameters.ems.strompreis_euro_pro_wh[start_slot:]], # title="Electricity Price", # not enough space # xlabel="Date", # not enough space ylabel="Electricity Price (€/Wh)", x2label=None, # not enough space ) - labels = list( - item - for sublist in zip( - list(str(i) for i in range(0, 23, 2)), list(str(" ") for i in range(0, 23, 2)) - ) - for item in sublist - ) - labels = labels[start_hour:] + labels - charge_discharge_series = [ - results["ac_charge"][start_hour:], - results["dc_charge"][start_hour:], - results["discharge_allowed"][start_hour:], + results["ac_charge"][start_slot:], + results["dc_charge"][start_slot:], + results["discharge_allowed"][start_slot:], + ] + labels = [ + start_datetime.add(seconds=i * interval_s).format("HH:mm") + for i in range(len(charge_discharge_series[0])) ] charge_discharge_labels = [ "AC Charging (relative)", @@ -561,7 +558,7 @@ def prepare_visualize( ] charge_discharge_colors = ["blue", "green", "red"] if results.get("battery_grid_export_allowed"): - charge_discharge_series.append(results["battery_grid_export_allowed"][start_hour:]) + charge_discharge_series.append(results["battery_grid_export_allowed"][start_slot:]) charge_discharge_labels.append("Battery Grid Export Allowed") charge_discharge_colors.append("purple") @@ -580,12 +577,12 @@ def prepare_visualize( # Group 4: report.create_line_chart_date( - next_full_hour_date, # start_date + start_datetime, [ results["result"]["Kosten_Euro_pro_Stunde"], results["result"]["Einnahmen_Euro_pro_Stunde"], ], - title="Financial Balance per Hour", + title="Financial Balance per Interval", # xlabel="Date", # not enough space ylabel="Euro", labels=["Costs", "Revenue"], diff --git a/tests/test_battery.py b/tests/test_battery.py index 0021a620..1d6864ac 100644 --- a/tests/test_battery.py +++ b/tests/test_battery.py @@ -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, ( "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) diff --git a/tests/test_elecpriceenergycharts.py b/tests/test_elecpriceenergycharts.py index fdfbcd32..1ea28cd4 100644 --- a/tests/test_elecpriceenergycharts.py +++ b/tests/test_elecpriceenergycharts.py @@ -118,9 +118,9 @@ def test_update_data(mock_get, provider, sample_energycharts_json, cache_store): # Assert: Verify the result is as expected mock_get.assert_called_once() - assert ( - len(provider) == 73 - ) # 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 + assert len(provider) == 72 + # The final raw timestamp already represents its complete interval. Thus the + # 48 API values need 24, rather than 25, additional hourly forecasts. # Assert we get hours prioce values by resampling 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 +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") def test_update_data_with_incomplete_forecast(mock_get, provider): """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.status_code = 200 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 from urllib.parse import parse_qs, urlparse + parsed = urlparse(actual_url) query_params = parse_qs(parsed.query) diff --git a/tests/test_elecpricetibber.py b/tests/test_elecpricetibber.py index 7297071d..85250f9f 100644 --- a/tests/test_elecpricetibber.py +++ b/tests/test_elecpricetibber.py @@ -12,6 +12,7 @@ from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings from akkudoktoreos.prediction.elecpricetibber import ( ElecPriceTibber, ElecPriceTibberCommonSettings, + TIBBER_PRICE_QUERY_QUARTER_HOURLY, TibberGraphQLResponse, ) from akkudoktoreos.utils.datetimeutil import to_datetime @@ -255,6 +256,16 @@ def test_request_forecast_uses_tibber_graphql_api( 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( tibber_provider, monkeypatch ): diff --git a/tests/test_feedintariffenergycharts.py b/tests/test_feedintariffenergycharts.py index 949f4ebc..c17e1398 100644 --- a/tests/test_feedintariffenergycharts.py +++ b/tests/test_feedintariffenergycharts.py @@ -75,3 +75,27 @@ def test_request_forecast_uses_feedintariff_bidding_zone( actual_url = mock_get.call_args[0][0] 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] diff --git a/tests/test_geneticsimulation.py b/tests/test_geneticsimulation.py index 747755b6..0c068ee0 100644 --- a/tests/test_geneticsimulation.py +++ b/tests/test_geneticsimulation.py @@ -43,13 +43,15 @@ def genetic_simulation(config_eos) -> GeneticSimulation: initial_soc_percentage=80, min_soc_percentage=10, ), - prediction_hours = config_eos.prediction.hours, + prediction_hours=config_eos.prediction.hours, ) akku.reset() inverter = Inverter( - InverterParameters(device_id="inverter1", max_power_wh=10000, battery_id=akku.parameters.device_id), - battery = akku, + InverterParameters( + device_id="inverter1", max_power_wh=10000, battery_id=akku.parameters.device_id + ), + battery=akku, ) # Household device (currently not used, set to None) @@ -60,8 +62,8 @@ def genetic_simulation(config_eos) -> GeneticSimulation: duration_h=2, time_windows=None, ), - optimization_hours = config_eos.optimization.horizon_hours, - prediction_hours = config_eos.prediction.hours, + optimization_hours=config_eos.optimization.horizon_hours, + prediction_hours=config_eos.prediction.hours, ) # Example initialization of electric car battery @@ -69,7 +71,7 @@ def genetic_simulation(config_eos) -> GeneticSimulation: ElectricVehicleParameters( device_id="ev1", capacity_wh=26400, initial_soc_percentage=10, min_soc_percentage=10 ), - prediction_hours = config_eos.prediction.hours, + prediction_hours=config_eos.prediction.hours, ) eauto.set_charge_per_hour(np.full(config_eos.prediction.hours, 1)) @@ -240,8 +242,8 @@ def genetic_simulation(config_eos) -> GeneticSimulation: preis_euro_pro_wh_akku=preis_euro_pro_wh_akku, gesamtlast=gesamtlast, ), - optimization_hours = config_eos.optimization.horizon_hours, - prediction_hours = config_eos.prediction.hours, + optimization_hours=config_eos.optimization.horizon_hours, + prediction_hours=config_eos.prediction.hours, inverter=inverter, ev=eauto, home_appliance=home_appliance, @@ -301,69 +303,67 @@ def test_simulation(genetic_simulation): assert GeneticSimulationResult(**result) is not None # Check the length of the main arrays - assert 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, ( - "The length of 'Netzeinspeisung_Wh_pro_Stunde' should be 48." - ) - assert 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, ( - "The length of 'Kosten_Euro_pro_Stunde' should be 48." - ) - assert len(result["akku_soc_pro_stunde"]) == 47, ( - "The length of 'akku_soc_pro_stunde' should be 48." - ) + assert ( + 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 + ), "The length of 'Netzeinspeisung_Wh_pro_Stunde' should be 48." + assert ( + 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 + ), "The length of 'Kosten_Euro_pro_Stunde' should be 48." + assert ( + 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 - assert 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, ( - "The value at index 2 of 'Last_Wh_pro_Stunde' should be 1468.88." - ) - assert result["Last_Wh_pro_Stunde"][12] == 1132.03, ( - "The value at index 12 of 'Last_Wh_pro_Stunde' should be 1132.03." - ) + assert ( + 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 + ), "The value at index 2 of 'Last_Wh_pro_Stunde' should be 1468.88." + assert ( + 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' # Check that 'Netzeinspeisung_Wh_pro_Stunde' and 'Netzbezug_Wh_pro_Stunde' are consistent - assert result["Netzbezug_Wh_pro_Stunde"][1] == 1527.13, ( - "The value at index 1 of 'Netzbezug_Wh_pro_Stunde' should be 1527.13." - ) + assert ( + 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 - assert abs(result["Gesamtbilanz_Euro"] - 6.612835813556755) < 1e-5, ( - "Total balance should be 6.612835813556755." - ) + assert ( + 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 - assert abs(result["Gesamteinnahmen_Euro"] - 1.964301131937134) < 1e-5, ( - "Total revenue should be 1.964301131937134." - ) - assert abs(result["Gesamtkosten_Euro"] - 8.577136945493889) < 1e-5, ( - "Total costs should be 8.577136945493889 ." - ) + assert ( + 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.588879079137512) < 1e-5 + ), "Total costs should respect the shared per-slot battery power limit." # Check the losses - assert abs(result["Gesamt_Verluste"] - 1620.0) < 1e-5, ( - "Total losses should be 1620.0 ." - ) + assert abs(result["Gesamt_Verluste"] - 1620.0) < 1e-5, "Total losses should be 1620.0 ." # Check the values in 'akku_soc_pro_stunde' - assert 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, ( - "The value at index 1 of 'akku_soc_pro_stunde' should be 98.0." - ) + assert ( + 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 + ), "The value at index 1 of 'akku_soc_pro_stunde' should be 98.0." # Check home appliances - assert sum(simulation.home_appliance.get_load_curve()) == 2000, ( - "The sum of 'simulation.home_appliance.get_load_curve()' should be 2000." - ) + assert ( + sum(simulation.home_appliance.get_load_curve()) == 2000 + ), "The sum of 'simulation.home_appliance.get_load_curve()' should be 2000." assert ( np.nansum( @@ -379,13 +379,17 @@ def test_simulation(genetic_simulation): 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( {"prediction": {"hours": 2}, "optimization": {"horizon_hours": 2}} ) 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.prepare( diff --git a/tests/test_interpolator.py b/tests/test_interpolator.py new file mode 100644 index 00000000..c39591bc --- /dev/null +++ b/tests/test_interpolator.py @@ -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 diff --git a/tests/test_inverter.py b/tests/test_inverter.py index b5a9d798..041b00c5 100644 --- a/tests/test_inverter.py +++ b/tests/test_inverter.py @@ -1,8 +1,11 @@ from unittest.mock import Mock, call, patch +import numpy as np import pytest +from akkudoktoreos.devices.genetic.battery import Battery from akkudoktoreos.devices.genetic.inverter import Inverter, InverterParameters +from akkudoktoreos.optimization.genetic.geneticdevices import SolarPanelBatteryParameters @pytest.fixture @@ -26,11 +29,51 @@ def inverter(mock_battery) -> Inverter: InverterParameters( device_id="iv1", max_power_wh=500.0, battery_id=mock_battery.parameters.device_id ), - battery = mock_battery + battery=mock_battery, ) 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): # Battery charges 100 Wh with 10 Wh loss 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): 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)] grid_export, grid_import, losses, self_consumption = inverter.process_energy( diff --git a/tests/test_optimization_interval.py b/tests/test_optimization_interval.py index 17a69ab8..33964752 100644 --- a/tests/test_optimization_interval.py +++ b/tests/test_optimization_interval.py @@ -16,6 +16,7 @@ from akkudoktoreos.config.config import ConfigEOS from akkudoktoreos.core.cache import CacheEnergyManagementStore from akkudoktoreos.core.coreabc import get_ems from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization +from akkudoktoreos.optimization.genetic.geneticdevices import HomeApplianceParameters from akkudoktoreos.optimization.genetic.geneticparams import ( GeneticOptimizationParameters, ) @@ -27,6 +28,12 @@ ems_eos = get_ems(init=True) # init once 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( "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 +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): """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 = GeneticOptimizationParameters(**json.load(f_in)) + input_data = load_hourly_parameters() ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0)) CacheEnergyManagementStore().clear() @@ -127,14 +316,15 @@ def test_optimize_15min_slot_grid(config_eos: ConfigEOS): parameters, results, filename=visualize_filename, **kwargs ), ): - genetic_solution = opt.optimierung_ems( - parameters=input_data, start_hour=10, ngen=3 - ) + genetic_solution = opt.optimierung_ems(parameters=input_data, start_hour=10, ngen=3) # The genetic core emitted a full-day grid at 15-min resolution. assert len(genetic_solution.ac_charge) == 192 assert len(genetic_solution.dc_charge) == 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 # spaced solution index. diff --git a/tests/test_prediction.py b/tests/test_prediction.py index d5795231..9da3e0e8 100644 --- a/tests/test_prediction.py +++ b/tests/test_prediction.py @@ -7,6 +7,7 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber +from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport from akkudoktoreos.prediction.loadakkudoktor import ( @@ -46,6 +47,7 @@ def forecast_providers(): ElecPriceTibber(), ElecPriceFixed(), ElecPriceImport(), + FeedInTariffEnergyCharts(), FeedInTariffFixed(), FeedInTariffImport(), LoadAkkudoktor(), @@ -99,28 +101,32 @@ def test_provider_sequence(prediction): assert isinstance(prediction.providers[2], ElecPriceTibber) assert isinstance(prediction.providers[3], ElecPriceFixed) assert isinstance(prediction.providers[4], ElecPriceImport) - assert isinstance(prediction.providers[5], FeedInTariffFixed) - assert isinstance(prediction.providers[6], FeedInTariffImport) - assert isinstance(prediction.providers[7], LoadAkkudoktor) - assert isinstance(prediction.providers[8], LoadAkkudoktorAdjusted) - assert isinstance(prediction.providers[9], LoadVrm) - assert isinstance(prediction.providers[10], LoadImport) - assert isinstance(prediction.providers[11], PVForecastAkkudoktor) - assert isinstance(prediction.providers[12], PVForecastVrm) - assert isinstance(prediction.providers[13], PVForecastPVNode) - assert isinstance(prediction.providers[14], PVForecastForecastSolar) - assert isinstance(prediction.providers[15], PVForecastSolcast) - assert isinstance(prediction.providers[16], PVForecastImport) - assert isinstance(prediction.providers[17], WeatherBrightSky) - assert isinstance(prediction.providers[18], WeatherClearOutside) - assert isinstance(prediction.providers[19], WeatherOpenMeteo) - assert isinstance(prediction.providers[20], WeatherImport) + assert isinstance(prediction.providers[5], FeedInTariffEnergyCharts) + assert isinstance(prediction.providers[6], FeedInTariffFixed) + assert isinstance(prediction.providers[7], FeedInTariffImport) + assert isinstance(prediction.providers[8], LoadAkkudoktor) + assert isinstance(prediction.providers[9], LoadAkkudoktorAdjusted) + assert isinstance(prediction.providers[10], LoadVrm) + assert isinstance(prediction.providers[11], LoadImport) + assert isinstance(prediction.providers[12], PVForecastAkkudoktor) + assert isinstance(prediction.providers[13], PVForecastVrm) + assert isinstance(prediction.providers[14], PVForecastPVNode) + assert isinstance(prediction.providers[15], PVForecastForecastSolar) + assert isinstance(prediction.providers[16], PVForecastSolcast) + assert isinstance(prediction.providers[17], PVForecastImport) + assert isinstance(prediction.providers[18], WeatherBrightSky) + assert isinstance(prediction.providers[19], WeatherClearOutside) + assert isinstance(prediction.providers[20], WeatherOpenMeteo) + assert isinstance(prediction.providers[21], WeatherImport) def test_provider_by_id(prediction, forecast_providers): """Test that provider_by_id method returns the correct provider.""" 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): diff --git a/tests/testdata/new_optimize_15min.pdf b/tests/testdata/new_optimize_15min.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5cbf4937e3eee6ddda75a647fe63d292c369f221 GIT binary patch literal 84717 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

K&OlJ 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 K8RC6vtm_|oQ$RTY literal 0 HcmV?d00001 diff --git a/tests/testdata/optimize_result_1.json b/tests/testdata/optimize_result_1.json index 42f70525..12b01bf9 100644 --- a/tests/testdata/optimize_result_1.json +++ b/tests/testdata/optimize_result_1.json @@ -14,10 +14,11 @@ 0.0, 0.0, 0.0, + 1.0, + 1.0, 0.0, - 0.0, - 0.0, - 0.0, + 1.0, + 1.0, 0.0, 1.0, 0.0, @@ -39,12 +40,11 @@ 0.0, 0.0, 0.0, + 1.0, + 1.0, 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, + 1.0, + 1.0, 0.0, 0.0, 0.0 @@ -111,44 +111,45 @@ 0, 0, 1, - 0, - 1, 1, 1, 0, - 1, + 0, + 0, + 0, + 0, + 0, + 0, 0, 1, 0, - 1, 0, 1, 1, 1, 1, 0, - 1, 0, 0, 1, - 0, - 1, 1, 1, 1, 1, 0, 1, - 0, - 1, - 0, 1, 0, 0, 0, 1, + 0, + 0, + 1, + 1, 0 ], + "battery_grid_export_allowed": [], "eautocharge_hours_float": null, "result": { "Last_Wh_pro_Stunde": [ @@ -156,7 +157,7 @@ 1063.91, 1320.56, 1132.03, - 1163.67, + 1308.5200000002487, 1176.82, 1216.22, 1103.78, @@ -237,10 +238,10 @@ 0.0, 0.0, 0.0, - 0.19391086083906173, - 0.18681973047764083, - 0.12880892587597292, - 0.02404700392596282, + 0.20303854854278033, + 0.1899652543898917, + 0.12833851757957424, + 0.04233866391809883, 0.0, 0.0, 0.0, @@ -261,20 +262,20 @@ 0.0, 0.0, 0.0, - 0.12500582038027028, - 0.14608958812480227, - 0.09047346757070289, - 0.010404817872487553, + 0.16357655037574115, + 0.14900060381217387, + 0.08949503544160814, + 0.010110585812541543, 0.0, 0.0, 0.0, 0.0, 0.0 ], - "Gesamt_Verluste": 2878.660271824896, - "Gesamtbilanz_Euro": 1.053854835771092, - "Gesamteinnahmen_Euro": 0.9055602150669013, - "Gesamtkosten_Euro": 1.9594150508379933, + "Gesamt_Verluste": 2807.7292841655817, + "Gesamtbilanz_Euro": 0.8879905947253857, + "Gesamteinnahmen_Euro": 0.9758637598724098, + "Gesamtkosten_Euro": 1.8638543545977955, "Home_appliance_wh_per_hour": [ 0.0, 0.0, @@ -316,82 +317,82 @@ 0.0 ], "Kosten_Euro_pro_Stunde": [ - 0.0, - 0.004569992000000018, 0.0, 0.0, 0.0, + 0.001880612819166666, + 0.026623430000091274, 4.482711108977355e-14, - 0.0, - 0.016809914659344942, - 0.0, + 0.008763569215739961, + 0.018335381563380656, + 0.06267084962493971, 0.05480703000000003, + 0.225316611, 0.0, - 0.291163892, + 0.26650619799999997, + 0.19588158, 0.0, 0.0, 0.0, 0.0, - 0.22802125600000003, - 0.0, 0.182970359, 0.162995926, - 0.0, - 0.26411047, + 0.16677339, 0.0, 0.0, 0.0, 0.0, 0.0, - 0.010682755832597498, + 0.02844719513362201, 0.0, - 0.0003442778967139274, 0.0, - 0.028137079449292023, + 0.0004412281431084693, + 0.008372170029774037, + 0.03130999506792791, 0.0, 0.08231598, 0.174597189, - 0.293043269, + 0.0, 0.0, 0.16484566 ], "Netzbezug_Wh_pro_Stunde": [ - 0.0, - 20.660000000000082, 0.0, 0.0, 0.0, + 10.008583390988111, + 144.8500000004966, 2.236881790906864e-10, - 0.0, - 74.05248748610107, - 0.0, + 39.870651572975255, + 80.77260600608219, + 209.11194402715952, 171.54000000000008, + 731.31, 0.0, - 980.68, + 912.38, + 704.61, 0.0, 0.0, 0.0, 0.0, - 694.34, - 0.0, 556.31, 488.89, - 0.0, - 799.85, + 506.91, 0.0, 0.0, 0.0, 0.0, 0.0, - 56.853410498124, + 135.91588692604878, 0.0, - 1.7179535764168035, 0.0, - 123.95189184710142, + 2.201737241060226, + 38.089945540373236, + 137.92949369131236, 0.0, 257.64, 566.69, - 987.01, + 0.0, 0.0, 592.97 ], @@ -401,10 +402,10 @@ 0.0, 0.0, 0.0, - 2770.155154843739, - 2668.8532925377262, - 1840.127512513899, - 343.5286275137546, + 2900.550693468291, + 2713.7893484270244, + 1833.4073939939178, + 604.8380559728405, 0.0, 0.0, 0.0, @@ -425,10 +426,10 @@ 0.0, 0.0, 0.0, - 1785.797434003861, - 2086.994116068604, - 1292.4781081528986, - 148.64025532125078, + 2336.807862510588, + 2128.580054459627, + 1278.5005063086878, + 144.4369401791649, 0.0, 0.0, 0.0, @@ -437,83 +438,83 @@ ], "Verluste_Pro_Stunde": [ 16.744090909090914, - 0.0, + 2.817272727272737, 29.157272727272726, - 3.7179773678125034, - 582.6180000000041, - 188.65138141872444, - 10.782457846871594, + 2.358169993081436, + 600.0000000000002, + 173.00391678377832, 0.0, - 59.810111380126784, 0.0, - 99.72409090909093, 0.0, - 124.41545454545451, - 96.08318181818186, + 0.0, + 0.0, + 133.72909090909081, + 0.0, + 0.0, 70.41409090909087, 118.37045454545455, - 0.0, + 94.68272727272722, 83.01681818181817, 0.0, 0.0, - 69.12409090909085, 0.0, + 109.0704545454546, 109.96227272727276, 47.952272727272714, - 16.01263877609336, - 55.946263193163475, - 161.62968357967037, - 14.209990740225123, + 16.031648664443644, + 55.9754990365584, + 143.33449356887422, + 21.973794868109557, 538.2984000000038, - 227.42215349036655, - 10.13011689299087, - 0.0, - 44.377460775206174, + 161.2428480298022, 0.0, 0.0, + 44.91187685729284, 0.0, + 0.0, + 134.59227272727276, 100.08954545454549, 0.0 ], "akku_soc_pro_stunde": [ 80.0, 79.4714617768595, - 79.4714617768595, - 78.55109331955923, - 78.57585051614844, - 94.75968384947988, - 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, + 79.38253271349862, + 78.46216425619835, + 78.52766897822838, + 95.19433564489505, 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, - 96.84060778236915 + 100.0, + 100.0, + 100.0, + 100.0, + 100.0, + 95.75150654269973, + 92.59211432506888 ], "Electricity_price": [ 0.000228, @@ -554,6 +555,46 @@ 0.0002969, 0.0002921, 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": { @@ -667,14 +708,12 @@ "initial_soc_percentage": 54 }, "start_solution": [ - 1.0, - 1.0, - 1.0, - 1.0, + 0.0, 0.0, 1.0, + 2.0, 0.0, - 1.0, + 2.0, 2.0, 1.0, 1.0, @@ -683,90 +722,40 @@ 1.0, 1.0, 0.0, - 1.0, + 2.0, + 2.0, + 0.0, + 2.0, + 2.0, 0.0, - 1.0, 2.0, 1.0, 0.0, + 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, - 1.0, 0.0, 0.0, 1.0, - 0.0, - 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, - 0.0, 1.0, 0.0, + 2.0, + 2.0, 1.0, - 0.0, - 0.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, + 2.0, + 2.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 + 0.0 ], "washingstart": null -} \ No newline at end of file +} diff --git a/tests/testdata/optimize_result_1_be.json b/tests/testdata/optimize_result_1_be.json index 971025f2..c2772055 100644 --- a/tests/testdata/optimize_result_1_be.json +++ b/tests/testdata/optimize_result_1_be.json @@ -14,6 +14,13 @@ 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, @@ -34,14 +41,7 @@ 0.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, @@ -111,16 +111,15 @@ 0, 0, 1, - 0, + 1, 1, 0, - 1, 0, - 1, 0, - 1, 0, - 1, + 0, + 0, + 0, 0, 1, 0, @@ -129,6 +128,16 @@ 1, 1, 1, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 1, 1, 0, 1, @@ -136,19 +145,11 @@ 0, 0, 0, - 1, 0, 1, - 0, - 1, - 0, - 1, - 0, - 1, - 0, - 1, - 0 + 1 ], + "battery_grid_export_allowed": [], "eautocharge_hours_float": null, "result": { "Last_Wh_pro_Stunde": [ @@ -156,7 +157,7 @@ 1063.91, 1320.56, 1132.03, - 1163.67, + 1308.5200000002487, 1176.82, 1216.22, 1103.78, @@ -237,10 +238,10 @@ 0.0, 0.0, 0.0, - 0.19478794541504615, - 0.18681973047764083, - 0.12880892587597292, - 0.02404700392596282, + 0.20303854854278033, + 0.1899652543898917, + 0.12833851757957424, + 0.04233866391809883, 0.0, 0.0, 0.0, @@ -261,20 +262,20 @@ 0.0, 0.0, 0.0, - 0.17104472158211628, - 0.14608958812480227, - 0.09047346757070289, - 0.010404817872487553, + 0.16452297290911175, + 0.1455575560489687, + 0.08949503544160814, + 0.024046008596276005, 0.0, 0.0, 0.0, 0.0, 0.0 ], - "Gesamt_Verluste": 2804.7326610375394, - "Gesamtbilanz_Euro": 0.9004618481798127, - "Gesamteinnahmen_Euro": 0.9524762008447317, - "Gesamtkosten_Euro": 1.8529380490245444, + "Gesamt_Verluste": 2717.1309464905708, + "Gesamtbilanz_Euro": 0.998163925609791, + "Gesamteinnahmen_Euro": 0.9873025574263097, + "Gesamtkosten_Euro": 1.9854664830361006, "Home_appliance_wh_per_hour": [ 0.0, 0.0, @@ -317,83 +318,83 @@ ], "Kosten_Euro_pro_Stunde": [ 0.0, - 0.004569992000000018, 0.0, - 0.0018232052307313393, 0.0, + 0.001880612819166666, + 0.026623430000091274, 4.482711108977355e-14, - 0.0, - 0.016809914659344942, - 0.0, + 0.008763569215739961, + 0.018335381563380656, + 0.06267084962493971, 0.05480703000000003, + 0.225316611, 0.0, - 0.291163892, - 0.0, + 0.26650619799999997, 0.19588158, - 0.174739608, 0.0, 0.0, 0.0, 0.0, + 0.182970359, + 0.162995926, + 0.0, 0.0, - 0.16677339, 0.0, - 0.24530383800000005, 0.08545095, - 0.007989913613567745, - 0.028255713342252034, + 0.008254784724581705, + 0.028650916976410694, + 0.02844719513362201, 0.0, - 0.010682755832597498, 0.0, - 0.0003442778967139274, - 0.0, - 0.028137079449292023, + 0.0004412281431084693, 0.0, + 0.03130999506792791, + 0.04620342776708689, 0.08231598, - 0.0, + 0.174597189, 0.293043269, 0.0, - 0.16484566 + 0.0 ], "Netzbezug_Wh_pro_Stunde": [ 0.0, - 20.660000000000082, 0.0, - 9.703061366318996, 0.0, + 10.008583390988111, + 144.8500000004966, 2.236881790906864e-10, - 0.0, - 74.05248748610107, - 0.0, + 39.870651572975255, + 80.77260600608219, + 209.11194402715952, 171.54000000000008, + 731.31, 0.0, - 980.68, - 0.0, + 912.38, 704.61, - 516.37, 0.0, 0.0, 0.0, 0.0, + 556.31, + 488.89, + 0.0, 0.0, - 506.91, 0.0, - 806.3900000000001, 351.65, - 35.04348076126204, - 127.73830624887898, + 36.20519616044607, + 129.52494112301397, + 135.91588692604878, 0.0, - 56.853410498124, 0.0, - 1.7179535764168035, - 0.0, - 123.95189184710142, + 2.201737241060226, 0.0, + 137.92949369131236, + 154.1655914817714, 257.64, - 0.0, + 566.69, 987.01, 0.0, - 592.97 + 0.0 ], "Netzeinspeisung_Wh_pro_Stunde": [ 0.0, @@ -401,10 +402,10 @@ 0.0, 0.0, 0.0, - 2782.6849345006594, - 2668.8532925377262, - 1840.127512513899, - 343.5286275137546, + 2900.550693468291, + 2713.7893484270244, + 1833.4073939939178, + 604.8380559728405, 0.0, 0.0, 0.0, @@ -425,10 +426,10 @@ 0.0, 0.0, 0.0, - 2443.4960226016615, - 2086.994116068604, - 1292.4781081528986, - 148.64025532125078, + 2350.3281844158823, + 2079.39365784241, + 1278.5005063086878, + 343.51440851822866, 0.0, 0.0, 0.0, @@ -437,83 +438,83 @@ ], "Verluste_Pro_Stunde": [ 16.744090909090914, - 0.0, + 2.817272727272737, 29.157272727272726, - 2.3948326360417305, - 582.6180000000041, - 187.1478078598941, - 10.782457846871594, - 0.0, - 59.810111380126784, - 0.0, - 99.72409090909093, - 0.0, - 124.41545454545451, + 2.358169993081436, + 600.0000000000002, + 173.00391678377832, 0.0, 0.0, + 0.0, + 0.0, + 0.0, + 133.72909090909081, + 0.0, + 0.0, + 70.41409090909087, 118.37045454545455, 94.68272727272722, 83.01681818181817, - 75.86045454545456, - 66.66681818181814, 0.0, + 0.0, + 69.12409090909085, 109.0704545454546, + 109.96227272727276, 0.0, - 0.0, - 11.233982308648535, - 38.52740325013451, - 161.62968357967037, - 14.209990740225123, + 11.094576460746453, + 38.31300706523831, + 143.33449356887422, + 21.973794868109557, 538.2984000000038, - 148.49832285863067, - 10.13011689299087, + 159.62040940116685, + 11.096451076844168, + 0.0, + 0.0, 0.0, - 44.377460775206174, 0.0, - 77.27590909090907, 0.0, 100.08954545454549, - 0.0 + 80.85954545454547 ], "akku_soc_pro_stunde": [ 80.0, 79.4714617768595, - 79.4714617768595, - 78.55109331955923, - 78.61761644833817, - 94.80144978166962, + 79.38253271349862, + 78.46216425619835, + 78.52766897822838, + 95.19433564489505, 100.0, 100.0, 100.0, 100.0, 100.0, - 96.85214359504131, - 96.85214359504131, - 92.92488808539943, - 92.92488808539943, - 92.92488808539943, - 89.18844696969695, - 86.19972451790632, - 83.57924414600548, - 81.18465909090907, - 79.08027720385672, - 79.08027720385672, - 75.63739669421484, - 75.63739669421484, - 75.63739669421484, - 75.94945175834397, - 77.01965740418103, - 80.52759128892241, - 80.92231325392866, - 95.87504658726026, + 100.0, + 95.77875344352617, + 95.77875344352617, + 95.77875344352617, + 93.55608643250687, + 89.8196453168044, + 86.83092286501376, + 84.21044249311292, + 84.21044249311292, + 84.21044249311292, + 82.02849517906333, + 78.58561466942146, + 75.1145833333333, + 75.1145833333333, + 75.42276601279848, + 76.4870162090551, + 80.4685299193016, + 80.61336640552487, + 95.56609973885648, 100.0, 100.0, 100.0, 100.0, 100.0, - 97.56073519283747, - 97.56073519283747, - 94.40134297520663 + 100.0, + 100.0, + 96.84060778236915 ], "Electricity_price": [ 0.000228, @@ -554,6 +555,46 @@ 0.0002969, 0.0002921, 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": { @@ -667,28 +708,27 @@ "initial_soc_percentage": 54 }, "start_solution": [ - 1.0, - 1.0, 1.0, 1.0, 0.0, 1.0, - 0.0, + 1.0, + 2.0, + 2.0, + 1.0, + 1.0, 0.0, 1.0, 1.0, 1.0, 0.0, - 1.0, + 2.0, + 2.0, 0.0, - 1.0, - 0.0, - 1.0, - 0.0, - 1.0, - 0.0, - 1.0, + 2.0, + 2.0, 0.0, + 2.0, 1.0, 0.0, 0.0, @@ -696,77 +736,26 @@ 1.0, 1.0, 1.0, - 1.0, 0.0, + 0.0, + 1.0, + 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, - 0.0, 1.0, 0.0, 1.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, - 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 ], "washingstart": null -} \ No newline at end of file +} diff --git a/tests/testdata/optimize_result_2.json b/tests/testdata/optimize_result_2.json index d00a3d74..618e5843 100644 --- a/tests/testdata/optimize_result_2.json +++ b/tests/testdata/optimize_result_2.json @@ -149,17 +149,18 @@ 1, 1 ], + "battery_grid_export_allowed": [], "eautocharge_hours_float": [ - 1.0, - 0.5, - 0.375, - 0.375, - 1.0, - 0.75, - 1.0, - 0.75, - 0.5, - 0.875, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, 0.0, 0.875, 0.75, @@ -168,36 +169,36 @@ 0.375, 0.75, 0.875, - 0.625, - 1.0, - 0.75, - 0.375, - 0.75, - 0.5, - 0.5, - 0.625, - 0.875, - 0.5, + 0.1, 0.0, 0.0, - 0.75, - 0.875, 0.0, 0.0, - 1.0, - 0.375, - 0.375, - 1.0, 0.0, - 0.875, - 0.5, - 1.0, - 0.625, - 0.75, - 0.875, - 1.0, - 0.5, - 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 ], "result": { "Last_Wh_pro_Stunde": [ @@ -309,21 +310,21 @@ 0.0, 0.0, 0.0, - 0.010924611164505103, - 0.25751345302450973, - 0.14608958812480227, - 0.07926913850394514, - 0.010404817872487553, + 0.00826914812545985, + 0.25743585772309185, + 0.1455575560489687, + 0.07702723513376727, + 0.010110585812541543, 0.0, 0.0, 0.0, 0.0, 0.0 ], - "Gesamt_Verluste": 5766.286846118221, - "Gesamtbilanz_Euro": 12.78299149726557, - "Gesamteinnahmen_Euro": 0.5042016086902498, - "Gesamtkosten_Euro": 13.28719310595582, + "Gesamt_Verluste": 5776.448801897527, + "Gesamtbilanz_Euro": 12.789452797857164, + "Gesamteinnahmen_Euro": 0.49840038284382926, + "Gesamtkosten_Euro": 13.287853180700994, "Home_appliance_wh_per_hour": [ 0.0, 0.0, @@ -389,8 +390,8 @@ 0.0, 0.0, 0.0, - 0.007989913613567745, - 0.028255713342252034, + 0.008254784724581705, + 0.028650916976410694, 0.0, 0.0, 0.0, @@ -429,8 +430,8 @@ 0.0, 0.0, 0.0, - 35.04348076126204, - 127.73830624887898, + 36.20519616044607, + 129.52494112301397, 0.0, 0.0, 0.0, @@ -473,11 +474,11 @@ 0.0, 0.0, 0.0, - 156.06587377864435, - 3678.7636146358536, - 2086.994116068604, - 1132.4162643420734, - 148.64025532125078, + 118.1306875065693, + 3677.655110329884, + 2079.39365784241, + 1100.3890733395326, + 144.4369401791649, 0.0, 0.0, 0.0, @@ -509,15 +510,15 @@ 109.0704545454546, 109.96227272727276, 47.952272727272714, - 11.233982308648535, - 38.52740325013451, - 161.62968357967037, - 21.962728535423857, - 519.5704951465664, - 0.5004782113116364, - 10.13011689299087, - 36.109951963721926, - 44.377460775206174, + 11.094576460746453, + 38.31300706523831, + 161.86847814969906, + 21.973794868109557, + 524.1227174992155, + 0.6414151879949013, + 11.096451076844168, + 40.181939277841224, + 44.91187685729284, 0.0, 0.0, 0.0, @@ -550,10 +551,10 @@ 85.51196625344349, 82.04093491735533, 80.52728994490354, - 80.83934500903268, - 81.90955065486975, - 85.41748453961112, - 85.56748624593055, + 80.83547262436872, + 81.89972282062534, + 85.29619913880036, + 85.44103562502363, 100.0, 100.0, 100.0, @@ -603,6 +604,46 @@ 0.0002969, 0.0002921, 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": { @@ -815,4 +856,4 @@ 14.0 ], "washingstart": 14 -} \ No newline at end of file +} diff --git a/tests/testdata/optimize_result_2_be.json b/tests/testdata/optimize_result_2_be.json index 11781a0d..a7db2f39 100644 --- a/tests/testdata/optimize_result_2_be.json +++ b/tests/testdata/optimize_result_2_be.json @@ -10,11 +10,19 @@ 0.0, 0.0, 0.0, + 1.0, + 0.0, + 1.0, 0.0, 0.0, 0.0, 0.0, + 1.0, 0.0, + 1.0, + 0.0, + 0.0, + 1.0, 0.0, 0.0, 0.0, @@ -26,23 +34,15 @@ 0.0, 0.0, 0.0, - 1.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, + 1.0, 0.0, 0.0, 0.0, @@ -110,32 +110,6 @@ 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, @@ -143,90 +117,117 @@ 1, 1, 0, + 0, + 0, + 0, + 1, + 0, + 0, + 1, + 0, + 0, + 1, + 0, + 1, + 1, + 0, + 1, 1, 1, 0, 0, - 1 + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 0, + 1, + 1, + 0 ], + "battery_grid_export_allowed": [], "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.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.5, - 0.875, - 0.375, - 0.5, - 0.875, - 0.5, - 0.875, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, 0.75, 0.625, + 0.5, + 0.375, + 0.875, + 0.5, 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 ], "result": { "Last_Wh_pro_Stunde": [ - 8919.07, - 10240.91, - 7875.5599999999995, - 7687.03, - 7718.67, - 11664.82, - 5149.22, - 6347.78, + 16541.07, + 8929.91, + 8875.56, + 6376.03, + 5096.67, + 12853.82, + 8960.220000000001, + 13969.78, 1129.12, - 8678.71, - 3550.98, + 1178.71, + 1050.98, 988.56, - 912.38, + 1912.38, 704.61, 516.37, 868.05, - 5694.34, - 608.79, + 694.34, + 2108.79, 556.31, 488.89, 506.91, 804.89, - 6141.98, + 1141.98, 1056.97, 992.46, - 6155.99, + 1155.99, 827.01, 1257.98, 1232.67, @@ -242,13 +243,13 @@ ], "EAuto_SoC_pro_Stunde": [ 5.0, - 18.11, - 33.405, - 44.330000000000005, + 22.48, + 35.589999999999996, + 46.515, 55.254999999999995, - 66.18, - 83.66, - 90.215, + 61.809999999999995, + 77.105, + 85.845, 98.955, 98.955, 98.955, @@ -285,6 +286,7 @@ 0.0, 0.0, 0.0, + 0.06455049999999336, 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.0, - 0.0, + 0.12279079241545988, + 0.25743585772309185, + 0.1455575560489687, + 0.07702723513376727, + 0.024046008596276005, 0.0, 0.0, 0.0, 0.0, 0.0 ], - "Gesamt_Verluste": 10674.660531928814, - "Gesamtbilanz_Euro": 14.366070195145795, - "Gesamteinnahmen_Euro": 0.0, - "Gesamtkosten_Euro": 14.366070195145795, + "Gesamt_Verluste": 6842.366945701403, + "Gesamtbilanz_Euro": 14.10088133917546, + "Gesamteinnahmen_Euro": 0.6914079499175572, + "Gesamtkosten_Euro": 14.792289289093018, "Home_appliance_wh_per_hour": [ - 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 ], "Kosten_Euro_pro_Stunde": [ - 0.81824412, - 1.061242392, - 0.49579402599999994, - 0.399351386, - 0.13127915000000007, - 1.2316083, - 0.259218932, - 0.7558691399999999, - 0.061530097476751734, - 2.4510570300000003, + 3.55926012, + 1.7445291920000001, + 1.6260140259999998, + 0.979774486, 0.0, + 0.5881238999999999, + 1.0968767320000004, + 2.4860631399999997, + 0.06267084962493971, + 0.05480703000000003, 0.0, - 0.26650619799999997, - 0.19588158, - 0.13029273082161758, + 0.291163892, + 0.558606198, + 0.0, + 0.174739608, 0.28801899, - 1.870021256, - 0.199865757, + 0.0, + 0.692315757, 0.0, 0.0, 0.16677339, 0.0, - 1.7663038380000002, - 0.08545095, - 0.007989913613567745, - 1.134255713342252, - 0.025392879919306634, - 0.010682755832597498, - 4.174095896658514e-14, - 0.0003442778967139274, 0.0, 0.0, - 0.04565364324294593, + 0.008254784724581705, + 0.028650916976410694, 0.0, 0.0, - 0.293043269, - 0.214398479, - 0.0 + 0.0, + 0.0, + 0.0, + 0.0, + 0.04620342776708689, + 0.0, + 0.174597189, + 0.0, + 0.0, + 0.16484566 ], "Netzbezug_Wh_pro_Stunde": [ - 3588.79, - 4797.66, - 2368.8199999999997, - 2125.34, - 714.2500000000003, - 6145.75, - 1179.3400000000001, - 3329.8199999999997, - 205.30563055305882, - 7671.54, + 15610.789999999999, + 7886.66, + 7768.82, + 5214.34, 0.0, + 2934.75, + 4990.340000000001, + 10951.82, + 209.11194402715952, + 171.54000000000008, 0.0, - 912.38, - 704.61, - 385.0258003002884, + 980.68, + 1912.38, + 0.0, + 516.37, 868.05, - 5694.34, - 608.79, + 0.0, + 2108.79, 0.0, 0.0, 506.91, 0.0, - 5806.39, - 351.65, - 35.04348076126204, - 5127.738306248879, - 121.32288542430308, - 56.853410498124, - 2.270998855635753e-10, - 1.7179535764168035, 0.0, 0.0, - 152.3311419517715, + 36.20519616044607, + 129.52494112301397, 0.0, 0.0, - 987.01, - 733.99, - 0.0 + 0.0, + 0.0, + 0.0, + 0.0, + 154.1655914817714, + 0.0, + 566.69, + 0.0, + 0.0, + 592.97 ], "Netzeinspeisung_Wh_pro_Stunde": [ 0.0, 0.0, 0.0, 0.0, + 922.1499999999053, 0.0, 0.0, 0.0, @@ -472,12 +474,11 @@ 0.0, 0.0, 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, + 1754.1541773637127, + 3677.655110329884, + 2079.39365784241, + 1100.3890733395326, + 343.51440851822866, 0.0, 0.0, 0.0, @@ -485,84 +486,84 @@ 0.0 ], "Verluste_Pro_Stunde": [ - 1014.0, - 1083.0, - 945.0, - 945.0, - 479.4, - 552.0, - 207.0, + 1152.0, + 414.0, + 465.0, 276.0, - 73.03732433363291, - 600.0, - 440.6331818181816, - 133.72909090909081, + 207.00000000001197, + 1083.0, + 276.0, + 1014.0, + 72.5805667167408, + 0.0, + 99.72409090909093, + 0.0, + 120.0, + 96.08318181818186, 0.0, 0.0, - 17.910572686324315, - 0.0, - 600.0, - 0.0, + 94.68272727272722, + 180.0, 75.86045454545456, 66.66681818181814, 0.0, 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, - 11.233982308648535, - 638.5274032501345, - 145.08565374908358, - 14.209990740225123, - 538.2983999999728, - 441.7178455708299, - 260.56941082122324, - 171.99990368477063, - 41.441862965787436, 35.132727272727266, - 77.27590909090907, 0.0, - 0.0, - 80.85954545454547 + 134.59227272727276, + 100.08954545454549, + 0.0 ], "akku_soc_pro_stunde": [ 80.0, - 61.06060606060606, - 42.12121212121212, - 23.18181818181818, - 4.242424242424243, - 0.0, - 0.0, - 0.0, - 0.0, - 2.0288145648231377, - 18.695481231489804, - 4.786605542784571, - 0.5653589863107422, - 0.5653589863107422, - 0.5653589863107422, - 0.0, - 0.0, - 16.666666666666664, - 16.666666666666664, - 14.272081611570247, - 12.167699724517906, - 12.167699724517906, - 8.724819214876034, - 25.391485881542703, - 25.391485881542703, - 25.703540945671826, - 43.44041325817556, - 47.47057030676122, - 47.86529227176747, - 62.81802560510005, - 75.08796575984533, - 82.04461281340734, - 85.81933369454761, - 86.97049655470836, - 85.86150895140257, - 83.42224414424004, - 83.42224414424004, - 83.42224414424004 + 96.66666666666667, + 96.66666666666667, + 100.0, + 100.0, + 100.0, + 81.06060606060606, + 81.06060606060606, + 97.72727272727273, + 99.74339958051553, + 99.74339958051553, + 96.59554317555684, + 96.59554317555684, + 99.92887650889017, + 96.89594778988192, + 96.89594778988192, + 96.89594778988192, + 93.90722533809128, + 98.90722533809128, + 96.51264028299485, + 94.40825839594251, + 94.40825839594251, + 90.96537788630063, + 87.49434655021247, + 85.9807015777607, + 86.28888425722586, + 87.35313445348248, + 90.7496107716575, + 90.89444725788077, + 100.0, + 100.0, + 100.0, + 100.0, + 100.0, + 98.89101239669421, + 98.89101239669421, + 94.64251893939394, + 91.48312672176309 ], "Electricity_price": [ 0.000228, @@ -603,6 +604,46 @@ 0.0002969, 0.0002921, 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": { @@ -619,14 +660,14 @@ 0.0, 0.0, 0.0, - 0.75, - 0.875, - 0.625, - 0.625, - 0.625, 1.0, - 0.375, + 0.75, + 0.625, 0.5, + 0.375, + 0.875, + 0.5, + 0.75, 0.0, 0.0, 0.0, @@ -716,103 +757,103 @@ "initial_soc_percentage": 5 }, "start_solution": [ - 2.0, - 2.0, 0.0, 0.0, 2.0, 0.0, - 0.0, + 1.0, 2.0, 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 1.0, - 0.0, 0.0, 2.0, - 1.0, - 1.0, + 2.0, + 2.0, 0.0, - 0.0, - 1.0, - 1.0, 2.0, 0.0, 1.0, 1.0, 0.0, - 1.0, 2.0, 0.0, + 2.0, + 1.0, 0.0, 2.0, + 1.0, 0.0, 0.0, - 0.0, - 0.0, + 1.0, + 2.0, 1.0, 1.0, 0.0, 1.0, 1.0, + 1.0, 0.0, 0.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, - 1.0, 2.0, 6.0, - 6.0, 4.0, + 3.0, 2.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, - 3.0, 4.0, 5.0, 3.0, - 3.0, - 3.0, 6.0, - 1.0, 3.0, 5.0, - 0.0, - 2.0, - 0.0, - 0.0, - 2.0, - 5.0, 3.0, - 4.0, - 2.0, 1.0, - 1.0, - 4.0, - 3.0, 6.0, - 1.0, - 3.0, - 3.0, + 5.0, + 2.0, 0.0, - 2.0, - 5.0, - 1.0, - 2.0, - 5.0, - 2.0, + 6.0, + 4.0, 5.0, 4.0, 3.0, - 4.0, - 0.0, - 19.0 + 3.0, + 1.0, + 3.0, + 15.0 ], - "washingstart": 19 -} \ No newline at end of file + "washingstart": 15 +}