From 553742ee95b9624ee5eed74287f92af436144cbf Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Thu, 28 May 2026 12:55:19 -0600 Subject: [PATCH 01/11] create battery model files for including degradation --- h2integrate/core/supported_models.py | 1 + h2integrate/storage/battery/__init__.py | 1 + .../storage/battery/battery_performance.py | 164 ++++++++++++++++++ .../battery/test/test_battery_performance.py | 1 + 4 files changed, 167 insertions(+) create mode 100644 h2integrate/storage/battery/battery_performance.py create mode 100644 h2integrate/storage/battery/test/test_battery_performance.py diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index a22ae427e..429d60bae 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -141,6 +141,7 @@ def copy(self): "GenericSummerPerformanceModel": "transporters:GenericSummerPerformanceModel", # Storage "PySAMBatteryPerformanceModel": "storage.battery:PySAMBatteryPerformanceModel", + "BatteryPerformanceModel": "storage.battery:BatteryPerformanceModel", "StoragePerformanceModel": "storage:StoragePerformanceModel", "StorageAutoSizingModel": "storage:StorageAutoSizingModel", "LinedRockCavernStorageCostModel": "storage.hydrogen:LinedRockCavernStorageCostModel", diff --git a/h2integrate/storage/battery/__init__.py b/h2integrate/storage/battery/__init__.py index 1a32933c3..432e095a4 100644 --- a/h2integrate/storage/battery/__init__.py +++ b/h2integrate/storage/battery/__init__.py @@ -1,2 +1,3 @@ from h2integrate.storage.battery.pysam_battery import PySAMBatteryPerformanceModel from h2integrate.storage.battery.atb_battery_cost import ATBBatteryCostModel +from h2integrate.storage.battery.battery_performance import BatteryPerformanceModel diff --git a/h2integrate/storage/battery/battery_performance.py b/h2integrate/storage/battery/battery_performance.py new file mode 100644 index 000000000..d2b676a59 --- /dev/null +++ b/h2integrate/storage/battery/battery_performance.py @@ -0,0 +1,164 @@ +import numpy as np +from attrs import field, define + +from h2integrate.core.utilities import merge_shared_inputs +from h2integrate.core.validators import gt_zero, range_val, range_val_or_none +from h2integrate.storage.storage_baseclass import ( + StoragePerformanceBase, + StoragePerformanceBaseConfig, +) + + +@define(kw_only=True) +class BatteryPerformanceModelConfig(StoragePerformanceBaseConfig): + """Configuration class for storage performance models. + + This class defines configuration parameters for simulating storage + performance with the Pyomo controllers. It includes + specifications such as capacity, charge rate, state-of-charge limits, + and charge/discharge efficiencies. + + Attributes: + commodity (str): name of commodity + commodity_rate_units (str): Units of the commodity (e.g., "kg/h"). + demand_profile (int | float | list): Demand values for each timestep, in + the same units as `commodity_rate_units`. May be a scalar for constant + demand or a list/array for time-varying demand. + max_capacity (float): Maximum storage energy capacity in commodity_amount_units. + Must be greater than zero. + max_charge_rate (float): Rated commodity capacity of the storage in commodity_rate_units. + Must be greater than zero. + min_soc_fraction (float): Minimum allowable state of charge as a fraction (0 to 1). + max_soc_fraction (float): Maximum allowable state of charge as a fraction (0 to 1). + init_soc_fraction (float): Initial state of charge as a fraction (0 to 1). + commodity_amount_units (str | None, optional): Units of the commodity as an amount + (i.e., kW*h or kg). If not provided, defaults to commodity_rate_units*h. + max_discharge_rate (float | None, optional): Maximum rate at which the commodity can be + discharged (in units per time step, e.g., "kg/time step"). This rate does not include + the discharge_efficiency. Only required if `charge_equals_discharge` is False. + charge_equals_discharge (bool, optional): If True, set the max_discharge_rate equal to the + max_charge_rate. If False, specify the max_discharge_rate as a value different than + the max_charge_rate. Defaults to True. + charge_efficiency (float | None, optional): Efficiency of charging the storage, represented + as a decimal between 0 and 1 (e.g., 0.9 for 90% efficiency). Optional if + `round_trip_efficiency` is provided. + discharge_efficiency (float | None, optional): Efficiency of discharging the storage, + represented as a decimal between 0 and 1 (e.g., 0.9 for 90% efficiency). Optional if + `round_trip_efficiency` is provided. + round_trip_efficiency (float | None, optional): Combined efficiency of charging and + discharging the storage, represented as a decimal between 0 and 1 (e.g., 0.81 for + 81% efficiency). Optional if `charge_efficiency` and `discharge_efficiency` are + provided. + + """ + + commodity: str = field() + commodity_rate_units: str = field() + + max_capacity: float = field(validator=gt_zero) + max_charge_rate: float = field(validator=gt_zero) + + init_soc_fraction: float = field(validator=range_val(0, 1)) + + commodity_amount_units: str = field(default=None) + max_discharge_rate: float | None = field(default=None) + charge_equals_discharge: bool = field(default=True) + + charge_efficiency: float | None = field(default=None, validator=range_val_or_none(0, 1)) + discharge_efficiency: float | None = field(default=None, validator=range_val_or_none(0, 1)) + round_trip_efficiency: float | None = field(default=None, validator=range_val_or_none(0, 1)) + + # TODO degredation: add additional parameters for degradation here + + def __attrs_post_init__(self): + """ + Post-initialization logic to validate and calculate efficiencies. + + Ensures that either `charge_efficiency` and `discharge_efficiency` are provided, + or `round_trip_efficiency` is provided. If `round_trip_efficiency` is provided, + it calculates `charge_efficiency` and `discharge_efficiency` as the square root + of `round_trip_efficiency`. + """ + if (self.round_trip_efficiency is not None) and ( + self.charge_efficiency is None and self.discharge_efficiency is None + ): + # Calculate charge and discharge efficiencies from round-trip efficiency + self.charge_efficiency = np.sqrt(self.round_trip_efficiency) + self.discharge_efficiency = np.sqrt(self.round_trip_efficiency) + + if self.charge_efficiency is None or self.discharge_efficiency is None: + raise ValueError( + "Exactly one of the following sets of parameters must be set: (a) " + "`round_trip_efficiency`, or (b) both `charge_efficiency` " + "and `discharge_efficiency`." + ) + + if self.charge_equals_discharge: + if ( + self.max_discharge_rate is not None + and self.max_discharge_rate != self.max_charge_rate + ): + msg = ( + "Max discharge rate does not equal max charge rate but charge_equals_discharge " + f"is True. Discharge rate is {self.max_discharge_rate} and charge rate " + f"is {self.max_charge_rate}." + ) + raise ValueError(msg) + + self.max_discharge_rate = self.max_charge_rate + + if not self.charge_equals_discharge and self.max_discharge_rate is None: + msg = ( + "max_discharge_rate is required when charge_equals_discharge is False. " + "Please input the discharge rate using the key `max_discharge_rate`." + ) + raise ValueError(msg) + + if self.commodity_amount_units is None: + self.commodity_amount_units = f"({self.commodity_rate_units})*h" + + +class BatteryPerformanceModel(StoragePerformanceBase): + """OpenMDAO component for a storage component.""" + + _time_step_bounds = ( + 1, + 3600, + ) # (min, max) time step lengths (in seconds) compatible with this model + + def setup(self): + self.config = BatteryPerformanceModelConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "performance"), + strict=False, + additional_cls_name=self.__class__.__name__, + ) + + self.commodity = self.config.commodity + self.commodity_rate_units = self.config.commodity_rate_units + self.commodity_amount_units = self.config.commodity_amount_units + + # TODO degredation: adjustments for degradation + + super().setup() + + def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): + """Run the storage performance model.""" + self.current_soc = self.config.init_soc_fraction + + charge_rate = inputs["max_charge_rate"][0] + if "max_discharge_rate" in inputs: + discharge_rate = inputs["max_discharge_rate"][0] + else: + discharge_rate = inputs["max_charge_rate"][0] + storage_capacity = inputs["storage_capacity"][0] + + # TODO degredation: adjust compute method for degradation as needed + outputs = self.run_storage( + charge_rate, discharge_rate, storage_capacity, inputs, outputs, discrete_inputs + ) + + # TODO degredation: add degradation method here + def degradation( + self, + ): + return diff --git a/h2integrate/storage/battery/test/test_battery_performance.py b/h2integrate/storage/battery/test/test_battery_performance.py new file mode 100644 index 000000000..83808572c --- /dev/null +++ b/h2integrate/storage/battery/test/test_battery_performance.py @@ -0,0 +1 @@ +# TODO degredation: tests for battery performance From bdcfcc29889c04e0ef6b3a03f53b8243953369c0 Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Thu, 28 May 2026 14:52:30 -0600 Subject: [PATCH 02/11] spelling correction --- h2integrate/storage/battery/battery_performance.py | 8 ++++---- .../storage/battery/test/test_battery_performance.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/h2integrate/storage/battery/battery_performance.py b/h2integrate/storage/battery/battery_performance.py index d2b676a59..40cd5a353 100644 --- a/h2integrate/storage/battery/battery_performance.py +++ b/h2integrate/storage/battery/battery_performance.py @@ -68,7 +68,7 @@ class BatteryPerformanceModelConfig(StoragePerformanceBaseConfig): discharge_efficiency: float | None = field(default=None, validator=range_val_or_none(0, 1)) round_trip_efficiency: float | None = field(default=None, validator=range_val_or_none(0, 1)) - # TODO degredation: add additional parameters for degradation here + # TODO degradation: add additional parameters for degradation here def __attrs_post_init__(self): """ @@ -137,7 +137,7 @@ def setup(self): self.commodity_rate_units = self.config.commodity_rate_units self.commodity_amount_units = self.config.commodity_amount_units - # TODO degredation: adjustments for degradation + # TODO degradation: adjustments for degradation super().setup() @@ -152,12 +152,12 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): discharge_rate = inputs["max_charge_rate"][0] storage_capacity = inputs["storage_capacity"][0] - # TODO degredation: adjust compute method for degradation as needed + # TODO degradation: adjust compute method for degradation as needed outputs = self.run_storage( charge_rate, discharge_rate, storage_capacity, inputs, outputs, discrete_inputs ) - # TODO degredation: add degradation method here + # TODO degradation: add degradation method here def degradation( self, ): diff --git a/h2integrate/storage/battery/test/test_battery_performance.py b/h2integrate/storage/battery/test/test_battery_performance.py index 83808572c..7f0641c82 100644 --- a/h2integrate/storage/battery/test/test_battery_performance.py +++ b/h2integrate/storage/battery/test/test_battery_performance.py @@ -1 +1 @@ -# TODO degredation: tests for battery performance +# TODO degradation: tests for battery performance From 3c48f03d50d1fb7739a4933189a28c94e0876a73 Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Thu, 28 May 2026 17:38:45 -0600 Subject: [PATCH 03/11] make solar resource data available to the battery performance model --- examples/24_solar_battery_grid/plant_config.yaml | 4 +++- examples/24_solar_battery_grid/tech_config.yaml | 2 +- h2integrate/storage/battery/battery_performance.py | 8 ++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/examples/24_solar_battery_grid/plant_config.yaml b/examples/24_solar_battery_grid/plant_config.yaml index 3fed5d46d..114bccce4 100644 --- a/examples/24_solar_battery_grid/plant_config.yaml +++ b/examples/24_solar_battery_grid/plant_config.yaml @@ -34,8 +34,10 @@ technology_interconnections: - [solar, combiner, electricity, cable] - [grid_buy, combiner, electricity, cable] resource_to_tech_connections: - # connect the wind resource to the wind technology + # connect the solar resource to the pv solar technology - [site.solar_resource, solar, solar_resource_data] + # connect the solar resource to the battery technology + - [site.solar_resource, battery, solar_resource_data] plant: plant_life: 30 simulation: diff --git a/examples/24_solar_battery_grid/tech_config.yaml b/examples/24_solar_battery_grid/tech_config.yaml index 969cfbf63..d16665938 100644 --- a/examples/24_solar_battery_grid/tech_config.yaml +++ b/examples/24_solar_battery_grid/tech_config.yaml @@ -31,7 +31,7 @@ technologies: cost_year: 2024 battery: performance_model: - model: StoragePerformanceModel + model: BatteryPerformanceModel cost_model: model: ATBBatteryCostModel control_strategy: diff --git a/h2integrate/storage/battery/battery_performance.py b/h2integrate/storage/battery/battery_performance.py index 40cd5a353..8344241b5 100644 --- a/h2integrate/storage/battery/battery_performance.py +++ b/h2integrate/storage/battery/battery_performance.py @@ -137,6 +137,12 @@ def setup(self): self.commodity_rate_units = self.config.commodity_rate_units self.commodity_amount_units = self.config.commodity_amount_units + self.add_discrete_input( + "solar_resource_data", + val={}, + desc="Solar resource data dictionary", + ) + # TODO degradation: adjustments for degradation super().setup() @@ -153,6 +159,7 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): storage_capacity = inputs["storage_capacity"][0] # TODO degradation: adjust compute method for degradation as needed + self.degradation(discrete_inputs["solar_resource_data"]) outputs = self.run_storage( charge_rate, discharge_rate, storage_capacity, inputs, outputs, discrete_inputs ) @@ -160,5 +167,6 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): # TODO degradation: add degradation method here def degradation( self, + solar_resource_data, ): return From f62b25f6df635c954b0aec52687190ea73fadece Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Thu, 28 May 2026 17:46:08 -0600 Subject: [PATCH 04/11] add doc string comment --- h2integrate/storage/battery/battery_performance.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/h2integrate/storage/battery/battery_performance.py b/h2integrate/storage/battery/battery_performance.py index 8344241b5..d5ffd168d 100644 --- a/h2integrate/storage/battery/battery_performance.py +++ b/h2integrate/storage/battery/battery_performance.py @@ -169,4 +169,10 @@ def degradation( self, solar_resource_data, ): + """_summary_ + + Args: + solar_resource_data (_type_): a dictionary of hourly (or by timestep) solar resource + information including irradiance and temperature + """ return From 37b813a44b326b0066826141ca2773e156a8efe7 Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Fri, 29 May 2026 09:15:38 -0600 Subject: [PATCH 05/11] use plm example 33 for degradation instead of example 24 --- examples/24_solar_battery_grid/plant_config.yaml | 4 +--- examples/24_solar_battery_grid/tech_config.yaml | 2 +- examples/33_peak_load_management/plant_config.yaml | 10 ++++++++++ examples/33_peak_load_management/tech_config.yaml | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/examples/24_solar_battery_grid/plant_config.yaml b/examples/24_solar_battery_grid/plant_config.yaml index 114bccce4..3fed5d46d 100644 --- a/examples/24_solar_battery_grid/plant_config.yaml +++ b/examples/24_solar_battery_grid/plant_config.yaml @@ -34,10 +34,8 @@ technology_interconnections: - [solar, combiner, electricity, cable] - [grid_buy, combiner, electricity, cable] resource_to_tech_connections: - # connect the solar resource to the pv solar technology + # connect the wind resource to the wind technology - [site.solar_resource, solar, solar_resource_data] - # connect the solar resource to the battery technology - - [site.solar_resource, battery, solar_resource_data] plant: plant_life: 30 simulation: diff --git a/examples/24_solar_battery_grid/tech_config.yaml b/examples/24_solar_battery_grid/tech_config.yaml index d16665938..969cfbf63 100644 --- a/examples/24_solar_battery_grid/tech_config.yaml +++ b/examples/24_solar_battery_grid/tech_config.yaml @@ -31,7 +31,7 @@ technologies: cost_year: 2024 battery: performance_model: - model: BatteryPerformanceModel + model: StoragePerformanceModel cost_model: model: ATBBatteryCostModel control_strategy: diff --git a/examples/33_peak_load_management/plant_config.yaml b/examples/33_peak_load_management/plant_config.yaml index 93be1cecf..26ef6faad 100644 --- a/examples/33_peak_load_management/plant_config.yaml +++ b/examples/33_peak_load_management/plant_config.yaml @@ -2,14 +2,24 @@ name: plant_config description: Demonstrates multivariable streams with a gas combiner plant: plant_life: 30 + latitude: 30.6617 + longitude: -101.7096 simulation: n_timesteps: 8760 dt: 3600 timezone: -6 start_time: 2025/07/01 00:00:00 + resources: + solar_resource: + resource_model: GOESAggregatedSolarAPI + resource_parameters: + resource_year: 2025 technology_interconnections: - [grid_buy, battery, electricity, cable] # include battery charge/discharge in the load - [battery, electrical_load_demand, [electricity_out, electricity_in]] # buy power from the grid to fulfill demand including to accommodate battery operation - [electrical_load_demand, grid_buy, [unmet_electricity_demand_out, electricity_set_point]] +resource_to_tech_connections: + # connect the solar resource to the battery technology + - [site.solar_resource, battery, solar_resource_data] diff --git a/examples/33_peak_load_management/tech_config.yaml b/examples/33_peak_load_management/tech_config.yaml index e1a9ff88f..9f247d33b 100644 --- a/examples/33_peak_load_management/tech_config.yaml +++ b/examples/33_peak_load_management/tech_config.yaml @@ -3,7 +3,7 @@ description: This plant charges a battery from the grid to reduce peak demand technologies: battery: performance_model: - model: StoragePerformanceModel + model: BatteryPerformanceModel cost_model: model: ATBBatteryCostModel control_strategy: From 185c6d67dfb061bd0d03a43d78a089d6d72852c7 Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Thu, 4 Jun 2026 10:56:00 -0600 Subject: [PATCH 06/11] add performance param example --- examples/33_peak_load_management/tech_config.yaml | 3 +++ h2integrate/storage/battery/battery_performance.py | 1 + 2 files changed, 4 insertions(+) diff --git a/examples/33_peak_load_management/tech_config.yaml b/examples/33_peak_load_management/tech_config.yaml index 9f247d33b..75c0d6d04 100644 --- a/examples/33_peak_load_management/tech_config.yaml +++ b/examples/33_peak_load_management/tech_config.yaml @@ -22,6 +22,9 @@ technologies: charge_efficiency: 1.0 # percent as decimal discharge_efficiency: 1.0 # percent as decimal demand_profile: !include demand_profiles/demand_profile.yaml + performance_parameters: + cop: 0.3 + # TODO: add remaining performance parameters here control_parameters: demand_profile_upstream: !include demand_profiles/demand_profile_upstream.yaml # demand used to define when the supervisor commands battery dispatch. This may represent an upstream load. dispatch_priority_demand_profile: demand_profile_upstream # demand profile the controller prioritizes when deciding dispatch diff --git a/h2integrate/storage/battery/battery_performance.py b/h2integrate/storage/battery/battery_performance.py index d5ffd168d..971fb380e 100644 --- a/h2integrate/storage/battery/battery_performance.py +++ b/h2integrate/storage/battery/battery_performance.py @@ -69,6 +69,7 @@ class BatteryPerformanceModelConfig(StoragePerformanceBaseConfig): round_trip_efficiency: float | None = field(default=None, validator=range_val_or_none(0, 1)) # TODO degradation: add additional parameters for degradation here + cop: float = field(validator=gt_zero) def __attrs_post_init__(self): """ From fd86452cd4a018684502f5836a48e19fd0957bfd Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Fri, 12 Jun 2026 11:02:56 -0600 Subject: [PATCH 07/11] add battery auxiliary power output --- h2integrate/storage/battery/battery_performance.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/h2integrate/storage/battery/battery_performance.py b/h2integrate/storage/battery/battery_performance.py index 971fb380e..ed526b134 100644 --- a/h2integrate/storage/battery/battery_performance.py +++ b/h2integrate/storage/battery/battery_performance.py @@ -144,6 +144,12 @@ def setup(self): desc="Solar resource data dictionary", ) + self.add_output( + f"{self.commodity}_auxiliary_demand", + shape=self.n_timesteps, + desc="Electricity demand for running battery auxiliary systems", + ) + # TODO degradation: adjustments for degradation super().setup() From 33ddd07e1e371d37adf1ed926bec08ec9f1b4c5a Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Fri, 12 Jun 2026 11:38:47 -0600 Subject: [PATCH 08/11] add commented code for assigning auxiliary power --- h2integrate/storage/battery/battery_performance.py | 1 + 1 file changed, 1 insertion(+) diff --git a/h2integrate/storage/battery/battery_performance.py b/h2integrate/storage/battery/battery_performance.py index ed526b134..04db3e9f3 100644 --- a/h2integrate/storage/battery/battery_performance.py +++ b/h2integrate/storage/battery/battery_performance.py @@ -167,6 +167,7 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): # TODO degradation: adjust compute method for degradation as needed self.degradation(discrete_inputs["solar_resource_data"]) + # outputs[f"{self.commodity}_auxiliary_demand"] = outputs = self.run_storage( charge_rate, discharge_rate, storage_capacity, inputs, outputs, discrete_inputs ) From 879221f446a541b433c6dd4124121dac47a0f79b Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Wed, 15 Jul 2026 08:18:49 -0600 Subject: [PATCH 09/11] wip: integrate simses --- .../storage/battery/battery_performance.py | 125 +++++++++++++++--- 1 file changed, 108 insertions(+), 17 deletions(-) diff --git a/h2integrate/storage/battery/battery_performance.py b/h2integrate/storage/battery/battery_performance.py index 04db3e9f3..ca1ffff28 100644 --- a/h2integrate/storage/battery/battery_performance.py +++ b/h2integrate/storage/battery/battery_performance.py @@ -1,5 +1,10 @@ import numpy as np +import pandas as pd from attrs import field, define +from openmdao.utils import units as om_units +from simses.battery.battery import Battery +from simses.degradation.state import DegradationState +from simses.model.cell.sony_lfp import SonyLFP from h2integrate.core.utilities import merge_shared_inputs from h2integrate.core.validators import gt_zero, range_val, range_val_or_none @@ -9,6 +14,19 @@ ) +class LFP280Ah(SonyLFP): + """280 Ah / 3.2 V prismatic LFP cell scaled from the SonyLFP OCV/resistance curves.""" + + _SCALE = 3.0 / 280.0 # resistance scales inversely with capacity + + def __init__(self): + super().__init__() + self.electrical.nominal_capacity = 280.0 # Ah + + def internal_resistance(self, state): + return super().internal_resistance(state) * self._SCALE + + @define(kw_only=True) class BatteryPerformanceModelConfig(StoragePerformanceBaseConfig): """Configuration class for storage performance models. @@ -158,29 +176,102 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): """Run the storage performance model.""" self.current_soc = self.config.init_soc_fraction - charge_rate = inputs["max_charge_rate"][0] + inputs["max_charge_rate"][0] if "max_discharge_rate" in inputs: discharge_rate = inputs["max_discharge_rate"][0] else: discharge_rate = inputs["max_charge_rate"][0] storage_capacity = inputs["storage_capacity"][0] - # TODO degradation: adjust compute method for degradation as needed - self.degradation(discrete_inputs["solar_resource_data"]) - # outputs[f"{self.commodity}_auxiliary_demand"] = - outputs = self.run_storage( - charge_rate, discharge_rate, storage_capacity, inputs, outputs, discrete_inputs + ### from Ankit + cell = LFP280Ah() + + battery = Battery( + cell=cell, + circuit=(239, 18), + initial_states={"start_soc": 0.5, "start_T": 25.0}, + degradation=LFP280Ah.default_degradation_model( + initial_soc=0.5, + initial_state=DegradationState(qloss_cal=1e-4), + ), + ) + + summary = pd.Series( + { + "nominal_capacity [Ah]": battery.nominal_capacity, + "nominal_voltage [V]": battery.nominal_voltage, + "nominal_energy [kWh]": battery.nominal_energy_capacity / 1e3, + "max_charge_current [A]": battery.max_charge_current, + "max_discharge_current [A]": battery.max_discharge_current, + "thermal_capacity [kJ/K]": battery.thermal_capacity / 1e3, + } + ) + print("Battery summary:") + print(summary.to_string()) + print() + + dt = self.dt_hr * 60.0 + + # power_profile = inputs[f"{self.commodity}_in"] + power_profile = inputs[f"{self.commodity}_set_point"] + + log = { + k: np.empty(self.n_timesteps) + for k in ["soc", "v", "i", "T", "loss", "heat", "soh_Q", "soh_R", "power"] + } + for i, p in enumerate(power_profile): + battery.step(float(p), dt) + for k in log: + log[k][i] = getattr(battery.state, k) + + index = pd.date_range("2025-01-01", periods=self.n_timesteps, freq=f"{int(dt)}s") + df_bat = pd.DataFrame(log, index=index) + print("\nFirst rows:") + print(df_bat.head().to_string()) + + ############# + + # Populate all OpenMDAO outputs defined in this class and its parent classes, + # pulling the time-series results from the simses battery run (``df_bat``). + + soc_ts = df_bat["soc"].to_numpy() + # Convert battery power (W) into the desired commodity rate units. + # Sign convention: positive = discharge (out of storage), negative = charge. + power_ts = om_units.convert_units( + df_bat["power"].to_numpy(), "W", self.commodity_rate_units ) - # TODO degradation: add degradation method here - def degradation( - self, - solar_resource_data, - ): - """_summary_ + # --- BatteryPerformanceModel outputs --- + outputs[f"{self.commodity}_auxiliary_demand"] = np.zeros(self.n_timesteps) - Args: - solar_resource_data (_type_): a dictionary of hourly (or by timestep) solar resource - information including irradiance and temperature - """ - return + # --- StoragePerformanceBase outputs --- + outputs["storage_duration"] = ( + storage_capacity / discharge_rate if discharge_rate > 0 else 0.0 + ) + outputs["SOC"] = soc_ts * 100.0 # fraction -> percent + outputs[f"storage_{self.commodity}_charge"] = np.where(power_ts < 0, power_ts, 0.0) + outputs[f"storage_{self.commodity}_discharge"] = np.where(power_ts > 0, power_ts, 0.0) + + # --- PerformanceModelBaseClass outputs --- + outputs[f"{self.commodity}_out"] = power_ts + outputs[f"rated_{self.commodity}_production"] = discharge_rate + outputs[f"total_{self.commodity}_produced"] = np.sum(power_ts) * self.dt_amount + outputs[f"annual_{self.commodity}_produced"] = outputs[ + f"total_{self.commodity}_produced" + ] * (1 / self.fraction_of_year_simulated) + outputs["replacement_schedule"] = np.zeros(self.plant_life) + outputs["operational_life"] = self.plant_life + + if discharge_rate <= 0: + outputs["capacity_factor"] = 0.0 + outputs["standard_capacity_factor"] = 0.0 + else: + outputs["capacity_factor"] = outputs[f"total_{self.commodity}_produced"] / ( + discharge_rate * self.n_timesteps * self.dt_amount + ) + total_commodity_discharged = ( + outputs[f"storage_{self.commodity}_discharge"].sum() * self.dt_amount + ) + outputs["standard_capacity_factor"] = total_commodity_discharged / ( + discharge_rate * self.n_timesteps * self.dt_amount + ) From f4a23b9ff1460b883c0021b1d5edaae2df9164e6 Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Fri, 17 Jul 2026 11:19:45 -0600 Subject: [PATCH 10/11] progress towards revised approach --- .../storage/battery/battery_performance.py | 155 +++++++++++++++--- 1 file changed, 134 insertions(+), 21 deletions(-) diff --git a/h2integrate/storage/battery/battery_performance.py b/h2integrate/storage/battery/battery_performance.py index ca1ffff28..77515d594 100644 --- a/h2integrate/storage/battery/battery_performance.py +++ b/h2integrate/storage/battery/battery_performance.py @@ -1,10 +1,40 @@ +import math + import numpy as np import pandas as pd from attrs import field, define from openmdao.utils import units as om_units +from simses.degradation import DegradationModel +from simses.battery.state import BatteryState from simses.battery.battery import Battery +from simses.thermal.ambient import AmbientThermalModel from simses.degradation.state import DegradationState from simses.model.cell.sony_lfp import SonyLFP +from simses.degradation.cycle_detector import HalfCycle +from simses.model.degradation.sony_lfp_cyclic import ( + A_RINC, + B_RINC, + C_RINC as CYC_C_RINC, + D_RINC as CYC_D_RINC, + A_QLOSS, + B_QLOSS, + C_QLOSS as CYC_C_QLOSS, + D_QLOSS as CYC_D_QLOSS, + SonyLFPCyclicDegradation, +) +from simses.model.degradation.sony_lfp_calendar import ( + T_REF, + C_RINC as CAL_C_RINC, + D_RINC as CAL_D_RINC, + C_QLOSS as CAL_C_QLOSS, + D_QLOSS as CAL_D_QLOSS, + EA_RINC, + EA_QLOSS, + K_REF_RINC, + K_REF_QLOSS, + R, + SonyLFPCalendarDegradation, +) from h2integrate.core.utilities import merge_shared_inputs from h2integrate.core.validators import gt_zero, range_val, range_val_or_none @@ -22,11 +52,76 @@ class LFP280Ah(SonyLFP): def __init__(self): super().__init__() self.electrical.nominal_capacity = 280.0 # Ah + # Thermal properties for a large-format prismatic cell (vs. the 70 g 26650 reference). + # mass=1.5 kg, h=23 W/m²K gives C_th≈6.5 MJ/K, R_th≈1.73 mK/W, τ≈3.1 h + # → ΔT ≈ 10 °C at end of 2-hour C/2 discharge. + self.thermal.mass = 3.0 # kg per cell + self.thermal.convection_coefficient = 23.0 # W/m²K def internal_resistance(self, state): return super().internal_resistance(state) * self._SCALE +class ScaledLFPCalendarDegradation(SonyLFPCalendarDegradation): + def update_capacity( + self, state: BatteryState, dt: float, accumulated_qloss: float, _DEG_SCALE + ) -> float: + if dt == 0.0: + return 0.0 + T_K = state.T + 273.15 + T_REF_K = T_REF + 273.15 + k_T_q = (K_REF_QLOSS * _DEG_SCALE) * math.exp(-EA_QLOSS / R * (1.0 / T_K - 1.0 / T_REF_K)) + k_soc_q = CAL_C_QLOSS * (state.soc - 0.5) ** 3 + CAL_D_QLOSS + stress_q = k_T_q * k_soc_q + if stress_q > 0.0: + virtual_time = (accumulated_qloss / stress_q) ** 2 + delta_q = stress_q * math.sqrt(virtual_time + dt) - accumulated_qloss + else: + delta_q = 0.0 + return delta_q + + def update_resistance(self, state: BatteryState, dt: float, _DEG_SCALE) -> float: + if dt == 0.0: + return 0.0 + T_K = state.T + 273.15 + T_REF_K = T_REF + 273.15 + k_T_r = (K_REF_RINC * _DEG_SCALE) * math.exp(-EA_RINC / R * (1.0 / T_K - 1.0 / T_REF_K)) + k_soc_r = CAL_C_RINC * (state.soc - 0.5) ** 2 + CAL_D_RINC + return k_T_r * k_soc_r * dt + + +class ScaledLFPCyclicDegradation(SonyLFPCyclicDegradation): + def update_capacity( + self, + state: BatteryState, + half_cycle: HalfCycle, + accumulated_qloss: float, + _DEG_SCALE: float, + ) -> float: + delta_fec = half_cycle.full_equivalent_cycles + if delta_fec == 0.0: + return 0.0 + k_crate_q = (A_QLOSS * _DEG_SCALE) * half_cycle.c_rate + (B_QLOSS * _DEG_SCALE) + k_dod_q = CYC_C_QLOSS * (half_cycle.depth_of_discharge - 0.6) ** 3 + CYC_D_QLOSS + stress_q = k_crate_q * k_dod_q + if stress_q > 0.0: + virtual_fec = (accumulated_qloss * 100.0 / stress_q) ** 2 + delta_q = stress_q * math.sqrt(virtual_fec + delta_fec) / 100.0 - accumulated_qloss + else: + delta_q = 0.0 + return delta_q + + def update_resistance( + self, state: BatteryState, half_cycle: HalfCycle, _DEG_SCALE: float + ) -> float: + delta_fec = half_cycle.full_equivalent_cycles + if delta_fec == 0.0: + return 0.0 + k_crate_r = (A_RINC * _DEG_SCALE) * half_cycle.c_rate + (B_RINC * _DEG_SCALE) + k_dod_r = CYC_C_RINC * (half_cycle.depth_of_discharge - 0.5) ** 3 + CYC_D_RINC + return k_crate_r * k_dod_r * delta_fec / 100.0 + + @define(kw_only=True) class BatteryPerformanceModelConfig(StoragePerformanceBaseConfig): """Configuration class for storage performance models. @@ -86,6 +181,8 @@ class BatteryPerformanceModelConfig(StoragePerformanceBaseConfig): discharge_efficiency: float | None = field(default=None, validator=range_val_or_none(0, 1)) round_trip_efficiency: float | None = field(default=None, validator=range_val_or_none(0, 1)) + _DEG_SCALE: float = field(default=0.4, validator=range_val(0, 1)) + # TODO degradation: add additional parameters for degradation here cop: float = field(validator=gt_zero) @@ -183,15 +280,25 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): discharge_rate = inputs["max_charge_rate"][0] storage_capacity = inputs["storage_capacity"][0] + power_profile = inputs[f"{self.commodity}_command_value"] ### from Ankit + + # --------------------------------------------------------------------------- + # Battery pack: 239s x 18p -> 764.8 V * 5040 Ah ~ 3855 kWh + # --------------------------------------------------------------------------- cell = LFP280Ah() battery = Battery( cell=cell, - circuit=(239, 18), - initial_states={"start_soc": 0.5, "start_T": 25.0}, - degradation=LFP280Ah.default_degradation_model( - initial_soc=0.5, + circuit=(239, 18), # TODO update to be based on provided battery power and energy + initial_states={ + "start_soc": self.config.init_soc_fraction, + "start_T": 25.0, + }, # TODO should be user inputs + degradation=DegradationModel( + calendar=ScaledLFPCalendarDegradation(), + cyclic=ScaledLFPCyclicDegradation(), + initial_soc=self.config.init_soc_fraction, initial_state=DegradationState(qloss_cal=1e-4), ), ) @@ -210,36 +317,42 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]): print(summary.to_string()) print() - dt = self.dt_hr * 60.0 + # --------------------------------------------------------------------------- + # Thermal model: constant 25 °C ambient, battery registered as thermal node + # --------------------------------------------------------------------------- + thermal = AmbientThermalModel(T_ambient=25.0, components=[battery]) - # power_profile = inputs[f"{self.commodity}_in"] - power_profile = inputs[f"{self.commodity}_set_point"] + # --------------------------------------------------------------------------- + # Simulation loop + # --------------------------------------------------------------------------- + keys = ["soc", "v", "i", "T", "loss", "heat", "soh_Q", "soh_R", "power"] + log = {k: np.empty(self.n_timesteps) for k in keys} - log = { - k: np.empty(self.n_timesteps) - for k in ["soc", "v", "i", "T", "loss", "heat", "soh_Q", "soh_R", "power"] - } for i, p in enumerate(power_profile): - battery.step(float(p), dt) - for k in log: + battery.step(float(p), self.dt) + thermal.step(self.dt) # update battery temperature after each step + for k in keys: log[k][i] = getattr(battery.state, k) - index = pd.date_range("2025-01-01", periods=self.n_timesteps, freq=f"{int(dt)}s") - df_bat = pd.DataFrame(log, index=index) - print("\nFirst rows:") - print(df_bat.head().to_string()) + index = pd.date_range("2026-01-01", periods=self.n_timesteps, freq=f"{int(self.dt)}s") + df = pd.DataFrame(log, index=index) + print("\nFirst rows:") + print(df.head().to_string()) + # print( + # f"\nFinal SOH_Q : {df['soh_Q'].iloc[-1]:.4f} ({(1 - df['soh_Q'].iloc[-1]) + # * 100:.2f} % capacity fade)" + # ) + print(f"Final SOH_R : {df['soh_R'].iloc[-1]:.4f}") ############# # Populate all OpenMDAO outputs defined in this class and its parent classes, # pulling the time-series results from the simses battery run (``df_bat``). - soc_ts = df_bat["soc"].to_numpy() + soc_ts = df["soc"].to_numpy() # Convert battery power (W) into the desired commodity rate units. # Sign convention: positive = discharge (out of storage), negative = charge. - power_ts = om_units.convert_units( - df_bat["power"].to_numpy(), "W", self.commodity_rate_units - ) + power_ts = om_units.convert_units(df["power"].to_numpy(), "W", self.commodity_rate_units) # --- BatteryPerformanceModel outputs --- outputs[f"{self.commodity}_auxiliary_demand"] = np.zeros(self.n_timesteps) From 77c8310e39b1286db7350efd62a55067d314348b Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Mon, 20 Jul 2026 15:45:03 -0600 Subject: [PATCH 11/11] including batt deg example --- .../run_battery_degradation.py | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 examples/98_battery_degradation/run_battery_degradation.py diff --git a/examples/98_battery_degradation/run_battery_degradation.py b/examples/98_battery_degradation/run_battery_degradation.py new file mode 100644 index 000000000..507305f2f --- /dev/null +++ b/examples/98_battery_degradation/run_battery_degradation.py @@ -0,0 +1,322 @@ +import math + +import matplotlib + + +matplotlib.use("Agg") +from pathlib import Path + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +from tqdm import tqdm + + +HERE = Path(__file__).parent + +from simses.degradation import DegradationModel +from simses.battery.state import BatteryState +from simses.battery.battery import Battery +from simses.thermal.ambient import AmbientThermalModel +from simses.degradation.state import DegradationState +from simses.model.cell.sony_lfp import SonyLFP +from simses.degradation.cycle_detector import HalfCycle +from simses.model.degradation.sony_lfp_cyclic import ( + A_RINC, + B_RINC, + C_RINC as CYC_C_RINC, + D_RINC as CYC_D_RINC, + A_QLOSS, + B_QLOSS, + C_QLOSS as CYC_C_QLOSS, + D_QLOSS as CYC_D_QLOSS, + SonyLFPCyclicDegradation, +) +from simses.model.degradation.sony_lfp_calendar import ( + T_REF, + C_RINC as CAL_C_RINC, + D_RINC as CAL_D_RINC, + C_QLOSS as CAL_C_QLOSS, + D_QLOSS as CAL_D_QLOSS, + EA_RINC, + EA_QLOSS, + K_REF_RINC, + K_REF_QLOSS, + R, + SonyLFPCalendarDegradation, +) + + +# --------------------------------------------------------------------------- +# Large-format 280 Ah LFP cell +# --------------------------------------------------------------------------- + + +class LFP280Ah(SonyLFP): + """280 Ah / 3.2 V prismatic LFP cell scaled from the SonyLFP OCV/resistance curves.""" + + _SCALE = 3.0 / 280.0 # resistance scales inversely with capacity + + def __init__(self): + super().__init__() + self.electrical.nominal_capacity = 280.0 # Ah + # Thermal properties for a large-format prismatic cell (vs. the 70 g 26650 reference). + # mass=1.5 kg, h=23 W/m²K gives C_th≈6.5 MJ/K, R_th≈1.73 mK/W, τ≈3.1 h + # → ΔT ≈ 10 °C at end of 2-hour C/2 discharge. + self.thermal.mass = 3.0 # kg per cell + self.thermal.convection_coefficient = 23.0 # W/m²K + + def internal_resistance(self, state): + return super().internal_resistance(state) * self._SCALE + + +# --------------------------------------------------------------------------- +# Scaled degradation models targeting ~1-2 % capacity fade over 365 days. +# The Sony LFP parametrization is for a 3 Ah cylindrical cell; large-format +# prismatic LFP cells degrade roughly 5x slower per calendar/cycle unit. +# A uniform scale factor of 0.2 brings the total fade into the 1-2 % range. +# --------------------------------------------------------------------------- +_DEG_SCALE = 0.4 + + +class ScaledLFPCalendarDegradation(SonyLFPCalendarDegradation): + def update_capacity(self, state: BatteryState, dt: float, accumulated_qloss: float) -> float: + if dt == 0.0: + return 0.0 + T_K = state.T + 273.15 + T_REF_K = T_REF + 273.15 + k_T_q = (K_REF_QLOSS * _DEG_SCALE) * math.exp(-EA_QLOSS / R * (1.0 / T_K - 1.0 / T_REF_K)) + k_soc_q = CAL_C_QLOSS * (state.soc - 0.5) ** 3 + CAL_D_QLOSS + stress_q = k_T_q * k_soc_q + if stress_q > 0.0: + virtual_time = (accumulated_qloss / stress_q) ** 2 + delta_q = stress_q * math.sqrt(virtual_time + dt) - accumulated_qloss + else: + delta_q = 0.0 + return delta_q + + def update_resistance(self, state: BatteryState, dt: float) -> float: + if dt == 0.0: + return 0.0 + T_K = state.T + 273.15 + T_REF_K = T_REF + 273.15 + k_T_r = (K_REF_RINC * _DEG_SCALE) * math.exp(-EA_RINC / R * (1.0 / T_K - 1.0 / T_REF_K)) + k_soc_r = CAL_C_RINC * (state.soc - 0.5) ** 2 + CAL_D_RINC + return k_T_r * k_soc_r * dt + + +class ScaledLFPCyclicDegradation(SonyLFPCyclicDegradation): + def update_capacity( + self, state: BatteryState, half_cycle: HalfCycle, accumulated_qloss: float + ) -> float: + delta_fec = half_cycle.full_equivalent_cycles + if delta_fec == 0.0: + return 0.0 + k_crate_q = (A_QLOSS * _DEG_SCALE) * half_cycle.c_rate + (B_QLOSS * _DEG_SCALE) + k_dod_q = CYC_C_QLOSS * (half_cycle.depth_of_discharge - 0.6) ** 3 + CYC_D_QLOSS + stress_q = k_crate_q * k_dod_q + if stress_q > 0.0: + virtual_fec = (accumulated_qloss * 100.0 / stress_q) ** 2 + delta_q = stress_q * math.sqrt(virtual_fec + delta_fec) / 100.0 - accumulated_qloss + else: + delta_q = 0.0 + return delta_q + + def update_resistance(self, state: BatteryState, half_cycle: HalfCycle) -> float: + delta_fec = half_cycle.full_equivalent_cycles + if delta_fec == 0.0: + return 0.0 + k_crate_r = (A_RINC * _DEG_SCALE) * half_cycle.c_rate + (B_RINC * _DEG_SCALE) + k_dod_r = CYC_C_RINC * (half_cycle.depth_of_discharge - 0.5) ** 3 + CYC_D_RINC + return k_crate_r * k_dod_r * delta_fec / 100.0 + + +# --------------------------------------------------------------------------- +# Battery pack: 239s x 18p -> 764.8 V * 5040 Ah ~ 3855 kWh +# --------------------------------------------------------------------------- +cell = LFP280Ah() + +battery = Battery( + cell=cell, + circuit=(239, 18), + initial_states={"start_soc": 1.0, "start_T": 25.0}, + degradation=DegradationModel( + calendar=ScaledLFPCalendarDegradation(), + cyclic=ScaledLFPCyclicDegradation(), + initial_soc=1.0, + initial_state=DegradationState(qloss_cal=1e-4), + ), +) + +summary = pd.Series( + { + "nominal_capacity [Ah]": battery.nominal_capacity, + "nominal_voltage [V]": battery.nominal_voltage, + "nominal_energy [kWh]": battery.nominal_energy_capacity / 1e3, + "max_charge_current [A]": battery.max_charge_current, + "max_discharge_current [A]": battery.max_discharge_current, + "thermal_capacity [kJ/K]": battery.thermal_capacity / 1e3, + } +) +print("Battery summary:") +print(summary.to_string()) +print() + +# --------------------------------------------------------------------------- +# 15-year power demand profile (dt = 15 min = 900 s) +# +# Each day (96 steps): +# 8 steps discharge at C/2 (~-1.927 MW) +# 40 steps charge at C/10 (~+385 kW) +# 48 steps rest 0 W +# --------------------------------------------------------------------------- +dt = 900.0 +STEPS_PER_DAY = 96 +N_YEARS = 15 +N_DAYS = N_YEARS * 365 +n_steps = N_DAYS * STEPS_PER_DAY + +P_nom = battery.nominal_energy_capacity # Wh +P_disch = -0.5 * P_nom # C/2 discharge +P_chg = +0.1 * P_nom # C/10 charge + +day_profile = np.concatenate( + [ + np.full(8, P_disch), + np.full(40, P_chg), + np.full(48, 0.0), + ] +) +power_profile = np.tile(day_profile, N_DAYS) + +print(f"Simulation: {N_YEARS} years ({N_DAYS} days), {n_steps:,} steps, dt={int(dt)} s") +print(f"Discharge: {P_disch/1e6:.3f} MW (C/2)") +print(f"Charge: {P_chg/1e3:.1f} kW (C/10)") +print() + +# --------------------------------------------------------------------------- +# Thermal model: constant 25 °C ambient, battery registered as thermal node +# --------------------------------------------------------------------------- +thermal = AmbientThermalModel(T_ambient=25.0, components=[battery]) + +# --------------------------------------------------------------------------- +# Simulation loop +# --------------------------------------------------------------------------- +keys = ["soc", "v", "i", "T", "loss", "heat", "soh_Q", "soh_R", "power"] +log = {k: np.empty(n_steps) for k in keys} + +for i, p in enumerate(tqdm(power_profile, desc=f"Simulating {N_YEARS} years")): + battery.step(float(p), dt) + thermal.step(dt) # update battery temperature after each step + for k in keys: + log[k][i] = getattr(battery.state, k) + +index = pd.date_range("2026-01-01", periods=n_steps, freq=f"{int(dt)}s") +df = pd.DataFrame(log, index=index) + +print("\nFirst rows:") +print(df.head().to_string()) +print( + f"\nFinal SOH_Q : {df['soh_Q'].iloc[-1]:.4f} \ + ({(1 - df['soh_Q'].iloc[-1]) * 100:.2f} % capacity fade)" +) +print(f"Final SOH_R : {df['soh_R'].iloc[-1]:.4f}") + +# --------------------------------------------------------------------------- +# Plots +# --------------------------------------------------------------------------- + +# Plot 1: Power demand (first 7 days) +fig, ax = plt.subplots(figsize=(12, 3)) +df["power"].iloc[: 7 * STEPS_PER_DAY].plot(ax=ax) +ax.set_title("Power demand profile -- first 7 days") +ax.set_ylabel("Power [W]") +ax.axhline(0, color="gray", linewidth=0.5) +ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x/1e6:.2f} MW")) +fig.tight_layout() +fig.savefig(HERE / "plot_power.png", dpi=150) +print("Saved plot_power.png") + +# Plot 2: SOC (hourly mean) +fig, ax = plt.subplots(figsize=(12, 3)) +df["soc"].resample("1h").mean().plot(ax=ax, title="State of Charge -- hourly mean") +ax.set_ylabel("SOC [p.u.]") +fig.tight_layout() +fig.savefig(HERE / "plot_soc.png", dpi=150) +print("Saved plot_soc.png") + +# Plot 2b: Battery temperature -- first 7 days (15-min resolution) +fig, ax = plt.subplots(figsize=(12, 3)) +df["T"].iloc[: 7 * STEPS_PER_DAY].plot(ax=ax, title="Battery temperature -- first 7 days") +ax.set_ylabel("Temperature [°C]") +ax.axhline(25.0, color="gray", linewidth=0.8, linestyle="--", label="T_ambient = 25 °C") +ax.legend() +fig.tight_layout() +fig.savefig(HERE / "plot_temperature_7days.png", dpi=150) +print("Saved plot_temperature_7days.png") + +# Plot 3a: Terminal voltage -- first 7 days (raw 15-min data) +fig, ax = plt.subplots(figsize=(12, 3)) +df["v"].iloc[: 7 * STEPS_PER_DAY].plot(ax=ax, title="Terminal voltage -- first 7 days [V]") +ax.set_ylabel("Voltage [V]") +fig.tight_layout() +fig.savefig(HERE / "plot_voltage_7days.png", dpi=150) +print("Saved plot_voltage_7days.png") + +# Plot 3b: Terminal voltage (daily mean, full year) +fig, ax = plt.subplots(figsize=(12, 3)) +df["v"].resample("1D").mean().plot(ax=ax, title="Terminal voltage -- daily mean [V]") +ax.set_ylabel("Voltage [V]") +fig.tight_layout() +fig.savefig(HERE / "plot_voltage.png", dpi=150) +print("Saved plot_voltage.png") + +# Plot 4: Degradation over 15 years +df_monthly = df[["soh_Q", "soh_R"]].resample("ME").last() +capacity_fade_pct = (1 - df_monthly["soh_Q"]) * 100 +resistance_growth_pct = (df_monthly["soh_R"] - 1) * 100 + +fig, ax1 = plt.subplots(figsize=(12, 4)) +ax2 = ax1.twinx() + +ax1.plot( + capacity_fade_pct.index, capacity_fade_pct.values, color="steelblue", label="Capacity fade [%]" +) +ax2.plot( + resistance_growth_pct.index, + resistance_growth_pct.values, + color="darkorange", + label="Resistance growth [%]", +) + +ax1.set_ylabel("Capacity fade [%]", color="steelblue") +ax2.set_ylabel("Resistance growth [%]", color="darkorange") +ax1.tick_params(axis="y", labelcolor="steelblue") +ax2.tick_params(axis="y", labelcolor="darkorange") +ax1.set_title( + f"Battery degradation over {N_YEARS} years " + f"(final: {capacity_fade_pct.iloc[-1]:.1f} % capacity fade, " + f"{resistance_growth_pct.iloc[-1]:.1f} % resistance growth)" +) +ax1.set_xlabel("Date") + +lines1, labels1 = ax1.get_legend_handles_labels() +lines2, labels2 = ax2.get_legend_handles_labels() +ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper left") + +# Mark end-of-life reference (20 % capacity fade) +ax1.axhline(20, color="steelblue", linewidth=0.8, linestyle="--", alpha=0.6, label="EoL (20 %)") + +fig.tight_layout() +fig.savefig(HERE / "plot_degradation_15yr.png", dpi=150) +print("Saved plot_degradation_15yr.png") + +# Plot 5: Losses and heat (daily mean) +fig, ax = plt.subplots(figsize=(12, 3)) +df[["loss", "heat"]].resample("1D").mean().plot( + ax=ax, title="Battery losses and heat -- daily mean [W]" +) +ax.set_ylabel("Power [W]") +fig.tight_layout() +fig.savefig(HERE / "plot_losses.png", dpi=150) +print("Saved plot_losses.png")