diff --git a/docs/_generated/configexample.md b/docs/_generated/configexample.md index bec6d28..c94a1e3 100644 --- a/docs/_generated/configexample.md +++ b/docs/_generated/configexample.md @@ -122,9 +122,11 @@ "mode": "OPTIMIZATION" }, "feedintariff": { + "direct_marketing_enabled": false, "provider": "FeedInTariffFixed", "provider_settings": { "FeedInTariffFixed": null, + "FeedInTariffEnergyCharts": null, "FeedInTariffImport": null } }, diff --git a/docs/_generated/configfeedintariff.md b/docs/_generated/configfeedintariff.md index 8e882a1..68e3d6b 100644 --- a/docs/_generated/configfeedintariff.md +++ b/docs/_generated/configfeedintariff.md @@ -7,6 +7,7 @@ | Name | Environment Variable | Type | Read-Only | Default | Description | | ---- | -------------------- | ---- | --------- | ------- | ----------- | +| direct_marketing_enabled | `EOS_FEEDINTARIFF__DIRECT_MARKETING_ENABLED` | `bool` | `rw` | `False` | Use the electricity market price as feed-in tariff and enable export-aware direct marketing optimization. | | provider | `EOS_FEEDINTARIFF__PROVIDER` | `Optional[str]` | `rw` | `None` | Feed in tariff provider id of provider to be used. | | provider_settings | `EOS_FEEDINTARIFF__PROVIDER_SETTINGS` | `FeedInTariffCommonProviderSettings` | `rw` | `required` | Provider settings | | providers | | `list[str]` | `ro` | `N/A` | Available feed in tariff provider ids. | @@ -21,9 +22,11 @@ ```json { "feedintariff": { + "direct_marketing_enabled": false, "provider": "FeedInTariffFixed", "provider_settings": { "FeedInTariffFixed": null, + "FeedInTariffEnergyCharts": null, "FeedInTariffImport": null } } @@ -39,12 +42,15 @@ ```json { "feedintariff": { + "direct_marketing_enabled": false, "provider": "FeedInTariffFixed", "provider_settings": { "FeedInTariffFixed": null, + "FeedInTariffEnergyCharts": null, "FeedInTariffImport": null }, "providers": [ + "FeedInTariffEnergyCharts", "FeedInTariffFixed", "FeedInTariffImport" ] @@ -86,6 +92,37 @@ ``` +### Common settings for Energy-Charts feed-in tariff provider + + +:::{table} feedintariff::provider_settings::FeedInTariffEnergyCharts +:widths: 10 10 5 5 30 +:align: left + +| Name | Type | Read-Only | Default | Description | +| ---- | ---- | --------- | ------- | ----------- | +| bidding_zone | `` | `rw` | `DE-LU` | Bidding Zone: 'AT', 'BE', 'CH', 'CZ', 'DE-LU', 'DE-AT-LU', 'DK1', 'DK2', 'FR', 'HU', 'IT-NORTH', 'NL', 'NO2', 'PL', 'SE4' or 'SI' | +::: + + + +**Example Input/Output** + + + +```json + { + "feedintariff": { + "provider_settings": { + "FeedInTariffEnergyCharts": { + "bidding_zone": "DE-LU" + } + } + } + } +``` + + ### Common settings for elecprice fixed price @@ -126,6 +163,7 @@ | Name | Type | Read-Only | Default | Description | | ---- | ---- | --------- | ------- | ----------- | +| FeedInTariffEnergyCharts | `Optional[akkudoktoreos.prediction.feedintariffenergycharts.FeedInTariffEnergyChartsCommonSettings]` | `rw` | `None` | FeedInTariffEnergyCharts settings | | FeedInTariffFixed | `Optional[akkudoktoreos.prediction.feedintarifffixed.FeedInTariffFixedCommonSettings]` | `rw` | `None` | FeedInTariffFixed settings | | FeedInTariffImport | `Optional[akkudoktoreos.prediction.feedintariffimport.FeedInTariffImportCommonSettings]` | `rw` | `None` | FeedInTariffImport settings | ::: @@ -141,6 +179,7 @@ "feedintariff": { "provider_settings": { "FeedInTariffFixed": null, + "FeedInTariffEnergyCharts": null, "FeedInTariffImport": null } } diff --git a/docs/akkudoktoreos/optimpost.md b/docs/akkudoktoreos/optimpost.md index 5451550..73e884e 100644 --- a/docs/akkudoktoreos/optimpost.md +++ b/docs/akkudoktoreos/optimpost.md @@ -262,6 +262,7 @@ smaller values (e.g. `0.0`) disable the penalty entirely. "ac_charge": [0.625, 0, ..., 0.75, 0], "dc_charge": [1, 1, ..., 1, 1], "discharge_allowed": [0, 0, 1, ..., 0, 0], + "battery_grid_export_allowed": [0, 0, 0, ..., 1, 0], "eautocharge_hours_float": [0.625, 0, ..., 0.75, 0], "result": { "Last_Wh_pro_Stunde": [...], @@ -282,7 +283,8 @@ smaller values (e.g. `0.0`) disable the penalty entirely. - `ac_charge`: Grid charging schedule (0.0-1.0) - `dc_charge`: DC charging schedule (0-1) -- `discharge_allowed`: Discharge permission (0 or 1) +- `discharge_allowed`: Battery discharge permission for local self-consumption/load coverage (0 or 1) +- `battery_grid_export_allowed`: Battery discharge permission for grid export/direct marketing (0 or 1) 0 (no charge) 1 (charge with full load) diff --git a/openapi.json b/openapi.json index 8386aca..c06f222 100644 --- a/openapi.json +++ b/openapi.json @@ -8,7 +8,7 @@ "name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0.html" }, - "version": "v0.3.0.dev2607071966322885" + "version": "v0.3.0.dev2607101210858200" }, "paths": { "/v1/admin/cache/clear": { @@ -3621,7 +3621,7 @@ } ], "title": "Home Id", - "description": "Tibber home id to read prices from.", + "description": "Optional Tibber home id. If omitted, the first home with a subscription is used.", "examples": [ "00000000-0000-0000-0000-000000000000" ] @@ -4283,6 +4283,20 @@ null ] }, + "FeedInTariffEnergyCharts": { + "anyOf": [ + { + "$ref": "#/components/schemas/FeedInTariffEnergyChartsCommonSettings" + }, + { + "type": "null" + } + ], + "description": "FeedInTariffEnergyCharts settings", + "examples": [ + null + ] + }, "FeedInTariffImport": { "anyOf": [ { @@ -4304,6 +4318,16 @@ }, "FeedInTariffCommonSettings-Input": { "properties": { + "direct_marketing_enabled": { + "type": "boolean", + "title": "Direct Marketing Enabled", + "description": "Use the electricity market price as feed-in tariff and enable export-aware direct marketing optimization.", + "default": false, + "examples": [ + false, + true + ] + }, "provider": { "anyOf": [ { @@ -4317,7 +4341,8 @@ "description": "Feed in tariff provider id of provider to be used.", "examples": [ "FeedInTariffFixed", - "FeedInTarifImport" + "FeedInTariffEnergyCharts", + "FeedInTariffImport" ] }, "provider_settings": { @@ -4334,6 +4359,16 @@ }, "FeedInTariffCommonSettings-Output": { "properties": { + "direct_marketing_enabled": { + "type": "boolean", + "title": "Direct Marketing Enabled", + "description": "Use the electricity market price as feed-in tariff and enable export-aware direct marketing optimization.", + "default": false, + "examples": [ + false, + true + ] + }, "provider": { "anyOf": [ { @@ -4347,7 +4382,8 @@ "description": "Feed in tariff provider id of provider to be used.", "examples": [ "FeedInTariffFixed", - "FeedInTarifImport" + "FeedInTariffEnergyCharts", + "FeedInTariffImport" ] }, "provider_settings": { @@ -4374,6 +4410,21 @@ "title": "FeedInTariffCommonSettings", "description": "Feed In Tariff Prediction Configuration." }, + "FeedInTariffEnergyChartsCommonSettings": { + "properties": { + "bidding_zone": { + "$ref": "#/components/schemas/EnergyChartsBiddingZones", + "description": "Bidding Zone: 'AT', 'BE', 'CH', 'CZ', 'DE-LU', 'DE-AT-LU', 'DK1', 'DK2', 'FR', 'HU', 'IT-NORTH', 'NL', 'NO2', 'PL', 'SE4' or 'SI'", + "default": "DE-LU", + "examples": [ + "DE-LU" + ] + } + }, + "type": "object", + "title": "FeedInTariffEnergyChartsCommonSettings", + "description": "Common settings for Energy-Charts feed-in tariff provider." + }, "FeedInTariffFixedCommonSettings": { "properties": { "feed_in_tariff_kwh": { @@ -5107,7 +5158,15 @@ }, "type": "array", "title": "Discharge Allowed", - "description": "Array with discharge values (1 for discharge, 0 otherwise)." + "description": "Array with self-consumption discharge values (1 for discharge, 0 otherwise)." + }, + "battery_grid_export_allowed": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Battery Grid Export Allowed", + "description": "Array with battery-to-grid export values (1 for export discharge, 0 otherwise)." }, "eautocharge_hours_float": { "anyOf": [ diff --git a/src/akkudoktoreos/devices/genetic/inverter.py b/src/akkudoktoreos/devices/genetic/inverter.py index e689896..c2a1ce0 100644 --- a/src/akkudoktoreos/devices/genetic/inverter.py +++ b/src/akkudoktoreos/devices/genetic/inverter.py @@ -30,8 +30,23 @@ class Inverter: self.ac_to_dc_efficiency = self.parameters.ac_to_dc_efficiency self.max_ac_charge_power_w = self.parameters.max_ac_charge_power_w + def _discharge_battery_to_ac(self, requested_ac_wh: float, hour: int) -> tuple[float, float]: + """Discharge battery energy and convert it to AC energy.""" + if not self.battery or requested_ac_wh <= 0.0: + return 0.0, 0.0 + + dc_request = requested_ac_wh / self.dc_to_ac_efficiency + battery_discharge_dc, discharge_losses = self.battery.discharge_energy(dc_request, hour) + battery_discharge_ac = battery_discharge_dc * self.dc_to_ac_efficiency + inverter_discharge_losses = battery_discharge_dc - battery_discharge_ac + return battery_discharge_ac, discharge_losses + inverter_discharge_losses + def process_energy( - self, generation: float, consumption: float, hour: int + self, + generation: float, + consumption: float, + hour: int, + allow_battery_grid_export: bool = False, ) -> tuple[float, float, float, float]: losses = 0.0 grid_export = 0.0 @@ -59,6 +74,7 @@ class Inverter: # Remaining load Self Consumption not perfect remaining_load_evq = (generation - consumption) * (1.0 - scr) + from_battery_dc = 0.0 if remaining_load_evq > 0: # Akku muss den Restverbrauch decken if self.battery: @@ -105,6 +121,20 @@ class Inverter: consumption + from_battery_ac ) # Self-consumption is equal to the load + 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) + battery_export_ac, battery_export_losses = self._discharge_battery_to_ac( + export_capacity, hour + ) + grid_export += battery_export_ac + losses += battery_export_losses + else: # Case 2: Insufficient generation, cover shortfall shortfall = consumption - generation @@ -129,4 +159,18 @@ class Inverter: grid_import = shortfall - battery_discharge_ac self_consumption = generation + battery_discharge_ac + 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) + battery_export_ac, battery_export_losses = self._discharge_battery_to_ac( + export_capacity, hour + ) + grid_export += battery_export_ac + losses += battery_export_losses + return grid_export, grid_import, losses, self_consumption diff --git a/src/akkudoktoreos/optimization/genetic/genetic.py b/src/akkudoktoreos/optimization/genetic/genetic.py index 3b3bf04..031699c 100644 --- a/src/akkudoktoreos/optimization/genetic/genetic.py +++ b/src/akkudoktoreos/optimization/genetic/genetic.py @@ -76,7 +76,12 @@ class GeneticSimulation(PydanticBaseModel): "description": "An array of floats representing the feed-in compensation in euros per watt-hour." }, ) - + direct_marketing_enabled: bool = Field( + default=False, + json_schema_extra={ + "description": "Use direct marketing behavior for feed-in/export decisions." + }, + ) battery: Optional[Battery] = Field(default=None, json_schema_extra={"description": "TBD."}) ev: Optional[Battery] = Field(default=None, json_schema_extra={"description": "TBD."}) home_appliance: Optional[HomeAppliance] = Field( @@ -93,6 +98,12 @@ class GeneticSimulation(PydanticBaseModel): bat_discharge_hours: Optional[NDArray[Shape["*"], float]] = Field( default=None, json_schema_extra={"description": "TBD"} ) + bat_grid_export_hours: Optional[NDArray[Shape["*"], float]] = Field( + default=None, + 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"} ) @@ -112,6 +123,7 @@ class GeneticSimulation(PydanticBaseModel): ev: Optional[Battery] = None, home_appliance: Optional[HomeAppliance] = None, inverter: Optional[Inverter] = None, + direct_marketing_enabled: bool = False, ) -> None: """Prepare simulation runs. @@ -119,13 +131,14 @@ class GeneticSimulation(PydanticBaseModel): """ self.optimization_hours = optimization_hours self.prediction_hours = prediction_hours + self.direct_marketing_enabled = direct_marketing_enabled # Load arrays from provided EMS parameters self.load_energy_array = np.array(parameters.gesamtlast, float) self.pv_prediction_wh = np.array(parameters.pv_prognose_wh, float) self.elect_price_hourly = np.array(parameters.strompreis_euro_pro_wh, float) self.elect_revenue_per_hour_arr = ( - parameters.einspeiseverguetung_euro_pro_wh + np.array(parameters.einspeiseverguetung_euro_pro_wh, float) if isinstance(parameters.einspeiseverguetung_euro_pro_wh, list) else np.full( len(self.load_energy_array), parameters.einspeiseverguetung_euro_pro_wh, float @@ -145,6 +158,7 @@ class GeneticSimulation(PydanticBaseModel): self.ac_charge_hours = np.full(self.prediction_hours, 0.0) self.dc_charge_hours = np.full(self.prediction_hours, 0.0) self.bat_discharge_hours = np.full(self.prediction_hours, 0.0) + self.bat_grid_export_hours = np.full(self.prediction_hours, 0.0) self.ev_charge_hours = np.full(self.prediction_hours, 0.0) self.ev_discharge_hours = np.full(self.prediction_hours, 0.0) self.home_appliance_start_hour = None @@ -172,6 +186,7 @@ class GeneticSimulation(PydanticBaseModel): ac_charge_hours_fast = self.ac_charge_hours dc_charge_hours_fast = self.dc_charge_hours bat_discharge_hours_fast = self.bat_discharge_hours + bat_grid_export_hours_fast = self.bat_grid_export_hours elect_price_hourly_fast = self.elect_price_hourly elect_revenue_per_hour_arr_fast = self.elect_revenue_per_hour_arr pv_prediction_wh_fast = self.pv_prediction_wh @@ -179,6 +194,7 @@ class GeneticSimulation(PydanticBaseModel): ev_fast = self.ev home_appliance_fast = self.home_appliance inverter_fast = self.inverter + direct_marketing_enabled_fast = self.direct_marketing_enabled # Check for simulation integrity (in a way that mypy understands) if ( @@ -190,6 +206,7 @@ class GeneticSimulation(PydanticBaseModel): or dc_charge_hours_fast is None or elect_revenue_per_hour_arr_fast is None or bat_discharge_hours_fast is None + or bat_grid_export_hours_fast is None or ev_discharge_hours_fast is None ): missing = [] @@ -209,6 +226,8 @@ class GeneticSimulation(PydanticBaseModel): missing.append("Electricity Revenue Per Hour") if bat_discharge_hours_fast is None: missing.append("Battery Discharge Hours") + if bat_grid_export_hours_fast is None: + missing.append("Battery Grid Export Hours") if ev_discharge_hours_fast is None: missing.append("EV Discharge Hours") msg = ", ".join(missing) @@ -235,6 +254,7 @@ class GeneticSimulation(PydanticBaseModel): revenue_per_hour = np.full((total_hours), np.nan) losses_wh_per_hour = np.full((total_hours), np.nan) electricity_price_per_hour = np.full((total_hours), np.nan) + feed_in_tariff_per_hour = np.full((total_hours), np.nan) # Set initial state if battery_fast: @@ -272,7 +292,18 @@ class GeneticSimulation(PydanticBaseModel): # Fill the discharge array of the battery bat_discharge_hours_fast[0:start_hour] = 0 bat_discharge_hours_fast[end_hour:] = 0 - battery_fast.discharge_array = bat_discharge_hours_fast + bat_grid_export_hours_fast[0:start_hour] = 0 + bat_grid_export_hours_fast[end_hour:] = 0 + battery_fast.discharge_array = np.where( + (bat_discharge_hours_fast > 0) + | ( + direct_marketing_enabled_fast + & (bat_grid_export_hours_fast > 0) + & (elect_revenue_per_hour_arr_fast > 0.0) + ), + 1, + 0, + ) else: # Default return if no battery is available soc_per_hour = np.full((total_hours), 0) @@ -348,12 +379,25 @@ class GeneticSimulation(PydanticBaseModel): if inverter_fast: energy_produced = pv_prediction_wh_fast[hour] + hourly_feed_in_tariff = elect_revenue_per_hour_arr_fast[hour] + battery_grid_export_allowed = ( + direct_marketing_enabled_fast + and hourly_feed_in_tariff > 0.0 + and bat_grid_export_hours_fast[hour] > 0 + ) ( energy_feedin_grid_actual, energy_consumption_grid_actual, losses, eigenverbrauch, - ) = inverter_fast.process_energy(energy_produced, consumption, hour) + ) = inverter_fast.process_energy( + energy_produced, + consumption, + hour, + allow_battery_grid_export=battery_grid_export_allowed, + ) + else: + hourly_feed_in_tariff = elect_revenue_per_hour_arr_fast[hour] # AC PV Battery Charge if battery_fast: @@ -391,17 +435,26 @@ class GeneticSimulation(PydanticBaseModel): ) # Update hourly arrays + if ( + direct_marketing_enabled_fast + and hourly_feed_in_tariff < 0.0 + and energy_feedin_grid_actual > 0.0 + ): + losses_wh_per_hour[hour_idx] += energy_feedin_grid_actual + energy_feedin_grid_actual = 0.0 + feedin_energy_per_hour[hour_idx] = energy_feedin_grid_actual consumption_energy_per_hour[hour_idx] = energy_consumption_grid_actual losses_wh_per_hour[hour_idx] += losses loads_energy_per_hour[hour_idx] = consumption hourly_electricity_price = elect_price_hourly_fast[hour] electricity_price_per_hour[hour_idx] = hourly_electricity_price + feed_in_tariff_per_hour[hour_idx] = hourly_feed_in_tariff # Financial calculations costs_per_hour[hour_idx] = energy_consumption_grid_actual * hourly_electricity_price revenue_per_hour[hour_idx] = ( - energy_feedin_grid_actual * elect_revenue_per_hour_arr_fast[hour] + energy_feedin_grid_actual * hourly_feed_in_tariff ) total_cost = np.nansum(costs_per_hour) @@ -424,6 +477,7 @@ class GeneticSimulation(PydanticBaseModel): "Gesamt_Verluste": total_losses, "Home_appliance_wh_per_hour": home_appliance_wh_per_hour, "Electricity_price": electricity_price_per_hour, + "Feed_in_tariff": feed_in_tariff_per_hour, } @@ -448,6 +502,7 @@ class GeneticOptimization(OptimizationBase): self.fix_seed = fixed_seed self.optimize_ev = True self.optimize_dc_charge = False + self.optimize_battery_grid_export = False self.fitness_history: dict[str, Any] = {} # Set a fixed seed for random operations if provided or in debug mode @@ -460,10 +515,42 @@ class GeneticOptimization(OptimizationBase): # Create Simulation self.simulation = GeneticSimulation() + def _direct_marketing_enabled(self) -> bool: + """Return whether direct marketing mode is enabled in configuration.""" + try: + return bool(self.config.feedintariff.direct_marketing_enabled) + except Exception: + return False + + def _parameters_for_config( + self, parameters: GeneticOptimizationParameters + ) -> GeneticOptimizationParameters: + """Apply configuration-derived parameter overrides before optimization.""" + if not self._direct_marketing_enabled(): + return parameters + + feed_in_tariff = parameters.ems.einspeiseverguetung_euro_pro_wh + if ( + isinstance(feed_in_tariff, list) + and len(feed_in_tariff) == len(parameters.ems.strompreis_euro_pro_wh) + and len(set(feed_in_tariff)) > 1 + ): + return parameters + + ems_parameters = parameters.ems.model_copy( + 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 decode_charge_discharge( self, discharge_hours_bin: np.ndarray - ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """Decode the input array into ac_charge, dc_charge, and discharge arrays.""" + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Decode the input array into charge, self-consumption discharge and export arrays.""" discharge_hours_bin_np = np.array(discharge_hours_bin) # Battery AC charge uses its own charge-level list (bat_possible_charge_values). len_bat = len(self.bat_possible_charge_values) @@ -473,6 +560,7 @@ class GeneticOptimization(OptimizationBase): # Discharge: len_bat .. 2*len_bat - 1 # AC Charge: 2*len_bat .. 3*len_bat - 1 (maps to bat_possible_charge_values) # DC optional: 3*len_bat (not allowed), 3*len_bat + 1 (allowed) + # Grid export: next state, if direct marketing/export optimization is enabled # Idle states idle_mask = (discharge_hours_bin_np >= 0) & (discharge_hours_bin_np < len_bat) @@ -501,9 +589,14 @@ class GeneticOptimization(OptimizationBase): ac_charge = np.zeros_like(discharge_hours_bin_np, dtype=float) ac_charge[ac_mask] = [self.bat_possible_charge_values[i] for i in ac_indices] + battery_grid_export = np.zeros_like(discharge_hours_bin_np, dtype=int) + if self.optimize_battery_grid_export: + grid_export_state = 3 * len_bat + (2 if self.optimize_dc_charge else 0) + battery_grid_export = np.where(discharge_hours_bin_np == grid_export_state, 1, 0) + # Idle is just 0, already default. - return ac_charge, dc_charge, discharge + return ac_charge, dc_charge, discharge, battery_grid_export def mutate(self, individual: list[int]) -> tuple[list[int]]: """Custom mutation function for the individual.""" @@ -513,6 +606,8 @@ class GeneticOptimization(OptimizationBase): total_states = 3 * len_bat + 2 else: total_states = 3 * len_bat + if self.optimize_battery_grid_export: + total_states += 1 # 1. Mutating the charge_discharge part charge_discharge_part = individual[: self.config.prediction.hours] @@ -652,10 +747,13 @@ class GeneticOptimization(OptimizationBase): # Discharge: len_bat states # AC-Charge: len_bat states (maps to bat_possible_charge_values) # With DC: + 2 additional states + # With battery grid export: + 1 additional state if self.optimize_dc_charge: total_states = 3 * len_bat + 2 else: total_states = 3 * len_bat + if self.optimize_battery_grid_export: + total_states += 1 # State space: 0 .. (total_states - 1) self.toolbox.register("attr_discharge_state", random.randint, 0, total_states - 1) @@ -711,11 +809,12 @@ class GeneticOptimization(OptimizationBase): # Set start hour for appliance self.simulation.home_appliance_start_hour = washingstart_int - ac_charge_hours, dc_charge_hours, discharge = self.decode_charge_discharge( - discharge_hours_bin + ac_charge_hours, dc_charge_hours, discharge, battery_grid_export = ( + self.decode_charge_discharge(discharge_hours_bin) ) self.simulation.bat_discharge_hours = discharge + self.simulation.bat_grid_export_hours = battery_grid_export # Set DC charge hours only if DC optimization is enabled if self.optimize_dc_charge: self.simulation.dc_charge_hours = dc_charge_hours @@ -1077,6 +1176,11 @@ class GeneticOptimization(OptimizationBase): ngen: Optional[int] = None, ) -> GeneticSolution: """Perform EMS (Energy Management System) optimization and visualize results.""" + direct_marketing_enabled = self._direct_marketing_enabled() + parameters = self._parameters_for_config(parameters) + self.optimize_dc_charge = direct_marketing_enabled + self.optimize_battery_grid_export = direct_marketing_enabled + if start_hour is None: start_hour = self.ems.start_datetime.hour # Start hour has to be in sync with energy management @@ -1094,10 +1198,6 @@ class GeneticOptimization(OptimizationBase): generations = 400 logger.error("Generations not configured. Using {}.", generations) - einspeiseverguetung_euro_pro_wh = np.full( - self.config.prediction.hours, parameters.ems.einspeiseverguetung_euro_pro_wh - ) - self.simulation.reset() # Initialize PV and EV batteries @@ -1195,6 +1295,7 @@ class GeneticOptimization(OptimizationBase): inverter=inverter, # battery is part of inverter ev=eauto, home_appliance=dishwasher, + direct_marketing_enabled=direct_marketing_enabled, ) # Setup the DEAP environment and optimization process @@ -1240,6 +1341,11 @@ class GeneticOptimization(OptimizationBase): discharge = [] else: discharge = discharge.tolist() + battery_grid_export = self.simulation.bat_grid_export_hours + if not direct_marketing_enabled or battery_grid_export is None: + battery_grid_export = [] + else: + battery_grid_export = battery_grid_export.tolist() # Visualize the results in PDF try: @@ -1249,6 +1355,7 @@ class GeneticOptimization(OptimizationBase): "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, @@ -1270,6 +1377,7 @@ class GeneticOptimization(OptimizationBase): "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": GeneticSimulationResult(**simulation_result), "eauto_obj": self.simulation.ev, diff --git a/src/akkudoktoreos/optimization/genetic/geneticparams.py b/src/akkudoktoreos/optimization/genetic/geneticparams.py index ec2d32b..77ee172 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticparams.py +++ b/src/akkudoktoreos/optimization/genetic/geneticparams.py @@ -330,33 +330,48 @@ class GeneticOptimizationParameters( ) # Retry continue - try: - feed_in_tariff_wh = cls.prediction.key_to_array( - key="feed_in_tariff_wh", - start_datetime=parameter_start_datetime, - end_datetime=parameter_end_datetime, - interval=interval, - fill_method="ffill", - ).tolist() - except: - logger.info( - "No feed in tariff forecast data available - defaulting to demo data. Parameter preparation attempt {}.", - attempt, - ) - cls.config.merge_settings_from_dict( - { - "feedintariff": { - "provider": "FeedInTariffFixed", - "provider_settings": { - "FeedInTariffFixed": { - "feed_in_tariff_kwh": 0.078, + if cls.config.feedintariff.direct_marketing_enabled: + if cls.config.feedintariff.provider == "FeedInTariffEnergyCharts": + try: + feed_in_tariff_wh = cls.prediction.key_to_array( + key="feed_in_tariff_wh", + start_datetime=parameter_start_datetime, + end_datetime=parameter_end_datetime, + interval=interval, + fill_method="ffill", + ).tolist() + except: + feed_in_tariff_wh = list(elecprice_marketprice_wh) + else: + feed_in_tariff_wh = list(elecprice_marketprice_wh) + else: + try: + feed_in_tariff_wh = cls.prediction.key_to_array( + key="feed_in_tariff_wh", + start_datetime=parameter_start_datetime, + end_datetime=parameter_end_datetime, + interval=interval, + fill_method="ffill", + ).tolist() + except: + logger.info( + "No feed in tariff forecast data available - defaulting to demo data. Parameter preparation attempt {}.", + attempt, + ) + cls.config.merge_settings_from_dict( + { + "feedintariff": { + "provider": "FeedInTariffFixed", + "provider_settings": { + "FeedInTariffFixed": { + "feed_in_tariff_kwh": 0.078, + }, }, }, - }, - } - ) - # Retry - continue + } + ) + # Retry + continue try: weather_temp_air = cls.prediction.key_to_array( key="weather_temp_air", diff --git a/src/akkudoktoreos/optimization/genetic/geneticsolution.py b/src/akkudoktoreos/optimization/genetic/geneticsolution.py index b03227a..ed3b2d5 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticsolution.py +++ b/src/akkudoktoreos/optimization/genetic/geneticsolution.py @@ -130,6 +130,12 @@ class GeneticSimulationResult(GeneticParametersBaseModel): Electricity_price: list[float] = Field( json_schema_extra={"description": "Used Electricity Price, including predictions"} ) + Feed_in_tariff: list[float] = Field( + default_factory=list, + json_schema_extra={ + "description": "Used feed-in tariff in €/Wh per hour, including predictions" + }, + ) @field_validator( "Last_Wh_pro_Stunde", @@ -142,6 +148,7 @@ class GeneticSimulationResult(GeneticParametersBaseModel): "Verluste_Pro_Stunde", "Home_appliance_wh_per_hour", "Electricity_price", + "Feed_in_tariff", mode="before", ) def convert_numpy(cls, field: Any) -> Any: @@ -163,7 +170,13 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): ) discharge_allowed: list[int] = Field( json_schema_extra={ - "description": "Array with discharge values (1 for discharge, 0 otherwise)." + "description": "Array with self-consumption discharge values (1 for discharge, 0 otherwise)." + } + ) + battery_grid_export_allowed: list[int] = Field( + 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"}) @@ -186,6 +199,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): "ac_charge", "dc_charge", "discharge_allowed", + "battery_grid_export_allowed", mode="before", ) def convert_numpy(cls, field: Any) -> Any: @@ -226,13 +240,15 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): ac_charge: float, dc_charge: float, discharge_allowed: bool, + battery_grid_export_allowed: bool = False, ) -> tuple[BatteryOperationMode, float]: """Maps low-level solution to a representative operation mode and factor. Args: ac_charge (float): Allowed AC-side charging power (relative units). dc_charge (float): Allowed DC-side charging power (relative units). - discharge_allowed (bool): Whether discharging is permitted. + discharge_allowed (bool): Whether discharging to local load is permitted. + battery_grid_export_allowed (bool): Whether discharge into the grid is permitted. Returns: tuple[BatteryOperationMode, float]: A tuple containing @@ -240,15 +256,28 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): - `float`: the operation factor corresponding to the active signal. Notes: - - The mapping prioritizes AC charge > DC charge > discharge. + - Explicit grid export is separate from local-load discharge. + - The mapping prioritizes export > AC charge > DC charge > discharge. - Multiple strategies can produce the same low-level signals; this function returns a representative mode based on a defined priority order. """ # (0,0,0) → Nothing allowed - if ac_charge <= 0.0 and dc_charge <= 0.0 and not discharge_allowed: + if ( + ac_charge <= 0.0 + and dc_charge <= 0.0 + and not discharge_allowed + and not battery_grid_export_allowed + ): return BatteryOperationMode.IDLE, 1.0 - # (0,0,1) → Discharge only + if battery_grid_export_allowed: + if ac_charge > 0.0 or dc_charge > 0.0: + raise ValueError( + "Illegal state: battery_grid_export_allowed cannot be combined with charging" + ) + return BatteryOperationMode.GRID_SUPPORT_EXPORT, 1.0 + + # (0,0,1) -> Discharge for local load only if ac_charge <= 0.0 and dc_charge <= 0.0 and discharge_allowed: return BatteryOperationMode.PEAK_SHAVING, 1.0 @@ -289,7 +318,8 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): dc_charge: float, discharge_allowed: bool, soc_pct: float, - ) -> tuple[float, float, bool]: + battery_grid_export_allowed: bool = False, + ) -> tuple[float, float, bool, bool]: """Clamp raw genetic gene values by the battery's actual SOC at that hour. The raw gene values represent the optimizer's *intent* and are stored @@ -305,10 +335,11 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): - 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. + - Battery grid export: blocked when SOC is at or below min SOC. """ bat_list = self.config.devices.batteries if not bat_list: - return ac_charge, dc_charge, discharge_allowed + return ac_charge, dc_charge, discharge_allowed, battery_grid_export_allowed bat = bat_list[0] min_soc = float(bat.min_soc_percentage) @@ -341,19 +372,22 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): # --- Discharge: block at min SOC --- effective_dis = discharge_allowed and (soc_pct > min_soc) + effective_grid_export = battery_grid_export_allowed and (soc_pct > min_soc) - return effective_ac, effective_dc, effective_dis + return effective_ac, effective_dc, effective_dis, effective_grid_export def optimization_solution(self) -> OptimizationSolution: """Provide the genetic solution as a general optimization solution. The battery modes are controlled by the grid control triggers: - ac_charge: charge from grid - - discharge_allowed: discharge to grid + - discharge_allowed: discharge to local load + - battery_grid_export_allowed: discharge to grid The following battery modes are supported: - - SELF_CONSUMPTION: ac_charge == 0 and discharge_allowed == 0 - - GRID_SUPPORT_EXPORT: ac_charge == 0 and discharge_allowed == 1 + - SELF_CONSUMPTION: dc_charge > 0 and discharge_allowed == 1 + - PEAK_SHAVING: ac_charge == 0 and discharge_allowed == 1 + - GRID_SUPPORT_EXPORT: battery_grid_export_allowed == 1 - GRID_SUPPORT_IMPORT: ac_charge > 0 and discharge_allowed == 0 or 1 """ start_datetime = get_ems().start_datetime @@ -407,6 +441,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): "genetic_ac_charge_factor": [], "genetic_dc_charge_factor": [], "genetic_discharge_allowed_factor": [], + "genetic_battery_grid_export_allowed_factor": [], } # ac_charge, dc_charge, discharge_allowed start at hour 0 of start day for hour_idx, rate in enumerate(self.ac_charge): @@ -417,11 +452,19 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): ac_charge_hour = self.ac_charge[hour_idx] dc_charge_hour = self.dc_charge[hour_idx] discharge_allowed_hour = bool(self.discharge_allowed[hour_idx]) + battery_grid_export_allowed_hour = ( + bool(self.battery_grid_export_allowed[hour_idx]) + if hour_idx < len(self.battery_grid_export_allowed) + else False + ) # Raw genetic gene values — optimizer intent, stored verbatim operation["genetic_ac_charge_factor"].append(ac_charge_hour) operation["genetic_dc_charge_factor"].append(dc_charge_hour) operation["genetic_discharge_allowed_factor"].append(float(discharge_allowed_hour)) + operation["genetic_battery_grid_export_allowed_factor"].append( + float(battery_grid_export_allowed_hour) + ) # SOC-clamped effective values — what can physically be executed at # this hour given the expected battery state of charge. @@ -431,11 +474,15 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): if result_idx < len(self.result.akku_soc_pro_stunde) else 0.0 ) - eff_ac, eff_dc, eff_dis = self._soc_clamped_operation_factors( - ac_charge_hour, dc_charge_hour, discharge_allowed_hour, soc_h_pct + eff_ac, eff_dc, eff_dis, eff_grid_export = self._soc_clamped_operation_factors( + ac_charge_hour, + dc_charge_hour, + discharge_allowed_hour, + soc_h_pct, + battery_grid_export_allowed_hour, ) operation_mode, operation_mode_factor = self._battery_operation_from_solution( - eff_ac, eff_dc, eff_dis + eff_ac, eff_dc, eff_dis, eff_grid_export ) for mode in BatteryOperationMode: mode_key = f"{battery_device_id}_{mode.lower()}_op_mode" @@ -671,14 +718,20 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel): if result_idx < len(self.result.akku_soc_pro_stunde) else 0.0 ) - eff_ac, eff_dc, eff_dis = self._soc_clamped_operation_factors( + battery_grid_export_allowed_hour = ( + bool(self.battery_grid_export_allowed[hour_idx]) + if hour_idx < len(self.battery_grid_export_allowed) + else False + ) + eff_ac, eff_dc, eff_dis, eff_grid_export = self._soc_clamped_operation_factors( self.ac_charge[hour_idx], self.dc_charge[hour_idx], bool(self.discharge_allowed[hour_idx]), soc_h_pct, + battery_grid_export_allowed_hour, ) operation_mode, operation_mode_factor = self._battery_operation_from_solution( - eff_ac, eff_dc, eff_dis + eff_ac, eff_dc, eff_dis, eff_grid_export ) if ( operation_mode == last_operation_mode diff --git a/src/akkudoktoreos/prediction/feedintariff.py b/src/akkudoktoreos/prediction/feedintariff.py index 4c5ac8d..70ad830 100644 --- a/src/akkudoktoreos/prediction/feedintariff.py +++ b/src/akkudoktoreos/prediction/feedintariff.py @@ -5,6 +5,9 @@ from pydantic import Field, computed_field, field_validator from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.core.coreabc import get_prediction from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider +from akkudoktoreos.prediction.feedintariffenergycharts import ( + FeedInTariffEnergyChartsCommonSettings, +) from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixedCommonSettings from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImportCommonSettings @@ -16,7 +19,7 @@ def elecprice_provider_ids() -> list[str]: except: # Prediction may not be initialized # Return at least provider used in example - return ["FeedInTariffFixed", "FeedInTarifImport"] + return ["FeedInTariffFixed", "FeedInTariffEnergyCharts", "FeedInTariffImport"] return [ provider.provider_id() @@ -32,6 +35,10 @@ class FeedInTariffCommonProviderSettings(SettingsBaseModel): default=None, json_schema_extra={"description": "FeedInTariffFixed settings", "examples": [None]}, ) + FeedInTariffEnergyCharts: Optional[FeedInTariffEnergyChartsCommonSettings] = Field( + default=None, + json_schema_extra={"description": "FeedInTariffEnergyCharts settings", "examples": [None]}, + ) FeedInTariffImport: Optional[FeedInTariffImportCommonSettings] = Field( default=None, json_schema_extra={"description": "FeedInTariffImport settings", "examples": [None]}, @@ -41,11 +48,23 @@ class FeedInTariffCommonProviderSettings(SettingsBaseModel): class FeedInTariffCommonSettings(SettingsBaseModel): """Feed In Tariff Prediction Configuration.""" + direct_marketing_enabled: bool = Field( + default=False, + json_schema_extra={ + "description": "Use the electricity market price as feed-in tariff and enable export-aware direct marketing optimization.", + "examples": [False, True], + }, + ) + provider: Optional[str] = Field( default=None, json_schema_extra={ "description": "Feed in tariff provider id of provider to be used.", - "examples": ["FeedInTariffFixed", "FeedInTarifImport"], + "examples": [ + "FeedInTariffFixed", + "FeedInTariffEnergyCharts", + "FeedInTariffImport", + ], }, ) @@ -57,6 +76,7 @@ class FeedInTariffCommonSettings(SettingsBaseModel): # Example 1: Empty/default settings (all providers None) { "FeedInTariffFixed": None, + "FeedInTariffEnergyCharts": None, "FeedInTariffImport": None, }, ], diff --git a/src/akkudoktoreos/prediction/feedintariffenergycharts.py b/src/akkudoktoreos/prediction/feedintariffenergycharts.py new file mode 100644 index 0000000..4513e75 --- /dev/null +++ b/src/akkudoktoreos/prediction/feedintariffenergycharts.py @@ -0,0 +1,179 @@ +"""Provides feed-in tariff data from Energy-Charts market prices.""" + +from datetime import datetime +from typing import Optional + +import pandas as pd +import requests +from loguru import logger +from pydantic import Field + +from akkudoktoreos.config.configabc import SettingsBaseModel +from akkudoktoreos.core.cache import cache_in_file +from akkudoktoreos.prediction.elecpriceenergycharts import ( + ElecPriceEnergyCharts, + EnergyChartsBiddingZones, + EnergyChartsElecPrice, +) +from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider +from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration + + +class FeedInTariffEnergyChartsCommonSettings(SettingsBaseModel): + """Common settings for Energy-Charts feed-in tariff provider.""" + + bidding_zone: EnergyChartsBiddingZones = Field( + default=EnergyChartsBiddingZones.DE_LU, + json_schema_extra={ + "description": ( + "Bidding Zone: 'AT', 'BE', 'CH', 'CZ', 'DE-LU', 'DE-AT-LU', 'DK1', " + "'DK2', 'FR', 'HU', 'IT-NORTH', 'NL', 'NO2', 'PL', 'SE4' or 'SI'" + ), + "examples": ["DE-LU"], + }, + ) + + +class FeedInTariffEnergyCharts(FeedInTariffProvider): + """Fetch Energy-Charts market prices as feed-in tariff data. + + This provider stores the raw Energy-Charts day-ahead market price as + ``feed_in_tariff_wh``. Unlike ``ElecPriceEnergyCharts`` it intentionally + does not add electricity import charges or VAT. + """ + + highest_orig_datetime: Optional[datetime] = None + + @classmethod + def provider_id(cls) -> str: + """Return the unique identifier for the Energy-Charts feed-in tariff provider.""" + return "FeedInTariffEnergyCharts" + + def _bidding_zone(self) -> str: + settings = self.config.feedintariff.provider_settings.FeedInTariffEnergyCharts + if settings is None: + return EnergyChartsBiddingZones.DE_LU.value + bidding_zone = settings.bidding_zone + if isinstance(bidding_zone, EnergyChartsBiddingZones): + return bidding_zone.value + return str(bidding_zone) + + @cache_in_file(with_ttl="1 hour") + def _request_forecast(self, start_date: Optional[str] = None) -> EnergyChartsElecPrice: + """Fetch market price forecast data from Energy-Charts.""" + source = "https://api.energy-charts.info" + if start_date is None: + start_date = to_datetime( + self.ems_start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD" + ) + + last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD") + url = f"{source}/price?bzn={self._bidding_zone()}&start={start_date}&end={last_date}" + response = requests.get(url, timeout=30) + logger.debug(f"Response from {url}: {response}") + response.raise_for_status() + energy_charts_data = ElecPriceEnergyCharts._validate_data(response.content) + self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) + return energy_charts_data + + def _parse_data(self, energy_charts_data: EnergyChartsElecPrice) -> pd.Series: + series_data = pd.Series(dtype=float) + for unix_sec, price_eur_per_mwh in zip( + energy_charts_data.unix_seconds, energy_charts_data.price, strict=False + ): + orig_datetime = to_datetime(unix_sec, in_timezone=self.config.general.timezone) + series_data.at[orig_datetime] = price_eur_per_mwh / 1_000_000 + return series_data + + def _predict_prices(self, history, hours: 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) + logger.error("No feed-in tariff data available for Energy-Charts prediction") + raise ValueError("No data available") + + def _update_data(self, force_update: Optional[bool] = False) -> None: + """Update feed-in tariff forecast data from Energy-Charts.""" + now = pd.Timestamp.now(tz=self.config.general.timezone) + midnight = now.normalize() + hours_ahead = 23 if now.time() < pd.Timestamp("14:00").time() else 47 + end = midnight + pd.Timedelta(hours=hours_ahead) + + if not self.ems_start_datetime: + raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}") + + past_days = 35 + if self.highest_orig_datetime: + history_series = self.key_to_series( + key="feed_in_tariff_wh", start_datetime=self.ems_start_datetime + ) + if not history_series.empty and history_series.index.min() <= self.ems_start_datetime: + past_days = 0 + needs_update = end > self.highest_orig_datetime + else: + needs_update = True + + if needs_update: + logger.info( + "Update FeedInTariffEnergyCharts is needed, last in history: {}", + self.highest_orig_datetime, + ) + start_date = to_datetime( + self.ems_start_datetime - to_duration(f"{past_days} days"), + as_string="YYYY-MM-DD", + ) + energy_charts_data = self._request_forecast( + start_date=start_date, force_update=force_update + ) + series_data = self._parse_data(energy_charts_data) + if series_data.empty: + raise ValueError("No Energy-Charts feed-in tariff data available") + self.highest_orig_datetime = series_data.index.max() + self.key_from_series("feed_in_tariff_wh", series_data) + else: + logger.info( + "No update FeedInTariffEnergyCharts is needed, last in history: {}", + 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) + ) + + if needed_hours <= 0: + logger.warning( + "No feed-in tariff prediction needed. needed_hours={}, hours={}, " + "highest_orig_datetime={}, start_datetime={}", + needed_hours, + self.config.prediction.hours, + self.highest_orig_datetime, + self.ems_start_datetime, + ) + return + + prediction = self._predict_prices(history, needed_hours) + prediction_series = pd.Series( + data=prediction, + index=[ + self.highest_orig_datetime + to_duration(f"{i + 1} hours") + for i in range(len(prediction)) + ], + ) + self.key_from_series("feed_in_tariff_wh", prediction_series) diff --git a/src/akkudoktoreos/prediction/prediction.py b/src/akkudoktoreos/prediction/prediction.py index 9d4a637..be4a1e9 100644 --- a/src/akkudoktoreos/prediction/prediction.py +++ b/src/akkudoktoreos/prediction/prediction.py @@ -36,6 +36,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 ( @@ -81,6 +82,8 @@ 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() loadforecast_akkudoktor = LoadAkkudoktor() @@ -106,6 +109,8 @@ def prediction_providers() -> list[ ElecPriceTibber, ElecPriceFixed, ElecPriceImport, + ElecPriceTibber, + FeedInTariffEnergyCharts, FeedInTariffFixed, FeedInTariffImport, LoadAkkudoktor, @@ -134,6 +139,8 @@ def prediction_providers() -> list[ elecprice_tibber, \ elecprice_fixed, \ elecprice_import, \ + elecprice_tibber, \ + feedintariff_energy_charts, \ feedintariff_fixed, \ feedintariff_import, \ loadforecast_akkudoktor, \ @@ -158,6 +165,8 @@ def prediction_providers() -> list[ elecprice_tibber, elecprice_fixed, elecprice_import, + elecprice_tibber, + feedintariff_energy_charts, feedintariff_fixed, feedintariff_import, loadforecast_akkudoktor, @@ -187,6 +196,8 @@ class Prediction(PredictionContainer): ElecPriceTibber, ElecPriceFixed, ElecPriceImport, + ElecPriceTibber, + FeedInTariffEnergyCharts, FeedInTariffFixed, FeedInTariffImport, LoadAkkudoktor, diff --git a/src/akkudoktoreos/utils/visualize.py b/src/akkudoktoreos/utils/visualize.py index b2b481f..4bffa0d 100644 --- a/src/akkudoktoreos/utils/visualize.py +++ b/src/akkudoktoreos/utils/visualize.py @@ -549,17 +549,29 @@ def prepare_visualize( ) labels = labels[start_hour:] + labels + charge_discharge_series = [ + results["ac_charge"][start_hour:], + results["dc_charge"][start_hour:], + results["discharge_allowed"][start_hour:], + ] + charge_discharge_labels = [ + "AC Charging (relative)", + "DC Charging (relative)", + "Discharge Allowed", + ] + 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_labels.append("Battery Grid Export Allowed") + charge_discharge_colors.append("purple") + report.create_bar_chart( labels, - [ - results["ac_charge"][start_hour:], - results["dc_charge"][start_hour:], - results["discharge_allowed"][start_hour:], - ], + charge_discharge_series, title="AC/DC Charging and Discharge Overview", ylabel="Relative Power (0-1) / Discharge (0 or 1)", - label_names=["AC Charging (relative)", "DC Charging (relative)", "Discharge Allowed"], - colors=["blue", "green", "red"], + label_names=charge_discharge_labels, + colors=charge_discharge_colors, bottom=3, xlabels=labels, ) diff --git a/tests/test_feedintariffenergycharts.py b/tests/test_feedintariffenergycharts.py new file mode 100644 index 0000000..949f4eb --- /dev/null +++ b/tests/test_feedintariffenergycharts.py @@ -0,0 +1,77 @@ +# ruff: noqa: S101 + +import json +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from akkudoktoreos.core.coreabc import get_ems +from akkudoktoreos.prediction.elecpriceenergycharts import EnergyChartsElecPrice +from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts +from akkudoktoreos.utils.datetimeutil import to_datetime + +DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata") + +FILE_TESTDATA_ELECPRICE_ENERGYCHARTS_JSON = DIR_TESTDATA.joinpath( + "elecpriceforecast_energycharts.json" +) + + +@pytest.fixture +def sample_energycharts_json(): + with FILE_TESTDATA_ELECPRICE_ENERGYCHARTS_JSON.open( + "r", encoding="utf-8", newline=None + ) as f_res: + return json.load(f_res) + + +@pytest.fixture +def provider(config_eos): + config_eos.merge_settings_from_dict( + { + "elecprice": { + "charges_kwh": 0.21, + "energycharts": {"bidding_zone": "DE-LU"}, + }, + "feedintariff": { + "provider": "FeedInTariffEnergyCharts", + "provider_settings": { + "FeedInTariffEnergyCharts": {"bidding_zone": "AT"}, + }, + }, + } + ) + provider = FeedInTariffEnergyCharts() + provider.highest_orig_datetime = None + provider.records.clear() + assert provider.enabled() + return provider + + +def test_provider_is_available(config_eos): + assert "FeedInTariffEnergyCharts" in config_eos.feedintariff.providers + + +def test_parse_data_uses_raw_market_price(provider, sample_energycharts_json): + energy_charts_data = EnergyChartsElecPrice.model_validate(sample_energycharts_json) + + series = provider._parse_data(energy_charts_data) + + assert series.iloc[0] == pytest.approx(sample_energycharts_json["price"][0] / 1_000_000) + + +@patch("requests.get") +def test_request_forecast_uses_feedintariff_bidding_zone( + mock_get, provider, sample_energycharts_json +): + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = json.dumps(sample_energycharts_json) + mock_get.return_value = mock_response + get_ems().set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin")) + + provider._request_forecast(start_date="2024-12-10", force_update=True) + + actual_url = mock_get.call_args[0][0] + assert "bzn=AT" in actual_url diff --git a/tests/test_feedintarifffixed.py b/tests/test_feedintarifffixed.py index ca7b6e4..1614386 100644 --- a/tests/test_feedintarifffixed.py +++ b/tests/test_feedintarifffixed.py @@ -57,6 +57,16 @@ def test_invalid_provider(provider, config_eos): config_eos.merge_settings_from_dict(settings) +def test_direct_marketing_switch(config_eos): + assert config_eos.feedintariff.direct_marketing_enabled is False + + config_eos.merge_settings_from_dict( + {"feedintariff": {"direct_marketing_enabled": True}} + ) + + assert config_eos.feedintariff.direct_marketing_enabled is True + + # ------------------------------------------------ # Fixed feed in tariv values # ------------------------------------------------ diff --git a/tests/test_geneticoptimize.py b/tests/test_geneticoptimize.py index 609e0ea..7686c72 100644 --- a/tests/test_geneticoptimize.py +++ b/tests/test_geneticoptimize.py @@ -37,6 +37,51 @@ def compare_dict(actual: dict[str, Any], expected: dict[str, Any]): assert actual[key] == pytest.approx(value) +def test_direct_marketing_uses_market_price_as_feed_in_tariff(config_eos: ConfigEOS): + config_eos.merge_settings_from_dict( + {"feedintariff": {"direct_marketing_enabled": True}} + ) + parameters = GeneticOptimizationParameters( + ems={ + "pv_prognose_wh": [0.0, 0.0], + "strompreis_euro_pro_wh": [0.0002, -0.0001], + "einspeiseverguetung_euro_pro_wh": [0.00007, 0.00007], + "preis_euro_pro_wh_akku": 0.0, + "gesamtlast": [0.0, 0.0], + }, + pv_akku=None, + inverter=None, + eauto=None, + ) + + adjusted = GeneticOptimization()._parameters_for_config(parameters) + + assert adjusted.ems.einspeiseverguetung_euro_pro_wh == [0.0002, -0.0001] + assert parameters.ems.einspeiseverguetung_euro_pro_wh == [0.00007, 0.00007] + + +def test_direct_marketing_keeps_variable_feed_in_tariff(config_eos: ConfigEOS): + config_eos.merge_settings_from_dict( + {"feedintariff": {"direct_marketing_enabled": True}} + ) + parameters = GeneticOptimizationParameters( + ems={ + "pv_prognose_wh": [0.0, 0.0], + "strompreis_euro_pro_wh": [0.0002, 0.0003], + "einspeiseverguetung_euro_pro_wh": [0.0001, -0.00005], + "preis_euro_pro_wh_akku": 0.0, + "gesamtlast": [0.0, 0.0], + }, + pv_akku=None, + inverter=None, + eauto=None, + ) + + adjusted = GeneticOptimization()._parameters_for_config(parameters) + + assert adjusted.ems.einspeiseverguetung_euro_pro_wh == [0.0001, -0.00005] + + @pytest.mark.parametrize( "fn_in, fn_out, ngen, break_even", [ diff --git a/tests/test_geneticsimulation.py b/tests/test_geneticsimulation.py index cc09a08..747755b 100644 --- a/tests/test_geneticsimulation.py +++ b/tests/test_geneticsimulation.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import numpy as np import pytest @@ -249,6 +251,7 @@ def genetic_simulation(config_eos) -> GeneticSimulation: assert simulation.ac_charge_hours is not None assert simulation.dc_charge_hours is not None assert simulation.bat_discharge_hours is not None + assert simulation.bat_grid_export_hours is not None assert simulation.ev_charge_hours is not None simulation.ac_charge_hours[start_hour] = 1.0 simulation.dc_charge_hours[start_hour] = 1.0 @@ -374,3 +377,101 @@ def test_simulation(genetic_simulation): ), "The sum of 'Home_appliance_wh_per_hour' should be 2000." print("All tests passed successfully.") + + +def test_direct_marketing_curtails_negative_feed_in(config_eos): + 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) + + simulation = GeneticSimulation() + simulation.prepare( + GeneticEnergyManagementParameters( + pv_prognose_wh=[500.0, 500.0], + strompreis_euro_pro_wh=[-0.0001, -0.0001], + einspeiseverguetung_euro_pro_wh=[-0.0001, -0.0001], + preis_euro_pro_wh_akku=0.0, + gesamtlast=[0.0, 0.0], + ), + optimization_hours=config_eos.optimization.horizon_hours, + prediction_hours=config_eos.prediction.hours, + inverter=inverter, + direct_marketing_enabled=True, + ) + + result = simulation.simulate(start_hour=0) + + assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == 0.0 + assert result["Einnahmen_Euro_pro_Stunde"][0] == 0.0 + assert result["Verluste_Pro_Stunde"][0] == pytest.approx(500.0) + + +def _direct_marketing_battery_export_simulation(config_eos) -> GeneticSimulation: + config_eos.merge_settings_from_dict( + {"prediction": {"hours": 2}, "optimization": {"horizon_hours": 2}} + ) + + battery = Battery( + SolarPanelBatteryParameters( + device_id="battery1", + capacity_wh=1000, + initial_soc_percentage=100, + min_soc_percentage=0, + charging_efficiency=1.0, + discharging_efficiency=1.0, + max_charge_power_w=500, + ), + prediction_hours=config_eos.prediction.hours, + ) + inverter = Inverter( + InverterParameters( + device_id="inverter1", + max_power_wh=500.0, + battery_id=battery.parameters.device_id, + ), + battery=battery, + ) + + simulation = GeneticSimulation() + simulation.prepare( + GeneticEnergyManagementParameters( + pv_prognose_wh=[0.0, 0.0], + strompreis_euro_pro_wh=[0.0, 0.0], + einspeiseverguetung_euro_pro_wh=[0.0002, 0.0002], + preis_euro_pro_wh_akku=0.0, + gesamtlast=[0.0, 0.0], + ), + optimization_hours=config_eos.optimization.horizon_hours, + prediction_hours=config_eos.prediction.hours, + inverter=inverter, + direct_marketing_enabled=True, + ) + return simulation + + +def test_direct_marketing_discharge_allowed_does_not_export_battery(config_eos): + simulation = _direct_marketing_battery_export_simulation(config_eos) + assert simulation.bat_discharge_hours is not None + simulation.bat_discharge_hours[0] = 1 + + result = simulation.simulate(start_hour=0) + + assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == 0.0 + assert simulation.battery is not None + assert simulation.battery.current_soc_percentage() == 100.0 + + +def test_direct_marketing_battery_grid_export_uses_separate_signal(config_eos): + simulation = _direct_marketing_battery_export_simulation(config_eos) + assert simulation.bat_grid_export_hours is not None + simulation.bat_grid_export_hours[0] = 1 + + result = simulation.simulate(start_hour=0) + + assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == pytest.approx(500.0) + assert result["Einnahmen_Euro_pro_Stunde"][0] == pytest.approx(0.1) + assert simulation.battery is not None + assert simulation.battery.current_soc_percentage() == 50.0 diff --git a/tests/test_geneticsolution.py b/tests/test_geneticsolution.py new file mode 100644 index 0000000..66e7405 --- /dev/null +++ b/tests/test_geneticsolution.py @@ -0,0 +1,56 @@ +# ruff: noqa: S101 + +import numpy as np + +from akkudoktoreos.devices.devicesabc import BatteryOperationMode +from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization +from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution + + +def test_battery_discharge_allowed_remains_local_load_mode(config_eos): + config_eos.merge_settings_from_dict( + {"feedintariff": {"direct_marketing_enabled": True}} + ) + solution = GeneticSolution.model_construct() + + operation_mode, operation_mode_factor = solution._battery_operation_from_solution( + ac_charge=0.0, + dc_charge=0.0, + discharge_allowed=True, + ) + + assert operation_mode == BatteryOperationMode.PEAK_SHAVING + assert operation_mode_factor == 1.0 + + +def test_battery_grid_export_signal_maps_to_grid_support_export(config_eos): + config_eos.merge_settings_from_dict( + {"feedintariff": {"direct_marketing_enabled": True}} + ) + solution = GeneticSolution.model_construct() + + operation_mode, operation_mode_factor = solution._battery_operation_from_solution( + ac_charge=0.0, + dc_charge=0.0, + discharge_allowed=False, + battery_grid_export_allowed=True, + ) + + assert operation_mode == BatteryOperationMode.GRID_SUPPORT_EXPORT + assert operation_mode_factor == 1.0 + + +def test_decode_charge_discharge_has_separate_battery_grid_export_state(): + optimization = GeneticOptimization() + optimization.bat_possible_charge_values = [1.0] + optimization.optimize_dc_charge = True + optimization.optimize_battery_grid_export = True + + ac_charge, dc_charge, discharge, battery_grid_export = ( + optimization.decode_charge_discharge(np.array([5])) + ) + + assert ac_charge.tolist() == [0.0] + assert dc_charge.tolist() == [0] + assert discharge.tolist() == [0] + assert battery_grid_export.tolist() == [1] diff --git a/tests/test_inverter.py b/tests/test_inverter.py index ff82d43..b5a9d79 100644 --- a/tests/test_inverter.py +++ b/tests/test_inverter.py @@ -1,4 +1,4 @@ -from unittest.mock import Mock, patch +from unittest.mock import Mock, call, patch import pytest @@ -123,6 +123,25 @@ def test_process_energy_battery_discharges(inverter, mock_battery): inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called() +def test_process_energy_allows_battery_grid_export(inverter, mock_battery): + mock_battery.max_charge_power_w = 300.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( + generation=0.0, + consumption=100.0, + hour=12, + allow_battery_grid_export=True, + ) + + assert grid_export == pytest.approx(200.0, rel=1e-2) + assert grid_import == 0.0 + assert losses == 0.0 + assert self_consumption == 100.0 + mock_battery.discharge_energy.assert_has_calls([call(100.0, 12), call(200.0, 12)]) + inverter.self_consumption_predictor.calculate_self_consumption.assert_not_called() + + def test_process_energy_battery_empty(inverter, mock_battery): # Battery is empty, so no energy can be discharged mock_battery.discharge_energy.return_value = (0.0, 0.0) diff --git a/tests/test_strompreis_endpoint.py b/tests/test_strompreis_endpoint.py new file mode 100644 index 0000000..69b48b5 --- /dev/null +++ b/tests/test_strompreis_endpoint.py @@ -0,0 +1,36 @@ +import pandas as pd +import pytest + +from akkudoktoreos.server import eos as eos_server + + +class _FakeEms: + async def run(self, **kwargs): + return None + + +class _FakePrediction: + def __init__(self): + self.key_to_series_kwargs = None + + def key_to_series(self, **kwargs): + self.key_to_series_kwargs = kwargs + start = pd.Timestamp(kwargs["start_datetime"].isoformat()) + index = pd.date_range(start=start, periods=8, freq="15min") + values = [1.0, 3.0, 5.0, 7.0, 10.0, 14.0, 18.0, 22.0] + return pd.Series(values, index=index, name=kwargs["key"]) + + +@pytest.mark.asyncio +async def test_strompreis_endpoint_averages_quarter_hour_prices(monkeypatch, config_eos): + """Deprecated /strompreis aggregates 15-minute spot prices to hourly means.""" + prediction = _FakePrediction() + + monkeypatch.setattr(eos_server, "get_ems", lambda: _FakeEms()) + monkeypatch.setattr(eos_server, "get_prediction", lambda: prediction) + + result = await eos_server.fastapi_strompreis() + + assert result[:3] == [4.0, 16.0, 16.0] + assert len(result) == 48 + assert prediction.key_to_series_kwargs["key"] == "elecprice_marketprice_wh"