diff --git a/.github/workflows/benchmark_on_push.yml b/.github/workflows/benchmark_on_push.yml index 18a9e7931d..f70556c782 100644 --- a/.github/workflows/benchmark_on_push.yml +++ b/.github/workflows/benchmark_on_push.yml @@ -40,7 +40,7 @@ jobs: enable-cache: true - name: Install python dependencies - run: uv pip install asv[virtualenv] + run: uv pip install --system asv - name: Fetch base branch run: | diff --git a/.github/workflows/periodic_benchmarks.yml b/.github/workflows/periodic_benchmarks.yml index 86cd1991ab..0a7a7d9c47 100644 --- a/.github/workflows/periodic_benchmarks.yml +++ b/.github/workflows/periodic_benchmarks.yml @@ -48,7 +48,7 @@ jobs: enable-cache: true - name: Install python dependencies - run: uv pip install asv[virtualenv] + run: uv pip install --system asv - name: Run benchmarks run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 29af0fbe72..3463d08e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,12 @@ ## Features +- Adds `silence_sundials_errors` IDAKLU solver option with `default=False` to match historical output. ([#5290](https://github.com/pybamm-team/PyBaMM/pull/5290)) + ## Bug fixes +- Fix a bug with serialising `InputParameter`s. ([#5289](https://github.com/pybamm-team/PyBaMM/pull/5289)) + # [v25.10.1](https://github.com/pybamm-team/PyBaMM/tree/v25.10.1) - 2025-11-14 ## Features @@ -19,6 +23,7 @@ ## Features +- Added uniform grid sizing across subdomains in the x-dimension, ensuring consistent grid spacing when geometries have varying lengths. ([#5253](https://github.com/pybamm-team/PyBaMM/pull/5253)) - Added the `electrode_phases` kwarg to `plot_voltage_components()` which allows choosing between plotting primary or secondary phase overpotentials. ([#5229](https://github.com/pybamm-team/PyBaMM/pull/5229)) - Added the `num_steps_no_progress` and `t_no_progress` options in the `IDAKLUSolver` to early terminate the simulation if little progress is detected. ([#5201](https://github.com/pybamm-team/PyBaMM/pull/5201)) - EvaluateAt symbol: add support for children evaluated at edges ([#5190](https://github.com/pybamm-team/PyBaMM/pull/5190)) diff --git a/docs/source/examples/notebooks/creating_models/4-comparing-full-and-reduced-order-models.ipynb b/docs/source/examples/notebooks/creating_models/4-comparing-full-and-reduced-order-models.ipynb index 071fd54f95..19780371c0 100644 --- a/docs/source/examples/notebooks/creating_models/4-comparing-full-and-reduced-order-models.ipynb +++ b/docs/source/examples/notebooks/creating_models/4-comparing-full-and-reduced-order-models.ipynb @@ -24,7 +24,7 @@ "$$\n", "\\left.c\\right\\vert_{t=0} = c_0,\n", "$$\n", - "where $c$$ is the concentration, $r$ the radial coordinate, $t$ time, $R$ the particle radius, $D$ the diffusion coefficient, $j$ the interfacial current density, $F$ Faraday's constant, and $c_0$ the initial concentration. \n", + "where $c$ is the concentration, $r$ the radial coordinate, $t$ time, $R$ the particle radius, $D$ the diffusion coefficient, $j$ the interfacial current density, $F$ Faraday's constant, and $c_0$ the initial concentration. \n", "\n", "As in the previous example we use the following parameters:\n", "\n", diff --git a/src/pybamm/expression_tree/operations/serialise.py b/src/pybamm/expression_tree/operations/serialise.py index 6b733e0fad..3cc8f3e98f 100644 --- a/src/pybamm/expression_tree/operations/serialise.py +++ b/src/pybamm/expression_tree/operations/serialise.py @@ -1625,6 +1625,8 @@ def convert_symbol_from_json(json_data): elif json_data["type"] == "Parameter": # Convert stored parameters back to PyBaMM Parameter objects return pybamm.Parameter(json_data["name"]) + elif json_data["type"] == "InputParameter": + return pybamm.InputParameter(json_data["name"]) elif json_data["type"] == "Scalar": # Convert stored numerical values back to PyBaMM Scalar objects return pybamm.Scalar(json_data["value"]) diff --git a/src/pybamm/meshes/meshes.py b/src/pybamm/meshes/meshes.py index f717b0a347..ddc295d57b 100644 --- a/src/pybamm/meshes/meshes.py +++ b/src/pybamm/meshes/meshes.py @@ -9,6 +9,39 @@ import pybamm +def compute_var_pts_from_thicknesses(electrode_thicknesses, grid_size): + """ + Compute a ``var_pts`` dictionary using electrode thicknesses and a target cell size (dx). + + Added as per maintainer feedback in issue # to make mesh generation + explicit — ``grid_size`` now represents the mesh cell size in metres. + + Parameters + ---------- + electrode_thicknesses : dict + Domain thicknesses in metres. + grid_size : float + Desired uniform mesh cell size (m). + + Returns + ------- + dict + Mapping of each domain to its computed grid points. + """ + if not isinstance(electrode_thicknesses, dict): + raise TypeError("electrode_thicknesses must be a dictionary") + + if not isinstance(grid_size, (int | float)) or grid_size <= 0: + raise ValueError("grid_size must be a positive number") + + var_pts = {} + for domain, thickness in electrode_thicknesses.items(): + npts = max(round(thickness / grid_size), 2) + var_pts[domain] = {f"x_{domain[0]}": npts} + + return var_pts + + class Mesh(dict): """ Mesh contains a list of submeshes on each subdomain. diff --git a/src/pybamm/models/base_model.py b/src/pybamm/models/base_model.py index 9fd7483385..67096912cf 100644 --- a/src/pybamm/models/base_model.py +++ b/src/pybamm/models/base_model.py @@ -906,7 +906,12 @@ def _build_model(self): self.build_model_equations() def set_initial_conditions_from( - self, solution, inplace=True, return_type="model", mesh=None + self, + solution, + inputs=None, + inplace=True, + return_type="model", + mesh=None, ): """ Update initial conditions with the final states from a Solution object or from @@ -918,6 +923,8 @@ def set_initial_conditions_from( ---------- solution : :class:`pybamm.Solution`, or dict The solution to use to initialize the model + inputs : dict + The dictionary of model input parameters. inplace : bool, optional Whether to modify the model inplace or create a new model (default True) return_type : str, optional @@ -1081,7 +1088,7 @@ def get_variable_state(var): scale, reference = pybamm.Scalar(1), pybamm.Scalar(0) initial_conditions[var] = ( pybamm.Vector(final_state_eval) - reference - ) / scale.evaluate() + ) / scale.evaluate(inputs=inputs) # Also update the concatenated initial conditions if the model is already # discretised diff --git a/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh.py b/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh.py index a9322991b2..b9abb4261f 100644 --- a/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh.py +++ b/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh.py @@ -561,14 +561,14 @@ def _set_up_solve(self, inputs, direction): def _solve_full(self, inputs, ics, direction): sim = self._get_electrode_soh_sims_full(direction) sim.build() - sim.built_model.set_initial_conditions_from(ics) + sim.built_model.set_initial_conditions_from(ics, inputs=inputs) sol = sim.solve([0], inputs=inputs) return sol def _solve_split(self, inputs, ics, direction): x100_sim, x0_sim = self._get_electrode_soh_sims_split(direction) x100_sim.build() - x100_sim.built_model.set_initial_conditions_from(ics) + x100_sim.built_model.set_initial_conditions_from(ics, inputs=inputs) x100_sol = x100_sim.solve([0], inputs=inputs) if self.options["open-circuit potential"] == "MSMR": inputs["Un(x_100)"] = x100_sol["Un(x_100)"].data[0] @@ -577,7 +577,7 @@ def _solve_split(self, inputs, ics, direction): inputs["x_100"] = x100_sol["x_100"].data[0] inputs["y_100"] = x100_sol["y_100"].data[0] x0_sim.build() - x0_sim.built_model.set_initial_conditions_from(ics) + x0_sim.built_model.set_initial_conditions_from(ics, inputs=inputs) x0_sol = x0_sim.solve([0], inputs=inputs) return x0_sol diff --git a/src/pybamm/models/submodels/interface/kinetics/butler_volmer.py b/src/pybamm/models/submodels/interface/kinetics/butler_volmer.py index c3e9ac0fc0..84cee586a3 100644 --- a/src/pybamm/models/submodels/interface/kinetics/butler_volmer.py +++ b/src/pybamm/models/submodels/interface/kinetics/butler_volmer.py @@ -12,7 +12,7 @@ class SymmetricButlerVolmer(BaseKinetics): Submodel which implements the symmetric forward Butler-Volmer equation: .. math:: - j = 2 * j_0(c) * \\sinh(ne * F * \\eta_r(c) / RT) + j = 2 * j_0(c) * \\sinh(ne * F * \\eta_r(c) / 2RT) Parameters ---------- diff --git a/src/pybamm/models/submodels/interface/open_circuit_potential/base_hysteresis_ocp.py b/src/pybamm/models/submodels/interface/open_circuit_potential/base_hysteresis_ocp.py index 934ed5e6cf..cdfc47ac5e 100644 --- a/src/pybamm/models/submodels/interface/open_circuit_potential/base_hysteresis_ocp.py +++ b/src/pybamm/models/submodels/interface/open_circuit_potential/base_hysteresis_ocp.py @@ -84,7 +84,7 @@ def _get_coupled_variables(self, variables): U_eq = self.phase_param.U(sto_surf, T) U_eq_x_av = self.phase_param.U(sto_surf, T) U_lith = self.phase_param.U(sto_surf, T, "lithiation") - U_lith_bulk = self.phase_param.U(sto_bulk, T_bulk) + U_lith_bulk = self.phase_param.U(sto_bulk, T_bulk, "lithiation") U_delith = self.phase_param.U(sto_surf, T, "delithiation") U_delith_bulk = self.phase_param.U(sto_bulk, T_bulk, "delithiation") diff --git a/src/pybamm/parameters/parameter_values.py b/src/pybamm/parameters/parameter_values.py index 2bec326a93..845be2b0bf 100644 --- a/src/pybamm/parameters/parameter_values.py +++ b/src/pybamm/parameters/parameter_values.py @@ -866,12 +866,21 @@ def _process_function_parameter(self, symbol): else: new_children.append(self.process_symbol(child)) - # Get the expression and inputs for the function + # Get the expression and inputs for the function. + # func_args may include arguments that were not explicitly wired up + # in this FunctionParameter (e.g., kwargs with default values). After + # serialisation/deserialisation, we only recover the children that were + # actually connected. + # + # Using strict=True here therefore raises a ValueError when there are + # more args than children. We allow func_args to be longer than + # symbol.children and only build the mapping for the args for which we + # actually have children. expression = function_parameter.child inputs = { arg: child for arg, child in zip( - function_parameter.func_args, symbol.children, strict=True + function_parameter.func_args, symbol.children, strict=False ) } diff --git a/src/pybamm/solvers/base_solver.py b/src/pybamm/solvers/base_solver.py index 92c7381f43..a1db0c2953 100644 --- a/src/pybamm/solvers/base_solver.py +++ b/src/pybamm/solvers/base_solver.py @@ -1381,7 +1381,9 @@ def step( else: _, concatenated_initial_conditions = model.set_initial_conditions_from( - old_solution, return_type="ics" + old_solution, + inputs=model_inputs, + return_type="ics", ) model.y0 = concatenated_initial_conditions.evaluate(0, inputs=model_inputs) if using_sensitivities: diff --git a/src/pybamm/solvers/idaklu_solver.py b/src/pybamm/solvers/idaklu_solver.py index 4a0d9edb07..e418b4d3e5 100644 --- a/src/pybamm/solvers/idaklu_solver.py +++ b/src/pybamm/solvers/idaklu_solver.py @@ -76,6 +76,8 @@ class IDAKLUSolver(pybamm.BaseSolver): "increment_factor": 1.0, # Enable or disable linear solution scaling "linear_solution_scaling": True, + # Silence Sundials errors during solve + "silence_sundials_errors": False, ## Main solver # Maximum order of the linear multistep method "max_order_bdf": 5, @@ -176,6 +178,7 @@ def __init__( "epsilon_linear_tolerance": 0.05, "increment_factor": 1.0, "linear_solution_scaling": True, + "silence_sundials_errors": False, "max_order_bdf": 5, "max_num_steps": 100000, "dt_init": 0.0, diff --git a/tests/unit/test_meshes/test_meshes.py b/tests/unit/test_meshes/test_meshes.py index 8c26a0900f..15ef8317ba 100644 --- a/tests/unit/test_meshes/test_meshes.py +++ b/tests/unit/test_meshes/test_meshes.py @@ -584,6 +584,37 @@ def test_to_json(self): assert mesh_json == expected_json + def test_compute_var_pts_from_thicknesses_cell_size(self): + from pybamm.meshes.meshes import compute_var_pts_from_thicknesses + + electrode_thicknesses = { + "negative electrode": 100e-6, + "separator": 25e-6, + "positive electrode": 100e-6, + } + + cell_size = 5e-6 # 5 micrometres per cell + var_pts = compute_var_pts_from_thicknesses(electrode_thicknesses, cell_size) + + assert isinstance(var_pts, dict) + assert all(isinstance(v, dict) for v in var_pts.values()) + assert var_pts["negative electrode"]["x_n"] == 20 + assert var_pts["separator"]["x_s"] == 5 + assert var_pts["positive electrode"]["x_p"] == 20 + + def test_compute_var_pts_from_thicknesses_invalid_thickness_type(self): + from pybamm.meshes.meshes import compute_var_pts_from_thicknesses + + with pytest.raises(TypeError): + compute_var_pts_from_thicknesses(["not", "a", "dict"], 1e-6) + + def test_compute_var_pts_from_thicknesses_invalid_grid_size(self): + from pybamm.meshes.meshes import compute_var_pts_from_thicknesses + + electrode_thicknesses = {"negative electrode": 100e-6} + with pytest.raises(ValueError): + compute_var_pts_from_thicknesses(electrode_thicknesses, -1e-6) + class TestMeshGenerator: def test_init_name(self): diff --git a/tests/unit/test_parameters/conftest.py b/tests/unit/test_parameters/conftest.py new file mode 100644 index 0000000000..ffe1f698fe --- /dev/null +++ b/tests/unit/test_parameters/conftest.py @@ -0,0 +1,16 @@ +import numpy as np +import pytest + + +@pytest.fixture +def assert_is_ndarray(): + """Recursively assert that all items in a structure are numpy arrays.""" + + def _assert(obj): + if isinstance(obj, list | tuple): + for item in obj: + _assert(item) + else: + assert isinstance(obj, np.ndarray) + + return _assert diff --git a/tests/unit/test_parameters/test_parameter_values.py b/tests/unit/test_parameters/test_parameter_values.py index 769705cf51..50b4c7065d 100644 --- a/tests/unit/test_parameters/test_parameter_values.py +++ b/tests/unit/test_parameters/test_parameter_values.py @@ -1298,6 +1298,31 @@ def test_to_json_with_filename(self): finally: os.remove(temp_path) + def test_roundtrip_with_keyword_args(self): + def func_no_kwargs(x): + return 2 * x + + def func_with_kwargs(x, y=1): + return 2 * x + + x = pybamm.Scalar(2) + func_param = pybamm.FunctionParameter("func", {"x": x}) + + parameter_values = pybamm.ParameterValues({"func": func_no_kwargs}) + assert parameter_values.evaluate(func_param) == 4.0 + + serialized = parameter_values.to_json() + parameter_values_loaded = pybamm.ParameterValues.from_json(serialized) + assert parameter_values_loaded.evaluate(func_param) == 4.0 + + parameter_values = pybamm.ParameterValues({"func": func_with_kwargs}) + assert parameter_values.evaluate(func_param) == 4.0 + + serialized = parameter_values.to_json() + parameter_values_loaded = pybamm.ParameterValues.from_json(serialized) + + assert parameter_values_loaded.evaluate(func_param) == 4.0 + def test_convert_symbols_in_dict_with_interpolator(self): """Test convert_symbols_in_dict with interpolator (covers lines 1154-1170).""" import numpy as np diff --git a/tests/unit/test_parameters/test_process_parameter_data.py b/tests/unit/test_parameters/test_process_parameter_data.py index 47fdb9cad2..a22066a4e9 100644 --- a/tests/unit/test_parameters/test_process_parameter_data.py +++ b/tests/unit/test_parameters/test_process_parameter_data.py @@ -4,7 +4,6 @@ from pathlib import Path -import numpy as np import pytest import pybamm @@ -34,18 +33,13 @@ def test_processed_name(self, parameter_data): name, processed = parameter_data assert processed[0] == name - def test_processed_structure(self, parameter_data): - name, processed = parameter_data - assert isinstance(processed[1], tuple) - assert isinstance(processed[1][0][0], np.ndarray) - assert isinstance(processed[1][1], np.ndarray) + def test_processed_structure(self, parameter_data, assert_is_ndarray): + _, processed = parameter_data - if len(processed[1][0]) > 1: - assert isinstance(processed[1][0][1], np.ndarray) + assert isinstance(processed[1], tuple) - elif len(processed[1]) == 3: - assert isinstance(processed[1][0][1], np.ndarray) - assert isinstance(processed[1][0][2], np.ndarray) + # Recursively check that all numpy arrays exist where expected + assert_is_ndarray(processed[1]) def test_error(self): with pytest.raises(FileNotFoundError, match="Could not find file"): diff --git a/tests/unit/test_serialisation/test_serialisation.py b/tests/unit/test_serialisation/test_serialisation.py index d6ddac10e4..4dafcc5641 100644 --- a/tests/unit/test_serialisation/test_serialisation.py +++ b/tests/unit/test_serialisation/test_serialisation.py @@ -617,6 +617,14 @@ def test_serialise_time(self): t2 = convert_symbol_from_json(j) assert isinstance(t2, pybamm.Time) + def test_serialise_input_parameter(self): + """Test InputParameter serialization and deserialization.""" + ip = pybamm.InputParameter("test_param") + j = convert_symbol_to_json(ip) + ip_restored = convert_symbol_from_json(j) + assert isinstance(ip_restored, pybamm.InputParameter) + assert ip_restored.name == "test_param" + def test_convert_symbol_to_json_with_number_and_list(self): for val in (0, 3.14, -7, True): out = convert_symbol_to_json(val)