"""Battery and electric vehicle device settings. Note: Used for the GENETIC and GENETIC0 algorithm. """ import re from typing import TYPE_CHECKING, Any, Optional import numpy as np from numpydantic import NDArray, Shape from pydantic import Field, computed_field, field_validator, model_validator from akkudoktoreos.config.configabc import ConfigScope from akkudoktoreos.devices.settings.devicebasesettings import DevicesBaseSettings from akkudoktoreos.measurement.batterycapacity import ( BatteryCapacityEstimate, BatteryCapacityEstimationSettings, ) from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime if TYPE_CHECKING: from akkudoktoreos.devices.genetic0.genetic0battery import ( Genetic0ElectricVehicleParameters, Genetic0SolarPanelBatteryParameters, ) from akkudoktoreos.devices.genetic.battery import ( ElectricVehicleParameters, SolarPanelBatteryParameters, ) # Default charge rates for battery BATTERY_DEFAULT_CHARGE_RATES: list[float] = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] BATTERY_DEFAULT_GRID_EXPORT_RATES: list[float] = [0.25, 0.5, 0.75, 1.0] class BatteriesCommonSettings(DevicesBaseSettings): """Battery and electric vehicle device settings. Used for both stationary batteries and EV battery packs. Note: Used for the GENETIC and GENETIC0 algorithm. """ capacity_estimation: Optional[BatteryCapacityEstimationSettings] = None capacity_estimate: Optional[BatteryCapacityEstimate] = Field( default=None, description="Last explicitly stored capacity estimate; does not override capacity_wh.", ) capacity_wh: int = Field( default=8000, gt=0, json_schema_extra={"description": "Capacity [Wh].", "examples": [8000]}, ) charging_efficiency: float = Field( default=0.88, gt=0, le=1, json_schema_extra={ "description": "Charging efficiency [0.01 ... 1.00].", "examples": [0.88], }, ) discharging_efficiency: float = Field( default=0.88, gt=0, le=1, json_schema_extra={ "description": "Discharge efficiency [0.01 ... 1.00].", "examples": [0.88], }, ) levelized_cost_of_storage_amt_kwh: float = Field( default=0.0, json_schema_extra={ "description": ( "Levelized cost of storage (LCOS), the average lifetime cost " "of delivering one kWh [€/kWh]." ), "examples": [0.12], }, ) max_charge_power_w: Optional[float] = Field( default=5000, gt=0, json_schema_extra={ "description": "Maximum charging power [W].", "examples": [5000], }, ) min_charge_power_w: Optional[float] = Field( default=50, gt=0, json_schema_extra={ "description": "Minimum charging power [W].", "examples": [50], }, ) charge_rates: Optional[list[float]] = Field( default=BATTERY_DEFAULT_CHARGE_RATES, json_schema_extra={ "description": ( "Charge rates as factor of maximum charging power [0.00 ... 1.00]. " "None triggers fallback to default charge-rates." ), "examples": [[0.0, 0.25, 0.5, 0.75, 1.0], None], }, ) min_soc_percentage: int = Field( default=0, ge=0, le=100, json_schema_extra={ "description": ( "Minimum state of charge (SOC) as percentage of capacity [%]. " "This is the target SoC for charging." ), "examples": [10], }, ) max_soc_percentage: int = Field( default=100, ge=0, le=100, json_schema_extra={ "description": "Maximum state of charge (SOC) as percentage of capacity [%].", "examples": [100], }, ) # ------------------------------------------------------------------ # GENETIC domain conversion # ------------------------------------------------------------------ grid_export_rates: Optional[list[float]] = Field( default=BATTERY_DEFAULT_GRID_EXPORT_RATES, json_schema_extra={ "description": ( "Battery-to-grid export rates as factor of maximum discharge " "power ]0.00 ... 1.00]. Available to algorithms that explicitly " "enable battery-to-grid export; configuring rates alone does not " "enable export. [1.0] selects full-power export. None uses the " "default export rates." ), "examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None], }, ) @field_validator("grid_export_rates", mode="before") def validate_and_sort_grid_export_rates(cls, v: Any) -> list[float]: """Normalize export rates to a finite, sorted, duplicate-free list in ]0, 1].""" # None means fallback to default values if v is None: return BATTERY_DEFAULT_GRID_EXPORT_RATES.copy() if isinstance(v, str): numbers = re.split(r"[,\s]+", v.strip("[]")) arr = np.array([float(x) for x in numbers if x]) else: arr = np.array(v, dtype=float) if arr.ndim != 1 or arr.size == 0: raise ValueError("grid_export_rates must be a nonempty one-dimensional list.") if not np.isfinite(arr).all(): raise ValueError("grid_export_rates must contain finite values.") # A rate of 0.0 is not an export level - "no export" is expressed by the # other battery states - so the lower bound is exclusive. if (arr <= 0.0).any() or (arr > 1.0).any(): raise ValueError("grid_export_rates must be within ]0.0, 1.0].") arr = np.unique(arr) arr.sort() return arr.tolist() min_soc_deadline_datetime: Optional[DateTime] = Field( default=None, json_schema_extra={ "x-scope": [str(ConfigScope.GENETIC)], "description": ( "Absolute moment by which 'min_soc_percentage' has to be " "reached (departure time). A date time without timezone is read " "as local time. None means end of the optimization horizon." ), "examples": [None, "2026-07-16T07:00:00+02:00"], }, ) min_soc_max_duration_h: Optional[float] = Field( default=None, gt=0, allow_inf_nan=False, json_schema_extra={ "x-scope": [str(ConfigScope.GENETIC)], "description": ( "Maximum time from the start of the optimization until " "'min_soc_percentage' has to be reached [h]. Combined with " "'min_soc_deadline_datetime' the earlier of the two applies." ), "examples": [None, 6.0], }, ) @field_validator("min_soc_deadline_datetime", mode="before") @classmethod def transform_deadline_to_datetime(cls, value: Any) -> Optional[DateTime]: """Accept the usual date time representations, naive input is local time.""" if value is None: return None return to_datetime(value) def to_genetic_pv_bat_param(self) -> "SolarPanelBatteryParameters": """Return SolarPanelBatteryParameters for the GENETIC optimizer.""" from akkudoktoreos.devices.genetic.battery import SolarPanelBatteryParameters return SolarPanelBatteryParameters( device_id=self.device_id, capacity_wh=self.capacity_wh, charging_efficiency=self.charging_efficiency, discharging_efficiency=self.discharging_efficiency, max_charge_power_w=self.max_charge_power_w, min_soc_percentage=self.min_soc_percentage, max_soc_percentage=self.max_soc_percentage, charge_rates=self.charge_rates, grid_export_rates=self.grid_export_rates, levelized_cost_of_storage_kwh=self.levelized_cost_of_storage_amt_kwh, ) def to_genetic_ev_bat_param(self) -> "ElectricVehicleParameters": """Return ElectricVehicleParameters for the GENETIC optimizer.""" from akkudoktoreos.devices.genetic.battery import ElectricVehicleParameters return ElectricVehicleParameters( device_id=self.device_id, capacity_wh=self.capacity_wh, charging_efficiency=self.charging_efficiency, discharging_efficiency=self.discharging_efficiency, charge_rates=self.charge_rates, max_charge_power_w=self.max_charge_power_w, min_soc_percentage=self.min_soc_percentage, max_soc_percentage=self.max_soc_percentage, min_soc_deadline_datetime=self.min_soc_deadline_datetime, min_soc_max_duration_h=self.min_soc_max_duration_h, ) # ------------------------------------------------------------------ # GENETIC0 domain conversion # ------------------------------------------------------------------ def to_genetic0_pv_bat_param(self) -> "Genetic0SolarPanelBatteryParameters": """Return Genetic0SolarPanelBatteryParameters for the GENETIC0 optimizer.""" from akkudoktoreos.devices.genetic0.genetic0battery import ( Genetic0SolarPanelBatteryParameters, ) return Genetic0SolarPanelBatteryParameters( device_id=self.device_id, capacity_wh=self.capacity_wh, charging_efficiency=self.charging_efficiency, discharging_efficiency=self.discharging_efficiency, max_charge_power_w=self.max_charge_power_w, min_soc_percentage=self.min_soc_percentage, max_soc_percentage=self.max_soc_percentage, ) def to_genetic0_ev_bat_param(self) -> "Genetic0ElectricVehicleParameters": """Return Genetic0ElectricVehicleParameters for the GENETI0C optimizer.""" from akkudoktoreos.devices.genetic0.genetic0battery import ( Genetic0ElectricVehicleParameters, ) return Genetic0ElectricVehicleParameters( device_id=self.device_id, capacity_wh=self.capacity_wh, charging_efficiency=self.charging_efficiency, discharging_efficiency=self.discharging_efficiency, charge_rates=self.charge_rates, max_charge_power_w=self.max_charge_power_w, min_soc_percentage=self.min_soc_percentage, max_soc_percentage=self.max_soc_percentage, ) @field_validator("charge_rates", mode="before") def validate_and_sort_charge_rates(cls, v: Any) -> NDArray[Shape["*"], float]: # None means fallback to default values if v is None: return np.asarray(BATTERY_DEFAULT_CHARGE_RATES, dtype=float).copy() # Convert to numpy array if isinstance(v, str): # Remove brackets and split by comma or whitespace numbers = re.split(r"[,\s]+", v.strip("[]")) # Filter out any empty strings and convert to floats arr = np.array([float(x) for x in numbers if x]) else: arr = np.array(v, dtype=float) # Must not be empty if arr.size == 0: raise ValueError("charge_rates must contain at least one value.") # Enforce bounds: 0.0 ≤ x ≤ 1.0 if (arr < 0.0).any() or (arr > 1.0).any(): raise ValueError("charge_rates must be within [0.0, 1.0].") # Remove duplicates + sort arr = np.unique(arr) arr.sort() return arr @model_validator(mode="after") def _validate_soc_range(self) -> "BatteriesCommonSettings": if self.min_soc_percentage >= self.max_soc_percentage: raise ValueError("min_soc_percentage must be < max_soc_percentage") if ( self.min_charge_power_w is not None and self.max_charge_power_w is not None and self.min_charge_power_w > self.max_charge_power_w ): raise ValueError("min_charge_power_w must be <= max_charge_power_w") return self @computed_field # type: ignore[prop-decorator] @property def measurement_key_soc_factor(self) -> str: """Measurement key for SoC as factor of total capacity [0.0 ... 1.0].""" return f"{self.device_id}-soc-factor" @computed_field # type: ignore[prop-decorator] @property def measurement_key_power_l1_w(self) -> str: """Measurement key for L1 power [W].""" return f"{self.device_id}-power-l1-w" @computed_field # type: ignore[prop-decorator] @property def measurement_key_power_l2_w(self) -> str: """Measurement key for L2 power [W].""" return f"{self.device_id}-power-l2-w" @computed_field # type: ignore[prop-decorator] @property def measurement_key_power_l3_w(self) -> str: """Measurement key for L3 power [W].""" return f"{self.device_id}-power-l3-w" @computed_field # type: ignore[prop-decorator] @property def measurement_key_power_3_phase_sym_w(self) -> str: """Measurement key for symmetric 3-phase power [W].""" return f"{self.device_id}-power-3-phase-sym-w" @computed_field # type: ignore[prop-decorator] @property def measurement_keys(self) -> list[str]: """All measurement keys for this battery.""" return [ self.measurement_key_soc_factor, self.measurement_key_power_l1_w, self.measurement_key_power_l2_w, self.measurement_key_power_l3_w, self.measurement_key_power_3_phase_sym_w, ]