diff --git a/CHANGELOG.md b/CHANGELOG.md index d2938a9781..62ec4ec219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Added support for particle size distributions combined with particle mechanics. ([#4807](https://github.com/pybamm-team/PyBaMM/pull/4807)) - Added InputParameter support in PyBamm experiments ([#4826](https://github.com/pybamm-team/PyBaMM/pull/4826)) - Added support for the `"pchip"` interpolator using the CasADI backend. ([#4871](https://github.com/pybamm-team/PyBaMM/pull/4871)) +- Added support for time series temperature in an experiment step ([#4855](https://github.com/pybamm-team/PyBaMM/pull/4855)) ## Breaking changes diff --git a/src/pybamm/experiment/experiment.py b/src/pybamm/experiment/experiment.py index ce44457cb2..f6c2c67103 100644 --- a/src/pybamm/experiment/experiment.py +++ b/src/pybamm/experiment/experiment.py @@ -63,6 +63,8 @@ def __init__( steps_unprocessed = [cond for cycle in cycles for cond in cycle] + self.temperature_interpolant = None + # Convert strings to pybamm.step.BaseStep objects # We only do this once per unique step, to avoid unnecessary conversions # Assign experiment period and temperature if not specified in step @@ -75,6 +77,10 @@ def __init__( self.steps = [processed_steps[repr(step)] for step in steps_unprocessed] self.steps = self._set_next_start_time(self.steps) + for step in self.steps: + if getattr(step, "has_time_series_temperature", False): + self.temperature_interpolant = step.temperature + break # Save the processed unique steps and the processed operating conditions # for every step diff --git a/src/pybamm/experiment/step/base_step.py b/src/pybamm/experiment/step/base_step.py index 0895cdfaa6..720f0e36d7 100644 --- a/src/pybamm/experiment/step/base_step.py +++ b/src/pybamm/experiment/step/base_step.py @@ -188,7 +188,9 @@ def __init__( self.direction = direction self.temperature = _convert_temperature_to_kelvin(temperature) - + self.has_time_series_temperature = isinstance( + self.temperature, pybamm.Interpolant + ) if tags is None: tags = [] elif isinstance(tags, str): @@ -429,9 +431,13 @@ def record_tags( hash_args += f", termination={termination}" if period: repr_args += f", period={period}" - if temperature: - repr_args += f", temperature={temperature}" - hash_args += f", temperature={temperature}" + if temperature is not None: + if isinstance(temperature, np.ndarray): + repr_args += ", temperature=" + hash_args += ", temperature=" + else: + repr_args += f", temperature={temperature}" + hash_args += f", temperature={temperature}" if tags: repr_args += f", tags={tags}" if start_time: @@ -539,8 +545,33 @@ def _convert_time_to_seconds(time_and_units): return time_in_seconds +def process_temperature_input(temperature_and_units): + if isinstance(temperature_and_units, np.ndarray): + if temperature_and_units.ndim == 2 and temperature_and_units.shape[1] == 2: + # Assume first column is time (s) and second column is temperature (K) + times = temperature_and_units[:, 0] + temps = temperature_and_units[:, 1] + return pybamm.Interpolant(times, temps, pybamm.t, interpolator="linear") + else: + raise ValueError( + "Temperature time-series must be a 2D array with two columns (time, temperature)." + ) + + def _convert_temperature_to_kelvin(temperature_and_units): - """Convert a temperature in Celsius or Kelvin to a temperature in Kelvin""" + """ + If the input is a 2D numpy array (time series), return an Interpolant. + Otherwise Convert a temperature in Celsius or Kelvin to a temperature in Kelvin + """ + + # Check if the temperature input is a time-series array + if isinstance(temperature_and_units, np.ndarray): + return process_temperature_input(temperature_and_units) + + # If it's already an Interpolant, do nothing further. + if isinstance(temperature_and_units, pybamm.Interpolant): + return temperature_and_units + # If the temperature is a number, assume it is in Kelvin if isinstance(temperature_and_units, (int, float)) or temperature_and_units is None: return temperature_and_units diff --git a/src/pybamm/experiment/step/steps.py b/src/pybamm/experiment/step/steps.py index e66178dc81..c7e70059f6 100644 --- a/src/pybamm/experiment/step/steps.py +++ b/src/pybamm/experiment/step/steps.py @@ -176,7 +176,10 @@ class Voltage(BaseStepImplicit): """ def get_parameter_values(self, variables): - return {"Voltage function [V]": self.value} + params = {"Voltage function [V]": self.value} + if self.temperature is not None: + params["Ambient temperature [K]"] = self.temperature + return params def get_submodel(self, model): return pybamm.external_circuit.VoltageFunctionControl( diff --git a/src/pybamm/simulation.py b/src/pybamm/simulation.py index 548eddef78..5f2074807a 100644 --- a/src/pybamm/simulation.py +++ b/src/pybamm/simulation.py @@ -318,6 +318,15 @@ def build(self, initial_soc=None, inputs=None): if initial_soc is not None: self.set_initial_soc(initial_soc, inputs=inputs) + if ( + getattr(self, "experiment", None) + and hasattr(self.experiment, "temperature_interpolant") + and self.experiment.temperature_interpolant is not None + ): + self.parameter_values.update( + {"Ambient temperature [K]": self.experiment.temperature_interpolant} + ) + if self._built_model: return elif self._model.is_discretised: @@ -444,6 +453,14 @@ def solve( See :meth:`pybamm.BaseSolver.solve`. """ pybamm.telemetry.capture("simulation-solved") + if ( + getattr(self, "experiment", None) + and hasattr(self.experiment, "temperature_interpolant") + and self.experiment.temperature_interpolant is not None + ): + self.parameter_values.update( + {"Ambient temperature [K]": self.experiment.temperature_interpolant} + ) # Setup if solver is None: diff --git a/tests/unit/test_experiments/test_experiment.py b/tests/unit/test_experiments/test_experiment.py index 3ddf89412f..e1bf16846c 100644 --- a/tests/unit/test_experiments/test_experiment.py +++ b/tests/unit/test_experiments/test_experiment.py @@ -6,6 +6,7 @@ import pybamm import pytest import numpy as np +from pybamm.experiment.step.base_step import process_temperature_input import casadi from scipy.interpolate import PchipInterpolator @@ -218,6 +219,101 @@ def test_set_next_start_time(self): # TODO: once #3176 is completed, the test should pass for # operating_conditions_steps (or equivalent) as well + def test_temperature_time_series_simulation_build(self): + time_data = np.array([0, 600, 1200, 1800]) + voltage_data = np.array([4.2, 4.0, 3.8, 3.6]) + temperature_data = np.array([298.15, 310.15, 305.15, 300.00]) + + voltage_profile = np.column_stack((time_data, voltage_data)) + temperature_profile = np.column_stack((time_data, temperature_data)) + + experiment = pybamm.Experiment( + [pybamm.step.voltage(voltage_profile, temperature=temperature_profile)] + ) + + model = pybamm.lithium_ion.DFN() + + param_values = pybamm.ParameterValues("Marquis2019") + param_values.update({"Ambient temperature [K]": 298.15}) + + sim = pybamm.Simulation( + model, experiment=experiment, parameter_values=param_values + ) + sim.build() + + ambient_temp = sim.parameter_values["Ambient temperature [K]"] + assert hasattr(ambient_temp, "evaluate"), ( + "Ambient temperature parameter is not time-dependent as expected." + ) + + t_eval = 600 + interpolated_temp = ambient_temp.evaluate(t=t_eval) + np.testing.assert_allclose(interpolated_temp, 310.15, atol=1e-3) + + t_eval2 = 1200 + interpolated_temp2 = ambient_temp.evaluate(t=t_eval2) + np.testing.assert_allclose(interpolated_temp2, 305.15, atol=1e-3) + + def test_process_temperature_valid(self): + time_data = np.array([0, 600, 1200, 1800]) + temperature_data = np.array([298.15, 310.15, 305.15, 300.00]) + temperature_profile = np.column_stack((time_data, temperature_data)) + + result = process_temperature_input(temperature_profile) + + assert isinstance(result, pybamm.Interpolant), "Expected an Interpolant object" + np.testing.assert_allclose(result.evaluate(600), 310.15, atol=1e-3) + np.testing.assert_allclose(result.evaluate(1200), 305.15, atol=1e-3) + + def test_process_temperature_invalid(self): + invalid_data = np.array([298.15, 310.15, 305.15, 300.00]) + with pytest.raises( + ValueError, match="Temperature time-series must be a 2D array" + ): + process_temperature_input(invalid_data) + + invalid_data_2 = np.array([[0, 298.15, 310.15], [600, 305.15, 300.00]]) + with pytest.raises( + ValueError, match="Temperature time-series must be a 2D array" + ): + process_temperature_input(invalid_data_2) + + def test_temperature_time_series_simulation_solve(self): + time_data = np.array([0, 600, 1200, 1800]) + voltage_data = np.array([4.2, 4.0, 3.8, 3.6]) + temperature_data = np.array([298.15, 310.15, 305.15, 300.00]) + + voltage_profile = np.column_stack((time_data, voltage_data)) + temperature_profile = np.column_stack((time_data, temperature_data)) + + experiment = pybamm.Experiment( + [ + pybamm.step.voltage( + voltage_profile, temperature=temperature_profile, duration=1800 + ) + ] + ) + + model = pybamm.lithium_ion.DFN() + + param_values = pybamm.ParameterValues("Marquis2019") + + sim = pybamm.Simulation( + model, experiment=experiment, parameter_values=param_values + ) + + solution = sim.solve() + + ambient_temp = sim.parameter_values["Ambient temperature [K]"] + assert hasattr(ambient_temp, "evaluate"), ( + "Ambient temperature parameter is not time-dependent as expected." + ) + + assert solution is not None, "Solution object is None." + assert hasattr(solution, "t"), "Solution does not contain time vector." + + np.testing.assert_allclose(solution.t[0], time_data[0], atol=1e-3) + def test_simulation_solve_updates_input_parameters(self): model = pybamm.lithium_ion.SPM()