From 5e86f72e4b9312ecb9d4dd6e26331834009b5d50 Mon Sep 17 00:00:00 2001 From: Marc Berliner <34451391+MarcBerliner@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:57:18 -0400 Subject: [PATCH 01/10] feat: add IDAKLU hermite_upsampling_factor for output resolution Inside each accepted step, bisect and save extra points until the cubic Hermite interpolant of a scalar output (built from its value and analytic time derivative) matches the integrator's interpolant to solver_tol / (factor - 1). Points are only added. Works with or without stored Hermite data, with output_variables and with sensitivities. Experiment steps choose the output (voltage, or current for voltage steps) via hermite_upsampling_variable, carried on the built model as hermite_upsampling_variables; algebraic states are skipped. --- CHANGELOG.md | 1 + .../src/pybamm/experiment/step/base_step.py | 9 +- .../src/pybamm/experiment/step/steps.py | 1 + .../src/pybamm/simulation/simulation.py | 3 + .../src/pybamm/solvers/idaklu_solver.py | 77 +++++++- .../unit/test_experiments/test_base_step.py | 15 ++ .../unit/test_solvers/test_idaklu_solver.py | 174 ++++++++++++++++++ .../src/pybammsolvers/idaklu.cpp | 7 +- .../Expressions/Base/ExpressionSet.hpp | 2 + .../Expressions/Casadi/CasadiFunctions.hpp | 6 +- .../idaklu_source/IDAKLUSolverOpenMP.hpp | 35 ++++ .../idaklu_source/IDAKLUSolverOpenMP.inl | 103 +++++++++++ .../pybammsolvers/idaklu_source/Options.cpp | 11 ++ .../pybammsolvers/idaklu_source/Options.hpp | 2 + packages/pybammsolvers/tests/conftest.py | 5 + 15 files changed, 446 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c99486239e..cb21abf8fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## Features +- Added the `IDAKLUSolver` option `hermite_upsampling_factor`: a value above 1.0 saves extra points inside each step until the voltage (or current, for voltage control) is resolved by its own cubic Hermite interpolant. ([#5998](https://github.com/pybamm-team/PyBaMM/pull/5998)) - Replaced Codacy with open checks that also run locally. Ruff's `flake8-bandit` (`S`) rules replace Bandit, and CodeQL scans the Python and C++ code. `nox -s type-check` runs Pyrefly over `pybamm`, which now type-checks cleanly, and a new type error fails CI. clang-tidy reports solver findings to code scanning as an advisory check. ([#5974](https://github.com/pybamm-team/PyBaMM/pull/5974)) ## Bug fixes diff --git a/packages/pybamm/src/pybamm/experiment/step/base_step.py b/packages/pybamm/src/pybamm/experiment/step/base_step.py index 0aaebe1914..57868ffb0b 100644 --- a/packages/pybamm/src/pybamm/experiment/step/base_step.py +++ b/packages/pybamm/src/pybamm/experiment/step/base_step.py @@ -296,6 +296,9 @@ def __hash__(self): #: their target value collapse to one unified-mode branch. control_kind = None + #: The scalar output the solver's ``hermite_upsampling_factor`` resolves. + hermite_upsampling_variable = "Voltage [V]" + def get_control_residual(self, variables, target=None): """Control residual. ``target`` is the per-step input that collapsible steps share; when it is ``None`` the step falls back to its own ``_default_target``.""" @@ -457,11 +460,15 @@ def process_model( new_parameter_values["Ambient temperature [K]"] = self.temperature # Parameterise the model - return new_parameter_values.process_model( + processed_model = new_parameter_values.process_model( new_model, inplace=inplace, delayed_variable_processing=delayed_variable_processing, ) + processed_model.hermite_upsampling_variables = [ + self.hermite_upsampling_variable + ] + return processed_model def get_termination_events(self, variables): events = [] diff --git a/packages/pybamm/src/pybamm/experiment/step/steps.py b/packages/pybamm/src/pybamm/experiment/step/steps.py index 725b0e0965..b55551e69e 100644 --- a/packages/pybamm/src/pybamm/experiment/step/steps.py +++ b/packages/pybamm/src/pybamm/experiment/step/steps.py @@ -194,6 +194,7 @@ class Voltage(BaseStepImplicit): """ control_kind = ControlKind.VOLTAGE + hermite_upsampling_variable = "Current [A]" def get_parameter_values(self, variables): return {"Voltage function [V]": self.value} diff --git a/packages/pybamm/src/pybamm/simulation/simulation.py b/packages/pybamm/src/pybamm/simulation/simulation.py index 70d941441a..4d0178150d 100644 --- a/packages/pybamm/src/pybamm/simulation/simulation.py +++ b/packages/pybamm/src/pybamm/simulation/simulation.py @@ -349,6 +349,9 @@ def control_builder(step): # BaseModel.build_casadi_jacobian. The attribute rides this same model object # (in-place parameter processing and discretisation) through to solver.process. new_model.switching_control_variables = {var.name for var in submodel.algebraic} + new_model.hermite_upsampling_variables = list( + dict.fromkeys(step.hermite_upsampling_variable for step in unique_steps) + ) # Combine each step's local termination expression into one experiment event, # selecting the active branch with the step index input. diff --git a/packages/pybamm/src/pybamm/solvers/idaklu_solver.py b/packages/pybamm/src/pybamm/solvers/idaklu_solver.py index d8260b3755..ba80015717 100644 --- a/packages/pybamm/src/pybamm/solvers/idaklu_solver.py +++ b/packages/pybamm/src/pybamm/solvers/idaklu_solver.py @@ -13,7 +13,7 @@ import pybamm from pybamm.codegen.compilation import aot_compile -from pybamm.solvers.base_solver import flatten_inputs, stack_inputs +from pybamm.solvers.base_solver import flatten_inputs, process, stack_inputs from pybamm.solvers.observation import OutputAssembly _UNSET = object() @@ -85,6 +85,7 @@ def _sensitivity_scales(inputs_dict: dict, sensitivity_names: list[str]) -> np.n "rootfn", "alg_res", "alg_jac", + "hermite_upsampling_fcn", ) _SETUP_FCN_LIST_KEYS = ( "var_idaklu_fcns", @@ -241,6 +242,11 @@ class IDAKLUSolver(pybamm.BaseSolver): # only active if `hermite_interpolation` is True and sensitivities # are disabled. "hermite_reduction_factor": 1.0, + # Setting hermite_upsampling_factor > 1.0 adds points within each + # step until the cubic Hermite interpolant of the model's + # `hermite_upsampling_variables` (default: the voltage) is within + # the solver tolerances divided by (factor - 1). Ignored with t_interp. + "hermite_upsampling_factor": 1.0, ## Initial conditions calculation # Positive constant in the Newton iteration convergence test within the # initial condition calculation @@ -370,6 +376,7 @@ def _combine_options(self, user_options: dict | None) -> dict: "suppress_algebraic_error": False, "hermite_interpolation": True, "hermite_reduction_factor": 1.0, + "hermite_upsampling_factor": 1.0, "nonlinear_convergence_coefficient_ic": 0.0033, "max_num_steps_ic": 50, "max_num_jacobians_ic": 40, @@ -407,6 +414,15 @@ def _check_options(self, options: dict): if hermite_reduction_factor < 1.0: raise pybamm.SolverError("hermite_reduction_factor must be >= 1.0.") + hermite_upsampling_factor = options["hermite_upsampling_factor"] + if hermite_upsampling_factor < 1.0: + raise pybamm.SolverError("hermite_upsampling_factor must be >= 1.0.") + if hermite_upsampling_factor > 1.0 and hermite_reduction_factor > 1.0: + raise pybamm.SolverError( + "hermite_upsampling_factor and hermite_reduction_factor are " + "mutually exclusive; at most one can be > 1.0." + ) + if not isinstance(options["compile"], bool): raise pybamm.SolverError("compile must be a bool") @@ -642,6 +658,8 @@ def set_up(self, model, inputs=None, t_eval=None, ics_only=False): stacklevel=2, ) + hermite_upsampling_fcn = self._hermite_upsampling_fcn(model, inputs_dict) + # Collect all casadi functions for AOT compilation. With compile=True, # all functions are bundled into a single shared library via one gcc # invocation; each serialized Function then points at its entry point. @@ -658,6 +676,7 @@ def set_up(self, model, inputs=None, t_eval=None, ics_only=False): rootfn, alg_res_fn, jac_alg_fn, + hermite_upsampling_fcn, ), strict=True, ) @@ -743,6 +762,7 @@ def to_idaklu(fn): options=self._options, alg_res=self._setup["alg_res"], alg_jac=self._setup["alg_jac"], + hermite_upsampling_fcn=self._setup["hermite_upsampling_fcn"], ) # C++ solver group now owns each casadi::Function via shared_ptr; @@ -760,6 +780,61 @@ def to_idaklu(fn): return base_set_up_return + def _hermite_upsampling_fcn(self, model, inputs_dict): + """``g(t, [y; yp], p)``: each upsampled variable and its time derivative. + + Algebraic states are skipped: the integrator's error test already + resolves them. Empty if there is nothing to upsample. + """ + empty = casadi.Function("empty_hermite_upsampling", [], []) + if self._options["hermite_upsampling_factor"] <= 1.0: + return empty + # Model copies keep a state's name but not its id + algebraic_names = {variable.name for variable in model.algebraic} + names = [ + name + for name in getattr(model, "hermite_upsampling_variables", ["Voltage [V]"]) + if name in model.variables + and not ( + isinstance(model.variables[name], pybamm.Variable) + and model.variables[name].name in algebraic_names + ) + ] + if not names: + return empty + vars_for_processing = self._get_vars_for_processing(model, inputs_dict) + n = model.len_rhs_and_alg + t = casadi.MX.sym("t") + y_and_yp = casadi.MX.sym("y_and_yp", 2 * n) + y = y_and_yp[:n] + direction = casadi.vertcat(y_and_yp[n:], casadi.MX.zeros(n)) + p = casadi.MX.sym("p", vars_for_processing["p_casadi_stacked"].shape[0]) + outputs = [] + for name in names: + var_fcn = process( + model.get_processed_variable_or_event(name), + pybamm.BaseSolver._wrangle_name(name), + vars_for_processing, + use_jacobian=False, + )[0] + if var_fcn.numel_out() != 1: + raise pybamm.SolverError( + f"hermite_upsampling_factor needs a scalar variable, got '{name}'" + ) + value = var_fcn(t, y, p) + rate = casadi.jacobian(value, t) + casadi.jtimes(value, y_and_yp, direction) + outputs += [value, rate] + function = casadi.Function( + "hermite_upsampling", [t, y_and_yp, p], [casadi.vertcat(*outputs)] + ) + try: + return function.expand() + except RuntimeError as error: + # Interpolants, for example, cannot be expanded + if "'eval_sx' not defined for" not in str(error): + raise # pragma: no cover + return function + def __getstate__(self): # Drop the rebuildable state (C++ solver + casadi.Function graphs) # so the next solve() rebuilds from the model rather than shipping diff --git a/packages/pybamm/tests/unit/test_experiments/test_base_step.py b/packages/pybamm/tests/unit/test_experiments/test_base_step.py index fd3e7f9867..65f89fd140 100644 --- a/packages/pybamm/tests/unit/test_experiments/test_base_step.py +++ b/packages/pybamm/tests/unit/test_experiments/test_base_step.py @@ -160,3 +160,18 @@ def test_parse_termination_requires_operator_for_input_parameter(): pybamm.experiment.step.base_step._parse_termination( "2A", pybamm.InputParameter("I_app") ) + + +@pytest.mark.parametrize( + "step, variable", + [ + (pybamm.step.current(1), "Voltage [V]"), + (pybamm.step.c_rate(1), "Voltage [V]"), + (pybamm.step.rest(), "Voltage [V]"), + (pybamm.step.power(1), "Voltage [V]"), + (pybamm.step.resistance(1), "Voltage [V]"), + (pybamm.step.voltage(4.1), "Current [A]"), + ], +) +def test_hermite_upsampling_variable(step, variable): + assert step.hermite_upsampling_variable == variable diff --git a/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py b/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py index c83d32f265..1d598634c5 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py @@ -2733,3 +2733,177 @@ def test_iterative_linear_solver_statistics( stats = solution.solver_statistics assert stats.number_of_steps > 0 assert stats.number_of_nonlinear_solver_iterations >= stats.number_of_steps + + +class TestHermiteUpsampling: + @staticmethod + def _solve(options=None, **solver_kwargs): + parameter_values = pybamm.ParameterValues("Chen2020") + parameter_values["Current function [A]"] = 10 + solver = pybamm.IDAKLUSolver(options=options, **solver_kwargs) + sim = pybamm.Simulation( + pybamm.lithium_ion.SPM(), parameter_values=parameter_values, solver=solver + ) + return sim.solve([0, 3600]) + + def test_adds_points_in_every_storage_mode(self): + baseline = self._solve() + upsampled = self._solve({"hermite_upsampling_factor": 2.0}) + assert upsampled.t.size > baseline.t.size + assert np.all(np.isin(baseline.t, upsampled.t)) + + # Upsampling uses the integrator's own interpolant, not stored derivatives + no_hermite = self._solve( + {"hermite_upsampling_factor": 2.0, "hermite_interpolation": False} + ) + outputs_only = self._solve( + {"hermite_upsampling_factor": 2.0}, output_variables=["Voltage [V]"] + ) + np.testing.assert_array_equal(no_hermite.t, upsampled.t) + np.testing.assert_array_equal(outputs_only.t, upsampled.t) + np.testing.assert_allclose( + outputs_only["Voltage [V]"].entries, + upsampled["Voltage [V]"].entries, + rtol=1e-12, + ) + + def test_resolves_voltage_hermite_interpolant(self): + reference = self._solve(rtol=1e-10, atol=1e-12) + parameter_values = pybamm.ParameterValues("Chen2020") + parameter_values["Current function [A]"] = 10 + runs = [] + for factor in [1.0, 2.0]: + solver = pybamm.IDAKLUSolver(options={"hermite_upsampling_factor": factor}) + sim = pybamm.Simulation( + pybamm.lithium_ion.SPM(), + parameter_values=parameter_values, + solver=solver, + ) + runs.append((solver, sim, sim.solve([0, 3600]))) + # The factor-2 solver builds the voltage and its rate for both runs + value_and_rate = runs[1][0]._hermite_upsampling_fcn(runs[1][1].built_model, {}) + ratios = [] + for solver, _, solution in runs: + t = solution.t + voltage, rate = np.hstack( + [ + np.asarray( + value_and_rate( + t[k], + np.concatenate([solution.y[:, k], solution.yp[:, k]]), + [], + ) + ) + for k in range(t.size) + ] + ) + h = np.diff(t) + hermite = ( + 0.5 * (voltage[:-1] + voltage[1:]) + h * (rate[:-1] - rate[1:]) / 8 + ) + truth = reference["Voltage [V]"](t[:-1] + h / 2) + ratios.append( + np.max(np.abs(hermite - truth) / (solver.atol + solver.rtol * truth)) + ) + assert ratios[0] > 2 + assert ratios[1] < 2 + + def test_sensitivities(self): + parameter_values = pybamm.ParameterValues("Chen2020") + parameter_values["Current function [A]"] = "[input]" + inputs = {"Current function [A]": 5} + + def solve(factor, t_interp=None, output_variables=None): + sim = pybamm.Simulation( + pybamm.lithium_ion.SPM(), + parameter_values=parameter_values, + solver=pybamm.IDAKLUSolver( + options={"hermite_upsampling_factor": factor}, + output_variables=output_variables, + ), + ) + return sim.solve( + [0, 3600], + inputs=inputs, + calculate_sensitivities=True, + t_interp=t_interp, + ) + + baseline = solve(1.0) + upsampled = solve(2.0) + assert upsampled.t.size > baseline.t.size + + # The added points carry the integrator's own interpolated sensitivities + reference = solve(1.0, t_interp=upsampled.t) + outputs_only = solve(2.0, output_variables=["Voltage [V]"]) + np.testing.assert_array_equal(outputs_only.t, upsampled.t) + for solution in [upsampled, outputs_only]: + np.testing.assert_allclose( + solution["Voltage [V]"].sensitivities["Current function [A]"], + reference["Voltage [V]"].sensitivities["Current function [A]"], + rtol=1e-10, + atol=1e-12, + ) + + def test_option_errors(self): + with pytest.raises(pybamm.SolverError, match=r"must be >= 1.0"): + pybamm.IDAKLUSolver(options={"hermite_upsampling_factor": 0.5}) + with pytest.raises(pybamm.SolverError, match="mutually exclusive"): + pybamm.IDAKLUSolver( + options={ + "hermite_upsampling_factor": 2.0, + "hermite_reduction_factor": 2.0, + } + ) + model = pybamm.lithium_ion.SPM() + model.hermite_upsampling_variables = [ + "Negative particle concentration [mol.m-3]" + ] + solver = pybamm.IDAKLUSolver(options={"hermite_upsampling_factor": 2.0}) + sim = pybamm.Simulation(model, solver=solver) + with pytest.raises(pybamm.SolverError, match="needs a scalar variable"): + sim.solve([0, 600]) + + def test_skips_algebraic_and_missing_variables(self): + experiment = pybamm.Experiment( + ["Charge at 1C until 4.1 V", "Hold at 4.1 V for 10 minutes"] + ) + sim = pybamm.Simulation( + pybamm.lithium_ion.SPM(), + experiment=experiment, + solver=pybamm.IDAKLUSolver(options={"hermite_upsampling_factor": 2.0}), + ) + sim.solve() + charge, hold = experiment.steps + solver = sim._get_built_experiment_solver(charge) + model = sim._get_built_experiment_model(charge) + assert solver._hermite_upsampling_fcn(model, {}).n_out() == 1 + model.hermite_upsampling_variables = ["Not a variable"] + assert solver._hermite_upsampling_fcn(model, {}).n_out() == 0 + + # In a voltage hold the current is an algebraic state + solver = sim._get_built_experiment_solver(hold) + model = sim._get_built_experiment_model(hold) + assert model.hermite_upsampling_variables == ["Current [A]"] + assert solver._hermite_upsampling_fcn(model, {}).n_out() == 0 + + @pytest.mark.parametrize("mode", ["legacy", "unified"]) + def test_experiment_sets_variables_at_build(self, mode): + experiment = pybamm.Experiment( + ["Charge at 1C until 4.1 V", "Hold at 4.1 V for 10 minutes"] + ) + sim = pybamm.Simulation( + pybamm.lithium_ion.SPM(), + experiment=experiment, + solver=pybamm.IDAKLUSolver(options={"hermite_upsampling_factor": 2.0}), + experiment_model_mode=mode, + ) + sim.solve() + variables = [ + sim._get_built_experiment_model(step).hermite_upsampling_variables + for step in experiment.steps + ] + if mode == "legacy": + assert variables == [["Voltage [V]"], ["Current [A]"]] + else: + assert variables == [["Voltage [V]", "Current [A]"]] * 2 diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu.cpp b/packages/pybammsolvers/src/pybammsolvers/idaklu.cpp index 6f9d03d054..b070991dd2 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu.cpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu.cpp @@ -51,7 +51,8 @@ IDAKLUSolverGroup *create_casadi_solver_group( const std::vector& dvar_dp_fcns, py::dict py_opts, const CasadiFunctions::BaseFunctionType &alg_res, - const CasadiFunctions::BaseFunctionType &alg_jac) + const CasadiFunctions::BaseFunctionType &alg_jac, + const CasadiFunctions::BaseFunctionType &hermite_upsampling_fcn) { auto setup_opts = SetupOptions(py_opts); auto solver_opts = SolverOptions(py_opts); @@ -95,7 +96,8 @@ IDAKLUSolverGroup *create_casadi_solver_group( setup_opts, serialized_fcns ? &*serialized_fcns : nullptr, alg_res, - alg_jac + alg_jac, + hermite_upsampling_fcn ); solvers.emplace_back( std::unique_ptr( @@ -174,6 +176,7 @@ PYBIND11_MODULE(idaklu, m) py::arg("options"), py::arg("alg_res"), py::arg("alg_jac"), + py::arg("hermite_upsampling_fcn"), py::return_value_policy::take_ownership); m.def("observe", &observe, diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Base/ExpressionSet.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Base/ExpressionSet.hpp index b1b47013e0..2a7d8ceb04 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Base/ExpressionSet.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Base/ExpressionSet.hpp @@ -66,6 +66,8 @@ class ExpressionSet Expression *events = nullptr; Expression *alg_res = nullptr; Expression *alg_jac = nullptr; + // g(t, [y; yp], p) -> (value, time derivative) pairs of the upsampled outputs + Expression *hermite_upsampling_fcn = nullptr; std::vector var_fcns; std::vector dvar_dy_fcns; diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Casadi/CasadiFunctions.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Casadi/CasadiFunctions.hpp index 0f292d6612..9dd368535b 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Casadi/CasadiFunctions.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Casadi/CasadiFunctions.hpp @@ -110,7 +110,8 @@ class CasadiFunctions : public ExpressionSet const SetupOptions& setup_opts, const SerializedCasadiFunctions *serialized_fcns = nullptr, const BaseFunctionType &alg_res_fn = BaseFunctionType(), - const BaseFunctionType &alg_jac_fn = BaseFunctionType() + const BaseFunctionType &alg_jac_fn = BaseFunctionType(), + const BaseFunctionType &hermite_upsampling_fn = BaseFunctionType() ) : rhs_alg_casadi( rhs_alg, @@ -138,6 +139,7 @@ class CasadiFunctions : public ExpressionSet serialized_fcns != nullptr ? &serialized_fcns->events : nullptr), alg_res_casadi_(alg_res_fn), alg_jac_casadi_(alg_jac_fn), + hermite_upsampling_casadi_(hermite_upsampling_fn, setup_opts.num_solvers > 1), ExpressionSet( static_cast(&rhs_alg_casadi), static_cast(&jac_times_cjmass_casadi), @@ -158,6 +160,7 @@ class CasadiFunctions : public ExpressionSet { this->alg_res = static_cast(&alg_res_casadi_); this->alg_jac = static_cast(&alg_jac_casadi_); + this->hermite_upsampling_fcn = static_cast(&hermite_upsampling_casadi_); // NOTE: You must allocate ALL std::vector elements before taking references const bool deep_copy = setup_opts.num_solvers > 1; if (serialized_fcns != nullptr) { @@ -222,6 +225,7 @@ class CasadiFunctions : public ExpressionSet CasadiFunction events_casadi; CasadiFunction alg_res_casadi_; CasadiFunction alg_jac_casadi_; + CasadiFunction hermite_upsampling_casadi_; std::vector var_fcns_casadi; std::vector dvar_dy_fcns_casadi; diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp index d80ac45890..7c933837f7 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp @@ -179,6 +179,13 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver // ── Solve-duration state (valid only during solve()) ── bool use_knot_reduction_ = false; + bool use_upsampling_ = false; + sunrealtype upsampling_atol_ = 0.0; + std::vector upsample_in_; // [y; yp] passed to hermite_upsampling_fcn + std::vector upsample_end_; // [y; yp] at the end of the current step + // hermite_upsampling_fcn at the previous step end, reused as the next step start + std::vector upsample_f_prev_; + bool upsample_f_prev_valid_ = false; bool save_adaptive_steps_ = false; bool save_interp_steps_ = false; int i_save_ = 0; @@ -417,6 +424,34 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver */ void SavePoint(sunrealtype t_val, bool extend_arrays, bool is_breakpoint); + /** + * @brief Save points inside the last step [t_a, t_b] until the cubic Hermite + * interpolant of each upsampled output resolves it, then restore the state at t_b + */ + void UpsampleStep(sunrealtype t_a, sunrealtype t_b); + + /** + * @brief Bisect [t_a, t_b] while the outputs' Hermite interpolant misses the + * midpoint, saving each midpoint in time order + * @return The number of points saved + */ + int UpsampleInterval( + sunrealtype t_a, const std::vector &f_a, + sunrealtype t_b, const std::vector &f_b, + int depth); + + /** + * @brief Evaluate hermite_upsampling_fcn at t from y_and_yp = [y; yp] + */ + void EvaluateUpsampleOutputs( + sunrealtype t, sunrealtype *y_and_yp, std::vector &out); + + /** + * @brief Evaluate hermite_upsampling_fcn on the integrator's interpolant at t, + * loading only the states and their derivatives (not the sensitivities) + */ + void EvaluateUpsampleOutputsAt(sunrealtype t, std::vector &out); + /** * @brief Handle a breakpoint at t_eval: finalize interval, reinitialize */ diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl index b001058198..1776bdc958 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl @@ -419,6 +419,9 @@ SolutionData IDAKLUSolverOpenMP::solve( } if (hit_adaptive || hit_teval || hit_event || hit_final_time) { + if (use_upsampling_ && t_val > t_prev) { + UpsampleStep(t_prev, t_val); + } bool is_breakpoint = (hit_teval || hit_event) && !final_step; SavePoint(t_val, /*extend_arrays=*/hit_adaptive, is_breakpoint); } @@ -464,6 +467,20 @@ void IDAKLUSolverOpenMP::InitializeSolveStorage( !sensitivity ); + use_upsampling_ = ( + solver_opts.hermite_upsampling_factor > 1.0 && + save_adaptive_steps_ && + !use_knot_reduction_ && + functions->hermite_upsampling_fcn != nullptr && + functions->hermite_upsampling_fcn->nnz_out() > 0 + ); + upsample_f_prev_valid_ = false; + if (use_upsampling_) { + upsample_in_.resize(2 * number_of_states); + upsample_end_.resize(2 * number_of_states); + upsampling_atol_ = N_VMin(avtol); + } + length_of_return_vector = ReturnVectorLength(); i_save_ = 0; @@ -646,6 +663,90 @@ void IDAKLUSolverOpenMP::SavePoint( } } +template +void IDAKLUSolverOpenMP::UpsampleStep( + sunrealtype t_a, sunrealtype t_b +) { + DEBUG("IDAKLUSolver::UpsampleStep"); + // The state at t_b is already loaded, so it is evaluated once and reused + std::copy(y_val_, y_val_ + number_of_states, upsample_end_.begin()); + std::copy(yp_val_, yp_val_ + number_of_states, + upsample_end_.begin() + number_of_states); + std::vector f_b; + EvaluateUpsampleOutputs(t_b, upsample_end_.data(), f_b); + if (!upsample_f_prev_valid_) { + EvaluateUpsampleOutputsAt(t_a, upsample_f_prev_); + } + + if (UpsampleInterval(t_a, upsample_f_prev_, t_b, f_b, 0) > 0) { + // Saving a midpoint also moved the sensitivities + GetSolutionFull(t_b); + } else { + std::copy(upsample_end_.begin(), upsample_end_.begin() + number_of_states, + y_val_); + std::copy(upsample_end_.begin() + number_of_states, upsample_end_.end(), + yp_val_); + } + upsample_f_prev_ = std::move(f_b); + upsample_f_prev_valid_ = true; +} + +template +int IDAKLUSolverOpenMP::UpsampleInterval( + sunrealtype t_a, const std::vector &f_a, + sunrealtype t_b, const std::vector &f_b, + int depth +) { + constexpr int max_depth = 20; + if (depth >= max_depth) { + return 0; + } + sunrealtype const t_m = 0.5 * (t_a + t_b); + std::vector f_m; + EvaluateUpsampleOutputsAt(t_m, f_m); + + // f holds (value, rate) pairs; the cubic Hermite midpoint is + // (v_a + v_b) / 2 + h (r_a - r_b) / 8 + sunrealtype const h = t_b - t_a; + sunrealtype const budget = 1.0 / (solver_opts.hermite_upsampling_factor - 1.0); + bool resolved = true; + for (size_t k = 0; k + 1 < f_m.size(); k += 2) { + sunrealtype const hermite = + 0.5 * (f_a[k] + f_b[k]) + 0.125 * h * (f_a[k + 1] - f_b[k + 1]); + sunrealtype const tol = budget * (upsampling_atol_ + rtol * std::abs(f_m[k])); + resolved = resolved && std::abs(hermite - f_m[k]) <= tol; + } + if (resolved) { + return 0; + } + + int saved = UpsampleInterval(t_a, f_a, t_m, f_m, depth + 1); + GetSolutionFull(t_m); + SavePoint(t_m, /*extend_arrays=*/true, /*is_breakpoint=*/false); + return saved + 1 + UpsampleInterval(t_m, f_m, t_b, f_b, depth + 1); +} + +template +void IDAKLUSolverOpenMP::EvaluateUpsampleOutputs( + sunrealtype t, sunrealtype *y_and_yp, std::vector &out +) { + out.resize(functions->hermite_upsampling_fcn->nnz_out()); + (*functions->hermite_upsampling_fcn)( + {&t, y_and_yp, functions->inputs.data()}, {out.data()}); +} + +template +void IDAKLUSolverOpenMP::EvaluateUpsampleOutputsAt( + sunrealtype t, std::vector &out +) { + CheckErrors(IDAGetDky(ida_mem, t, 0, yy), "IDAGetDky"); + CheckErrors(IDAGetDky(ida_mem, t, 1, yyp), "IDAGetDky"); + std::copy(y_val_, y_val_ + number_of_states, upsample_in_.begin()); + std::copy(yp_val_, yp_val_ + number_of_states, + upsample_in_.begin() + number_of_states); + EvaluateUpsampleOutputs(t, upsample_in_.data(), out); +} + template void IDAKLUSolverOpenMP::HandleBreakpoint( sunrealtype t_val, @@ -667,6 +768,8 @@ void IDAKLUSolverOpenMP::HandleBreakpoint( // Reinitialize the solver to deal with the discontinuity at t = t_val ReinitializeIntegrator(t_val); + // yp jumps here, so the next step starts from a fresh evaluation + upsample_f_prev_valid_ = false; ConsistentInitialization(t_val, t_eval_next, IDA_YA_YDP_INIT); log_.log_consistent_init(t_val); diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp index aaaec75529..2a8082fd76 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp @@ -196,6 +196,17 @@ SolverOptions::SolverOptions(py::dict &py_opts) } } + // Output upsampling multiplier (1.0 = off, >1.0 = active) + hermite_upsampling_factor = 1.0; + if (py_opts.contains("hermite_upsampling_factor")) { + hermite_upsampling_factor = py_opts["hermite_upsampling_factor"].cast(); + if (hermite_upsampling_factor < 1.0) { + throw std::domain_error( + "hermite_upsampling_factor must be >= 1.0. Got " + std::to_string(hermite_upsampling_factor) + ); + } + } + // Newton solver for algebraic initial conditions newton_step_tol = 1e-4; if (py_opts.contains("newton_step_tol")) { diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp index 63a3f5362d..10233c4778 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp @@ -59,6 +59,8 @@ struct SolverOptions { sunrealtype t_no_progress; // Knot reduction (streaming spline compression) double hermite_reduction_factor; // >= 1.0; total error budget is M * solver_tol (threshold = N*(M-1)^2) + // > 1.0 bisects steps until hermite_upsampling_fcn is within solver_tol / (M-1) + double hermite_upsampling_factor; // Newton solver for algebraic initial conditions double newton_step_tol; std::string newton_mode; // "auto" or "full" diff --git a/packages/pybammsolvers/tests/conftest.py b/packages/pybammsolvers/tests/conftest.py index 36502c808c..918256b8ba 100644 --- a/packages/pybammsolvers/tests/conftest.py +++ b/packages/pybammsolvers/tests/conftest.py @@ -171,6 +171,7 @@ def make_exponential_decay_solver( # No algebraic equations for this pure ODE: provide empty functions alg_res = casadi.Function("empty_alg_res", [], []) alg_jac = casadi.Function("empty_alg_jac", [], []) + hermite_upsampling = casadi.Function("empty_hermite_upsampling", [], []) # DAE identifier (1 = differential, 0 = algebraic) # For ODE, all states are differential @@ -210,6 +211,9 @@ def make_exponential_decay_solver( events_func = idaklu_module.generate_function(events.serialize()) alg_res_func = idaklu_module.generate_function(alg_res.serialize()) alg_jac_func = idaklu_module.generate_function(alg_jac.serialize()) + hermite_upsampling_func = idaklu_module.generate_function( + hermite_upsampling.serialize() + ) # Solver options options = { @@ -281,6 +285,7 @@ def make_exponential_decay_solver( options, alg_res_func, alg_jac_func, + hermite_upsampling_func, ) # Initial conditions need to be 2D: [number_of_groups, n_coeffs] From 5ffc6c43f4bc19ffb5f25edc92c9127483f7c943 Mon Sep 17 00:00:00 2001 From: Marc Berliner <34451391+MarcBerliner@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:14:08 -0400 Subject: [PATCH 02/10] experiment: add upsampling_basis="linear" for output upsampling Prototype: check the midpoint against linear interpolation between step ends instead of the cubic Hermite, and skip the analytic time derivative. --- packages/pybamm/src/pybamm/solvers/idaklu_solver.py | 8 +++++++- .../pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl | 2 +- .../src/pybammsolvers/idaklu_source/Options.cpp | 5 +++++ .../src/pybammsolvers/idaklu_source/Options.hpp | 1 + 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/pybamm/src/pybamm/solvers/idaklu_solver.py b/packages/pybamm/src/pybamm/solvers/idaklu_solver.py index ba80015717..08d4bbd3aa 100644 --- a/packages/pybamm/src/pybamm/solvers/idaklu_solver.py +++ b/packages/pybamm/src/pybamm/solvers/idaklu_solver.py @@ -377,6 +377,7 @@ def _combine_options(self, user_options: dict | None) -> dict: "hermite_interpolation": True, "hermite_reduction_factor": 1.0, "hermite_upsampling_factor": 1.0, + "upsampling_basis": "hermite", "nonlinear_convergence_coefficient_ic": 0.0033, "max_num_steps_ic": 50, "max_num_jacobians_ic": 40, @@ -822,7 +823,12 @@ def _hermite_upsampling_fcn(self, model, inputs_dict): f"hermite_upsampling_factor needs a scalar variable, got '{name}'" ) value = var_fcn(t, y, p) - rate = casadi.jacobian(value, t) + casadi.jtimes(value, y_and_yp, direction) + if self._options["upsampling_basis"] == "linear": + rate = casadi.MX.zeros(1) + else: + rate = casadi.jacobian(value, t) + casadi.jtimes( + value, y_and_yp, direction + ) outputs += [value, rate] function = casadi.Function( "hermite_upsampling", [t, y_and_yp, p], [casadi.vertcat(*outputs)] diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl index 1776bdc958..1688a08896 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl @@ -707,7 +707,7 @@ int IDAKLUSolverOpenMP::UpsampleInterval( // f holds (value, rate) pairs; the cubic Hermite midpoint is // (v_a + v_b) / 2 + h (r_a - r_b) / 8 - sunrealtype const h = t_b - t_a; + sunrealtype const h = solver_opts.upsampling_linear ? 0.0 : t_b - t_a; sunrealtype const budget = 1.0 / (solver_opts.hermite_upsampling_factor - 1.0); bool resolved = true; for (size_t k = 0; k + 1 < f_m.size(); k += 2) { diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp index 2a8082fd76..9410013a2e 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp @@ -213,6 +213,11 @@ SolverOptions::SolverOptions(py::dict &py_opts) newton_step_tol = py_opts["newton_step_tol"].cast(); } + upsampling_linear = false; + if (py_opts.contains("upsampling_basis")) { + upsampling_linear = py_opts["upsampling_basis"].cast() == "linear"; + } + newton_mode = "auto"; if (py_opts.contains("newton_mode")) { newton_mode = py_opts["newton_mode"].cast(); diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp index 10233c4778..709c7ba10d 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp @@ -61,6 +61,7 @@ struct SolverOptions { double hermite_reduction_factor; // >= 1.0; total error budget is M * solver_tol (threshold = N*(M-1)^2) // > 1.0 bisects steps until hermite_upsampling_fcn is within solver_tol / (M-1) double hermite_upsampling_factor; + bool upsampling_linear; // check against linear instead of cubic Hermite // Newton solver for algebraic initial conditions double newton_step_tol; std::string newton_mode; // "auto" or "full" From 60e602a4d28098be133176705191e74ee71ce8b8 Mon Sep 17 00:00:00 2001 From: Marc Berliner <34451391+MarcBerliner@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:16:13 -0400 Subject: [PATCH 03/10] experiment: skip yp interpolation for linear upsampling midpoints --- .../src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl index 1688a08896..efabab098a 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl @@ -740,7 +740,10 @@ void IDAKLUSolverOpenMP::EvaluateUpsampleOutputsAt( sunrealtype t, std::vector &out ) { CheckErrors(IDAGetDky(ida_mem, t, 0, yy), "IDAGetDky"); - CheckErrors(IDAGetDky(ida_mem, t, 1, yyp), "IDAGetDky"); + // The linear check reads values only + if (!solver_opts.upsampling_linear) { + CheckErrors(IDAGetDky(ida_mem, t, 1, yyp), "IDAGetDky"); + } std::copy(y_val_, y_val_ + number_of_states, upsample_in_.begin()); std::copy(yp_val_, yp_val_ + number_of_states, upsample_in_.begin() + number_of_states); From 3eacb524757c88faa629f7bead195c102bdbc99f Mon Sep 17 00:00:00 2001 From: Marc Berliner <34451391+MarcBerliner@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:47:56 -0400 Subject: [PATCH 04/10] feat: replace Hermite output upsampling with linear upsampling_factor Check each step midpoint against linear interpolation of the upsampled variables' values, so the function needs only (t, y, p): no analytic time derivative and no yp interpolation. Rename hermite_upsampling_* to upsampling_* (option, function, step and model attributes). --- CHANGELOG.md | 2 +- .../src/pybamm/experiment/step/base_step.py | 8 +- .../src/pybamm/experiment/step/steps.py | 2 +- .../src/pybamm/simulation/simulation.py | 4 +- .../src/pybamm/solvers/idaklu_solver.py | 61 ++++++-------- .../unit/test_experiments/test_base_step.py | 4 +- .../unit/test_solvers/test_idaklu_solver.py | 79 +++++++------------ .../src/pybammsolvers/idaklu.cpp | 6 +- .../Expressions/Base/ExpressionSet.hpp | 4 +- .../Expressions/Casadi/CasadiFunctions.hpp | 8 +- .../idaklu_source/IDAKLUSolverOpenMP.hpp | 21 +++-- .../idaklu_source/IDAKLUSolverOpenMP.inl | 48 ++++------- .../pybammsolvers/idaklu_source/Options.cpp | 15 ++-- .../pybammsolvers/idaklu_source/Options.hpp | 5 +- packages/pybammsolvers/tests/conftest.py | 8 +- 15 files changed, 107 insertions(+), 168 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb21abf8fc..f378052af4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ ## Features -- Added the `IDAKLUSolver` option `hermite_upsampling_factor`: a value above 1.0 saves extra points inside each step until the voltage (or current, for voltage control) is resolved by its own cubic Hermite interpolant. ([#5998](https://github.com/pybamm-team/PyBaMM/pull/5998)) +- Added the `IDAKLUSolver` option `upsampling_factor`: a value above 1.0 saves extra points inside each step until linear interpolation of the voltage (or current, for voltage control) is within the solver tolerances divided by `factor - 1`. ([#5998](https://github.com/pybamm-team/PyBaMM/pull/5998)) - Replaced Codacy with open checks that also run locally. Ruff's `flake8-bandit` (`S`) rules replace Bandit, and CodeQL scans the Python and C++ code. `nox -s type-check` runs Pyrefly over `pybamm`, which now type-checks cleanly, and a new type error fails CI. clang-tidy reports solver findings to code scanning as an advisory check. ([#5974](https://github.com/pybamm-team/PyBaMM/pull/5974)) ## Bug fixes diff --git a/packages/pybamm/src/pybamm/experiment/step/base_step.py b/packages/pybamm/src/pybamm/experiment/step/base_step.py index 57868ffb0b..bea7e5bc3c 100644 --- a/packages/pybamm/src/pybamm/experiment/step/base_step.py +++ b/packages/pybamm/src/pybamm/experiment/step/base_step.py @@ -296,8 +296,8 @@ def __hash__(self): #: their target value collapse to one unified-mode branch. control_kind = None - #: The scalar output the solver's ``hermite_upsampling_factor`` resolves. - hermite_upsampling_variable = "Voltage [V]" + #: The scalar output the solver's ``upsampling_factor`` resolves. + upsampling_variable = "Voltage [V]" def get_control_residual(self, variables, target=None): """Control residual. ``target`` is the per-step input that collapsible steps @@ -465,9 +465,7 @@ def process_model( inplace=inplace, delayed_variable_processing=delayed_variable_processing, ) - processed_model.hermite_upsampling_variables = [ - self.hermite_upsampling_variable - ] + processed_model.upsampling_variables = [self.upsampling_variable] return processed_model def get_termination_events(self, variables): diff --git a/packages/pybamm/src/pybamm/experiment/step/steps.py b/packages/pybamm/src/pybamm/experiment/step/steps.py index b55551e69e..90f9707604 100644 --- a/packages/pybamm/src/pybamm/experiment/step/steps.py +++ b/packages/pybamm/src/pybamm/experiment/step/steps.py @@ -194,7 +194,7 @@ class Voltage(BaseStepImplicit): """ control_kind = ControlKind.VOLTAGE - hermite_upsampling_variable = "Current [A]" + upsampling_variable = "Current [A]" def get_parameter_values(self, variables): return {"Voltage function [V]": self.value} diff --git a/packages/pybamm/src/pybamm/simulation/simulation.py b/packages/pybamm/src/pybamm/simulation/simulation.py index 4d0178150d..5991807875 100644 --- a/packages/pybamm/src/pybamm/simulation/simulation.py +++ b/packages/pybamm/src/pybamm/simulation/simulation.py @@ -349,8 +349,8 @@ def control_builder(step): # BaseModel.build_casadi_jacobian. The attribute rides this same model object # (in-place parameter processing and discretisation) through to solver.process. new_model.switching_control_variables = {var.name for var in submodel.algebraic} - new_model.hermite_upsampling_variables = list( - dict.fromkeys(step.hermite_upsampling_variable for step in unique_steps) + new_model.upsampling_variables = list( + dict.fromkeys(step.upsampling_variable for step in unique_steps) ) # Combine each step's local termination expression into one experiment event, diff --git a/packages/pybamm/src/pybamm/solvers/idaklu_solver.py b/packages/pybamm/src/pybamm/solvers/idaklu_solver.py index 08d4bbd3aa..5c70a50707 100644 --- a/packages/pybamm/src/pybamm/solvers/idaklu_solver.py +++ b/packages/pybamm/src/pybamm/solvers/idaklu_solver.py @@ -85,7 +85,7 @@ def _sensitivity_scales(inputs_dict: dict, sensitivity_names: list[str]) -> np.n "rootfn", "alg_res", "alg_jac", - "hermite_upsampling_fcn", + "upsampling_fcn", ) _SETUP_FCN_LIST_KEYS = ( "var_idaklu_fcns", @@ -242,11 +242,11 @@ class IDAKLUSolver(pybamm.BaseSolver): # only active if `hermite_interpolation` is True and sensitivities # are disabled. "hermite_reduction_factor": 1.0, - # Setting hermite_upsampling_factor > 1.0 adds points within each - # step until the cubic Hermite interpolant of the model's - # `hermite_upsampling_variables` (default: the voltage) is within - # the solver tolerances divided by (factor - 1). Ignored with t_interp. - "hermite_upsampling_factor": 1.0, + # Setting upsampling_factor > 1.0 adds points within each step + # until linear interpolation of the model's `upsampling_variables` + # (default: the voltage) is within the solver tolerances divided + # by (factor - 1). Ignored with t_interp. + "upsampling_factor": 1.0, ## Initial conditions calculation # Positive constant in the Newton iteration convergence test within the # initial condition calculation @@ -376,8 +376,7 @@ def _combine_options(self, user_options: dict | None) -> dict: "suppress_algebraic_error": False, "hermite_interpolation": True, "hermite_reduction_factor": 1.0, - "hermite_upsampling_factor": 1.0, - "upsampling_basis": "hermite", + "upsampling_factor": 1.0, "nonlinear_convergence_coefficient_ic": 0.0033, "max_num_steps_ic": 50, "max_num_jacobians_ic": 40, @@ -415,12 +414,12 @@ def _check_options(self, options: dict): if hermite_reduction_factor < 1.0: raise pybamm.SolverError("hermite_reduction_factor must be >= 1.0.") - hermite_upsampling_factor = options["hermite_upsampling_factor"] - if hermite_upsampling_factor < 1.0: - raise pybamm.SolverError("hermite_upsampling_factor must be >= 1.0.") - if hermite_upsampling_factor > 1.0 and hermite_reduction_factor > 1.0: + upsampling_factor = options["upsampling_factor"] + if upsampling_factor < 1.0: + raise pybamm.SolverError("upsampling_factor must be >= 1.0.") + if upsampling_factor > 1.0 and hermite_reduction_factor > 1.0: raise pybamm.SolverError( - "hermite_upsampling_factor and hermite_reduction_factor are " + "upsampling_factor and hermite_reduction_factor are " "mutually exclusive; at most one can be > 1.0." ) @@ -659,7 +658,7 @@ def set_up(self, model, inputs=None, t_eval=None, ics_only=False): stacklevel=2, ) - hermite_upsampling_fcn = self._hermite_upsampling_fcn(model, inputs_dict) + upsampling_fcn = self._upsampling_fcn(model, inputs_dict) # Collect all casadi functions for AOT compilation. With compile=True, # all functions are bundled into a single shared library via one gcc @@ -677,7 +676,7 @@ def set_up(self, model, inputs=None, t_eval=None, ics_only=False): rootfn, alg_res_fn, jac_alg_fn, - hermite_upsampling_fcn, + upsampling_fcn, ), strict=True, ) @@ -763,7 +762,7 @@ def to_idaklu(fn): options=self._options, alg_res=self._setup["alg_res"], alg_jac=self._setup["alg_jac"], - hermite_upsampling_fcn=self._setup["hermite_upsampling_fcn"], + upsampling_fcn=self._setup["upsampling_fcn"], ) # C++ solver group now owns each casadi::Function via shared_ptr; @@ -781,20 +780,20 @@ def to_idaklu(fn): return base_set_up_return - def _hermite_upsampling_fcn(self, model, inputs_dict): - """``g(t, [y; yp], p)``: each upsampled variable and its time derivative. + def _upsampling_fcn(self, model, inputs_dict): + """``g(t, y, p)``: the upsampled variables. Algebraic states are skipped: the integrator's error test already resolves them. Empty if there is nothing to upsample. """ - empty = casadi.Function("empty_hermite_upsampling", [], []) - if self._options["hermite_upsampling_factor"] <= 1.0: + empty = casadi.Function("empty_upsampling", [], []) + if self._options["upsampling_factor"] <= 1.0: return empty # Model copies keep a state's name but not its id algebraic_names = {variable.name for variable in model.algebraic} names = [ name - for name in getattr(model, "hermite_upsampling_variables", ["Voltage [V]"]) + for name in getattr(model, "upsampling_variables", ["Voltage [V]"]) if name in model.variables and not ( isinstance(model.variables[name], pybamm.Variable) @@ -804,11 +803,8 @@ def _hermite_upsampling_fcn(self, model, inputs_dict): if not names: return empty vars_for_processing = self._get_vars_for_processing(model, inputs_dict) - n = model.len_rhs_and_alg t = casadi.MX.sym("t") - y_and_yp = casadi.MX.sym("y_and_yp", 2 * n) - y = y_and_yp[:n] - direction = casadi.vertcat(y_and_yp[n:], casadi.MX.zeros(n)) + y = casadi.MX.sym("y", model.len_rhs_and_alg) p = casadi.MX.sym("p", vars_for_processing["p_casadi_stacked"].shape[0]) outputs = [] for name in names: @@ -820,19 +816,10 @@ def _hermite_upsampling_fcn(self, model, inputs_dict): )[0] if var_fcn.numel_out() != 1: raise pybamm.SolverError( - f"hermite_upsampling_factor needs a scalar variable, got '{name}'" + f"upsampling_factor needs a scalar variable, got '{name}'" ) - value = var_fcn(t, y, p) - if self._options["upsampling_basis"] == "linear": - rate = casadi.MX.zeros(1) - else: - rate = casadi.jacobian(value, t) + casadi.jtimes( - value, y_and_yp, direction - ) - outputs += [value, rate] - function = casadi.Function( - "hermite_upsampling", [t, y_and_yp, p], [casadi.vertcat(*outputs)] - ) + outputs.append(var_fcn(t, y, p)) + function = casadi.Function("upsampling", [t, y, p], [casadi.vertcat(*outputs)]) try: return function.expand() except RuntimeError as error: diff --git a/packages/pybamm/tests/unit/test_experiments/test_base_step.py b/packages/pybamm/tests/unit/test_experiments/test_base_step.py index 65f89fd140..0a846a9496 100644 --- a/packages/pybamm/tests/unit/test_experiments/test_base_step.py +++ b/packages/pybamm/tests/unit/test_experiments/test_base_step.py @@ -173,5 +173,5 @@ def test_parse_termination_requires_operator_for_input_parameter(): (pybamm.step.voltage(4.1), "Current [A]"), ], ) -def test_hermite_upsampling_variable(step, variable): - assert step.hermite_upsampling_variable == variable +def test_upsampling_variable(step, variable): + assert step.upsampling_variable == variable diff --git a/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py b/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py index 1d598634c5..22d73fa437 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py @@ -2735,7 +2735,7 @@ def test_iterative_linear_solver_statistics( assert stats.number_of_nonlinear_solver_iterations >= stats.number_of_steps -class TestHermiteUpsampling: +class TestUpsampling: @staticmethod def _solve(options=None, **solver_kwargs): parameter_values = pybamm.ParameterValues("Chen2020") @@ -2748,16 +2748,16 @@ def _solve(options=None, **solver_kwargs): def test_adds_points_in_every_storage_mode(self): baseline = self._solve() - upsampled = self._solve({"hermite_upsampling_factor": 2.0}) + upsampled = self._solve({"upsampling_factor": 2.0}) assert upsampled.t.size > baseline.t.size assert np.all(np.isin(baseline.t, upsampled.t)) # Upsampling uses the integrator's own interpolant, not stored derivatives no_hermite = self._solve( - {"hermite_upsampling_factor": 2.0, "hermite_interpolation": False} + {"upsampling_factor": 2.0, "hermite_interpolation": False} ) outputs_only = self._solve( - {"hermite_upsampling_factor": 2.0}, output_variables=["Voltage [V]"] + {"upsampling_factor": 2.0}, output_variables=["Voltage [V]"] ) np.testing.assert_array_equal(no_hermite.t, upsampled.t) np.testing.assert_array_equal(outputs_only.t, upsampled.t) @@ -2767,46 +2767,29 @@ def test_adds_points_in_every_storage_mode(self): rtol=1e-12, ) - def test_resolves_voltage_hermite_interpolant(self): + def test_resolves_voltage_linear_interpolant(self): reference = self._solve(rtol=1e-10, atol=1e-12) - parameter_values = pybamm.ParameterValues("Chen2020") - parameter_values["Current function [A]"] = 10 - runs = [] + ratios = [] for factor in [1.0, 2.0]: - solver = pybamm.IDAKLUSolver(options={"hermite_upsampling_factor": factor}) + solver = pybamm.IDAKLUSolver(options={"upsampling_factor": factor}) + parameter_values = pybamm.ParameterValues("Chen2020") + parameter_values["Current function [A]"] = 10 sim = pybamm.Simulation( pybamm.lithium_ion.SPM(), parameter_values=parameter_values, solver=solver, ) - runs.append((solver, sim, sim.solve([0, 3600]))) - # The factor-2 solver builds the voltage and its rate for both runs - value_and_rate = runs[1][0]._hermite_upsampling_fcn(runs[1][1].built_model, {}) - ratios = [] - for solver, _, solution in runs: + solution = sim.solve([0, 3600]) t = solution.t - voltage, rate = np.hstack( - [ - np.asarray( - value_and_rate( - t[k], - np.concatenate([solution.y[:, k], solution.yp[:, k]]), - [], - ) - ) - for k in range(t.size) - ] - ) - h = np.diff(t) - hermite = ( - 0.5 * (voltage[:-1] + voltage[1:]) + h * (rate[:-1] - rate[1:]) / 8 - ) - truth = reference["Voltage [V]"](t[:-1] + h / 2) + voltage = solution["Voltage [V]"].entries + t_mid = 0.5 * (t[:-1] + t[1:]) + linear = 0.5 * (voltage[:-1] + voltage[1:]) + truth = reference["Voltage [V]"](t_mid) ratios.append( - np.max(np.abs(hermite - truth) / (solver.atol + solver.rtol * truth)) + np.max(np.abs(linear - truth) / (solver.atol + solver.rtol * truth)) ) - assert ratios[0] > 2 - assert ratios[1] < 2 + assert ratios[0] > 10 + assert ratios[1] < 3 def test_sensitivities(self): parameter_values = pybamm.ParameterValues("Chen2020") @@ -2818,7 +2801,7 @@ def solve(factor, t_interp=None, output_variables=None): pybamm.lithium_ion.SPM(), parameter_values=parameter_values, solver=pybamm.IDAKLUSolver( - options={"hermite_upsampling_factor": factor}, + options={"upsampling_factor": factor}, output_variables=output_variables, ), ) @@ -2847,19 +2830,17 @@ def solve(factor, t_interp=None, output_variables=None): def test_option_errors(self): with pytest.raises(pybamm.SolverError, match=r"must be >= 1.0"): - pybamm.IDAKLUSolver(options={"hermite_upsampling_factor": 0.5}) + pybamm.IDAKLUSolver(options={"upsampling_factor": 0.5}) with pytest.raises(pybamm.SolverError, match="mutually exclusive"): pybamm.IDAKLUSolver( options={ - "hermite_upsampling_factor": 2.0, + "upsampling_factor": 2.0, "hermite_reduction_factor": 2.0, } ) model = pybamm.lithium_ion.SPM() - model.hermite_upsampling_variables = [ - "Negative particle concentration [mol.m-3]" - ] - solver = pybamm.IDAKLUSolver(options={"hermite_upsampling_factor": 2.0}) + model.upsampling_variables = ["Negative particle concentration [mol.m-3]"] + solver = pybamm.IDAKLUSolver(options={"upsampling_factor": 2.0}) sim = pybamm.Simulation(model, solver=solver) with pytest.raises(pybamm.SolverError, match="needs a scalar variable"): sim.solve([0, 600]) @@ -2871,21 +2852,21 @@ def test_skips_algebraic_and_missing_variables(self): sim = pybamm.Simulation( pybamm.lithium_ion.SPM(), experiment=experiment, - solver=pybamm.IDAKLUSolver(options={"hermite_upsampling_factor": 2.0}), + solver=pybamm.IDAKLUSolver(options={"upsampling_factor": 2.0}), ) sim.solve() charge, hold = experiment.steps solver = sim._get_built_experiment_solver(charge) model = sim._get_built_experiment_model(charge) - assert solver._hermite_upsampling_fcn(model, {}).n_out() == 1 - model.hermite_upsampling_variables = ["Not a variable"] - assert solver._hermite_upsampling_fcn(model, {}).n_out() == 0 + assert solver._upsampling_fcn(model, {}).n_out() == 1 + model.upsampling_variables = ["Not a variable"] + assert solver._upsampling_fcn(model, {}).n_out() == 0 # In a voltage hold the current is an algebraic state solver = sim._get_built_experiment_solver(hold) model = sim._get_built_experiment_model(hold) - assert model.hermite_upsampling_variables == ["Current [A]"] - assert solver._hermite_upsampling_fcn(model, {}).n_out() == 0 + assert model.upsampling_variables == ["Current [A]"] + assert solver._upsampling_fcn(model, {}).n_out() == 0 @pytest.mark.parametrize("mode", ["legacy", "unified"]) def test_experiment_sets_variables_at_build(self, mode): @@ -2895,12 +2876,12 @@ def test_experiment_sets_variables_at_build(self, mode): sim = pybamm.Simulation( pybamm.lithium_ion.SPM(), experiment=experiment, - solver=pybamm.IDAKLUSolver(options={"hermite_upsampling_factor": 2.0}), + solver=pybamm.IDAKLUSolver(options={"upsampling_factor": 2.0}), experiment_model_mode=mode, ) sim.solve() variables = [ - sim._get_built_experiment_model(step).hermite_upsampling_variables + sim._get_built_experiment_model(step).upsampling_variables for step in experiment.steps ] if mode == "legacy": diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu.cpp b/packages/pybammsolvers/src/pybammsolvers/idaklu.cpp index b070991dd2..d11769c7bd 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu.cpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu.cpp @@ -52,7 +52,7 @@ IDAKLUSolverGroup *create_casadi_solver_group( py::dict py_opts, const CasadiFunctions::BaseFunctionType &alg_res, const CasadiFunctions::BaseFunctionType &alg_jac, - const CasadiFunctions::BaseFunctionType &hermite_upsampling_fcn) + const CasadiFunctions::BaseFunctionType &upsampling_fcn) { auto setup_opts = SetupOptions(py_opts); auto solver_opts = SolverOptions(py_opts); @@ -97,7 +97,7 @@ IDAKLUSolverGroup *create_casadi_solver_group( serialized_fcns ? &*serialized_fcns : nullptr, alg_res, alg_jac, - hermite_upsampling_fcn + upsampling_fcn ); solvers.emplace_back( std::unique_ptr( @@ -176,7 +176,7 @@ PYBIND11_MODULE(idaklu, m) py::arg("options"), py::arg("alg_res"), py::arg("alg_jac"), - py::arg("hermite_upsampling_fcn"), + py::arg("upsampling_fcn"), py::return_value_policy::take_ownership); m.def("observe", &observe, diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Base/ExpressionSet.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Base/ExpressionSet.hpp index 2a7d8ceb04..60a65169fe 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Base/ExpressionSet.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Base/ExpressionSet.hpp @@ -66,8 +66,8 @@ class ExpressionSet Expression *events = nullptr; Expression *alg_res = nullptr; Expression *alg_jac = nullptr; - // g(t, [y; yp], p) -> (value, time derivative) pairs of the upsampled outputs - Expression *hermite_upsampling_fcn = nullptr; + // g(t, y, p) -> the upsampled outputs + Expression *upsampling_fcn = nullptr; std::vector var_fcns; std::vector dvar_dy_fcns; diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Casadi/CasadiFunctions.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Casadi/CasadiFunctions.hpp index 9dd368535b..1c3f3267ae 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Casadi/CasadiFunctions.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Casadi/CasadiFunctions.hpp @@ -111,7 +111,7 @@ class CasadiFunctions : public ExpressionSet const SerializedCasadiFunctions *serialized_fcns = nullptr, const BaseFunctionType &alg_res_fn = BaseFunctionType(), const BaseFunctionType &alg_jac_fn = BaseFunctionType(), - const BaseFunctionType &hermite_upsampling_fn = BaseFunctionType() + const BaseFunctionType &upsampling_fn = BaseFunctionType() ) : rhs_alg_casadi( rhs_alg, @@ -139,7 +139,7 @@ class CasadiFunctions : public ExpressionSet serialized_fcns != nullptr ? &serialized_fcns->events : nullptr), alg_res_casadi_(alg_res_fn), alg_jac_casadi_(alg_jac_fn), - hermite_upsampling_casadi_(hermite_upsampling_fn, setup_opts.num_solvers > 1), + upsampling_casadi_(upsampling_fn, setup_opts.num_solvers > 1), ExpressionSet( static_cast(&rhs_alg_casadi), static_cast(&jac_times_cjmass_casadi), @@ -160,7 +160,7 @@ class CasadiFunctions : public ExpressionSet { this->alg_res = static_cast(&alg_res_casadi_); this->alg_jac = static_cast(&alg_jac_casadi_); - this->hermite_upsampling_fcn = static_cast(&hermite_upsampling_casadi_); + this->upsampling_fcn = static_cast(&upsampling_casadi_); // NOTE: You must allocate ALL std::vector elements before taking references const bool deep_copy = setup_opts.num_solvers > 1; if (serialized_fcns != nullptr) { @@ -225,7 +225,7 @@ class CasadiFunctions : public ExpressionSet CasadiFunction events_casadi; CasadiFunction alg_res_casadi_; CasadiFunction alg_jac_casadi_; - CasadiFunction hermite_upsampling_casadi_; + CasadiFunction upsampling_casadi_; std::vector var_fcns_casadi; std::vector dvar_dy_fcns_casadi; diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp index 7c933837f7..32a7aef96f 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp @@ -181,9 +181,8 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver bool use_knot_reduction_ = false; bool use_upsampling_ = false; sunrealtype upsampling_atol_ = 0.0; - std::vector upsample_in_; // [y; yp] passed to hermite_upsampling_fcn - std::vector upsample_end_; // [y; yp] at the end of the current step - // hermite_upsampling_fcn at the previous step end, reused as the next step start + std::vector upsample_end_; // y at the end of the current step + // upsampling_fcn at the previous step end, reused as the next step start std::vector upsample_f_prev_; bool upsample_f_prev_valid_ = false; bool save_adaptive_steps_ = false; @@ -425,14 +424,14 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver void SavePoint(sunrealtype t_val, bool extend_arrays, bool is_breakpoint); /** - * @brief Save points inside the last step [t_a, t_b] until the cubic Hermite - * interpolant of each upsampled output resolves it, then restore the state at t_b + * @brief Save points inside the last step [t_a, t_b] until linear interpolation + * of each upsampled output resolves it, then restore the state at t_b */ void UpsampleStep(sunrealtype t_a, sunrealtype t_b); /** - * @brief Bisect [t_a, t_b] while the outputs' Hermite interpolant misses the - * midpoint, saving each midpoint in time order + * @brief Bisect [t_a, t_b] while linear interpolation of the outputs misses + * the midpoint, saving each midpoint in time order * @return The number of points saved */ int UpsampleInterval( @@ -441,14 +440,14 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver int depth); /** - * @brief Evaluate hermite_upsampling_fcn at t from y_and_yp = [y; yp] + * @brief Evaluate upsampling_fcn at t from the states y */ void EvaluateUpsampleOutputs( - sunrealtype t, sunrealtype *y_and_yp, std::vector &out); + sunrealtype t, sunrealtype *y, std::vector &out); /** - * @brief Evaluate hermite_upsampling_fcn on the integrator's interpolant at t, - * loading only the states and their derivatives (not the sensitivities) + * @brief Evaluate upsampling_fcn on the integrator's interpolant at t, + * loading only the states (not their derivatives or sensitivities) */ void EvaluateUpsampleOutputsAt(sunrealtype t, std::vector &out); diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl index efabab098a..00cc98b400 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl @@ -468,16 +468,15 @@ void IDAKLUSolverOpenMP::InitializeSolveStorage( ); use_upsampling_ = ( - solver_opts.hermite_upsampling_factor > 1.0 && + solver_opts.upsampling_factor > 1.0 && save_adaptive_steps_ && !use_knot_reduction_ && - functions->hermite_upsampling_fcn != nullptr && - functions->hermite_upsampling_fcn->nnz_out() > 0 + functions->upsampling_fcn != nullptr && + functions->upsampling_fcn->nnz_out() > 0 ); upsample_f_prev_valid_ = false; if (use_upsampling_) { - upsample_in_.resize(2 * number_of_states); - upsample_end_.resize(2 * number_of_states); + upsample_end_.resize(number_of_states); upsampling_atol_ = N_VMin(avtol); } @@ -670,8 +669,6 @@ void IDAKLUSolverOpenMP::UpsampleStep( DEBUG("IDAKLUSolver::UpsampleStep"); // The state at t_b is already loaded, so it is evaluated once and reused std::copy(y_val_, y_val_ + number_of_states, upsample_end_.begin()); - std::copy(yp_val_, yp_val_ + number_of_states, - upsample_end_.begin() + number_of_states); std::vector f_b; EvaluateUpsampleOutputs(t_b, upsample_end_.data(), f_b); if (!upsample_f_prev_valid_) { @@ -679,13 +676,10 @@ void IDAKLUSolverOpenMP::UpsampleStep( } if (UpsampleInterval(t_a, upsample_f_prev_, t_b, f_b, 0) > 0) { - // Saving a midpoint also moved the sensitivities + // Saving a midpoint also moved the derivatives and sensitivities GetSolutionFull(t_b); } else { - std::copy(upsample_end_.begin(), upsample_end_.begin() + number_of_states, - y_val_); - std::copy(upsample_end_.begin() + number_of_states, upsample_end_.end(), - yp_val_); + std::copy(upsample_end_.begin(), upsample_end_.end(), y_val_); } upsample_f_prev_ = std::move(f_b); upsample_f_prev_valid_ = true; @@ -705,16 +699,12 @@ int IDAKLUSolverOpenMP::UpsampleInterval( std::vector f_m; EvaluateUpsampleOutputsAt(t_m, f_m); - // f holds (value, rate) pairs; the cubic Hermite midpoint is - // (v_a + v_b) / 2 + h (r_a - r_b) / 8 - sunrealtype const h = solver_opts.upsampling_linear ? 0.0 : t_b - t_a; - sunrealtype const budget = 1.0 / (solver_opts.hermite_upsampling_factor - 1.0); + sunrealtype const budget = 1.0 / (solver_opts.upsampling_factor - 1.0); bool resolved = true; - for (size_t k = 0; k + 1 < f_m.size(); k += 2) { - sunrealtype const hermite = - 0.5 * (f_a[k] + f_b[k]) + 0.125 * h * (f_a[k + 1] - f_b[k + 1]); + for (size_t k = 0; k < f_m.size(); k++) { + sunrealtype const linear = 0.5 * (f_a[k] + f_b[k]); sunrealtype const tol = budget * (upsampling_atol_ + rtol * std::abs(f_m[k])); - resolved = resolved && std::abs(hermite - f_m[k]) <= tol; + resolved = resolved && std::abs(linear - f_m[k]) <= tol; } if (resolved) { return 0; @@ -728,11 +718,10 @@ int IDAKLUSolverOpenMP::UpsampleInterval( template void IDAKLUSolverOpenMP::EvaluateUpsampleOutputs( - sunrealtype t, sunrealtype *y_and_yp, std::vector &out + sunrealtype t, sunrealtype *y, std::vector &out ) { - out.resize(functions->hermite_upsampling_fcn->nnz_out()); - (*functions->hermite_upsampling_fcn)( - {&t, y_and_yp, functions->inputs.data()}, {out.data()}); + out.resize(functions->upsampling_fcn->nnz_out()); + (*functions->upsampling_fcn)({&t, y, functions->inputs.data()}, {out.data()}); } template @@ -740,14 +729,7 @@ void IDAKLUSolverOpenMP::EvaluateUpsampleOutputsAt( sunrealtype t, std::vector &out ) { CheckErrors(IDAGetDky(ida_mem, t, 0, yy), "IDAGetDky"); - // The linear check reads values only - if (!solver_opts.upsampling_linear) { - CheckErrors(IDAGetDky(ida_mem, t, 1, yyp), "IDAGetDky"); - } - std::copy(y_val_, y_val_ + number_of_states, upsample_in_.begin()); - std::copy(yp_val_, yp_val_ + number_of_states, - upsample_in_.begin() + number_of_states); - EvaluateUpsampleOutputs(t, upsample_in_.data(), out); + EvaluateUpsampleOutputs(t, y_val_, out); } template @@ -771,7 +753,7 @@ void IDAKLUSolverOpenMP::HandleBreakpoint( // Reinitialize the solver to deal with the discontinuity at t = t_val ReinitializeIntegrator(t_val); - // yp jumps here, so the next step starts from a fresh evaluation + // Algebraic states can jump here, so the next step starts afresh upsample_f_prev_valid_ = false; ConsistentInitialization(t_val, t_eval_next, IDA_YA_YDP_INIT); log_.log_consistent_init(t_val); diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp index 9410013a2e..e6279894e6 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp @@ -197,12 +197,12 @@ SolverOptions::SolverOptions(py::dict &py_opts) } // Output upsampling multiplier (1.0 = off, >1.0 = active) - hermite_upsampling_factor = 1.0; - if (py_opts.contains("hermite_upsampling_factor")) { - hermite_upsampling_factor = py_opts["hermite_upsampling_factor"].cast(); - if (hermite_upsampling_factor < 1.0) { + upsampling_factor = 1.0; + if (py_opts.contains("upsampling_factor")) { + upsampling_factor = py_opts["upsampling_factor"].cast(); + if (upsampling_factor < 1.0) { throw std::domain_error( - "hermite_upsampling_factor must be >= 1.0. Got " + std::to_string(hermite_upsampling_factor) + "upsampling_factor must be >= 1.0. Got " + std::to_string(upsampling_factor) ); } } @@ -213,11 +213,6 @@ SolverOptions::SolverOptions(py::dict &py_opts) newton_step_tol = py_opts["newton_step_tol"].cast(); } - upsampling_linear = false; - if (py_opts.contains("upsampling_basis")) { - upsampling_linear = py_opts["upsampling_basis"].cast() == "linear"; - } - newton_mode = "auto"; if (py_opts.contains("newton_mode")) { newton_mode = py_opts["newton_mode"].cast(); diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp index 709c7ba10d..45bd6304f3 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp @@ -59,9 +59,8 @@ struct SolverOptions { sunrealtype t_no_progress; // Knot reduction (streaming spline compression) double hermite_reduction_factor; // >= 1.0; total error budget is M * solver_tol (threshold = N*(M-1)^2) - // > 1.0 bisects steps until hermite_upsampling_fcn is within solver_tol / (M-1) - double hermite_upsampling_factor; - bool upsampling_linear; // check against linear instead of cubic Hermite + // > 1.0 bisects steps until upsampling_fcn is linear to within solver_tol / (M-1) + double upsampling_factor; // Newton solver for algebraic initial conditions double newton_step_tol; std::string newton_mode; // "auto" or "full" diff --git a/packages/pybammsolvers/tests/conftest.py b/packages/pybammsolvers/tests/conftest.py index 918256b8ba..91fb2ad01f 100644 --- a/packages/pybammsolvers/tests/conftest.py +++ b/packages/pybammsolvers/tests/conftest.py @@ -171,7 +171,7 @@ def make_exponential_decay_solver( # No algebraic equations for this pure ODE: provide empty functions alg_res = casadi.Function("empty_alg_res", [], []) alg_jac = casadi.Function("empty_alg_jac", [], []) - hermite_upsampling = casadi.Function("empty_hermite_upsampling", [], []) + upsampling = casadi.Function("empty_upsampling", [], []) # DAE identifier (1 = differential, 0 = algebraic) # For ODE, all states are differential @@ -211,9 +211,7 @@ def make_exponential_decay_solver( events_func = idaklu_module.generate_function(events.serialize()) alg_res_func = idaklu_module.generate_function(alg_res.serialize()) alg_jac_func = idaklu_module.generate_function(alg_jac.serialize()) - hermite_upsampling_func = idaklu_module.generate_function( - hermite_upsampling.serialize() - ) + upsampling_func = idaklu_module.generate_function(upsampling.serialize()) # Solver options options = { @@ -285,7 +283,7 @@ def make_exponential_decay_solver( options, alg_res_func, alg_jac_func, - hermite_upsampling_func, + upsampling_func, ) # Initial conditions need to be 2D: [number_of_groups, n_coeffs] From 82eeea5297f7707a424b16cc2c7fbdd86b8fb88e Mon Sep 17 00:00:00 2001 From: Marc Berliner <34451391+MarcBerliner@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:05:48 -0400 Subject: [PATCH 05/10] refactor: upsample a single scalar variable; midpoints use y_cache The upsampled variable is now one scalar (model.upsampling_variable), passed to C++ as the function from process(). Midpoints interpolate into y_cache so the step-end state in yy needs no copy or restore, and the redundant t_val > t_prev guard is dropped. --- .../src/pybamm/experiment/step/base_step.py | 2 +- .../src/pybamm/simulation/simulation.py | 3 - .../src/pybamm/solvers/idaklu_solver.py | 62 +++++++------------ .../unit/test_solvers/test_idaklu_solver.py | 40 ++++++------ .../Expressions/Base/ExpressionSet.hpp | 2 +- .../idaklu_source/IDAKLUSolverOpenMP.hpp | 20 +++--- .../idaklu_source/IDAKLUSolverOpenMP.inl | 49 +++++---------- 7 files changed, 70 insertions(+), 108 deletions(-) diff --git a/packages/pybamm/src/pybamm/experiment/step/base_step.py b/packages/pybamm/src/pybamm/experiment/step/base_step.py index bea7e5bc3c..88c0769bef 100644 --- a/packages/pybamm/src/pybamm/experiment/step/base_step.py +++ b/packages/pybamm/src/pybamm/experiment/step/base_step.py @@ -465,7 +465,7 @@ def process_model( inplace=inplace, delayed_variable_processing=delayed_variable_processing, ) - processed_model.upsampling_variables = [self.upsampling_variable] + processed_model.upsampling_variable = self.upsampling_variable return processed_model def get_termination_events(self, variables): diff --git a/packages/pybamm/src/pybamm/simulation/simulation.py b/packages/pybamm/src/pybamm/simulation/simulation.py index 5991807875..70d941441a 100644 --- a/packages/pybamm/src/pybamm/simulation/simulation.py +++ b/packages/pybamm/src/pybamm/simulation/simulation.py @@ -349,9 +349,6 @@ def control_builder(step): # BaseModel.build_casadi_jacobian. The attribute rides this same model object # (in-place parameter processing and discretisation) through to solver.process. new_model.switching_control_variables = {var.name for var in submodel.algebraic} - new_model.upsampling_variables = list( - dict.fromkeys(step.upsampling_variable for step in unique_steps) - ) # Combine each step's local termination expression into one experiment event, # selecting the active branch with the step index input. diff --git a/packages/pybamm/src/pybamm/solvers/idaklu_solver.py b/packages/pybamm/src/pybamm/solvers/idaklu_solver.py index 5c70a50707..6a20122254 100644 --- a/packages/pybamm/src/pybamm/solvers/idaklu_solver.py +++ b/packages/pybamm/src/pybamm/solvers/idaklu_solver.py @@ -243,7 +243,7 @@ class IDAKLUSolver(pybamm.BaseSolver): # are disabled. "hermite_reduction_factor": 1.0, # Setting upsampling_factor > 1.0 adds points within each step - # until linear interpolation of the model's `upsampling_variables` + # until linear interpolation of the model's `upsampling_variable` # (default: the voltage) is within the solver tolerances divided # by (factor - 1). Ignored with t_interp. "upsampling_factor": 1.0, @@ -781,52 +781,32 @@ def to_idaklu(fn): return base_set_up_return def _upsampling_fcn(self, model, inputs_dict): - """``g(t, y, p)``: the upsampled variables. + """``g(t, y, p)``: the model's scalar ``upsampling_variable``. - Algebraic states are skipped: the integrator's error test already - resolves them. Empty if there is nothing to upsample. + Empty if upsampling is off, or the variable is missing or an algebraic + state, which the integrator's error test already resolves. """ empty = casadi.Function("empty_upsampling", [], []) - if self._options["upsampling_factor"] <= 1.0: + name = getattr(model, "upsampling_variable", "Voltage [V]") + if self._options["upsampling_factor"] <= 1.0 or name not in model.variables: return empty # Model copies keep a state's name but not its id - algebraic_names = {variable.name for variable in model.algebraic} - names = [ - name - for name in getattr(model, "upsampling_variables", ["Voltage [V]"]) - if name in model.variables - and not ( - isinstance(model.variables[name], pybamm.Variable) - and model.variables[name].name in algebraic_names - ) - ] - if not names: + variable = model.variables[name] + if isinstance(variable, pybamm.Variable) and variable.name in { + state.name for state in model.algebraic + }: return empty - vars_for_processing = self._get_vars_for_processing(model, inputs_dict) - t = casadi.MX.sym("t") - y = casadi.MX.sym("y", model.len_rhs_and_alg) - p = casadi.MX.sym("p", vars_for_processing["p_casadi_stacked"].shape[0]) - outputs = [] - for name in names: - var_fcn = process( - model.get_processed_variable_or_event(name), - pybamm.BaseSolver._wrangle_name(name), - vars_for_processing, - use_jacobian=False, - )[0] - if var_fcn.numel_out() != 1: - raise pybamm.SolverError( - f"upsampling_factor needs a scalar variable, got '{name}'" - ) - outputs.append(var_fcn(t, y, p)) - function = casadi.Function("upsampling", [t, y, p], [casadi.vertcat(*outputs)]) - try: - return function.expand() - except RuntimeError as error: - # Interpolants, for example, cannot be expanded - if "'eval_sx' not defined for" not in str(error): - raise # pragma: no cover - return function + upsampling_fcn = process( + model.get_processed_variable_or_event(name), + pybamm.BaseSolver._wrangle_name(name), + self._get_vars_for_processing(model, inputs_dict), + use_jacobian=False, + )[0] + if upsampling_fcn.numel_out() != 1: + raise pybamm.SolverError( + f"upsampling_factor needs a scalar variable, got '{name}'" + ) + return upsampling_fcn def __getstate__(self): # Drop the rebuildable state (C++ solver + casadi.Function graphs) diff --git a/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py b/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py index 22d73fa437..aad0ddca0e 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py @@ -2839,7 +2839,7 @@ def test_option_errors(self): } ) model = pybamm.lithium_ion.SPM() - model.upsampling_variables = ["Negative particle concentration [mol.m-3]"] + model.upsampling_variable = "Negative particle concentration [mol.m-3]" solver = pybamm.IDAKLUSolver(options={"upsampling_factor": 2.0}) sim = pybamm.Simulation(model, solver=solver) with pytest.raises(pybamm.SolverError, match="needs a scalar variable"): @@ -2859,32 +2859,36 @@ def test_skips_algebraic_and_missing_variables(self): solver = sim._get_built_experiment_solver(charge) model = sim._get_built_experiment_model(charge) assert solver._upsampling_fcn(model, {}).n_out() == 1 - model.upsampling_variables = ["Not a variable"] + model.upsampling_variable = "Not a variable" assert solver._upsampling_fcn(model, {}).n_out() == 0 # In a voltage hold the current is an algebraic state solver = sim._get_built_experiment_solver(hold) model = sim._get_built_experiment_model(hold) - assert model.upsampling_variables == ["Current [A]"] + assert model.upsampling_variable == "Current [A]" assert solver._upsampling_fcn(model, {}).n_out() == 0 @pytest.mark.parametrize("mode", ["legacy", "unified"]) - def test_experiment_sets_variables_at_build(self, mode): + def test_experiment_sets_variable_at_build(self, mode): experiment = pybamm.Experiment( - ["Charge at 1C until 4.1 V", "Hold at 4.1 V for 10 minutes"] + ["Discharge at 1C until 3.0 V", "Hold at 3.0 V for 10 minutes"] ) - sim = pybamm.Simulation( - pybamm.lithium_ion.SPM(), - experiment=experiment, - solver=pybamm.IDAKLUSolver(options={"upsampling_factor": 2.0}), - experiment_model_mode=mode, - ) - sim.solve() - variables = [ - sim._get_built_experiment_model(step).upsampling_variables - for step in experiment.steps - ] + solutions = [] + for factor in [1.0, 2.0]: + sim = pybamm.Simulation( + pybamm.lithium_ion.SPM(), + experiment=experiment, + solver=pybamm.IDAKLUSolver(options={"upsampling_factor": factor}), + experiment_model_mode=mode, + ) + solutions.append(sim.solve()) + models = [sim._get_built_experiment_model(step) for step in experiment.steps] if mode == "legacy": - assert variables == [["Voltage [V]"], ["Current [A]"]] + assert [model.upsampling_variable for model in models] == [ + "Voltage [V]", + "Current [A]", + ] else: - assert variables == [["Voltage [V]", "Current [A]"]] * 2 + # One model serves every step, so it keeps the default voltage + assert not hasattr(models[0], "upsampling_variable") + assert solutions[1].cycles[0].t.size > solutions[0].cycles[0].t.size diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Base/ExpressionSet.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Base/ExpressionSet.hpp index 60a65169fe..8f382f8afc 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Base/ExpressionSet.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Expressions/Base/ExpressionSet.hpp @@ -66,7 +66,7 @@ class ExpressionSet Expression *events = nullptr; Expression *alg_res = nullptr; Expression *alg_jac = nullptr; - // g(t, y, p) -> the upsampled outputs + // g(t, y, p) -> the scalar upsampled output Expression *upsampling_fcn = nullptr; std::vector var_fcns; diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp index 32a7aef96f..7c13387a53 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp @@ -181,9 +181,8 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver bool use_knot_reduction_ = false; bool use_upsampling_ = false; sunrealtype upsampling_atol_ = 0.0; - std::vector upsample_end_; // y at the end of the current step // upsampling_fcn at the previous step end, reused as the next step start - std::vector upsample_f_prev_; + sunrealtype upsample_f_prev_ = 0.0; bool upsample_f_prev_valid_ = false; bool save_adaptive_steps_ = false; bool save_interp_steps_ = false; @@ -425,31 +424,28 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver /** * @brief Save points inside the last step [t_a, t_b] until linear interpolation - * of each upsampled output resolves it, then restore the state at t_b + * of the upsampled output resolves it */ void UpsampleStep(sunrealtype t_a, sunrealtype t_b); /** - * @brief Bisect [t_a, t_b] while linear interpolation of the outputs misses + * @brief Bisect [t_a, t_b] while linear interpolation of the output misses * the midpoint, saving each midpoint in time order * @return The number of points saved */ int UpsampleInterval( - sunrealtype t_a, const std::vector &f_a, - sunrealtype t_b, const std::vector &f_b, - int depth); + sunrealtype t_a, sunrealtype f_a, sunrealtype t_b, sunrealtype f_b, int depth); /** * @brief Evaluate upsampling_fcn at t from the states y */ - void EvaluateUpsampleOutputs( - sunrealtype t, sunrealtype *y, std::vector &out); + sunrealtype EvaluateUpsampleOutput(sunrealtype t, sunrealtype *y); /** - * @brief Evaluate upsampling_fcn on the integrator's interpolant at t, - * loading only the states (not their derivatives or sensitivities) + * @brief Evaluate upsampling_fcn on the integrator's interpolant at t, using + * y_cache so the current state in yy is left untouched */ - void EvaluateUpsampleOutputsAt(sunrealtype t, std::vector &out); + sunrealtype EvaluateUpsampleOutputAt(sunrealtype t); /** * @brief Handle a breakpoint at t_eval: finalize interval, reinitialize diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl index 00cc98b400..f1d8d437cd 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl @@ -419,7 +419,7 @@ SolutionData IDAKLUSolverOpenMP::solve( } if (hit_adaptive || hit_teval || hit_event || hit_final_time) { - if (use_upsampling_ && t_val > t_prev) { + if (use_upsampling_) { UpsampleStep(t_prev, t_val); } bool is_breakpoint = (hit_teval || hit_event) && !final_step; @@ -476,7 +476,6 @@ void IDAKLUSolverOpenMP::InitializeSolveStorage( ); upsample_f_prev_valid_ = false; if (use_upsampling_) { - upsample_end_.resize(number_of_states); upsampling_atol_ = N_VMin(avtol); } @@ -668,45 +667,32 @@ void IDAKLUSolverOpenMP::UpsampleStep( ) { DEBUG("IDAKLUSolver::UpsampleStep"); // The state at t_b is already loaded, so it is evaluated once and reused - std::copy(y_val_, y_val_ + number_of_states, upsample_end_.begin()); - std::vector f_b; - EvaluateUpsampleOutputs(t_b, upsample_end_.data(), f_b); + sunrealtype const f_b = EvaluateUpsampleOutput(t_b, y_val_); if (!upsample_f_prev_valid_) { - EvaluateUpsampleOutputsAt(t_a, upsample_f_prev_); + upsample_f_prev_ = EvaluateUpsampleOutputAt(t_a); } if (UpsampleInterval(t_a, upsample_f_prev_, t_b, f_b, 0) > 0) { - // Saving a midpoint also moved the derivatives and sensitivities + // Saving a midpoint loaded its state, so reload t_b GetSolutionFull(t_b); - } else { - std::copy(upsample_end_.begin(), upsample_end_.end(), y_val_); } - upsample_f_prev_ = std::move(f_b); + upsample_f_prev_ = f_b; upsample_f_prev_valid_ = true; } template int IDAKLUSolverOpenMP::UpsampleInterval( - sunrealtype t_a, const std::vector &f_a, - sunrealtype t_b, const std::vector &f_b, - int depth + sunrealtype t_a, sunrealtype f_a, sunrealtype t_b, sunrealtype f_b, int depth ) { constexpr int max_depth = 20; if (depth >= max_depth) { return 0; } sunrealtype const t_m = 0.5 * (t_a + t_b); - std::vector f_m; - EvaluateUpsampleOutputsAt(t_m, f_m); - + sunrealtype const f_m = EvaluateUpsampleOutputAt(t_m); sunrealtype const budget = 1.0 / (solver_opts.upsampling_factor - 1.0); - bool resolved = true; - for (size_t k = 0; k < f_m.size(); k++) { - sunrealtype const linear = 0.5 * (f_a[k] + f_b[k]); - sunrealtype const tol = budget * (upsampling_atol_ + rtol * std::abs(f_m[k])); - resolved = resolved && std::abs(linear - f_m[k]) <= tol; - } - if (resolved) { + sunrealtype const tol = budget * (upsampling_atol_ + rtol * std::abs(f_m)); + if (std::abs(0.5 * (f_a + f_b) - f_m) <= tol) { return 0; } @@ -717,19 +703,18 @@ int IDAKLUSolverOpenMP::UpsampleInterval( } template -void IDAKLUSolverOpenMP::EvaluateUpsampleOutputs( - sunrealtype t, sunrealtype *y, std::vector &out +sunrealtype IDAKLUSolverOpenMP::EvaluateUpsampleOutput( + sunrealtype t, sunrealtype *y ) { - out.resize(functions->upsampling_fcn->nnz_out()); - (*functions->upsampling_fcn)({&t, y, functions->inputs.data()}, {out.data()}); + sunrealtype out; + (*functions->upsampling_fcn)({&t, y, functions->inputs.data()}, {&out}); + return out; } template -void IDAKLUSolverOpenMP::EvaluateUpsampleOutputsAt( - sunrealtype t, std::vector &out -) { - CheckErrors(IDAGetDky(ida_mem, t, 0, yy), "IDAGetDky"); - EvaluateUpsampleOutputs(t, y_val_, out); +sunrealtype IDAKLUSolverOpenMP::EvaluateUpsampleOutputAt(sunrealtype t) { + CheckErrors(IDAGetDky(ida_mem, t, 0, y_cache), "IDAGetDky"); + return EvaluateUpsampleOutput(t, N_VGetArrayPointer(y_cache)); } template From 2ef8b64d901f1d8808f77c92c23fad366c54cac8 Mon Sep 17 00:00:00 2001 From: Marc Berliner <34451391+MarcBerliner@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:03:26 -0400 Subject: [PATCH 06/10] fix: backfill new IDAKLU options when unpickling older solvers --- packages/pybamm/src/pybamm/solvers/idaklu_solver.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/pybamm/src/pybamm/solvers/idaklu_solver.py b/packages/pybamm/src/pybamm/solvers/idaklu_solver.py index 6a20122254..e3ca39ee02 100644 --- a/packages/pybamm/src/pybamm/solvers/idaklu_solver.py +++ b/packages/pybamm/src/pybamm/solvers/idaklu_solver.py @@ -819,6 +819,8 @@ def __getstate__(self): def __setstate__(self, d): self.__dict__.update(d) + # Pickles from older versions lack newer options + self._options = self._combine_options(self._options) # Restore the empty defaults BaseSolver.__init__ would set, so the # solver reads as "not yet set up" until the next solve(). self._model_set_up = {} From 69a1f496e877860b2fbaf6f155620790f3ab85ed Mon Sep 17 00:00:00 2001 From: Marc Berliner <34451391+MarcBerliner@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:08:47 -0400 Subject: [PATCH 07/10] feat: upsample into t_additive with upsampling_tolerance; deprecate Solution.y/yp Replace upsampling_factor with upsampling_tolerance (0 = off), an absolute tolerance on the upsampling variable divided by its scale. Added times go to Solution.t_additive and are included in Solution.t, without growing the stored states; variables' entries are evaluated at them by Hermite interpolation. With Hermite reduction, the reduced knots' spline is upsampled once the knots are final. Without Hermite data or with sensitivities, the points are saved into the states as before. Unified experiments select each step's output through a conditional variable, skipping algebraic states per branch. Solution.y and Solution.yp are deprecated, as they hold only the stored states, which need not match Solution.t; no code in the repo uses them. --- CHANGELOG.md | 3 +- .../creating_models/1-an-ode-model.ipynb | 4 +- .../models/unsteady-heat-equation.ipynb | 4 +- .../3d_eqation_cylindric_non_zero_flux.py | 2 +- .../3d_examples/3d_equation_cylindric.py | 2 +- .../3d_examples/3d_equation_prismatic.py | 2 +- .../compare_comsol/compare_comsol_DFN.py | 2 +- .../src/pybamm/experiment/step/base_step.py | 2 +- .../pybamm/src/pybamm/models/base_model.py | 12 +- .../pybamm/src/pybamm/plotting/quick_plot.py | 7 +- .../src/pybamm/simulation/simulation.py | 19 +- .../src/pybamm/solvers/casadi_solver.py | 17 +- .../src/pybamm/solvers/idaklu_solver.py | 55 ++-- .../src/pybamm/solvers/processed_variable.py | 8 + .../pybamm/src/pybamm/solvers/solution.py | 38 ++- packages/pybamm/tests/__init__.py | 1 + .../tests/integration/test_simulations.py | 10 +- .../integration/test_solvers/test_diffsl.py | 4 +- .../integration/test_solvers/test_idaklu.py | 8 +- packages/pybamm/tests/shared.py | 8 + .../pybamm/tests/unit/test_base_simulation.py | 4 +- .../test_simulation_with_experiment.py | 10 +- .../unit/test_models/test_model_to_json.py | 2 +- .../unit/test_plotting/test_quick_plot.py | 11 + .../test_serialisation/test_round_trip.py | 2 +- packages/pybamm/tests/unit/test_simulation.py | 18 +- .../test_solvers/test_algebraic_solver.py | 52 ++-- .../test_casadi_algebraic_solver.py | 8 +- .../unit/test_solvers/test_casadi_solver.py | 205 +++++++++++---- .../test_solvers/test_composite_solver.py | 4 +- .../unit/test_solvers/test_dummy_solver.py | 7 +- .../unit/test_solvers/test_idaklu_solver.py | 241 +++++++++++------- .../test_solvers/test_nonlinear_solver.py | 26 +- .../unit/test_solvers/test_observation.py | 2 +- .../unit/test_solvers/test_scipy_solver.py | 54 ++-- .../tests/unit/test_solvers/test_solution.py | 91 +++++-- .../test_scikit_finite_element.py | 6 +- .../test_scikit_finite_element_3d.py | 11 +- .../src/pybammsolvers/idaklu.cpp | 1 + .../idaklu_source/IDAKLUSolverOpenMP.hpp | 43 +++- .../idaklu_source/IDAKLUSolverOpenMP.inl | 111 ++++++-- .../pybammsolvers/idaklu_source/Options.cpp | 12 +- .../pybammsolvers/idaklu_source/Options.hpp | 4 +- .../pybammsolvers/idaklu_source/Solution.hpp | 5 +- .../idaklu_source/SolutionData.hpp | 4 + 45 files changed, 796 insertions(+), 346 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f378052af4..0dd7e3d24f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,11 @@ - `FiniteVolume2D` is deprecated in favour of `FiniteVolumeUnstructured` and raises a `DeprecationWarning` when created. Remaining gaps before removal are tracked in [#5938](https://github.com/pybamm-team/PyBaMM/issues/5938). ([#5939](https://github.com/pybamm-team/PyBaMM/pull/5939)) - `pybamm.Magnitude` is deprecated and will be removed in 27.1; use `pybamm.Component(symbol, 0)` / `pybamm.Component(symbol, 1)` for the lr/tb components of a `VectorField`. ([#5943](https://github.com/pybamm-team/PyBaMM/pull/5943)) +- `Solution.y` and `Solution.yp` are deprecated: they hold only the stored states, which need not match `Solution.t`. Use `solution[name]` for variables, or `all_ys` and `all_yps` for the raw states. ([#5998](https://github.com/pybamm-team/PyBaMM/pull/5998)) ## Features -- Added the `IDAKLUSolver` option `upsampling_factor`: a value above 1.0 saves extra points inside each step until linear interpolation of the voltage (or current, for voltage control) is within the solver tolerances divided by `factor - 1`. ([#5998](https://github.com/pybamm-team/PyBaMM/pull/5998)) +- Added the `IDAKLUSolver` option `upsampling_tolerance`, which adds times within each step until linear interpolation of the voltage (current, for voltage control) is within tolerance. They are stored in `Solution.t_additive` and included in `Solution.t`. ([#5998](https://github.com/pybamm-team/PyBaMM/pull/5998)) - Replaced Codacy with open checks that also run locally. Ruff's `flake8-bandit` (`S`) rules replace Bandit, and CodeQL scans the Python and C++ code. `nox -s type-check` runs Pyrefly over `pybamm`, which now type-checks cleanly, and a new type error fails CI. clang-tidy reports solver findings to code scanning as an advisory check. ([#5974](https://github.com/pybamm-team/PyBaMM/pull/5974)) ## Bug fixes diff --git a/docs/source/examples/notebooks/creating_models/1-an-ode-model.ipynb b/docs/source/examples/notebooks/creating_models/1-an-ode-model.ipynb index a87dd15b39..b2a8161e1d 100644 --- a/docs/source/examples/notebooks/creating_models/1-an-ode-model.ipynb +++ b/docs/source/examples/notebooks/creating_models/1-an-ode-model.ipynb @@ -213,7 +213,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "After solving, we can extract the variables from the solution. These are automatically post-processed so that the solutions can be called at any time $t$ using interpolation. The times at which the model was solved at are stored in `solution.t` and the statevectors at those times are stored in `solution.y`" + "After solving, we can extract the variables from the solution. These are automatically post-processed so that the solutions can be called at any time $t$ using interpolation. The times at which the model was solved at are stored in `solution.t`." ] }, { @@ -222,7 +222,7 @@ "metadata": {}, "outputs": [], "source": [ - "t_sol, y_sol = solution.t, solution.y # get solution times and states\n", + "t_sol = solution.t # get solution times\n", "x = solution[\"x\"] # extract and process x from the solution\n", "y = solution[\"y\"] # extract and process y from the solution" ] diff --git a/docs/source/examples/notebooks/models/unsteady-heat-equation.ipynb b/docs/source/examples/notebooks/models/unsteady-heat-equation.ipynb index a7dae64a21..0f71917b35 100644 --- a/docs/source/examples/notebooks/models/unsteady-heat-equation.ipynb +++ b/docs/source/examples/notebooks/models/unsteady-heat-equation.ipynb @@ -260,7 +260,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Now that the model has been discretised we are ready to solve. We must first choose a solver to use. For this model we choose the Scipy ODE solver, but other solvers are available in PyBaMM (see [here](https://docs.pybamm.org/en/latest/source/api/solvers/index.html)). To solve the model, we use the method `solver.solve` which takes in a model and an array of times at which we would like the solution to be returned. Ths solution is then stored in the `solution` object. The times and states can be accessed with `solver.t` and `solver.y`." + "Now that the model has been discretised we are ready to solve. We must first choose a solver to use. For this model we choose the Scipy ODE solver, but other solvers are available in PyBaMM (see [here](https://docs.pybamm.org/en/latest/source/api/solvers/index.html)). To solve the model, we use the method `solver.solve` which takes in a model and an array of times at which we would like the solution to be returned. Ths solution is then stored in the `solution` object. The times can be accessed with `solution.t`." ] }, { @@ -278,7 +278,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "After solving, we can process variables using the class `pybamm.ProcessedVariable`. This returns a callable object which can be evaluated at any time and position by means of interpolating the solution. Processed variables provide a convinient way of comparing the solution to solutions from different models, or to exact solutions. Since all of the variables are names with informative strings, the user doesn't need to keep track of where they are stored in the state vector `solution.y`. This is particularly useful in complex models with lots of variables, and is automatically handled by the solution dictionary." + "After solving, we can process variables using the class `pybamm.ProcessedVariable`. This returns a callable object which can be evaluated at any time and position by means of interpolating the solution. Processed variables provide a convinient way of comparing the solution to solutions from different models, or to exact solutions. Since all of the variables are names with informative strings, the user doesn't need to keep track of where they are stored in the state vector. This is particularly useful in complex models with lots of variables, and is automatically handled by the solution dictionary." ] }, { diff --git a/examples/scripts/3d_examples/3d_eqation_cylindric_non_zero_flux.py b/examples/scripts/3d_examples/3d_eqation_cylindric_non_zero_flux.py index 5409771cd6..d120f094f2 100644 --- a/examples/scripts/3d_examples/3d_eqation_cylindric_non_zero_flux.py +++ b/examples/scripts/3d_examples/3d_eqation_cylindric_non_zero_flux.py @@ -65,7 +65,7 @@ solution = solver.solve(model) nodes = mesh["current collector"].nodes -T_solution = solution.y.flatten() +T_solution = solution.all_ys[0].flatten() print(f"Number of FEM nodes: {nodes.shape[0]}") print(f"Temperature range: [{T_solution.min():.2f}, {T_solution.max():.2f}]°C") diff --git a/examples/scripts/3d_examples/3d_equation_cylindric.py b/examples/scripts/3d_examples/3d_equation_cylindric.py index e7713209f9..7a6de3706c 100644 --- a/examples/scripts/3d_examples/3d_equation_cylindric.py +++ b/examples/scripts/3d_examples/3d_equation_cylindric.py @@ -66,7 +66,7 @@ solution = solver.solve(model) nodes = mesh["current collector"].nodes -T_solution = solution.y.flatten() +T_solution = solution.all_ys[0].flatten() print(f"Number of FEM nodes: {nodes.shape[0]}") print(f"Temperature range: [{T_solution.min():.2f}, {T_solution.max():.2f}]°C") diff --git a/examples/scripts/3d_examples/3d_equation_prismatic.py b/examples/scripts/3d_examples/3d_equation_prismatic.py index ae60db1aff..989b13b3cd 100644 --- a/examples/scripts/3d_examples/3d_equation_prismatic.py +++ b/examples/scripts/3d_examples/3d_equation_prismatic.py @@ -61,7 +61,7 @@ solution = solver.solve(model) nodes = mesh["current collector"].nodes -T_solution = solution.y.flatten() +T_solution = solution.all_ys[0].flatten() print(f"Number of FEM nodes: {nodes.shape[0]}") print(f"Temperature range: [{T_solution.min():.2f}, {T_solution.max():.2f}]°C") diff --git a/examples/scripts/compare_comsol/compare_comsol_DFN.py b/examples/scripts/compare_comsol/compare_comsol_DFN.py index 13959aab55..965f4f477f 100644 --- a/examples/scripts/compare_comsol/compare_comsol_DFN.py +++ b/examples/scripts/compare_comsol/compare_comsol_DFN.py @@ -120,7 +120,7 @@ def get_interp_fun(variable_name, domain): # Make new solution with same t and y # Update solution scales to match the pybamm model comsol_solution = pybamm.Solution( - pybamm_solution.t, pybamm_solution.y, comsol_model, {} + pybamm_solution.all_ts, pybamm_solution.all_ys, comsol_model, {} ) # plot diff --git a/packages/pybamm/src/pybamm/experiment/step/base_step.py b/packages/pybamm/src/pybamm/experiment/step/base_step.py index 88c0769bef..6eecf9f271 100644 --- a/packages/pybamm/src/pybamm/experiment/step/base_step.py +++ b/packages/pybamm/src/pybamm/experiment/step/base_step.py @@ -296,7 +296,7 @@ def __hash__(self): #: their target value collapse to one unified-mode branch. control_kind = None - #: The scalar output the solver's ``upsampling_factor`` resolves. + #: The scalar output the solver's ``upsampling_tolerance`` resolves. upsampling_variable = "Voltage [V]" def get_control_residual(self, variables, target=None): diff --git a/packages/pybamm/src/pybamm/models/base_model.py b/packages/pybamm/src/pybamm/models/base_model.py index 613d2b230b..6eacd0281c 100644 --- a/packages/pybamm/src/pybamm/models/base_model.py +++ b/packages/pybamm/src/pybamm/models/base_model.py @@ -427,6 +427,15 @@ def get_processed_variable(self, name: str) -> pybamm.Symbol: self.process_and_register_variable(name, value) return self._variables_processed[name] + def is_algebraic_state(self, symbol: pybamm.Symbol) -> bool: + """Whether ``symbol`` is one of the model's algebraic state variables. + + Matched by name, since a model copy keeps a state's name but not its id. + """ + return isinstance(symbol, pybamm.Variable) and symbol.name in { + state.name for state in self.algebraic + } + def get_processed_variable_or_event(self, name: str) -> pybamm.Symbol: """ Get a processed variable or event by name. @@ -1425,7 +1434,8 @@ def _find_matching_variable(var, solution_model): def _extract_from_y_slices(var, solution_var, solution_model, solution): solution_y_slice = solution_model.y_slices[solution_var][0] - y_last = solution.y[:, -1] if solution.y.ndim > 1 else solution.y + y = solution.all_ys[-1] + y_last = y[:, -1] if y.ndim > 1 else y # Validate slice bounds slice_stop = ( diff --git a/packages/pybamm/src/pybamm/plotting/quick_plot.py b/packages/pybamm/src/pybamm/plotting/quick_plot.py index b2161b23d2..52c98d18bb 100644 --- a/packages/pybamm/src/pybamm/plotting/quick_plot.py +++ b/packages/pybamm/src/pybamm/plotting/quick_plot.py @@ -88,7 +88,8 @@ class QuickPlot: variables). Options are: n_t_linear: int, optional The number of linearly spaced time points added to the t axis for each sub-solution. - Note: this is only used if the solution has hermite interpolation enabled. + Note: this is only used if the solution has hermite interpolation enabled + and no ``t_additive`` times, which are added instead. - "fixed" (default): keep all axes fixes so that all data is visible - "tight": make axes tight to plot at each time @@ -181,7 +182,9 @@ def __init__( hermite_interp = all(sol.hermite_interpolation for sol in solutions) def t_sample(sol): - if hermite_interp and n_t_linear > 2: + if sol.t_additive is not None: + t_plot = sol.t + elif hermite_interp and n_t_linear > 2: # Linearly spaced time points t_linspace = np.linspace(sol.t[0], sol.t[-1], n_t_linear + 2)[1:-1] t_plot = np.union1d(sol.t, t_linspace) diff --git a/packages/pybamm/src/pybamm/simulation/simulation.py b/packages/pybamm/src/pybamm/simulation/simulation.py index 70d941441a..ec6c8e41f1 100644 --- a/packages/pybamm/src/pybamm/simulation/simulation.py +++ b/packages/pybamm/src/pybamm/simulation/simulation.py @@ -40,6 +40,7 @@ class Simulation(BaseSimulation): _PADDING_REST_KEY = "Rest for padding" _STEP_INDEX_INPUT = "Experiment step index" _STEP_VALUE_INPUT = "Experiment step value" + _UPSAMPLING_VARIABLE = "Experiment upsampling variable" _TERMINATION_TIME = "experiment time limit reached" _TERMINATION_VOLTAGE = "experiment voltage limit reached" _TERMINATION_CAPACITY = "experiment capacity limit reached" @@ -350,6 +351,22 @@ def control_builder(step): # (in-place parameter processing and discretisation) through to solver.process. new_model.switching_control_variables = {var.name for var in submodel.algebraic} + # Upsample each step's own output by selecting it with the step index input. + # An algebraic state is resolved by the integrator, so its branch is constant. + upsampling_steps = list(unique_steps) + if self._experiment_includes_padding_rest: + upsampling_steps.append(padding_rest_step) + upsampling_branches = [] + for step in upsampling_steps: + variable = variables[step.upsampling_variable] + if new_model.is_algebraic_state(variable): + variable = pybamm.Scalar(0) + upsampling_branches.append(variable) + new_model.variables[self._UPSAMPLING_VARIABLE] = pybamm.Conditional( + pybamm.InputParameter(self._STEP_INDEX_INPUT), *upsampling_branches + ) + new_model.upsampling_variable = self._UPSAMPLING_VARIABLE + # Combine each step's local termination expression into one experiment event, # selecting the active branch with the step index input. termination_branches = [ @@ -474,7 +491,7 @@ def _decode_combined_step_termination(self, step_solution, step, model, inputs): y = ( step_solution.y_event if step_solution.y_event is not None - else step_solution.y[:, -1] + else step_solution.all_ys[-1][:, -1] ) try: diff --git a/packages/pybamm/src/pybamm/solvers/casadi_solver.py b/packages/pybamm/src/pybamm/solvers/casadi_solver.py index 40576d0b06..375080abff 100644 --- a/packages/pybamm/src/pybamm/solvers/casadi_solver.py +++ b/packages/pybamm/src/pybamm/solvers/casadi_solver.py @@ -218,10 +218,7 @@ def _integrate_single(self, model, t_eval, inputs_dict, y0): # create integrator once, without grid, # to avoid having to create several times self.create_integrator(model, y0, inputs) - # Initialize solution. Coerce y0 to a CasADi DM so that when - # subsequent CasADi integration segments are appended, the - # concatenated ``solution.y`` stays a CasADi type. When the - # root method is NonlinearSolver, y0 arrives as an ndarray. + # Keep every segment's states CasADi, even if y0 is an ndarray y0_init = y0 if isinstance(y0, casadi.DM | casadi.MX) else casadi.DM(y0) solution = pybamm.Solution( np.array([t]), @@ -395,7 +392,9 @@ def f(idx, f_eval=f_eval, event=event): # We take away 1e-5 to deal with the case where the event sits # exactly on zero, as can happen when the event switch is used # (fast with events mode) - f_eval[idx] = event(sol.t[idx], sol.y[:, idx], inputs) - 1e-5 + f_eval[idx] = ( + event(sol.t[idx], sol.all_ys[0][:, idx], inputs) - 1e-5 + ) return f_eval[idx] def integer_bisect(): @@ -435,7 +434,7 @@ def integer_bisect(): t_event = np.nanmin(t_events) # create interpolant to evaluate y in the current integration # window - y_sol = interp1d(sol.t, sol.y, kind="linear") + y_sol = interp1d(sol.t, sol.all_ys[0], kind="linear") y_event = y_sol(t_event) closest_event_idx = event_idx[np.nanargmin(t_events)] @@ -467,7 +466,7 @@ def integer_bisect(): self.create_integrator(model, y0, inputs, t_window_event_dense) use_grid = True - y0 = coarse_solution.y[:, event_idx_lower] + y0 = coarse_solution.all_ys[0][:, event_idx_lower] dense_step_sol = self._run_integrator( model, y0, @@ -487,12 +486,12 @@ def integer_bisect(): # This is extremely rare, it's difficult to find a test that triggers this # hence no coverage check t_event = coarse_solution.t[event_idx_lower + 1] - y_event = coarse_solution.y[:, event_idx_lower + 1].full().flatten() + y_event = coarse_solution.all_ys[0][:, event_idx_lower + 1].full().flatten() # Return solution truncated at the first coarse event time # Also assign t_event t_sol = coarse_solution.t[: event_idx_lower + 1] - y_sol = coarse_solution.y[:, : event_idx_lower + 1] + y_sol = coarse_solution.all_ys[0][:, : event_idx_lower + 1] solution = pybamm.Solution( t_sol, y_sol, diff --git a/packages/pybamm/src/pybamm/solvers/idaklu_solver.py b/packages/pybamm/src/pybamm/solvers/idaklu_solver.py index e3ca39ee02..7bd374d342 100644 --- a/packages/pybamm/src/pybamm/solvers/idaklu_solver.py +++ b/packages/pybamm/src/pybamm/solvers/idaklu_solver.py @@ -242,11 +242,16 @@ class IDAKLUSolver(pybamm.BaseSolver): # only active if `hermite_interpolation` is True and sensitivities # are disabled. "hermite_reduction_factor": 1.0, - # Setting upsampling_factor > 1.0 adds points within each step - # until linear interpolation of the model's `upsampling_variable` - # (default: the voltage) is within the solver tolerances divided - # by (factor - 1). Ignored with t_interp. - "upsampling_factor": 1.0, + # Output upsampling (0 = off). Adds times inside each solver step + # until linear interpolation of one scalar output variable between + # them is accurate to upsampling_tolerance times the variable's + # scale (1 if it has none). The variable is the voltage, or the + # current in voltage-controlled experiment steps. For example, 1e-3 + # gives a linear-interpolation voltage error of about 1 mV. The + # times go to `Solution.t_additive` (and `Solution.t`) when Hermite + # data is stored and sensitivities are off; otherwise they are + # saved as full solution points. Ignored with t_interp. + "upsampling_tolerance": 0.0, ## Initial conditions calculation # Positive constant in the Newton iteration convergence test within the # initial condition calculation @@ -376,7 +381,7 @@ def _combine_options(self, user_options: dict | None) -> dict: "suppress_algebraic_error": False, "hermite_interpolation": True, "hermite_reduction_factor": 1.0, - "upsampling_factor": 1.0, + "upsampling_tolerance": 0.0, "nonlinear_convergence_coefficient_ic": 0.0033, "max_num_steps_ic": 50, "max_num_jacobians_ic": 40, @@ -414,14 +419,8 @@ def _check_options(self, options: dict): if hermite_reduction_factor < 1.0: raise pybamm.SolverError("hermite_reduction_factor must be >= 1.0.") - upsampling_factor = options["upsampling_factor"] - if upsampling_factor < 1.0: - raise pybamm.SolverError("upsampling_factor must be >= 1.0.") - if upsampling_factor > 1.0 and hermite_reduction_factor > 1.0: - raise pybamm.SolverError( - "upsampling_factor and hermite_reduction_factor are " - "mutually exclusive; at most one can be > 1.0." - ) + if options["upsampling_tolerance"] < 0.0: + raise pybamm.SolverError("upsampling_tolerance must be >= 0.") if not isinstance(options["compile"], bool): raise pybamm.SolverError("compile must be a bool") @@ -726,6 +725,7 @@ def to_idaklu(fn): # The idaklu-bound Function isn't callable from Python; keep the # original for the closest_event_idx lookup in _post_process_solution. self._setup["rootfn_casadi"] = fns["rootfn"] + self._setup["upsampling"] = fns["upsampling_fcn"].n_out() > 0 for key in self.output_variables: self._setup["var_idaklu_fcns"].append(to_idaklu(fns[f"var:{key}"])) @@ -781,30 +781,31 @@ def to_idaklu(fn): return base_set_up_return def _upsampling_fcn(self, model, inputs_dict): - """``g(t, y, p)``: the model's scalar ``upsampling_variable``. + """``g(t, y, p)``: the model's scalar ``upsampling_variable`` divided + by its scale, or by 1 if it has none. Empty if upsampling is off, or the variable is missing or an algebraic state, which the integrator's error test already resolves. """ empty = casadi.Function("empty_upsampling", [], []) name = getattr(model, "upsampling_variable", "Voltage [V]") - if self._options["upsampling_factor"] <= 1.0 or name not in model.variables: + if self._options["upsampling_tolerance"] == 0 or name not in model.variables: return empty - # Model copies keep a state's name but not its id variable = model.variables[name] - if isinstance(variable, pybamm.Variable) and variable.name in { - state.name for state in model.algebraic - }: + if model.is_algebraic_state(variable): return empty + scale = getattr(variable, "scale", None) + if not (isinstance(scale, pybamm.Symbol) and scale.is_constant()): + scale = 1 upsampling_fcn = process( - model.get_processed_variable_or_event(name), + model.get_processed_variable_or_event(name) / scale, pybamm.BaseSolver._wrangle_name(name), self._get_vars_for_processing(model, inputs_dict), use_jacobian=False, )[0] if upsampling_fcn.numel_out() != 1: raise pybamm.SolverError( - f"upsampling_factor needs a scalar variable, got '{name}'" + f"upsampling_tolerance needs a scalar variable, got '{name}'" ) return upsampling_fcn @@ -991,6 +992,15 @@ def _post_process_solution(self, sol, model, integration_time, inputs_dict, t_ev # backend as the integration. solution_options = {"compile": self._options["compile"]} + # Without Hermite data or with sensitivities, the points went into the states + t_additive = None + if ( + yp is not None + and self._setup["upsampling"] + and number_of_sensitivity_parameters == 0 + ): + t_additive = sol.t_additive + newsol = pybamm.Solution( t, np.transpose(y_out), @@ -1004,6 +1014,7 @@ def _post_process_solution(self, sol, model, integration_time, inputs_dict, t_ev all_t_evals=t_eval, variables_returned=bool(save_outputs_only), options=solution_options, + t_additive=t_additive, ) # Set closest_event_idx so BaseSolver.get_termination_reason doesn't diff --git a/packages/pybamm/src/pybamm/solvers/processed_variable.py b/packages/pybamm/src/pybamm/solvers/processed_variable.py index fb6767f89e..592e99d1f8 100644 --- a/packages/pybamm/src/pybamm/solvers/processed_variable.py +++ b/packages/pybamm/src/pybamm/solvers/processed_variable.py @@ -168,7 +168,15 @@ def observe_raw(self): t = self.t_pts return self._observe_postfix(self._observe_raw(), t) + @property + def has_t_additive(self) -> bool: + """Whether ``t_pts`` includes times without stored states.""" + return len(self.t_pts) != sum(len(t) for t in self.all_ts) + def _observe_raw(self): + # Hermite interpolation fills in the times without stored states + if self.has_t_additive: + return self._observe_hermite(self.t_pts) return self._observer.observe_raw(self) def _observe_hermite(self, t): diff --git a/packages/pybamm/src/pybamm/solvers/solution.py b/packages/pybamm/src/pybamm/solvers/solution.py index e8adb41781..b41d1d8703 100644 --- a/packages/pybamm/src/pybamm/solvers/solution.py +++ b/packages/pybamm/src/pybamm/solvers/solution.py @@ -6,6 +6,7 @@ import json import numbers import pickle +import warnings from dataclasses import astuple, dataclass from itertools import chain @@ -326,6 +327,9 @@ class Solution(SolutionBase): Solvers should forward the user's selection so observed variables use the same backend as the integration. + t_additive : :class:`numpy.array`, optional + Sorted times between the stored states, included in ``t`` and evaluated by + Hermite interpolation of the states. """ # Solvers, Simulation and make_cycle_solution set all_first_states, closest_event_idx, @@ -350,6 +354,7 @@ class Solution(SolutionBase): "_sub_solutions", "_summary_variables", "_t", + "_t_additive", "_t_eval", "_t_event", "_termination", @@ -388,6 +393,7 @@ def __init__( variables_returned=False, check_solution=True, options=None, + t_additive=None, _validate_time_structure=True, ): if not isinstance(all_ts, list): @@ -474,6 +480,7 @@ def __init__( # Initialize empty inputs self._t = None + self._t_additive = t_additive self._t_eval = None self._y = None self._yp = None @@ -501,6 +508,7 @@ def __setstate__(self, state: dict | tuple) -> None: self.solver_statistics = None self._first_state = None self._last_state = None + self._t_additive = None self._y0 = None self._y0_sensitivities = None self._y_event_sensitivities = None @@ -565,11 +573,21 @@ def _ensure_sorted_t(ts: list[np.ndarray], name: str): @property def t(self) -> np.ndarray: - """Times at which the solution is evaluated""" + """Times at which the solution is evaluated, including ``t_additive``""" if self._t is None: self._t = np.concatenate(self.all_ts) + t_additive = self.t_additive + if t_additive is not None: + self._t = np.union1d(self._t, t_additive) return self._t + @property + def t_additive(self) -> np.ndarray | None: + """Times between the stored states that resolve the solver's upsampled + output variable, or None if the solver did not upsample""" + parts = [s._t_additive for s in self.sub_solutions if s._t_additive is not None] + return np.concatenate(parts) if parts else None + @property def t_eval(self) -> np.ndarray: """Evaluation time breakpoints for each segment""" @@ -579,14 +597,28 @@ def t_eval(self) -> np.ndarray: @property def y(self): - """Values of the solution""" + """The stored states, concatenated (deprecated).""" + warnings.warn( + "`Solution.y` is deprecated and will be removed in a future release; " + "it holds only the stored states, which need not match `Solution.t`. " + "Use `solution[name]` for variables, or `all_ys` for the raw states.", + DeprecationWarning, + stacklevel=2, + ) if self._y is None: self._y = self._concatenate_states(self.all_ys) return self._y @property def yp(self): - """Time derivatives of the solution""" + """The stored state time derivatives, concatenated (deprecated).""" + warnings.warn( + "`Solution.yp` is deprecated and will be removed in a future release; " + "it holds only the stored states, which need not match `Solution.t`. " + "Use `all_yps` for the raw state derivatives.", + DeprecationWarning, + stacklevel=2, + ) if self.hermite_interpolation and self._yp is None: self._yp = self._concatenate_states(self.all_yps) return self._yp diff --git a/packages/pybamm/tests/__init__.py b/packages/pybamm/tests/__init__.py index 0ad83917cb..ad991f2b11 100644 --- a/packages/pybamm/tests/__init__.py +++ b/packages/pybamm/tests/__init__.py @@ -23,6 +23,7 @@ ) from .shared import ( + concatenated_states, DummyDiscretisationClass, get_mesh_for_testing, get_3d_mesh_for_testing, diff --git a/packages/pybamm/tests/integration/test_simulations.py b/packages/pybamm/tests/integration/test_simulations.py index 6add2e592f..b6567498a6 100644 --- a/packages/pybamm/tests/integration/test_simulations.py +++ b/packages/pybamm/tests/integration/test_simulations.py @@ -47,11 +47,15 @@ def test_pickle_roundtrip_exact( sol_orig.t, sol_loaded.t, err_msg=f"{tag} sol.t mismatch" ) np.testing.assert_array_equal( - sol_orig.y, sol_loaded.y, err_msg=f"{tag} sol.y mismatch" + sol_orig.all_ys[0], + sol_loaded.all_ys[0], + err_msg=f"{tag} sol.all_ys[0] mismatch", ) - if sol_orig.yp is not None: + if sol_orig.all_yps[0] is not None: np.testing.assert_array_equal( - sol_orig.yp, sol_loaded.yp, err_msg=f"{tag} sol.yp mismatch" + sol_orig.all_yps[0], + sol_loaded.all_yps[0], + err_msg=f"{tag} sol.all_yps[0] mismatch", ) diff --git a/packages/pybamm/tests/integration/test_solvers/test_diffsl.py b/packages/pybamm/tests/integration/test_solvers/test_diffsl.py index f65466506c..a8521ec064 100644 --- a/packages/pybamm/tests/integration/test_solvers/test_diffsl.py +++ b/packages/pybamm/tests/integration/test_solvers/test_diffsl.py @@ -125,9 +125,7 @@ def test_models(self, model, inputs, request, timing_results): t_interp = np.linspace(t_eval[0], t_eval[1], 100) solver = pybamm.IDAKLUSolver(output_variables=[output_variable]) - _soln_pybamm = solver.solve( - model_disc, t_eval, t_interp=t_interp, inputs=pv_inputs - ).y + solver.solve(model_disc, t_eval, t_interp=t_interp, inputs=pv_inputs) t0 = time.perf_counter() voltage_pybamm = solver.solve( diff --git a/packages/pybamm/tests/integration/test_solvers/test_idaklu.py b/packages/pybamm/tests/integration/test_solvers/test_idaklu.py index 12e4ee1b7e..c9be784420 100644 --- a/packages/pybamm/tests/integration/test_solvers/test_idaklu.py +++ b/packages/pybamm/tests/integration/test_solvers/test_idaklu.py @@ -84,7 +84,7 @@ def test_on_spme_sensitivities(self): sol_neg = solver.solve( model, t_eval, inputs={param_name: param_value - 0.5 * h}, t_interp=t_interp ) - dyda_fd = (sol_plus.y - sol_neg.y) / h + dyda_fd = (sol_plus.all_ys[0] - sol_neg.all_ys[0]) / h dyda_fd = dyda_fd.transpose().reshape(-1, 1) np.testing.assert_allclose( @@ -177,11 +177,11 @@ def test_interpolation(self): for sol in sols: # test that y[0] = to true solution true_solution = a_value * sol.t - np.testing.assert_allclose(sol.y[0], true_solution) + np.testing.assert_allclose(sol.all_ys[0][0], true_solution) # test that y[1:3] = to true solution true_solution = b_value * sol.t - np.testing.assert_allclose(sol.y[1:3], true_solution) + np.testing.assert_allclose(sol.all_ys[0][1:3], true_solution) def test_with_experiments(self): summary_vars = [] @@ -277,7 +277,7 @@ def test_multiple_initial_conditions_against_independent_solves(self): sol_ind = indep_sols[idx] np.testing.assert_allclose(sol_vec.t, sol_ind.t) - np.testing.assert_allclose(sol_vec.y, sol_ind.y) + np.testing.assert_allclose(sol_vec.all_ys[0], sol_ind.all_ys[0]) def test_outvars_with_experiments_multi_simulation(self): model = pybamm.lithium_ion.SPM() diff --git a/packages/pybamm/tests/shared.py b/packages/pybamm/tests/shared.py index e599288699..a2197f52fa 100644 --- a/packages/pybamm/tests/shared.py +++ b/packages/pybamm/tests/shared.py @@ -5,6 +5,7 @@ import re import socket +import casadi import numpy as np from scipy.sparse import eye @@ -622,3 +623,10 @@ def assert_symbolic_mesh_matches_numeric( numeric.jac(state).evaluate(0, y).toarray(), **tolerance, ) + + +def concatenated_states(states): + """Join a solution's per-segment ``all_ys`` or ``all_yps`` along time.""" + if isinstance(states[0], casadi.DM | casadi.MX): + return casadi.horzcat(*states) + return np.hstack(states) diff --git a/packages/pybamm/tests/unit/test_base_simulation.py b/packages/pybamm/tests/unit/test_base_simulation.py index 5995b739bd..88cbcb989a 100644 --- a/packages/pybamm/tests/unit/test_base_simulation.py +++ b/packages/pybamm/tests/unit/test_base_simulation.py @@ -68,7 +68,9 @@ def test_base_solve_simple_model(self): model.initial_conditions = {v: 1} sim = pybamm.BaseSimulation(model) sol = sim.solve([0, 1]) - np.testing.assert_allclose(sol.y[0], np.exp(-sol.t), rtol=1e-4, atol=1e-4) + np.testing.assert_allclose( + sol.all_ys[0][0], np.exp(-sol.t), rtol=1e-4, atol=1e-4 + ) def test_base_solve_with_crate(self): model = pybamm.lithium_ion.SPM() diff --git a/packages/pybamm/tests/unit/test_experiments/test_simulation_with_experiment.py b/packages/pybamm/tests/unit/test_experiments/test_simulation_with_experiment.py index a80dfb9a9a..9a5a9a6d4d 100644 --- a/packages/pybamm/tests/unit/test_experiments/test_simulation_with_experiment.py +++ b/packages/pybamm/tests/unit/test_experiments/test_simulation_with_experiment.py @@ -1350,8 +1350,8 @@ def test_run_experiment_output_variables_repeated_step(self): full_solution, solution = solutions np.testing.assert_allclose( - solution.cycles[0].first_state.y, - full_solution.cycles[0].first_state.y, + solution.cycles[0].first_state.all_ys[0], + full_solution.cycles[0].first_state.all_ys[0], rtol=1e-6, ) change = "Change in throughput capacity [A.h]" @@ -2152,8 +2152,8 @@ def test_skipped_step_continuous(self): experiment_model_mode=experiment_model_mode, ) sim.solve(initial_soc=1) - previous_state = sim.solution.cycles[0].last_state.y - next_state = sim.solution.cycles[1].steps[-1].first_state.y + previous_state = sim.solution.cycles[0].last_state.all_ys[0] + next_state = sim.solution.cycles[1].steps[-1].first_state.all_ys[0] built_model = sim.steps_to_built_models[ sim.experiment.steps[0].basic_repr() @@ -2345,7 +2345,7 @@ def __init__(self, source): self.t_event = None self.y_event = None self.t = source.t - self.y = source.y + self.all_ys = source.all_ys self.all_inputs = source.all_inputs def __getitem__(self, key): diff --git a/packages/pybamm/tests/unit/test_models/test_model_to_json.py b/packages/pybamm/tests/unit/test_models/test_model_to_json.py index f08ce1c55e..458f69c312 100644 --- a/packages/pybamm/tests/unit/test_models/test_model_to_json.py +++ b/packages/pybamm/tests/unit/test_models/test_model_to_json.py @@ -12,7 +12,7 @@ def _assert_solution_y_almost_equal(sol_orig, sol_loaded, key_output="Voltage [V]"): """Assert solution state vector has same shape and key output is almost equal.""" - np.testing.assert_array_equal(sol_orig.y.shape, sol_loaded.y.shape) + np.testing.assert_array_equal(sol_orig.all_ys[0].shape, sol_loaded.all_ys[0].shape) v_orig = sol_orig[key_output].data v_loaded = sol_loaded[key_output].data np.testing.assert_allclose(v_orig, v_loaded, rtol=5e-3, atol=1e-4) diff --git a/packages/pybamm/tests/unit/test_plotting/test_quick_plot.py b/packages/pybamm/tests/unit/test_plotting/test_quick_plot.py index 45956fc020..80b6e69744 100644 --- a/packages/pybamm/tests/unit/test_plotting/test_quick_plot.py +++ b/packages/pybamm/tests/unit/test_plotting/test_quick_plot.py @@ -592,6 +592,17 @@ def test_model_with_inputs(self): quick_plot.slider_update(1) pybamm.close_plots() + def test_t_additive_time_points(self): + solver = pybamm.IDAKLUSolver(options={"upsampling_tolerance": 1e-4}) + sim = pybamm.Simulation(pybamm.lithium_ion.SPM(), solver=solver) + solution = sim.solve([0, 3600]) + quick_plot = pybamm.QuickPlot(solution, ["Voltage [V]"]) + np.testing.assert_allclose( + quick_plot.ts_seconds[0], + np.union1d(solution.t, solution.t_additive), + rtol=1e-14, + ) + class TestQuickPlotAxes: def test_quick_plot_axes(self): diff --git a/packages/pybamm/tests/unit/test_serialisation/test_round_trip.py b/packages/pybamm/tests/unit/test_serialisation/test_round_trip.py index d8f1d58fca..4fd3a8646f 100644 --- a/packages/pybamm/tests/unit/test_serialisation/test_round_trip.py +++ b/packages/pybamm/tests/unit/test_serialisation/test_round_trip.py @@ -817,7 +817,7 @@ def test_solution_matches_original(self, model_cls): loaded_solution = loaded.default_solver.solve(loaded, t_eval) assert np.allclose( - original_solution.y, loaded_solution.y, rtol=1e-6, atol=1e-8 + original_solution.all_ys[0], loaded_solution.all_ys[0], rtol=1e-6, atol=1e-8 ), ( f"{model_cls.__name__} state trajectory diverged after a " "discretised round-trip" diff --git a/packages/pybamm/tests/unit/test_simulation.py b/packages/pybamm/tests/unit/test_simulation.py index cd37f9173c..e3105c903c 100644 --- a/packages/pybamm/tests/unit/test_simulation.py +++ b/packages/pybamm/tests/unit/test_simulation.py @@ -7,7 +7,7 @@ import pybamm from pybamm.solvers.base_solver import BaseSolver -from tests import no_internet_connection +from tests import concatenated_states, no_internet_connection class TestSimulation: @@ -18,7 +18,9 @@ def test_simple_model(self): model.initial_conditions = {v: 1} sim = pybamm.Simulation(model) sol = sim.solve([0, 1]) - np.testing.assert_allclose(sol.y[0], np.exp(-sol.t), rtol=1e-4, atol=1e-4) + np.testing.assert_allclose( + sol.all_ys[0][0], np.exp(-sol.t), rtol=1e-4, atol=1e-4 + ) def test_solve(self): sim = pybamm.Simulation(pybamm.lithium_ion.SPM()) @@ -173,7 +175,7 @@ def test_step(self): sim = pybamm.Simulation(model) sim.step(dt) # 1 step stores first 12 points - assert sim.solution.y[0, :].size == 12 + assert sim.solution.all_ys[0][0, :].size == 12 np.testing.assert_allclose( [sim.solution.t[0], sim.solution.t[-1]], np.array([0, dt]), @@ -183,7 +185,7 @@ def test_step(self): saved_sol = sim.solution sim.step(dt) # automatically append the next step - assert sim.solution.y[0, :].size == 24 + assert concatenated_states(sim.solution.all_ys)[0, :].size == 24 np.testing.assert_allclose( [sim.solution.t[0], sim.solution.t[-1]], np.array([0, 2 * dt]), @@ -192,7 +194,7 @@ def test_step(self): ) sim.step(dt, save=False) # now only store the two end step points - assert sim.solution.y[0, :].size == 12 + assert sim.solution.all_ys[0][0, :].size == 12 np.testing.assert_allclose( [sim.solution.t[0], sim.solution.t[-1]], np.array([2 * dt + 1e-9, 3 * dt]), @@ -201,7 +203,7 @@ def test_step(self): ) # Start from saved solution sim.step(dt, starting_solution=saved_sol) - assert sim.solution.y[0, :].size == 24 + assert concatenated_states(sim.solution.all_ys)[0, :].size == 24 np.testing.assert_allclose( [sim.solution.t[0], sim.solution.t[-1]], np.array([0, 2 * dt]), @@ -661,7 +663,7 @@ def test_step_with_inputs(self): dt, inputs={"Current function [A]": 1} ) # 1 step stores first 12 points assert sim.solution.t.size == 12 - assert sim.solution.y[0, :].size == 12 + assert sim.solution.all_ys[0][0, :].size == 12 assert sim.solution.t[0] == 0 assert sim.solution.t[-1] == dt np.testing.assert_array_equal( @@ -670,7 +672,7 @@ def test_step_with_inputs(self): sim.step( dt, inputs={"Current function [A]": 2} ) # automatically append the next step - assert sim.solution.y[0, :].size == 24 + assert concatenated_states(sim.solution.all_ys)[0, :].size == 24 np.testing.assert_allclose( [sim.solution.t[0], sim.solution.t[-1]], np.array([0, 2 * dt]), diff --git a/packages/pybamm/tests/unit/test_solvers/test_algebraic_solver.py b/packages/pybamm/tests/unit/test_solvers/test_algebraic_solver.py index bcf950f05b..491dc6cf82 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_algebraic_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_algebraic_solver.py @@ -59,7 +59,7 @@ def algebraic_eval(self, t, y, inputs): solver = pybamm.AlgebraicSolver(extra_options={"maxiter": 100}) model = Model() solutions = solver._integrate(model, np.array([0])) - np.testing.assert_array_equal(solutions[0].y, -2) + np.testing.assert_array_equal(solutions[0].all_ys[0], -2) def test_root_find_fail(self): class Model(pybamm.BaseModel): @@ -118,7 +118,7 @@ def jac_algebraic_eval(self, t, y, inputs): solver = pybamm.AlgebraicSolver() solutions = solver._integrate(model, np.array([0])) - np.testing.assert_allclose(solutions[0].y, sol, rtol=1e-7, atol=1e-6) + np.testing.assert_allclose(solutions[0].all_ys[0], sol, rtol=1e-7, atol=1e-6) def test_model_solver(self): # Create model @@ -138,11 +138,11 @@ def test_model_solver(self): solver = pybamm.AlgebraicSolver() solution = solver.solve(model) np.testing.assert_array_equal( - model.get_processed_variable("var1").evaluate(t=None, y=solution.y), + model.get_processed_variable("var1").evaluate(t=None, y=solution.all_ys[0]), sol[:100], ) np.testing.assert_array_equal( - model.get_processed_variable("var2").evaluate(t=None, y=solution.y), + model.get_processed_variable("var2").evaluate(t=None, y=solution.all_ys[0]), sol[100:], ) @@ -151,11 +151,15 @@ def test_model_solver(self): solver.models_set_up = set() solution_no_jac = solver.solve(model) np.testing.assert_array_equal( - model.get_processed_variable("var1").evaluate(t=None, y=solution_no_jac.y), + model.get_processed_variable("var1").evaluate( + t=None, y=solution_no_jac.all_ys[0] + ), sol[:100], ) np.testing.assert_array_equal( - model.get_processed_variable("var2").evaluate(t=None, y=solution_no_jac.y), + model.get_processed_variable("var2").evaluate( + t=None, y=solution_no_jac.all_ys[0] + ), sol[100:], ) @@ -177,13 +181,13 @@ def test_model_solver_least_squares(self): solver = pybamm.AlgebraicSolver("lsq") solution = solver.solve(model) np.testing.assert_allclose( - model.get_processed_variable("var1").evaluate(t=None, y=solution.y), + model.get_processed_variable("var1").evaluate(t=None, y=solution.all_ys[0]), sol[:100], rtol=1e-7, atol=1e-6, ) np.testing.assert_allclose( - model.get_processed_variable("var2").evaluate(t=None, y=solution.y), + model.get_processed_variable("var2").evaluate(t=None, y=solution.all_ys[0]), sol[100:], rtol=1e-7, atol=1e-6, @@ -194,13 +198,17 @@ def test_model_solver_least_squares(self): solver = pybamm.AlgebraicSolver("lsq__trf") solution_no_jac = solver.solve(model) np.testing.assert_allclose( - model.get_processed_variable("var1").evaluate(t=None, y=solution_no_jac.y), + model.get_processed_variable("var1").evaluate( + t=None, y=solution_no_jac.all_ys[0] + ), sol[:100], rtol=1e-7, atol=1e-6, ) np.testing.assert_allclose( - model.get_processed_variable("var2").evaluate(t=None, y=solution_no_jac.y), + model.get_processed_variable("var2").evaluate( + t=None, y=solution_no_jac.all_ys[0] + ), sol[100:], rtol=1e-7, atol=1e-6, @@ -224,13 +232,13 @@ def test_model_solver_minimize(self): solver = pybamm.AlgebraicSolver("minimize", tol=1e-8) solution = solver.solve(model) np.testing.assert_allclose( - model.get_processed_variable("var1").evaluate(t=None, y=solution.y), + model.get_processed_variable("var1").evaluate(t=None, y=solution.all_ys[0]), sol[:100], rtol=1e-7, atol=1e-6, ) np.testing.assert_allclose( - model.get_processed_variable("var2").evaluate(t=None, y=solution.y), + model.get_processed_variable("var2").evaluate(t=None, y=solution.all_ys[0]), sol[100:], rtol=1e-7, atol=1e-6, @@ -241,13 +249,17 @@ def test_model_solver_minimize(self): solver = pybamm.AlgebraicSolver("minimize__BFGS") solution_no_jac = solver.solve(model) np.testing.assert_allclose( - model.get_processed_variable("var1").evaluate(t=None, y=solution_no_jac.y), + model.get_processed_variable("var1").evaluate( + t=None, y=solution_no_jac.all_ys[0] + ), sol[:100], rtol=1e-7, atol=1e-6, ) np.testing.assert_allclose( - model.get_processed_variable("var2").evaluate(t=None, y=solution_no_jac.y), + model.get_processed_variable("var2").evaluate( + t=None, y=solution_no_jac.all_ys[0] + ), sol[100:], rtol=1e-7, atol=1e-6, @@ -293,7 +305,7 @@ def test_model_solver_least_squares_with_bounds(self): solver = pybamm.AlgebraicSolver("lsq", tol=1e-5) solution = solver.solve(model) np.testing.assert_allclose( - model.get_processed_variable("var1").evaluate(t=None, y=solution.y), + model.get_processed_variable("var1").evaluate(t=None, y=solution.all_ys[0]), 3 * np.pi / 2, rtol=1e-3, atol=1e-2, @@ -312,7 +324,7 @@ def test_model_solver_minimize_with_bounds(self): solver = pybamm.AlgebraicSolver("minimize", tol=1e-16) solution = solver.solve(model) np.testing.assert_allclose( - model.get_processed_variable("var1").evaluate(t=None, y=solution.y), + model.get_processed_variable("var1").evaluate(t=None, y=solution.all_ys[0]), 3 * np.pi / 2, rtol=1e-5, atol=1e-4, @@ -336,16 +348,16 @@ def test_model_solver_with_time(self): solution = solver.solve(model, t_eval) sol = np.vstack((3 * t_eval, 6 * t_eval)) - np.testing.assert_array_equal(solution.y, sol) + np.testing.assert_array_equal(solution.all_ys[0], sol) np.testing.assert_array_equal( model.get_processed_variable("var1") - .evaluate(t=t_eval, y=solution.y) + .evaluate(t=t_eval, y=solution.all_ys[0]) .flatten(), sol[0, :], ) np.testing.assert_array_equal( model.get_processed_variable("var2") - .evaluate(t=t_eval, y=solution.y) + .evaluate(t=t_eval, y=solution.all_ys[0]) .flatten(), sol[1, :], ) @@ -364,4 +376,4 @@ def test_solve_with_input(self): # Solve solver = pybamm.AlgebraicSolver() solution = solver.solve(model, np.linspace(0, 1, 10), inputs={"value": 7}) - np.testing.assert_array_equal(solution.y, -7) + np.testing.assert_array_equal(solution.all_ys[0], -7) diff --git a/packages/pybamm/tests/unit/test_solvers/test_casadi_algebraic_solver.py b/packages/pybamm/tests/unit/test_solvers/test_casadi_algebraic_solver.py index 4aa36d92e3..f9617b83db 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_casadi_algebraic_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_casadi_algebraic_solver.py @@ -34,7 +34,7 @@ def test_simple_root_find(self): # Solve solver = pybamm.CasadiAlgebraicSolver() solution = solver.solve(model, np.linspace(0, 1, 10)) - np.testing.assert_array_equal(solution.y, -2) + np.testing.assert_array_equal(solution.all_ys[0], -2) def test_algebraic_root_solver_reuse(self): # Create a model with an input parameter and @@ -140,7 +140,7 @@ def test_model_solver_with_time(self): solution = solver.solve(model, t_eval) sol = np.vstack((3 * t_eval, 6 * t_eval)) - np.testing.assert_allclose(solution.y, sol, rtol=1e-7, atol=1e-6) + np.testing.assert_allclose(solution.all_ys[0], sol, rtol=1e-7, atol=1e-6) np.testing.assert_allclose( solution["var1"].data.flatten(), sol[0, :], rtol=1e-7, atol=1e-6 ) @@ -166,7 +166,7 @@ def test_model_solver_with_time_not_changing(self): solution = solver.solve(model, t_eval) sol = np.vstack((3 + 0 * t_eval, 6 + 0 * t_eval)) - np.testing.assert_allclose(solution.y, sol, rtol=1e-7, atol=1e-6) + np.testing.assert_allclose(solution.all_ys[0], sol, rtol=1e-7, atol=1e-6) def test_model_solver_with_bounds(self): # Note: we need a better test case to test this functionality properly @@ -198,7 +198,7 @@ def test_solve_with_input(self): # Solve solver = pybamm.CasadiAlgebraicSolver() solution = solver.solve(model, np.linspace(0, 1, 10), inputs={"param": 7}) - np.testing.assert_array_equal(solution.y, -7) + np.testing.assert_array_equal(solution.all_ys[0], -7) def test_deprecated(self): with pytest.warns( diff --git a/packages/pybamm/tests/unit/test_solvers/test_casadi_solver.py b/packages/pybamm/tests/unit/test_solvers/test_casadi_solver.py index a941074446..f81bbbf1b3 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_casadi_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_casadi_solver.py @@ -4,7 +4,11 @@ import pytest import pybamm -from tests import get_discretisation_for_testing, get_mesh_for_testing +from tests import ( + concatenated_states, + get_discretisation_for_testing, + get_mesh_for_testing, +) class TestCasadiSolver: @@ -48,7 +52,7 @@ def test_model_solver(self): solution = solver.solve(model_disc, t_eval) np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( - solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + solution.all_ys[0].full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) # Safe mode (enforce events that won't be triggered) @@ -58,7 +62,7 @@ def test_model_solver(self): solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( - solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + solution.all_ys[0].full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) # Fast with events @@ -67,7 +71,7 @@ def test_model_solver(self): solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( - solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + solution.all_ys[0].full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) def test_without_grid(self): @@ -85,7 +89,10 @@ def test_without_grid(self): solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( - solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + concatenated_states(solution.all_ys).full()[0], + np.exp(0.1 * solution.t), + rtol=1e-6, + atol=1e-5, ) # DAE model @@ -102,10 +109,16 @@ def test_without_grid(self): solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( - solution.y.full()[0], np.exp(0.1 * t_eval), rtol=1e-6, atol=1e-5 + concatenated_states(solution.all_ys).full()[0], + np.exp(0.1 * t_eval), + rtol=1e-6, + atol=1e-5, ) np.testing.assert_allclose( - solution.y.full()[1], np.ones_like(t_eval), rtol=1e-6, atol=1e-5 + concatenated_states(solution.all_ys).full()[1], + np.ones_like(t_eval), + rtol=1e-6, + atol=1e-5, ) # DAE model, errors @@ -136,7 +149,7 @@ def test_model_solver_python(self): solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( - solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + solution.all_ys[0].full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) pybamm.set_logging_level("WARNING") @@ -217,15 +230,27 @@ def test_model_solver_events(self): solver = pybamm.CasadiSolver(mode="safe", rtol=1e-8, atol=1e-8) t_eval = np.linspace(0, 5, 100) solution = solver.solve(model, t_eval) - np.testing.assert_array_less(solution.y.full()[0, :-1], 1.5) - np.testing.assert_array_less(solution.y.full()[-1, :-1], 2.5) + np.testing.assert_array_less( + concatenated_states(solution.all_ys).full()[0, :-1], 1.5 + ) + np.testing.assert_array_less( + concatenated_states(solution.all_ys).full()[-1, :-1], 2.5 + ) np.testing.assert_equal(solution.t_event[0], solution.t[-1]) - np.testing.assert_array_equal(solution.y_event[:, 0], solution.y.full()[:, -1]) + np.testing.assert_array_equal( + solution.y_event[:, 0], concatenated_states(solution.all_ys).full()[:, -1] + ) np.testing.assert_allclose( - solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + concatenated_states(solution.all_ys).full()[0], + np.exp(0.1 * solution.t), + rtol=1e-6, + atol=1e-5, ) np.testing.assert_allclose( - solution.y.full()[-1], 2 * np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + concatenated_states(solution.all_ys).full()[-1], + 2 * np.exp(0.1 * solution.t), + rtol=1e-6, + atol=1e-5, ) # Solve using "safe" mode with debug off @@ -233,15 +258,27 @@ def test_model_solver_events(self): solver = pybamm.CasadiSolver(mode="safe", rtol=1e-8, atol=1e-8, dt_max=1) t_eval = np.linspace(0, 5, 100) solution = solver.solve(model, t_eval) - np.testing.assert_array_less(solution.y.full()[0], 1.5) - np.testing.assert_array_less(solution.y.full()[-1], 2.5 + 1e-10) + np.testing.assert_array_less( + concatenated_states(solution.all_ys).full()[0], 1.5 + ) + np.testing.assert_array_less( + concatenated_states(solution.all_ys).full()[-1], 2.5 + 1e-10 + ) # test the last entry is exactly 2.5 - np.testing.assert_allclose(solution.y[-1, -1], 2.5, rtol=1e-3, atol=1e-2) np.testing.assert_allclose( - solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + concatenated_states(solution.all_ys)[-1, -1], 2.5, rtol=1e-3, atol=1e-2 + ) + np.testing.assert_allclose( + concatenated_states(solution.all_ys).full()[0], + np.exp(0.1 * solution.t), + rtol=1e-6, + atol=1e-5, ) np.testing.assert_allclose( - solution.y.full()[-1], 2 * np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + concatenated_states(solution.all_ys).full()[-1], + 2 * np.exp(0.1 * solution.t), + rtol=1e-6, + atol=1e-5, ) pybamm.settings.debug_mode = True @@ -249,13 +286,23 @@ def test_model_solver_events(self): solver = pybamm.CasadiSolver(dt_max=0, rtol=1e-8, atol=1e-8) t_eval = np.linspace(0, 5, 100) solution = solver.solve(model, t_eval) - np.testing.assert_array_less(solution.y.full()[0], 1.5) - np.testing.assert_array_less(solution.y.full()[-1], 2.5 + 1e-10) + np.testing.assert_array_less( + concatenated_states(solution.all_ys).full()[0], 1.5 + ) + np.testing.assert_array_less( + concatenated_states(solution.all_ys).full()[-1], 2.5 + 1e-10 + ) np.testing.assert_allclose( - solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + concatenated_states(solution.all_ys).full()[0], + np.exp(0.1 * solution.t), + rtol=1e-6, + atol=1e-5, ) np.testing.assert_allclose( - solution.y.full()[-1], 2 * np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + concatenated_states(solution.all_ys).full()[-1], + 2 * np.exp(0.1 * solution.t), + rtol=1e-6, + atol=1e-5, ) # Solve using "fast with events" mode @@ -275,17 +322,27 @@ def test_model_solver_events(self): solver = pybamm.CasadiSolver(mode="fast with events", rtol=1e-8, atol=1e-8) t_eval = np.linspace(0, 5, 100) solution = solver.solve(model, t_eval) - np.testing.assert_array_less(solution.y.full()[0, :-1], 1.5) - np.testing.assert_array_less(solution.y.full()[-1, :-1], 2.5) + np.testing.assert_array_less( + concatenated_states(solution.all_ys).full()[0, :-1], 1.5 + ) + np.testing.assert_array_less( + concatenated_states(solution.all_ys).full()[-1, :-1], 2.5 + ) np.testing.assert_equal(solution.t_event[0], solution.t[-1]) np.testing.assert_allclose( solution.y_event[:, 0].flatten(), [1.25, 2.5], rtol=1e-6, atol=1e-5 ) np.testing.assert_allclose( - solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + concatenated_states(solution.all_ys).full()[0], + np.exp(0.1 * solution.t), + rtol=1e-6, + atol=1e-5, ) np.testing.assert_allclose( - solution.y.full()[-1], 2 * np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + concatenated_states(solution.all_ys).full()[-1], + 2 * np.exp(0.1 * solution.t), + rtol=1e-6, + atol=1e-5, ) # Test when an event returns nan @@ -299,8 +356,12 @@ def test_model_solver_events(self): ] solver = pybamm.CasadiSolver(rtol=1e-8, atol=1e-8) solution = solver.solve(model, t_eval) - np.testing.assert_array_less(solution.y.full()[0], 1.02 + 1e-10) - np.testing.assert_allclose(solution.y[0, -1], 1.02, rtol=1e-3, atol=1e-2) + np.testing.assert_array_less( + concatenated_states(solution.all_ys).full()[0], 1.02 + 1e-10 + ) + np.testing.assert_allclose( + concatenated_states(solution.all_ys)[0, -1], 1.02, rtol=1e-3, atol=1e-2 + ) def test_model_step(self): # Create model @@ -322,7 +383,7 @@ def test_model_step(self): step_sol = solver.step(None, model, dt) np.testing.assert_array_equal(step_sol.t, [0, dt]) np.testing.assert_allclose( - step_sol.y.full()[0], np.exp(0.1 * step_sol.t), rtol=1e-7, atol=1e-6 + step_sol.all_ys[0].full()[0], np.exp(0.1 * step_sol.t), rtol=1e-7, atol=1e-6 ) # Step again (return 5 points) @@ -331,14 +392,20 @@ def test_model_step(self): step_sol_2.t, np.array([0, 1, np.nextafter(1, np.inf), 1.25, 1.5, 1.75, 2]) ) np.testing.assert_allclose( - step_sol_2.y.full()[0], np.exp(0.1 * step_sol_2.t), rtol=1e-7, atol=1e-6 + concatenated_states(step_sol_2.all_ys).full()[0], + np.exp(0.1 * step_sol_2.t), + rtol=1e-7, + atol=1e-6, ) # Check steps give same solution as solve t_eval = step_sol.t solution = solver.solve(model, t_eval) np.testing.assert_allclose( - solution.y.full()[0], step_sol.y.full()[0], rtol=1e-7, atol=1e-6 + solution.all_ys[0].full()[0], + step_sol.all_ys[0].full()[0], + rtol=1e-7, + atol=1e-6, ) def test_model_step_with_input(self): @@ -356,7 +423,9 @@ def test_model_step_with_input(self): dt = 0.1 step_sol = solver.step(None, model, dt, npts=5, inputs={"a": 0.1}) np.testing.assert_array_equal(step_sol.t, np.linspace(0, dt, 5)) - np.testing.assert_allclose(step_sol.y.full()[0], np.exp(0.1 * step_sol.t)) + np.testing.assert_allclose( + step_sol.all_ys[0].full()[0], np.exp(0.1 * step_sol.t) + ) # Step again with different inputs step_sol_2 = solver.step(step_sol, model, dt, npts=5, inputs={"a": -1}) @@ -371,7 +440,7 @@ def test_model_step_with_input(self): np.array([0.1, 0.1, 0.1, 0.1, 0.1, -1, -1, -1, -1, -1]), ) np.testing.assert_allclose( - step_sol_2.y.full()[0], + concatenated_states(step_sol_2.all_ys).full()[0], np.concatenate( [ np.exp(0.1 * step_sol_2.t[:5]), @@ -403,20 +472,25 @@ def test_model_step_events(self): while time < end_time: step_solution = step_solver.step(step_solution, model, dt=dt, npts=10) time += dt - np.testing.assert_array_less(step_solution.y.full()[0, :-1], 1.5) - np.testing.assert_array_less(step_solution.y.full()[-1, :-1], 2.5) + np.testing.assert_array_less( + concatenated_states(step_solution.all_ys).full()[0, :-1], 1.5 + ) + np.testing.assert_array_less( + concatenated_states(step_solution.all_ys).full()[-1, :-1], 2.5 + ) np.testing.assert_equal(step_solution.t_event[0], step_solution.t[-1]) np.testing.assert_array_equal( - step_solution.y_event[:, 0], step_solution.y.full()[:, -1] + step_solution.y_event[:, 0], + concatenated_states(step_solution.all_ys).full()[:, -1], ) np.testing.assert_allclose( - step_solution.y.full()[0], + concatenated_states(step_solution.all_ys).full()[0], np.exp(0.1 * step_solution.t), rtol=1e-6, atol=1e-5, ) np.testing.assert_allclose( - step_solution.y.full()[-1], + concatenated_states(step_solution.all_ys).full()[-1], 2 * np.exp(0.1 * step_solution.t), rtol=1e-5, atol=1e-4, @@ -441,7 +515,9 @@ def test_model_solver_with_inputs(self): solution = solver.solve(model, t_eval, inputs={"rate": 0.1}) assert len(solution.t) < len(t_eval) np.testing.assert_allclose( - solution.y.full()[0], np.exp(-0.1 * solution.t), rtol=1e-04 + concatenated_states(solution.all_ys).full()[0], + np.exp(-0.1 * solution.t), + rtol=1e-04, ) # Without grid @@ -450,12 +526,16 @@ def test_model_solver_with_inputs(self): solution = solver.solve(model, t_eval, inputs={"rate": 0.1}) assert len(solution.t) < len(t_eval) np.testing.assert_allclose( - solution.y.full()[0], np.exp(-0.1 * solution.t), rtol=1e-04 + concatenated_states(solution.all_ys).full()[0], + np.exp(-0.1 * solution.t), + rtol=1e-04, ) solution = solver.solve(model, t_eval, inputs={"rate": 1.1}) assert len(solution.t) < len(t_eval) np.testing.assert_allclose( - solution.y.full()[0], np.exp(-1.1 * solution.t), rtol=1e-04 + concatenated_states(solution.all_ys).full()[0], + np.exp(-1.1 * solution.t), + rtol=1e-04, ) def test_model_solver_dae_inputs_in_initial_conditions(self): @@ -481,13 +561,13 @@ def test_model_solver_dae_inputs_in_initial_conditions(self): for i, ipts in enumerate(inputs_list): np.testing.assert_equal(solutions[i]["var1"](0), ipts["ic 1"]) np.testing.assert_allclose( - solutions[i].y.full()[0], + solutions[i].all_ys[0].full()[0], ipts["ic 1"] * np.exp(ipts["rate"] * solutions[i].t), rtol=1e-6, atol=1e-5, ) np.testing.assert_allclose( - solutions[i].y.full()[-1], + solutions[i].all_ys[0].full()[-1], ipts["ic 1"] * np.exp(ipts["rate"] * solutions[i].t), rtol=1e-6, atol=1e-5, @@ -498,10 +578,16 @@ def test_model_solver_dae_inputs_in_initial_conditions(self): model, t_eval, inputs={"rate": -0.1, "ic 1": 1, "ic 2": 3} ) np.testing.assert_allclose( - solution.y.full()[0], 1 * np.exp(-0.1 * solution.t), rtol=1e-6, atol=1e-5 + solution.all_ys[0].full()[0], + 1 * np.exp(-0.1 * solution.t), + rtol=1e-6, + atol=1e-5, ) np.testing.assert_allclose( - solution.y.full()[-1], 1 * np.exp(-0.1 * solution.t), rtol=1e-6, atol=1e-5 + solution.all_ys[0].full()[-1], + 1 * np.exp(-0.1 * solution.t), + rtol=1e-6, + atol=1e-5, ) def test_model_solver_with_non_identity_mass(self): @@ -527,8 +613,12 @@ def test_model_solver_with_non_identity_mass(self): t_eval = np.linspace(0, 1, 100) solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) - np.testing.assert_allclose(solution.y.full()[0], np.exp(0.1 * solution.t)) - np.testing.assert_allclose(solution.y.full()[-1], 2 * np.exp(0.1 * solution.t)) + np.testing.assert_allclose( + solution.all_ys[0].full()[0], np.exp(0.1 * solution.t) + ) + np.testing.assert_allclose( + solution.all_ys[0].full()[-1], 2 * np.exp(0.1 * solution.t) + ) def test_dae_solver_algebraic_model(self): model = pybamm.BaseModel() @@ -622,19 +712,30 @@ def test_modulo_non_smooth_events(self): while time < end_time: step_solution = step_solver.step(step_solution, model, dt=dt, npts=10) time += dt - np.testing.assert_array_less(step_solution.y[0, :-1], 0.55) - np.testing.assert_array_less(step_solution.y[-1, :-1], 1.2) + np.testing.assert_array_less( + concatenated_states(step_solution.all_ys)[0, :-1], 0.55 + ) + np.testing.assert_array_less( + concatenated_states(step_solution.all_ys)[-1, :-1], 1.2 + ) np.testing.assert_equal(step_solution.t_event[0], step_solution.t[-1]) np.testing.assert_array_equal( - step_solution.y_event[:, 0], step_solution.y.full()[:, -1] + step_solution.y_event[:, 0], + concatenated_states(step_solution.all_ys).full()[:, -1], ) var1_soln = (step_solution.t % a) ** 2 / 2 + a**2 / 2 * (step_solution.t // a) var2_soln = 2 * var1_soln np.testing.assert_allclose( - step_solution.y.full()[0], var1_soln, rtol=1e-5, atol=1e-4 + concatenated_states(step_solution.all_ys).full()[0], + var1_soln, + rtol=1e-5, + atol=1e-4, ) np.testing.assert_allclose( - step_solution.y.full()[-1], var2_soln, rtol=1e-5, atol=1e-4 + concatenated_states(step_solution.all_ys).full()[-1], + var2_soln, + rtol=1e-5, + atol=1e-4, ) def test_solver_interpolation_warning(self): diff --git a/packages/pybamm/tests/unit/test_solvers/test_composite_solver.py b/packages/pybamm/tests/unit/test_solvers/test_composite_solver.py index 76538f760f..dfed6f8c95 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_composite_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_composite_solver.py @@ -39,7 +39,7 @@ def test_first_solver_succeeds(self): composite = pybamm.CompositeSolver([solver1, solver2]) solution = composite.solve(model) - np.testing.assert_allclose(solution.y, 3, rtol=1e-6) + np.testing.assert_allclose(solution.all_ys[0], 3, rtol=1e-6) def test_fallback_to_second_solver(self): # Create a model that the first solver will fail on @@ -62,7 +62,7 @@ def solve(self, *args, **kwargs): composite = pybamm.CompositeSolver([failing_solver, working_solver]) solution = composite.solve(model) - np.testing.assert_allclose(solution.y, 3, rtol=1e-6) + np.testing.assert_allclose(solution.all_ys[0], 3, rtol=1e-6) def test_all_solvers_fail(self): model = pybamm.BaseModel() diff --git a/packages/pybamm/tests/unit/test_solvers/test_dummy_solver.py b/packages/pybamm/tests/unit/test_solvers/test_dummy_solver.py index 7ab28823fd..ecb593e758 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_dummy_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_dummy_solver.py @@ -4,6 +4,7 @@ import numpy as np import pybamm +from tests import concatenated_states class TestDummySolver: @@ -19,7 +20,7 @@ def test_dummy_solver(self): t_eval = np.linspace(0, 1) sol = solver.solve(model, t_eval) np.testing.assert_array_equal(sol.t, t_eval) - np.testing.assert_array_equal(sol.y, np.zeros((1, t_eval.size))) + np.testing.assert_array_equal(sol.all_ys[0], np.zeros((1, t_eval.size))) np.testing.assert_array_equal(sol["v"].data, np.ones(t_eval.size)) def test_dummy_solver_step(self): @@ -40,5 +41,7 @@ def test_dummy_solver_step(self): # if t_eval is [0,1,2,3] # sol.t should be [0,1,1+eps,2,2+eps,3] i.e. size 2n-2 np.testing.assert_array_equal(len(sol.t), t_eval.size * 2 - 2) - np.testing.assert_array_equal(sol.y, np.zeros((1, sol.t.size))) + np.testing.assert_array_equal( + concatenated_states(sol.all_ys), np.zeros((1, sol.t.size)) + ) np.testing.assert_array_equal(sol["v"].data, np.ones(sol.t.size)) diff --git a/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py b/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py index aad0ddca0e..092af62d20 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py @@ -141,17 +141,17 @@ def test_ida_roberts_klu(self): np.testing.assert_allclose(solution.t[-1], 2.0, rtol=1e-7, atol=1e-6) # test that final value is the event value - np.testing.assert_allclose(solution.y[0, -1], 0.2, rtol=1e-7, atol=1e-6) + np.testing.assert_allclose(solution.all_ys[0][0, -1], 0.2, rtol=1e-7, atol=1e-6) # test that y[1] remains constant np.testing.assert_allclose( - solution.y[1, :], np.ones(solution.t.shape), rtol=1e-7, atol=1e-6 + solution.all_ys[0][1, :], np.ones(solution.t.shape), rtol=1e-7, atol=1e-6 ) # test that y[0] = to true solution true_solution = 0.1 * solution.t np.testing.assert_allclose( - solution.y[0, :], true_solution, rtol=1e-7, atol=1e-6 + solution.all_ys[0][0, :], true_solution, rtol=1e-7, atol=1e-6 ) def test_multiple_inputs(self): @@ -189,7 +189,7 @@ def test_multiple_inputs(self): print("checking solution", inputs, solution.all_inputs) np.testing.assert_array_equal(solution.t, t_interp) np.testing.assert_allclose( - solution.y[0], + solution.all_ys[0][0], 2 * np.exp(-inputs["rate"] * solution.t), atol=1e-4, rtol=1e-4, @@ -300,7 +300,7 @@ def test_model_events(self): t_interp, ) np.testing.assert_allclose( - solution.y[0], + solution.all_ys[0][0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5, @@ -320,7 +320,7 @@ def test_model_events(self): solution = solver.solve(model_disc, t_eval, t_interp=t_interp) np.testing.assert_array_equal(solution.t, t_interp) np.testing.assert_allclose( - solution.y[0], + solution.all_ys[0][0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5, @@ -336,7 +336,7 @@ def test_model_events(self): solution = solver.solve(model_disc, t_eval, t_interp=t_interp) assert len(solution.t) < len(t_interp) np.testing.assert_allclose( - solution.y[0], + solution.all_ys[0][0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5, @@ -363,18 +363,18 @@ def test_model_events(self): ) t_eval = np.array([0, 5]) solution = solver.solve(model, t_eval) - np.testing.assert_array_less(solution.y[0, :-1], 1.5) - np.testing.assert_array_less(solution.y[-1, :-1], 2.5) + np.testing.assert_array_less(solution.all_ys[0][0, :-1], 1.5) + np.testing.assert_array_less(solution.all_ys[0][-1, :-1], 2.5) np.testing.assert_equal(solution.t_event[0], solution.t[-1]) - np.testing.assert_array_equal(solution.y_event[:, 0], solution.y[:, -1]) + np.testing.assert_array_equal(solution.y_event[:, 0], solution.all_ys[0][:, -1]) np.testing.assert_allclose( - solution.y[0], + solution.all_ys[0][0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5, ) np.testing.assert_allclose( - solution.y[-1], + solution.all_ys[0][-1], 2 * np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5, @@ -412,7 +412,7 @@ def test_input_params(self): # test that y[3] remains constant np.testing.assert_allclose( - sol.y[3], + sol.all_ys[0][3], np.ones(sol.t.shape), rtol=1e-7, atol=1e-6, @@ -421,7 +421,7 @@ def test_input_params(self): # test that y[0] = to true solution true_solution = a_value * sol.t np.testing.assert_allclose( - sol.y[0], + sol.all_ys[0][0], true_solution, rtol=1e-7, atol=1e-6, @@ -430,7 +430,7 @@ def test_input_params(self): # test that y[1:3] = to true solution true_solution = b_value * sol.t np.testing.assert_allclose( - sol.y[1:3], + sol.all_ys[0][1:3], true_solution, rtol=1e-7, atol=1e-6, @@ -468,7 +468,7 @@ def test_ida_roberts_klu_sensitivities(self): # test that y[1] remains constant np.testing.assert_allclose( - sol.y[1, :], + sol.all_ys[0][1, :], np.ones(sol.t.shape), rtol=1e-7, atol=1e-6, @@ -477,7 +477,7 @@ def test_ida_roberts_klu_sensitivities(self): # test that y[0] = to true solution true_solution = a_value * sol.t np.testing.assert_allclose( - sol.y[0, :], + sol.all_ys[0][0, :], true_solution, rtol=1e-7, atol=1e-6, @@ -498,7 +498,7 @@ def test_ida_roberts_klu_sensitivities(self): # test that y[1] remains constant np.testing.assert_allclose( - sol.y[1, :], + sol.all_ys[0][1, :], np.ones(sol.t.shape), rtol=1e-7, atol=1e-6, @@ -507,7 +507,7 @@ def test_ida_roberts_klu_sensitivities(self): # test that y[0] = to true solution true_solution = a_value * sol.t np.testing.assert_allclose( - sol.y[0, :], + sol.all_ys[0][0, :], true_solution, rtol=1e-7, atol=1e-6, @@ -524,7 +524,7 @@ def test_ida_roberts_klu_sensitivities(self): sol_neg = solver.solve( model, t_eval, inputs={"a": a_value - 0.5 * h}, t_interp=t_interp ) - dyda_fd = (sol_plus.y - sol_neg.y) / h + dyda_fd = (sol_plus.all_ys[0] - sol_neg.all_ys[0]) / h dyda_fd = dyda_fd.transpose().reshape(-1, 1) np.testing.assert_allclose( @@ -566,7 +566,7 @@ def test_ida_roberts_consistent_initialization(self): # u(t0) = 0, v(t0) = 1 (corrected from v=2 by Newton IC solver) np.testing.assert_allclose( - sol.y[:, 0], + sol.all_ys[0][:, 0], [0, 1], rtol=1e-7, atol=1e-6, @@ -608,7 +608,7 @@ def test_sensitivities_with_events(self): # test that y[1] remains constant np.testing.assert_allclose( - sol.y[1, :], + sol.all_ys[0][1, :], np.ones(sol.t.shape), rtol=1e-7, atol=1e-6, @@ -617,7 +617,7 @@ def test_sensitivities_with_events(self): # test that y[0] = to true solution true_solution = a_value * sol.t np.testing.assert_allclose( - sol.y[0, :], + sol.all_ys[0][0, :], true_solution, rtol=1e-7, atol=1e-6, @@ -641,8 +641,10 @@ def test_sensitivities_with_events(self): inputs={"a": a_value - 0.5 * h, "b": b_value}, t_interp=t_interp, ) - max_index = min(sol_plus.y.shape[1], sol_neg.y.shape[1]) - 1 - dyda_fd = (sol_plus.y[:, :max_index] - sol_neg.y[:, :max_index]) / h + max_index = min(sol_plus.all_ys[0].shape[1], sol_neg.all_ys[0].shape[1]) - 1 + dyda_fd = ( + sol_plus.all_ys[0][:, :max_index] - sol_neg.all_ys[0][:, :max_index] + ) / h dyda_fd = dyda_fd.transpose().reshape(-1, 1) np.testing.assert_allclose( @@ -664,8 +666,10 @@ def test_sensitivities_with_events(self): inputs={"a": a_value, "b": b_value - 0.5 * h}, t_interp=t_interp, ) - max_index = min(sol_plus.y.shape[1], sol_neg.y.shape[1]) - 1 - dydb_fd = (sol_plus.y[:, :max_index] - sol_neg.y[:, :max_index]) / h + max_index = min(sol_plus.all_ys[0].shape[1], sol_neg.all_ys[0].shape[1]) - 1 + dydb_fd = ( + sol_plus.all_ys[0][:, :max_index] - sol_neg.all_ys[0][:, :max_index] + ) / h dydb_fd = dydb_fd.transpose().reshape(-1, 1) np.testing.assert_allclose( @@ -738,12 +742,12 @@ def test_dae_solver_algebraic_model(self): solver = pybamm.IDAKLUSolver() t_eval = [0, 1] solution = solver.solve(model, t_eval) - np.testing.assert_array_equal(solution.y, -1) + np.testing.assert_array_equal(solution.all_ys[0], -1) # change initial_conditions and re-solve (to test if ics_only works) model.concatenated_initial_conditions = pybamm.Vector(np.array([[1]])) solution = solver.solve(model, t_eval) - np.testing.assert_array_equal(solution.y, -1) + np.testing.assert_array_equal(solution.all_ys[0], -1) def test_banded(self): model = pybamm.lithium_ion.SPM() @@ -768,7 +772,9 @@ def test_banded(self): solver_banded = pybamm.IDAKLUSolver(options=options) soln_banded = solver_banded.solve(model, t_eval, t_interp=t_interp) - np.testing.assert_allclose(soln.y, soln_banded.y, rtol=1e-6, atol=1e-5) + np.testing.assert_allclose( + soln.all_ys[0], soln_banded.all_ys[0], rtol=1e-6, atol=1e-5 + ) @pytest.mark.parametrize( ("num_threads", "n_inputs"), @@ -1002,7 +1008,9 @@ def test_setup_options(self): if works: soln = solver.solve(model, t_eval, t_interp=t_interp) - np.testing.assert_allclose(soln.y, soln_base.y, rtol=1e-5, atol=1e-4) + np.testing.assert_allclose( + soln.all_ys[0], soln_base.all_ys[0], rtol=1e-5, atol=1e-4 + ) else: with pytest.raises(ValueError): _ = solver.solve(model, t_eval, t_interp=t_interp) @@ -1060,12 +1068,14 @@ def test_solver_options(self): solver = pybamm.IDAKLUSolver(rtol=1e-6, atol=1e-6, options=options) soln = solver.solve(model, t_eval) # Hermite upsample y - itp = CubicHermiteSpline(soln.t, soln.y, soln.yp, axis=1) + itp = CubicHermiteSpline(soln.t, soln.all_ys[0], soln.all_yps[0], axis=1) y_upsampled = itp(t_interp) # Asserts assert all(v == solver.options[k] for k, v in options.items()) - np.testing.assert_allclose(y_upsampled, soln_base.y, rtol=1e-5, atol=1e-4) + np.testing.assert_allclose( + y_upsampled, soln_base.all_ys[0], rtol=1e-5, atol=1e-4 + ) options_fail = { "max_order_bdf": -1, @@ -1672,8 +1682,8 @@ def test_model_solver_with_non_identity_mass(self): t_interp = np.linspace(t_eval[0], t_eval[-1], 100) solution = solver.solve(model, t_eval, t_interp=t_interp) np.testing.assert_array_equal(solution.t, t_interp) - np.testing.assert_allclose(solution.y[0], np.exp(0.1 * solution.t)) - np.testing.assert_allclose(solution.y[-1], 2 * np.exp(0.1 * solution.t)) + np.testing.assert_allclose(solution.all_ys[0][0], np.exp(0.1 * solution.t)) + np.testing.assert_allclose(solution.all_ys[0][-1], 2 * np.exp(0.1 * solution.t)) def test_multiple_initial_conditions_single_variable(self): model = pybamm.BaseModel() @@ -2547,7 +2557,9 @@ def test_solve_with_a_sequence_atol(self, sequence): expected = pybamm.IDAKLUSolver(atol=np.array([1e-3, 1e-9])).solve(model, t_eval) solution = pybamm.IDAKLUSolver(atol=sequence([1e-3, 1e-9])).solve(model, t_eval) assert solution.solver_statistics == expected.solver_statistics - np.testing.assert_allclose(solution.y, expected.y, rtol=1e-12, atol=0) + np.testing.assert_allclose( + solution.all_ys[0], expected.all_ys[0], rtol=1e-12, atol=0 + ) def test_per_state_atol_survives_a_config_round_trip(self): model = self._two_state_model() @@ -2746,63 +2758,95 @@ def _solve(options=None, **solver_kwargs): ) return sim.solve([0, 3600]) - def test_adds_points_in_every_storage_mode(self): + def test_t_additive(self): baseline = self._solve() - upsampled = self._solve({"upsampling_factor": 2.0}) - assert upsampled.t.size > baseline.t.size - assert np.all(np.isin(baseline.t, upsampled.t)) - - # Upsampling uses the integrator's own interpolant, not stored derivatives - no_hermite = self._solve( - {"upsampling_factor": 2.0, "hermite_interpolation": False} - ) - outputs_only = self._solve( - {"upsampling_factor": 2.0}, output_variables=["Voltage [V]"] - ) - np.testing.assert_array_equal(no_hermite.t, upsampled.t) - np.testing.assert_array_equal(outputs_only.t, upsampled.t) + assert baseline.t_additive is None + + options = {"upsampling_tolerance": 1e-4} + upsampled = self._solve(options) + # The added times leave the stored states untouched + stored = np.concatenate(upsampled.all_ts) + np.testing.assert_array_equal(stored, baseline.t) + assert upsampled.all_ys[0].shape == baseline.all_ys[0].shape + t_additive = upsampled.t_additive + assert t_additive.size > 0 + assert np.all(np.diff(t_additive) > 0) + assert not np.any(np.isin(t_additive, stored)) + # t and the variables' entries cover both + expected = np.union1d(stored, t_additive) + np.testing.assert_array_equal(upsampled.t, expected) + assert upsampled["Voltage [V]"].entries.shape == expected.shape + + # With knot reduction, the reduced Hermite spline is upsampled, so every + # interval's midpoint is linear to within the tolerance + reduced = self._solve(options | {"hermite_reduction_factor": 2.0}) + reduced_stored = np.concatenate(reduced.all_ts) + assert reduced_stored.size < stored.size + assert not np.any(np.isin(reduced.t_additive, reduced_stored)) + voltage = reduced["Voltage [V]"] + t_mid = 0.5 * (reduced.t[:-1] + reduced.t[1:]) + linear = 0.5 * (voltage.entries[:-1] + voltage.entries[1:]) + assert np.max(np.abs(linear - voltage(t_mid))) <= 1.0001e-4 + + # Without Hermite data the added times are saved into the solution + no_hermite = self._solve(options | {"hermite_interpolation": False}) + outputs_only = self._solve(options, output_variables=["Voltage [V]"]) + for solution in [no_hermite, outputs_only]: + assert solution.t_additive is None + np.testing.assert_array_equal(solution.t, expected) + # The Hermite interpolant matches IDA's own one to within tolerance np.testing.assert_allclose( outputs_only["Voltage [V]"].entries, upsampled["Voltage [V]"].entries, - rtol=1e-12, + rtol=1e-5, ) def test_resolves_voltage_linear_interpolant(self): reference = self._solve(rtol=1e-10, atol=1e-12) - ratios = [] - for factor in [1.0, 2.0]: - solver = pybamm.IDAKLUSolver(options={"upsampling_factor": factor}) - parameter_values = pybamm.ParameterValues("Chen2020") - parameter_values["Current function [A]"] = 10 - sim = pybamm.Simulation( - pybamm.lithium_ion.SPM(), - parameter_values=parameter_values, - solver=solver, - ) - solution = sim.solve([0, 3600]) + errors = [] + for tolerance in [0.0, 1e-3, 1e-4]: + solution = self._solve({"upsampling_tolerance": tolerance}) t = solution.t - voltage = solution["Voltage [V]"].entries + if solution.t_additive is not None: + t = np.union1d(t, solution.t_additive) + voltage = solution["Voltage [V]"](t) t_mid = 0.5 * (t[:-1] + t[1:]) linear = 0.5 * (voltage[:-1] + voltage[1:]) - truth = reference["Voltage [V]"](t_mid) - ratios.append( - np.max(np.abs(linear - truth) / (solver.atol + solver.rtol * truth)) - ) - assert ratios[0] > 10 - assert ratios[1] < 3 + errors.append(np.max(np.abs(linear - reference["Voltage [V]"](t_mid)))) + assert errors[0] > 1e-2 + assert errors[1] < 1.5e-3 + assert errors[2] < 1.5e-4 + + def test_scale(self): + model = pybamm.BaseModel() + x = pybamm.Variable("x", scale=10) + model.rhs = {x: -x} + model.initial_conditions = {x: 20} + model.variables = {"x": x, "2x": 2 * x} + model.upsampling_variable = "x" + solver = pybamm.IDAKLUSolver(options={"upsampling_tolerance": 1e-3}) + solver.solve(model, [0, 1]) + upsampling_fcn = solver._upsampling_fcn(model, {}) + y0 = model.concatenated_initial_conditions.evaluate() + np.testing.assert_allclose(float(upsampling_fcn(0, y0, [])), 2.0) + + # A variable without a scale is used as is + model.upsampling_variable = "2x" + upsampling_fcn = solver._upsampling_fcn(model, {}) + np.testing.assert_allclose(float(upsampling_fcn(0, y0, [])), 40.0) def test_sensitivities(self): parameter_values = pybamm.ParameterValues("Chen2020") parameter_values["Current function [A]"] = "[input]" inputs = {"Current function [A]": 5} + options = {"upsampling_tolerance": 1e-4} - def solve(factor, t_interp=None, output_variables=None): + def solve(options, t_interp=None, output_variables=None): sim = pybamm.Simulation( pybamm.lithium_ion.SPM(), parameter_values=parameter_values, solver=pybamm.IDAKLUSolver( - options={"upsampling_factor": factor}, - output_variables=output_variables, + options=options, output_variables=output_variables ), ) return sim.solve( @@ -2812,13 +2856,14 @@ def solve(factor, t_interp=None, output_variables=None): t_interp=t_interp, ) - baseline = solve(1.0) - upsampled = solve(2.0) + # With sensitivities the added points are saved into the solution, carrying + # the integrator's own interpolated sensitivities + baseline = solve({}) + upsampled = solve(options) + assert upsampled.t_additive is None assert upsampled.t.size > baseline.t.size - - # The added points carry the integrator's own interpolated sensitivities - reference = solve(1.0, t_interp=upsampled.t) - outputs_only = solve(2.0, output_variables=["Voltage [V]"]) + reference = solve({}, t_interp=upsampled.t) + outputs_only = solve(options, output_variables=["Voltage [V]"]) np.testing.assert_array_equal(outputs_only.t, upsampled.t) for solution in [upsampled, outputs_only]: np.testing.assert_allclose( @@ -2829,18 +2874,11 @@ def solve(factor, t_interp=None, output_variables=None): ) def test_option_errors(self): - with pytest.raises(pybamm.SolverError, match=r"must be >= 1.0"): - pybamm.IDAKLUSolver(options={"upsampling_factor": 0.5}) - with pytest.raises(pybamm.SolverError, match="mutually exclusive"): - pybamm.IDAKLUSolver( - options={ - "upsampling_factor": 2.0, - "hermite_reduction_factor": 2.0, - } - ) + with pytest.raises(pybamm.SolverError, match=r"must be >= 0"): + pybamm.IDAKLUSolver(options={"upsampling_tolerance": -1e-3}) model = pybamm.lithium_ion.SPM() model.upsampling_variable = "Negative particle concentration [mol.m-3]" - solver = pybamm.IDAKLUSolver(options={"upsampling_factor": 2.0}) + solver = pybamm.IDAKLUSolver(options={"upsampling_tolerance": 1e-3}) sim = pybamm.Simulation(model, solver=solver) with pytest.raises(pybamm.SolverError, match="needs a scalar variable"): sim.solve([0, 600]) @@ -2852,7 +2890,7 @@ def test_skips_algebraic_and_missing_variables(self): sim = pybamm.Simulation( pybamm.lithium_ion.SPM(), experiment=experiment, - solver=pybamm.IDAKLUSolver(options={"upsampling_factor": 2.0}), + solver=pybamm.IDAKLUSolver(options={"upsampling_tolerance": 1e-3}), ) sim.solve() charge, hold = experiment.steps @@ -2874,11 +2912,11 @@ def test_experiment_sets_variable_at_build(self, mode): ["Discharge at 1C until 3.0 V", "Hold at 3.0 V for 10 minutes"] ) solutions = [] - for factor in [1.0, 2.0]: + for tolerance in [0.0, 1e-4]: sim = pybamm.Simulation( pybamm.lithium_ion.SPM(), experiment=experiment, - solver=pybamm.IDAKLUSolver(options={"upsampling_factor": factor}), + solver=pybamm.IDAKLUSolver(options={"upsampling_tolerance": tolerance}), experiment_model_mode=mode, ) solutions.append(sim.solve()) @@ -2889,6 +2927,21 @@ def test_experiment_sets_variable_at_build(self, mode): "Current [A]", ] else: - # One model serves every step, so it keeps the default voltage - assert not hasattr(models[0], "upsampling_variable") - assert solutions[1].cycles[0].t.size > solutions[0].cycles[0].t.size + # One model serves every step, so it selects the step's output + model = models[0] + assert model.upsampling_variable == "Experiment upsampling variable" + assert isinstance( + model.variables["Experiment upsampling variable"], pybamm.Conditional + ) + assert solutions[0].t_additive is None + np.testing.assert_array_equal( + np.concatenate(solutions[1].all_ts), solutions[0].t + ) + discharge, hold = solutions[1].cycles + assert discharge.t_additive.size > 0 + # The hold's current is an algebraic state, so it is not upsampled + if mode == "legacy": + assert hold.t_additive is None + else: + assert hold.t_additive.size == 0 + np.testing.assert_array_equal(discharge.t_additive, solutions[1].t_additive) diff --git a/packages/pybamm/tests/unit/test_solvers/test_nonlinear_solver.py b/packages/pybamm/tests/unit/test_solvers/test_nonlinear_solver.py index 5afbb50abd..ded8d84f1b 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_nonlinear_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_nonlinear_solver.py @@ -53,7 +53,7 @@ def test_simple_root_find(self): solver = pybamm.NonlinearSolver() solution = solver.solve(model, np.linspace(0, 1, 10)) - np.testing.assert_array_almost_equal(solution.y, -2, decimal=8) + np.testing.assert_array_almost_equal(solution.all_ys[0], -2, decimal=8) def test_solve_with_input(self): var = pybamm.Variable("var") @@ -70,7 +70,7 @@ def test_solve_with_input(self): np.linspace(0, 1, 10), inputs={"param": 7}, ) - np.testing.assert_array_almost_equal(solution.y, -7, decimal=8) + np.testing.assert_array_almost_equal(solution.all_ys[0], -7, decimal=8) def test_model_solver_with_time(self): model = pybamm.BaseModel() @@ -88,7 +88,7 @@ def test_model_solver_with_time(self): solution = solver.solve(model, t_eval) sol = np.vstack((3 * t_eval, 6 * t_eval)) - np.testing.assert_allclose(solution.y, sol, rtol=1e-7, atol=1e-6) + np.testing.assert_allclose(solution.all_ys[0], sol, rtol=1e-7, atol=1e-6) np.testing.assert_allclose( solution["var1"].data.flatten(), sol[0, :], @@ -118,7 +118,7 @@ def test_model_solver_with_time_not_changing(self): solution = solver.solve(model, t_eval) sol = np.vstack((3 + 0 * t_eval, 6 + 0 * t_eval)) - np.testing.assert_allclose(solution.y, sol, rtol=1e-7, atol=1e-6) + np.testing.assert_allclose(solution.all_ys[0], sol, rtol=1e-7, atol=1e-6) def test_solver_caching(self): var = pybamm.Variable("var") @@ -148,7 +148,7 @@ def test_pickle_round_trip(self): solver = pybamm.NonlinearSolver() sol_pre = solver.solve(model, [0]) - np.testing.assert_array_almost_equal(sol_pre.y, -3, decimal=8) + np.testing.assert_array_almost_equal(sol_pre.all_ys[0], -3, decimal=8) # Sanity: live C++ solver was built and attached to the model. assert model.algebraic_root_solver @@ -161,7 +161,7 @@ def test_pickle_round_trip(self): assert not restored_model.algebraic_root_solver sol_post = restored_solver.solve(restored_model, [0]) - np.testing.assert_array_almost_equal(sol_post.y, -3, decimal=8) + np.testing.assert_array_almost_equal(sol_post.all_ys[0], -3, decimal=8) assert restored_model.algebraic_root_solver def test_sparse_and_dense_modes(self): @@ -181,7 +181,7 @@ def test_sparse_and_dense_modes(self): sol_s = pybamm.NonlinearSolver(use_sparse=True).solve(model_sparse, t_eval) sol_d = pybamm.NonlinearSolver(use_sparse=False).solve(model_dense, t_eval) - np.testing.assert_allclose(sol_s.y, sol_d.y, atol=1e-10) + np.testing.assert_allclose(sol_s.all_ys[0], sol_d.all_ys[0], atol=1e-10) def test_klu_pivot_tolerance(self): model = pybamm.BaseModel() @@ -197,8 +197,10 @@ def test_klu_pivot_tolerance(self): ) for value in [0.001, 1.0] ] - np.testing.assert_allclose(solutions[0].y, 2, rtol=1e-6) - np.testing.assert_allclose(solutions[1].y, solutions[0].y, rtol=1e-10) + np.testing.assert_allclose(solutions[0].all_ys[0], 2, rtol=1e-6) + np.testing.assert_allclose( + solutions[1].all_ys[0], solutions[0].all_ys[0], rtol=1e-10 + ) with pytest.raises(pybamm.SolverError, match="klu_pivot_tolerance"): pybamm.NonlinearSolver(options={"klu_pivot_tolerance": 2.0}) @@ -233,7 +235,9 @@ def test_matches_casadi_algebraic_solver(self): inputs=inputs, ) - np.testing.assert_allclose(sol_newton.y, sol_casadi.y, rtol=1e-3, atol=1e-5) + np.testing.assert_allclose( + sol_newton.all_ys[0], sol_casadi.all_ys[0], rtol=1e-3, atol=1e-5 + ) def test_used_in_composite_solver(self): model = pybamm.BaseModel() @@ -252,7 +256,7 @@ def test_used_in_composite_solver(self): ] ) solution = composite.solve(model) - np.testing.assert_allclose(solution.y, 7, rtol=1e-6) + np.testing.assert_allclose(solution.all_ys[0], 7, rtol=1e-6) def test_convergence_failure_raises(self): var = pybamm.Variable("var") diff --git a/packages/pybamm/tests/unit/test_solvers/test_observation.py b/packages/pybamm/tests/unit/test_solvers/test_observation.py index 71ef100907..00dbca79b0 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_observation.py +++ b/packages/pybamm/tests/unit/test_solvers/test_observation.py @@ -32,7 +32,7 @@ def _split(solution, *boundaries): return [ pybamm.Solution( solution.t[start:end], - solution.y[:, start:end], + solution.all_ys[0][:, start:end], model, {}, all_yps=solution.all_yps[0][:, start:end], diff --git a/packages/pybamm/tests/unit/test_solvers/test_scipy_solver.py b/packages/pybamm/tests/unit/test_solvers/test_scipy_solver.py index 99779c68ac..afeef8a36d 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_scipy_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_scipy_solver.py @@ -6,7 +6,7 @@ import pytest import pybamm -from tests import get_mesh_for_testing +from tests import concatenated_states, get_mesh_for_testing class TestScipySolver: @@ -63,7 +63,7 @@ def test_model_solver_python_and_jax(self): t_eval = np.linspace(0, 1, 80) solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) - np.testing.assert_allclose(solution.y[0], np.exp(0.1 * solution.t)) + np.testing.assert_allclose(solution.all_ys[0][0], np.exp(0.1 * solution.t)) # Test time assert solution.total_time == solution.solve_time + solution.set_up_time @@ -119,9 +119,13 @@ def test_model_solver_with_event_python(self): solution = solver.solve(model, t_eval) assert len(solution.t) < len(t_eval) np.testing.assert_array_equal(solution.t[:-1], t_eval[: len(solution.t) - 1]) - np.testing.assert_allclose(solution.y[0], np.exp(-0.1 * solution.t)) + np.testing.assert_allclose( + concatenated_states(solution.all_ys)[0], np.exp(-0.1 * solution.t) + ) np.testing.assert_equal(solution.t_event[0], solution.t[-1]) - np.testing.assert_array_equal(solution.y_event[:, 0], solution.y[:, -1]) + np.testing.assert_array_equal( + solution.y_event[:, 0], concatenated_states(solution.all_ys)[:, -1] + ) # Test event in solution variables_and_events np.testing.assert_allclose( @@ -168,7 +172,7 @@ def jacobian(t, y): solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) - T, Y = solution.t, solution.y + T, Y = solution.t, solution.all_ys[0] np.testing.assert_allclose( model.get_processed_variable("var1").evaluate(T, Y), np.ones((N, T.size)) * np.exp(T[np.newaxis, :]), @@ -204,7 +208,7 @@ def test_model_step_python(self): step_sol = solver.step(None, model, dt) np.testing.assert_array_equal(step_sol.t, [0, dt]) np.testing.assert_allclose( - step_sol.y[0], np.exp(0.1 * step_sol.t), rtol=1e-7, atol=1e-6 + step_sol.all_ys[0][0], np.exp(0.1 * step_sol.t), rtol=1e-7, atol=1e-6 ) # Step again (return 5 points) @@ -213,13 +217,18 @@ def test_model_step_python(self): step_sol_2.t, np.array([0, 1, np.nextafter(1, np.inf), 1.25, 1.5, 1.75, 2]) ) np.testing.assert_allclose( - step_sol_2.y[0], np.exp(0.1 * step_sol_2.t), rtol=1e-7, atol=1e-6 + concatenated_states(step_sol_2.all_ys)[0], + np.exp(0.1 * step_sol_2.t), + rtol=1e-7, + atol=1e-6, ) # Check steps give same solution as solve t_eval = step_sol.t solution = solver.solve(model, t_eval) - np.testing.assert_allclose(solution.y[0], step_sol.y[0], rtol=1e-7, atol=1e-6) + np.testing.assert_allclose( + solution.all_ys[0][0], step_sol.all_ys[0][0], rtol=1e-7, atol=1e-6 + ) def test_step_different_model(self): disc = pybamm.Discretisation() @@ -249,7 +258,7 @@ def test_step_different_model(self): step_sol1 = solver.step(None, model1, dt) np.testing.assert_array_equal(step_sol1.t, [0, dt]) np.testing.assert_allclose( - step_sol1.y[0], np.exp(0.1 * step_sol1.t), rtol=1e-7, atol=1e-6 + step_sol1.all_ys[0][0], np.exp(0.1 * step_sol1.t), rtol=1e-7, atol=1e-6 ) # Step again, the model has changed so this raises an error @@ -278,7 +287,9 @@ def test_model_solver_with_inputs(self): solution = solver.solve(model, t_eval, inputs={"rate": 0.1}) assert len(solution.t) < len(t_eval) np.testing.assert_array_equal(solution.t[:-1], t_eval[: len(solution.t) - 1]) - np.testing.assert_allclose(solution.y[0], np.exp(-0.1 * solution.t)) + np.testing.assert_allclose( + concatenated_states(solution.all_ys)[0], np.exp(-0.1 * solution.t) + ) def test_model_solver_multiple_inputs_happy_path(self, subtests): for convert_to_format in ["python", "casadi"]: @@ -306,7 +317,7 @@ def test_model_solver_multiple_inputs_happy_path(self, subtests): solution = solutions[i] np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( - solution.y[0], np.exp(-0.01 * (i + 1) * solution.t) + solution.all_ys[0][0], np.exp(-0.01 * (i + 1) * solution.t) ) def test_model_solver_multiple_inputs_initial_conditions(self): @@ -370,7 +381,9 @@ def test_model_solver_with_event_with_casadi(self): np.testing.assert_array_equal( solution.t[:-1], t_eval[: len(solution.t) - 1] ) - np.testing.assert_allclose(solution.y[0], np.exp(-0.1 * solution.t)) + np.testing.assert_allclose( + concatenated_states(solution.all_ys)[0], np.exp(-0.1 * solution.t) + ) def test_model_solver_with_inputs_with_casadi(self): # Create model @@ -395,7 +408,9 @@ def test_model_solver_with_inputs_with_casadi(self): solution = solver.solve(model, t_eval, inputs={"rate": 0.1}) assert len(solution.t) < len(t_eval) np.testing.assert_array_equal(solution.t[:-1], t_eval[: len(solution.t) - 1]) - np.testing.assert_allclose(solution.y[0], np.exp(-0.1 * solution.t)) + np.testing.assert_allclose( + concatenated_states(solution.all_ys)[0], np.exp(-0.1 * solution.t) + ) def test_model_solver_inputs_in_initial_conditions(self): # Create model @@ -411,7 +426,7 @@ def test_model_solver_inputs_in_initial_conditions(self): solutions = solver.solve(model, t_eval, inputs=inputs_list) for i, ic in enumerate(inputs_list): np.testing.assert_allclose( - solutions[i].y[0], + solutions[i].all_ys[0][0], ic["ic 1"] * np.exp(ic["rate"] * solutions[i].t), rtol=1e-6, atol=1e-5, @@ -421,7 +436,7 @@ def test_model_solver_inputs_in_initial_conditions(self): # Solve again with different initial conditions solution = solver.solve(model, t_eval, inputs={"rate": -0.1, "ic 1": 1}) np.testing.assert_allclose( - solution.y[0], 1 * np.exp(-0.1 * solution.t), rtol=1e-6, atol=1e-5 + solution.all_ys[0][0], 1 * np.exp(-0.1 * solution.t), rtol=1e-6, atol=1e-5 ) def test_model_solver_manually_update_initial_conditions(self): @@ -436,7 +451,7 @@ def test_model_solver_manually_update_initial_conditions(self): t_eval = np.linspace(0, 5, 100) solution = solver.solve(model, t_eval) np.testing.assert_allclose( - solution.y[0], 1 * np.exp(-solution.t), rtol=1e-6, atol=1e-5 + solution.all_ys[0][0], 1 * np.exp(-solution.t), rtol=1e-6, atol=1e-5 ) # Change initial conditions and solve again @@ -446,7 +461,7 @@ def test_model_solver_manually_update_initial_conditions(self): solver = pybamm.ScipySolver(rtol=1e-8, atol=1e-8) solution = solver.solve(model, t_eval) np.testing.assert_allclose( - solution.y[0], 2 * np.exp(-solution.t), rtol=1e-6, atol=1e-5 + solution.all_ys[0][0], 2 * np.exp(-solution.t), rtol=1e-6, atol=1e-5 ) def test_scale_and_reference(self): @@ -465,7 +480,10 @@ def test_scale_and_reference(self): model.concatenated_initial_conditions.evaluate(), 1, rtol=1e-7, atol=1e-6 ) np.testing.assert_allclose( - solution.y[0], (solution["var1"].data - 1) / 2, rtol=1e-15, atol=1e-14 + solution.all_ys[0][0], + (solution["var1"].data - 1) / 2, + rtol=1e-15, + atol=1e-14, ) def test_deprecated(self): diff --git a/packages/pybamm/tests/unit/test_solvers/test_solution.py b/packages/pybamm/tests/unit/test_solvers/test_solution.py index 415846716f..483a9613b3 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_solution.py +++ b/packages/pybamm/tests/unit/test_solvers/test_solution.py @@ -18,7 +18,7 @@ import pybamm from pybamm.solvers.solution import _DEFAULT_SOLUTION_OPTIONS, make_cycle_solution -from tests import get_discretisation_for_testing +from tests import concatenated_states, get_discretisation_for_testing class _PicklerBeforeVariableObservers(pickle.Pickler): @@ -45,7 +45,7 @@ def test_init(self): y = np.tile(t, (20, 1)) sol = pybamm.Solution(t, y, pybamm.BaseModel(), {}) np.testing.assert_array_equal(sol.t, t) - np.testing.assert_array_equal(sol.y, y) + np.testing.assert_array_equal(sol.all_ys[0], y) assert sol.t_event is None assert sol.y_event is None assert sol.termination == "final time" @@ -93,12 +93,12 @@ def test_yp(self): # Without yps, yp should be None sol_no_yp = pybamm.Solution(t, y, pybamm.BaseModel(), {}) assert sol_no_yp.hermite_interpolation is False - assert sol_no_yp.yp is None + assert sol_no_yp.all_yps is None # With yps, yp should return the concatenated time derivatives sol_with_yp = pybamm.Solution(t, y, pybamm.BaseModel(), {}, all_yps=yp) assert sol_with_yp.hermite_interpolation is True - np.testing.assert_array_equal(sol_with_yp.yp, yp) + np.testing.assert_array_equal(sol_with_yp.all_yps[0], yp) def test_sensitivities(self): t = np.linspace(0, 1) @@ -334,7 +334,7 @@ def test_add_solutions(self): assert sol_sum.integration_time == 0.8 np.testing.assert_array_equal(sol_sum.t, np.concatenate([t1, t2[1:]])) np.testing.assert_array_equal( - sol_sum.y, np.concatenate([y1, y2[:, 1:]], axis=1) + concatenated_states(sol_sum.all_ys), np.concatenate([y1, y2[:, 1:]], axis=1) ) np.testing.assert_array_equal(sol_sum.all_inputs, [{"a": 1}, {"a": 2}]) assert sol_sum.all_inputs_stacked[0] is sol1.all_inputs_stacked[0] @@ -605,8 +605,12 @@ def test_add_solutions_different_models(self): # Test np.testing.assert_array_equal(sol_sum.t, np.concatenate([t1, t2[1:]])) - with pytest.raises( - pybamm.SolverError, match=r"The solution is made up from different models" + with ( + pytest.warns(DeprecationWarning), + pytest.raises( + pybamm.SolverError, + match=r"The solution is made up from different models", + ), ): sol_sum.y @@ -693,6 +697,41 @@ def test_copy(self): assert sol_copy.solve_time == sol1.solve_time assert sol_copy.integration_time == sol1.integration_time + def test_states_are_deprecated(self): + t = np.linspace(0, 1) + y = np.tile(t, (2, 1)) + solution = pybamm.Solution(t, y, pybamm.BaseModel(), {}, all_yps=2 * y) + with pytest.warns(DeprecationWarning, match=r"`Solution\.y` is deprecated"): + np.testing.assert_array_equal(solution.y, y) + with pytest.warns(DeprecationWarning, match=r"`Solution\.yp` is deprecated"): + np.testing.assert_array_equal(solution.yp, 2 * y) + + def test_t_additive(self): + model = pybamm.BaseModel() + t1, t2, t3 = np.linspace(0, 1), np.linspace(1, 2), np.linspace(2, 3) + plain = pybamm.Solution(t1, np.tile(t1, (2, 1)), model, {}) + assert plain.t_additive is None + np.testing.assert_array_equal(plain.t, t1) + sol1, sol2, sol3 = ( + pybamm.Solution(t, np.tile(t, (2, 1)), model, {}, t_additive=t_additive) + for t, t_additive in [ + (t1, np.array([0.25])), + (t2, None), + (t3, np.array([2.5])), + ] + ) + assert (plain + sol2).t_additive is None + for joined in [ + sol1 + sol2 + sol3, + pybamm.Solution.from_sub_solutions([sol1, sol2, sol3]), + ]: + np.testing.assert_array_equal(joined.t_additive, [0.25, 2.5]) + np.testing.assert_array_equal(sol1.copy().t_additive, [0.25]) + np.testing.assert_array_equal(sol1.t, np.union1d(t1, [0.25])) + np.testing.assert_array_equal( + pickle.loads(pickle.dumps(sol1)).t_additive, [0.25] + ) + def test_copy_with_computed_variables(self): model = pybamm.BaseModel() u = pybamm.Variable("u") @@ -888,9 +927,13 @@ def test_first_state_of_output_variables_solve_is_consistent(self): for output_variables in (None, ["u"]) ) - np.testing.assert_allclose(solution.first_state.y, [[1], [2]], rtol=1e-6) np.testing.assert_allclose( - solution.first_state.y, full_solution.first_state.y, rtol=1e-6 + solution.first_state.all_ys[0], [[1], [2]], rtol=1e-6 + ) + np.testing.assert_allclose( + solution.first_state.all_ys[0], + full_solution.first_state.all_ys[0], + rtol=1e-6, ) def test_first_last_state_empty_y(self): @@ -938,11 +981,17 @@ def test_cycles(self): assert isinstance(sol.cycles[0], pybamm.Solution) np.testing.assert_array_equal(sol.cycles[0].t, sol.t[:len_cycle_1]) - np.testing.assert_array_equal(sol.cycles[0].y, sol.y[:, :len_cycle_1]) + np.testing.assert_array_equal( + concatenated_states(sol.cycles[0].all_ys), + concatenated_states(sol.all_ys)[:, :len_cycle_1], + ) assert isinstance(sol.cycles[1], pybamm.Solution) np.testing.assert_array_equal(sol.cycles[1].t, sol.t[len_cycle_1:]) - np.testing.assert_allclose(sol.cycles[1].y, sol.y[:, len_cycle_1:]) + np.testing.assert_allclose( + concatenated_states(sol.cycles[1].all_ys), + concatenated_states(sol.all_ys)[:, len_cycle_1:], + ) def test_solutions_reject_undefined_attributes(self): # An experiment keeps several solutions per step, so none carries a __dict__ @@ -977,7 +1026,9 @@ def test_pickle_keeps_slots(self): first_state = solution.first_state loaded = pickle.loads(pickle.dumps(solution)) np.testing.assert_array_equal(loaded.t, t) - np.testing.assert_array_equal(loaded.first_state.y, first_state.y) + np.testing.assert_array_equal( + loaded.first_state.all_ys[0], first_state.all_ys[0] + ) def test_total_time(self): sol = pybamm.Solution(np.array([0]), np.array([[1, 2]]), pybamm.BaseModel(), {}) @@ -1933,7 +1984,9 @@ def test_from_sub_solutions_matches_repeated_add(self): folded = functools.reduce(operator.add, [s.copy() for s in sols]) np.testing.assert_array_equal(batch.t, folded.t) - np.testing.assert_array_equal(batch.y, folded.y) + np.testing.assert_array_equal( + concatenated_states(batch.all_ys), concatenated_states(folded.all_ys) + ) assert [len(s) for s in batch.all_ts] == [len(s) for s in folded.all_ts] assert len(batch.sub_solutions) == len(folded.sub_solutions) assert batch.all_inputs == folded.all_inputs @@ -2057,7 +2110,9 @@ def test_from_sub_solutions_hermite_matches_add(self): folded = functools.reduce(operator.add, [s.copy() for s in sols]) assert out.hermite_interpolation - np.testing.assert_array_equal(out.yp, folded.yp) + np.testing.assert_array_equal( + concatenated_states(out.all_yps), concatenated_states(folded.all_yps) + ) def test_from_sub_solutions_mixed_hermite_is_not_hermite(self): # If any segment lacks yps, the fold is non-hermite (mirrors __add__'s AND). @@ -2268,7 +2323,9 @@ def test_from_sub_solutions_skipped_duplicate_preserves_hermite(self): assert out.hermite_interpolation == folded.hermite_interpolation assert out.hermite_interpolation is True - np.testing.assert_array_equal(out.yp, folded.yp) + np.testing.assert_array_equal( + concatenated_states(out.all_yps), concatenated_states(folded.all_yps) + ) def test_join_trailing_last_state_keeps_event_sensitivities(self): # Simulation carries last_state over a cycle whose steps are all infeasible; @@ -2425,5 +2482,7 @@ def __reduce__(self): assert not hasattr(loaded, "label") assert loaded.solver_statistics is None np.testing.assert_array_equal(loaded.t, solution.t) - np.testing.assert_array_equal(loaded.first_state.y, first_state.y) + np.testing.assert_array_equal( + loaded.first_state.all_ys[0], first_state.all_ys[0] + ) assert loaded.last_state.t[0] == pytest.approx(solution.t[-1]) diff --git a/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element.py b/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element.py index 980293b05f..4985b13afa 100644 --- a/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element.py +++ b/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element.py @@ -524,7 +524,9 @@ def test_pure_neumann_poisson(self): z = mesh["current collector"].coordinates[1, :][:, np.newaxis] u_exact = z**2 / 2 - 1 / 6 - np.testing.assert_allclose(solution.y[:-1], u_exact, rtol=1e-2, atol=1e-1) + np.testing.assert_allclose( + solution.all_ys[0][:-1], u_exact, rtol=1e-2, atol=1e-1 + ) def test_dirichlet_bcs(self): # manufactured solution u = a*z^2 + b*z + c @@ -562,7 +564,7 @@ def test_dirichlet_bcs(self): z = mesh["current collector"].edges["z"][:, np.newaxis] u_exact = a * z**2 + b * z + c np.testing.assert_allclose( - solution.y[0 : len(z)], u_exact, rtol=1e-7, atol=1e-6 + solution.all_ys[0][0 : len(z)], u_exact, rtol=1e-7, atol=1e-6 ) def test_disc_spatial_var(self): diff --git a/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element_3d.py b/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element_3d.py index a30805c870..d86f11ebdc 100644 --- a/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element_3d.py +++ b/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element_3d.py @@ -212,7 +212,7 @@ def test_3d_thermal_equation_analytical(self): disc.process_model(model) solver = pybamm.AlgebraicSolver() solution = solver.solve(model) - T_numerical = solution.y.flatten() + T_numerical = solution.all_ys[0].flatten() # Define the analytical solution: T(x) = 0.5 * x * (L - x) x_coords = mesh["current collector"].nodes[:, 0] @@ -488,7 +488,7 @@ def test_pure_neumann_poisson_3d(self): solver = pybamm.AlgebraicSolver() solution = solver.solve(model) - u_numerical = solution.y[:-1] + u_numerical = solution.all_ys[0][:-1] z_coords = mesh["current collector"].nodes[:, 2] @@ -535,7 +535,10 @@ def test_dirichlet_bcs_3d(self): u_exact_at_nodes = a_val * x_coords**2 + b_val * x_coords + k_val np.testing.assert_allclose( - solution.y.flatten(), u_exact_at_nodes.flatten(), rtol=1e-2, atol=1e-2 + solution.all_ys[0].flatten(), + u_exact_at_nodes.flatten(), + rtol=1e-2, + atol=1e-2, ) def test_scalar_field_discretization(self): @@ -659,7 +662,7 @@ def test_laplacian_cylinder_manufactured(self): disc.process_model(model) solver = pybamm.AlgebraicSolver() solution = solver.solve(model) - u_numerical = solution.y.flatten() + u_numerical = solution.all_ys[0].flatten() nodes = mesh["current collector"].nodes x_nodes, y_nodes, z_nodes = nodes.T r_nodes = np.sqrt(x_nodes**2 + y_nodes**2) diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu.cpp b/packages/pybammsolvers/src/pybammsolvers/idaklu.cpp index d11769c7bd..bb2e52e72a 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu.cpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu.cpp @@ -296,6 +296,7 @@ PYBIND11_MODULE(idaklu, m) .def_readwrite("t", &Solution::t) .def_readwrite("y", &Solution::y) .def_readwrite("yp", &Solution::yp) + .def_readwrite("t_additive", &Solution::t_additive) .def_readwrite("yS", &Solution::yS) .def_readwrite("ypS", &Solution::ypS) .def_readwrite("y_init", &Solution::y_init) diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp index 7c13387a53..cee9e1c0d4 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.hpp @@ -180,7 +180,18 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver // ── Solve-duration state (valid only during solve()) ── bool use_knot_reduction_ = false; bool use_upsampling_ = false; - sunrealtype upsampling_atol_ = 0.0; + // Upsampled times go to t_additive, not the states + bool upsample_to_t_additive_ = false; + std::vector t_additive_; + // Knot interval whose Hermite spline EvaluateUpsampleOutputAt uses (-1: IDA's) + int upsample_knot_ = -1; + // An interval of the step being upsampled, ordered by its midpoint error + struct UpsampleInterval { + sunrealtype error, t_a, f_a, t_m, f_m, t_b, f_b; + bool operator<(UpsampleInterval const &other) const { return error < other.error; } + }; + std::vector upsample_heap_; + std::vector upsample_times_; // upsampling_fcn at the previous step end, reused as the next step start sunrealtype upsample_f_prev_ = 0.0; bool upsample_f_prev_valid_ = false; @@ -423,18 +434,29 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver void SavePoint(sunrealtype t_val, bool extend_arrays, bool is_breakpoint); /** - * @brief Save points inside the last step [t_a, t_b] until linear interpolation + * @brief Add points inside the last step [t_a, t_b] until linear interpolation * of the upsampled output resolves it */ void UpsampleStep(sunrealtype t_a, sunrealtype t_b); /** - * @brief Bisect [t_a, t_b] while linear interpolation of the output misses - * the midpoint, saving each midpoint in time order - * @return The number of points saved + * @brief Add points between the reduced knots until linear interpolation of + * the upsampled output on their Hermite spline resolves it + */ + void UpsampleReducedKnots(); + + /** + * @brief Split [t_a, t_b], worst midpoint first, into upsample_times_ + */ + void RefineUpsampleInterval( + sunrealtype t_a, sunrealtype f_a, sunrealtype t_b, sunrealtype f_b); + + /** + * @brief Queue [t_a, t_b] for splitting if linear interpolation of the output + * misses its midpoint by more than upsampling_tolerance */ - int UpsampleInterval( - sunrealtype t_a, sunrealtype f_a, sunrealtype t_b, sunrealtype f_b, int depth); + void PushUpsampleInterval( + sunrealtype t_a, sunrealtype f_a, sunrealtype t_b, sunrealtype f_b); /** * @brief Evaluate upsampling_fcn at t from the states y @@ -442,10 +464,11 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver sunrealtype EvaluateUpsampleOutput(sunrealtype t, sunrealtype *y); /** - * @brief Evaluate upsampling_fcn on the integrator's interpolant at t, using - * y_cache so the current state in yy is left untouched + * @brief Evaluate upsampling_fcn at t_query on the integrator's interpolant, or + * on knot interval upsample_knot_'s Hermite spline, using y_cache so the + * current state in yy is left untouched */ - sunrealtype EvaluateUpsampleOutputAt(sunrealtype t); + sunrealtype EvaluateUpsampleOutputAt(sunrealtype t_query); /** * @brief Handle a breakpoint at t_eval: finalize interval, reinitialize diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl index f1d8d437cd..22634b70d7 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl @@ -2,6 +2,7 @@ #include "Expressions/Expressions.hpp" #include "sundials_functions.hpp" +#include #include #include "common.hpp" #include "SolutionData.hpp" @@ -419,7 +420,8 @@ SolutionData IDAKLUSolverOpenMP::solve( } if (hit_adaptive || hit_teval || hit_event || hit_final_time) { - if (use_upsampling_) { + // With knot reduction, upsampling waits for the final knots + if (use_upsampling_ && !use_knot_reduction_) { UpsampleStep(t_prev, t_val); } bool is_breakpoint = (hit_teval || hit_event) && !final_step; @@ -468,16 +470,16 @@ void IDAKLUSolverOpenMP::InitializeSolveStorage( ); use_upsampling_ = ( - solver_opts.upsampling_factor > 1.0 && + solver_opts.upsampling_tolerance > 0.0 && save_adaptive_steps_ && - !use_knot_reduction_ && functions->upsampling_fcn != nullptr && functions->upsampling_fcn->nnz_out() > 0 ); + // Sensitivities can't be interpolated at unsaved times, so they need the states + upsample_to_t_additive_ = use_upsampling_ && save_hermite && !sensitivity; upsample_f_prev_valid_ = false; - if (use_upsampling_) { - upsampling_atol_ = N_VMin(avtol); - } + t_additive_.clear(); + upsample_knot_ = -1; length_of_return_vector = ReturnVectorLength(); i_save_ = 0; @@ -672,34 +674,68 @@ void IDAKLUSolverOpenMP::UpsampleStep( upsample_f_prev_ = EvaluateUpsampleOutputAt(t_a); } - if (UpsampleInterval(t_a, upsample_f_prev_, t_b, f_b, 0) > 0) { + RefineUpsampleInterval(t_a, upsample_f_prev_, t_b, f_b); + if (upsample_to_t_additive_) { + t_additive_.insert(t_additive_.end(), upsample_times_.begin(), upsample_times_.end()); + } else if (!upsample_times_.empty()) { + // No Hermite data is stored here, so only the states are needed + for (sunrealtype const t_m : upsample_times_) { + GetSolutionStates(t_m); + SavePoint(t_m, /*extend_arrays=*/true, /*is_breakpoint=*/false); + } // Saving a midpoint loaded its state, so reload t_b - GetSolutionFull(t_b); + GetSolutionStates(t_b); } upsample_f_prev_ = f_b; upsample_f_prev_valid_ = true; } template -int IDAKLUSolverOpenMP::UpsampleInterval( - sunrealtype t_a, sunrealtype f_a, sunrealtype t_b, sunrealtype f_b, int depth +void IDAKLUSolverOpenMP::UpsampleReducedKnots() { + DEBUG("IDAKLUSolver::UpsampleReducedKnots"); + sunrealtype f_b = EvaluateUpsampleOutput(t[0], &y[0]); + for (int k = 0; k + 1 < number_of_timesteps; ++k) { + sunrealtype const f_a = f_b; + f_b = EvaluateUpsampleOutput(t[k + 1], &y[(k + 1) * number_of_states]); + upsample_knot_ = k; + RefineUpsampleInterval(t[k], f_a, t[k + 1], f_b); + t_additive_.insert(t_additive_.end(), upsample_times_.begin(), upsample_times_.end()); + } + upsample_knot_ = -1; +} + +template +void IDAKLUSolverOpenMP::RefineUpsampleInterval( + sunrealtype t_a, sunrealtype f_a, sunrealtype t_b, sunrealtype f_b +) { + // Split the worst interval first; the cap only guards against discontinuities + constexpr size_t max_points_per_interval = 100; + upsample_heap_.clear(); + upsample_times_.clear(); + PushUpsampleInterval(t_a, f_a, t_b, f_b); + while (!upsample_heap_.empty() && + upsample_times_.size() < max_points_per_interval) { + std::pop_heap(upsample_heap_.begin(), upsample_heap_.end()); + UpsampleInterval const worst = upsample_heap_.back(); + upsample_heap_.pop_back(); + upsample_times_.push_back(worst.t_m); + PushUpsampleInterval(worst.t_a, worst.f_a, worst.t_m, worst.f_m); + PushUpsampleInterval(worst.t_m, worst.f_m, worst.t_b, worst.f_b); + } + std::sort(upsample_times_.begin(), upsample_times_.end()); +} + +template +void IDAKLUSolverOpenMP::PushUpsampleInterval( + sunrealtype t_a, sunrealtype f_a, sunrealtype t_b, sunrealtype f_b ) { - constexpr int max_depth = 20; - if (depth >= max_depth) { - return 0; - } sunrealtype const t_m = 0.5 * (t_a + t_b); sunrealtype const f_m = EvaluateUpsampleOutputAt(t_m); - sunrealtype const budget = 1.0 / (solver_opts.upsampling_factor - 1.0); - sunrealtype const tol = budget * (upsampling_atol_ + rtol * std::abs(f_m)); - if (std::abs(0.5 * (f_a + f_b) - f_m) <= tol) { - return 0; + sunrealtype const error = std::abs(0.5 * (f_a + f_b) - f_m); + if (error > solver_opts.upsampling_tolerance) { + upsample_heap_.push_back({error, t_a, f_a, t_m, f_m, t_b, f_b}); + std::push_heap(upsample_heap_.begin(), upsample_heap_.end()); } - - int saved = UpsampleInterval(t_a, f_a, t_m, f_m, depth + 1); - GetSolutionFull(t_m); - SavePoint(t_m, /*extend_arrays=*/true, /*is_breakpoint=*/false); - return saved + 1 + UpsampleInterval(t_m, f_m, t_b, f_b, depth + 1); } template @@ -712,9 +748,28 @@ sunrealtype IDAKLUSolverOpenMP::EvaluateUpsampleOutput( } template -sunrealtype IDAKLUSolverOpenMP::EvaluateUpsampleOutputAt(sunrealtype t) { - CheckErrors(IDAGetDky(ida_mem, t, 0, y_cache), "IDAGetDky"); - return EvaluateUpsampleOutput(t, N_VGetArrayPointer(y_cache)); +sunrealtype IDAKLUSolverOpenMP::EvaluateUpsampleOutputAt(sunrealtype t_query) { + sunrealtype *y_t = N_VGetArrayPointer(y_cache); + if (upsample_knot_ < 0) { + CheckErrors(IDAGetDky(ida_mem, t_query, 0, y_cache), "IDAGetDky"); + return EvaluateUpsampleOutput(t_query, y_t); + } + // Cubic Hermite on the final knots, as the solution interpolates them + int const k = upsample_knot_; + sunrealtype const h = t[k + 1] - t[k]; + sunrealtype const s = (t_query - t[k]) / h; + sunrealtype const h00 = (1 + 2 * s) * (1 - s) * (1 - s); + sunrealtype const h10 = s * (1 - s) * (1 - s) * h; + sunrealtype const h01 = s * s * (3 - 2 * s); + sunrealtype const h11 = s * s * (s - 1) * h; + sunrealtype const *y_a = &y[k * number_of_states]; + sunrealtype const *y_b = y_a + number_of_states; + sunrealtype const *yp_a = &yp[k * number_of_states]; + sunrealtype const *yp_b = yp_a + number_of_states; + for (int j = 0; j < number_of_states; ++j) { + y_t[j] = h00 * y_a[j] + h10 * yp_a[j] + h01 * y_b[j] + h11 * yp_b[j]; + } + return EvaluateUpsampleOutput(t_query, y_t); } template @@ -760,6 +815,9 @@ SolutionData IDAKLUSolverOpenMP::BuildSolutionData(int retval) { if (use_knot_reduction_) { knot_reducer->Finalize(); number_of_timesteps = knot_reducer->GetOutputCount(); + if (upsample_to_t_additive_) { + UpsampleReducedKnots(); + } } else { number_of_timesteps = i_save_; t.resize(number_of_timesteps); @@ -793,6 +851,7 @@ SolutionData IDAKLUSolverOpenMP::BuildSolutionData(int retval) { std::move(t), std::move(y), save_hermite ? std::move(yp) : std::vector(), + std::move(t_additive_), std::move(yS_reordered), std::move(ypS_reordered), std::move(y_init_), diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp index e6279894e6..b7a5ebf993 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp @@ -196,13 +196,13 @@ SolverOptions::SolverOptions(py::dict &py_opts) } } - // Output upsampling multiplier (1.0 = off, >1.0 = active) - upsampling_factor = 1.0; - if (py_opts.contains("upsampling_factor")) { - upsampling_factor = py_opts["upsampling_factor"].cast(); - if (upsampling_factor < 1.0) { + // Output upsampling + upsampling_tolerance = 0.0; + if (py_opts.contains("upsampling_tolerance")) { + upsampling_tolerance = py_opts["upsampling_tolerance"].cast(); + if (upsampling_tolerance < 0.0) { throw std::domain_error( - "upsampling_factor must be >= 1.0. Got " + std::to_string(upsampling_factor) + "upsampling_tolerance must be >= 0. Got " + std::to_string(upsampling_tolerance) ); } } diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp index 45bd6304f3..a43191452d 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp @@ -59,8 +59,8 @@ struct SolverOptions { sunrealtype t_no_progress; // Knot reduction (streaming spline compression) double hermite_reduction_factor; // >= 1.0; total error budget is M * solver_tol (threshold = N*(M-1)^2) - // > 1.0 bisects steps until upsampling_fcn is linear to within solver_tol / (M-1) - double upsampling_factor; + // > 0 splits steps until upsampling_fcn is linear to within it (0 = off) + double upsampling_tolerance; // Newton solver for algebraic initial conditions double newton_step_tol; std::string newton_mode; // "auto" or "full" diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Solution.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Solution.hpp index bbfcb08f62..ecc269e4fd 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Solution.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Solution.hpp @@ -20,11 +20,11 @@ class Solution * @brief Constructor */ Solution(int retval, np_array t_np, np_array y_np, np_array yp_np, - np_array yS_np, np_array ypS_np, np_array y_init_np, + np_array t_additive_np, np_array yS_np, np_array ypS_np, np_array y_init_np, np_array y_term_np, np_array yS_init_np, np_array yS_term_np, const IDAKLUStats &stats) : flag(retval), t(std::move(t_np)), y(std::move(y_np)), - yp(std::move(yp_np)), yS(std::move(yS_np)), ypS(std::move(ypS_np)), + yp(std::move(yp_np)), t_additive(std::move(t_additive_np)), yS(std::move(yS_np)), ypS(std::move(ypS_np)), y_init(std::move(y_init_np)), y_term(std::move(y_term_np)), yS_init(std::move(yS_init_np)), yS_term(std::move(yS_term_np)), stats(stats) @@ -40,6 +40,7 @@ class Solution np_array t; np_array y; np_array yp; + np_array t_additive; np_array yS; np_array ypS; np_array y_init; diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/SolutionData.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/SolutionData.hpp index 0d164e27cd..2d8d6f8804 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/SolutionData.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/SolutionData.hpp @@ -53,6 +53,7 @@ class SolutionData std::vector&& t, std::vector&& y, std::vector&& yp, + std::vector&& t_additive, std::vector&& yS, std::vector&& ypS, std::vector&& yinit, @@ -68,6 +69,7 @@ class SolutionData t_vec(std::move(t)), y_vec(std::move(y)), yp_vec(std::move(yp)), + t_additive_vec(std::move(t_additive)), yS_vec(std::move(yS)), ypS_vec(std::move(ypS)), yinit_vec(std::move(yinit)), @@ -106,6 +108,7 @@ class SolutionData vector_to_numpy(std::move(t_vec)), vector_to_numpy(std::move(y_vec)), vector_to_numpy(std::move(yp_vec)), + vector_to_numpy(std::move(t_additive_vec)), vector_to_numpy_3d(std::move(yS_vec), arg_sens0, arg_sens1, arg_sens2), vector_to_numpy_3d(std::move(ypS_vec), save_hermite ? arg_sens0 : 0, arg_sens1, arg_sens2), @@ -122,6 +125,7 @@ class SolutionData std::vector t_vec; std::vector y_vec; std::vector yp_vec; + std::vector t_additive_vec; std::vector yS_vec; std::vector ypS_vec; std::vector yinit_vec; From 19b1299f6fdd274e1ad100f1acdf12d1e1113c43 Mon Sep 17 00:00:00 2001 From: Marc Berliner <34451391+MarcBerliner@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:29:55 -0400 Subject: [PATCH 08/10] fix: keep t_additive through saving and joining; None turns upsampling off Store t_additive on each Solution and join it explicitly in __add__, from_sub_solutions, copy and IDAKLUSolver.reduce_solution, instead of deriving it from _sub_solutions, which leaked it into first_state and last_state. upsampling_tolerance now defaults to None (off); any other value must be > 0. --- .../src/pybamm/solvers/idaklu_solver.py | 14 +++--- .../pybamm/src/pybamm/solvers/solution.py | 10 +++- .../unit/test_solvers/test_idaklu_solver.py | 50 ++++++++++++++++--- .../tests/unit/test_solvers/test_solution.py | 4 ++ .../pybammsolvers/idaklu_source/Options.cpp | 6 +-- .../pybammsolvers/idaklu_source/Options.hpp | 2 +- 6 files changed, 69 insertions(+), 17 deletions(-) diff --git a/packages/pybamm/src/pybamm/solvers/idaklu_solver.py b/packages/pybamm/src/pybamm/solvers/idaklu_solver.py index 7bd374d342..2f24074c4a 100644 --- a/packages/pybamm/src/pybamm/solvers/idaklu_solver.py +++ b/packages/pybamm/src/pybamm/solvers/idaklu_solver.py @@ -242,7 +242,7 @@ class IDAKLUSolver(pybamm.BaseSolver): # only active if `hermite_interpolation` is True and sensitivities # are disabled. "hermite_reduction_factor": 1.0, - # Output upsampling (0 = off). Adds times inside each solver step + # Output upsampling (None = off). Adds times inside each solver step # until linear interpolation of one scalar output variable between # them is accurate to upsampling_tolerance times the variable's # scale (1 if it has none). The variable is the voltage, or the @@ -251,7 +251,7 @@ class IDAKLUSolver(pybamm.BaseSolver): # times go to `Solution.t_additive` (and `Solution.t`) when Hermite # data is stored and sensitivities are off; otherwise they are # saved as full solution points. Ignored with t_interp. - "upsampling_tolerance": 0.0, + "upsampling_tolerance": None, ## Initial conditions calculation # Positive constant in the Newton iteration convergence test within the # initial condition calculation @@ -381,7 +381,7 @@ def _combine_options(self, user_options: dict | None) -> dict: "suppress_algebraic_error": False, "hermite_interpolation": True, "hermite_reduction_factor": 1.0, - "upsampling_tolerance": 0.0, + "upsampling_tolerance": None, "nonlinear_convergence_coefficient_ic": 0.0033, "max_num_steps_ic": 50, "max_num_jacobians_ic": 40, @@ -419,8 +419,9 @@ def _check_options(self, options: dict): if hermite_reduction_factor < 1.0: raise pybamm.SolverError("hermite_reduction_factor must be >= 1.0.") - if options["upsampling_tolerance"] < 0.0: - raise pybamm.SolverError("upsampling_tolerance must be >= 0.") + upsampling_tolerance = options["upsampling_tolerance"] + if upsampling_tolerance is not None and not upsampling_tolerance > 0.0: + raise pybamm.SolverError("upsampling_tolerance must be None or > 0.") if not isinstance(options["compile"], bool): raise pybamm.SolverError("compile must be a bool") @@ -789,7 +790,7 @@ def _upsampling_fcn(self, model, inputs_dict): """ empty = casadi.Function("empty_upsampling", [], []) name = getattr(model, "upsampling_variable", "Voltage [V]") - if self._options["upsampling_tolerance"] == 0 or name not in model.variables: + if self._options["upsampling_tolerance"] is None or name not in model.variables: return empty variable = model.variables[name] if model.is_algebraic_state(variable): @@ -1325,6 +1326,7 @@ def reduce_solution( all_t_evals=solution.all_t_evals, variables_returned=solution.variables_returned, options=solution.user_options, + t_additive=solution.t_additive, ) # Propagate metadata from the original solution diff --git a/packages/pybamm/src/pybamm/solvers/solution.py b/packages/pybamm/src/pybamm/solvers/solution.py index b41d1d8703..952d9ede40 100644 --- a/packages/pybamm/src/pybamm/solvers/solution.py +++ b/packages/pybamm/src/pybamm/solvers/solution.py @@ -585,7 +585,12 @@ def t(self) -> np.ndarray: def t_additive(self) -> np.ndarray | None: """Times between the stored states that resolve the solver's upsampled output variable, or None if the solver did not upsample""" - parts = [s._t_additive for s in self.sub_solutions if s._t_additive is not None] + return self._t_additive + + @staticmethod + def _join_t_additive(solutions): + """The solutions' ``t_additive`` joined, or None if none has any.""" + parts = [s._t_additive for s in solutions if s._t_additive is not None] return np.concatenate(parts) if parts else None @property @@ -1327,6 +1332,7 @@ def __add__(self, other): all_t_evals=all_t_evals, variables_returned=other.variables_returned, options=options, + t_additive=self._join_t_additive([self, other]), _validate_time_structure=False, ) @@ -1439,6 +1445,7 @@ def from_sub_solutions(cls, sub_solutions): all_t_evals=all_t_evals if t_evals_present else None, variables_returned=any(s.variables_returned for s in segments), options=options, + t_additive=cls._join_t_additive(sols), # validates the joined series once (O(N), not O(N^2)) _validate_time_structure=True, ) @@ -1487,6 +1494,7 @@ def copy(self): all_t_evals=self._all_t_evals, variables_returned=self.variables_returned, options=self.user_options, + t_additive=self._t_additive, _validate_time_structure=False, ) new_sol._all_inputs_stacked = self.all_inputs_stacked diff --git a/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py b/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py index 092af62d20..d2b219abd1 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py @@ -2777,8 +2777,13 @@ def test_t_additive(self): np.testing.assert_array_equal(upsampled.t, expected) assert upsampled["Voltage [V]"].entries.shape == expected.shape - # With knot reduction, the reduced Hermite spline is upsampled, so every - # interval's midpoint is linear to within the tolerance + # Reduction upsamples its own spline: every midpoint is within tolerance + # Reducing a finished solution keeps its added times + solver = pybamm.IDAKLUSolver() + np.testing.assert_array_equal( + solver.reduce_solution(upsampled).t_additive, t_additive + ) + reduced = self._solve(options | {"hermite_reduction_factor": 2.0}) reduced_stored = np.concatenate(reduced.all_ts) assert reduced_stored.size < stored.size @@ -2804,7 +2809,7 @@ def test_t_additive(self): def test_resolves_voltage_linear_interpolant(self): reference = self._solve(rtol=1e-10, atol=1e-12) errors = [] - for tolerance in [0.0, 1e-3, 1e-4]: + for tolerance in [None, 1e-3, 1e-4]: solution = self._solve({"upsampling_tolerance": tolerance}) t = solution.t if solution.t_additive is not None: @@ -2874,8 +2879,9 @@ def solve(options, t_interp=None, output_variables=None): ) def test_option_errors(self): - with pytest.raises(pybamm.SolverError, match=r"must be >= 0"): - pybamm.IDAKLUSolver(options={"upsampling_tolerance": -1e-3}) + for tolerance in [0.0, -1e-3]: + with pytest.raises(pybamm.SolverError, match=r"must be None or > 0"): + pybamm.IDAKLUSolver(options={"upsampling_tolerance": tolerance}) model = pybamm.lithium_ion.SPM() model.upsampling_variable = "Negative particle concentration [mol.m-3]" solver = pybamm.IDAKLUSolver(options={"upsampling_tolerance": 1e-3}) @@ -2906,13 +2912,45 @@ def test_skips_algebraic_and_missing_variables(self): assert model.upsampling_variable == "Current [A]" assert solver._upsampling_fcn(model, {}).n_out() == 0 + def test_t_additive_survives_saving_and_joining(self, tmp_path): + experiment = pybamm.Experiment( + [ + ("Discharge at 1C until 3.5 V", "Rest for 10 minutes"), + "Charge at 1C until 4 V", + ] + ) + sim = pybamm.Simulation( + pybamm.lithium_ion.SPM(), + experiment=experiment, + solver=pybamm.IDAKLUSolver(options={"upsampling_tolerance": 1e-4}), + ) + solution = sim.solve() + t_additive = solution.t_additive + assert t_additive.size > 0 + sim.save(tmp_path / "sim.pkl") + solution.save(tmp_path / "solution.pkl") + for other in [ + solution.copy(), + pybamm.load(tmp_path / "solution.pkl"), + pybamm.load_sim(tmp_path / "sim.pkl").solution, + pybamm.Solution.from_sub_solutions(solution.sub_solutions), + solution.cycles[0] + solution.cycles[1], + ]: + np.testing.assert_array_equal(other.t_additive, t_additive) + np.testing.assert_array_equal(other.t, solution.t) + # The cycles and steps split the added times between them + steps = [step for cycle in solution.cycles for step in cycle.steps] + np.testing.assert_array_equal( + np.concatenate([step.t_additive for step in steps]), t_additive + ) + @pytest.mark.parametrize("mode", ["legacy", "unified"]) def test_experiment_sets_variable_at_build(self, mode): experiment = pybamm.Experiment( ["Discharge at 1C until 3.0 V", "Hold at 3.0 V for 10 minutes"] ) solutions = [] - for tolerance in [0.0, 1e-4]: + for tolerance in [None, 1e-4]: sim = pybamm.Simulation( pybamm.lithium_ion.SPM(), experiment=experiment, diff --git a/packages/pybamm/tests/unit/test_solvers/test_solution.py b/packages/pybamm/tests/unit/test_solvers/test_solution.py index 483a9613b3..c6ca2524a8 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_solution.py +++ b/packages/pybamm/tests/unit/test_solvers/test_solution.py @@ -726,6 +726,10 @@ def test_t_additive(self): pybamm.Solution.from_sub_solutions([sol1, sol2, sol3]), ]: np.testing.assert_array_equal(joined.t_additive, [0.25, 2.5]) + # The single-point states hold no added times + for state in [joined.first_state, joined.last_state]: + assert state.t_additive is None + assert state.t.size == 1 np.testing.assert_array_equal(sol1.copy().t_additive, [0.25]) np.testing.assert_array_equal(sol1.t, np.union1d(t1, [0.25])) np.testing.assert_array_equal( diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp index b7a5ebf993..198d6c52b1 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp @@ -198,11 +198,11 @@ SolverOptions::SolverOptions(py::dict &py_opts) // Output upsampling upsampling_tolerance = 0.0; - if (py_opts.contains("upsampling_tolerance")) { + if (py_opts.contains("upsampling_tolerance") && !py_opts["upsampling_tolerance"].is_none()) { upsampling_tolerance = py_opts["upsampling_tolerance"].cast(); - if (upsampling_tolerance < 0.0) { + if (upsampling_tolerance <= 0.0) { throw std::domain_error( - "upsampling_tolerance must be >= 0. Got " + std::to_string(upsampling_tolerance) + "upsampling_tolerance must be None or > 0. Got " + std::to_string(upsampling_tolerance) ); } } diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp index a43191452d..c569338144 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp @@ -59,7 +59,7 @@ struct SolverOptions { sunrealtype t_no_progress; // Knot reduction (streaming spline compression) double hermite_reduction_factor; // >= 1.0; total error budget is M * solver_tol (threshold = N*(M-1)^2) - // > 0 splits steps until upsampling_fcn is linear to within it (0 = off) + // > 0 splits steps until upsampling_fcn is linear to within it; None is 0 (off) double upsampling_tolerance; // Newton solver for algebraic initial conditions double newton_step_tol; From 8bd123e285e27b911c088c55b9c1406c608a1ff7 Mon Sep 17 00:00:00 2001 From: Marc Berliner <34451391+MarcBerliner@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:06:58 -0400 Subject: [PATCH 09/10] refactor: default the C++ upsampling_tolerance to +inf (off) --- .../src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl | 2 +- .../pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp | 3 ++- .../pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl index 22634b70d7..34cd55ae7f 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/IDAKLUSolverOpenMP.inl @@ -470,7 +470,7 @@ void IDAKLUSolverOpenMP::InitializeSolveStorage( ); use_upsampling_ = ( - solver_opts.upsampling_tolerance > 0.0 && + std::isfinite(solver_opts.upsampling_tolerance) && save_adaptive_steps_ && functions->upsampling_fcn != nullptr && functions->upsampling_fcn->nnz_out() > 0 diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp index 198d6c52b1..257b666dfe 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.cpp @@ -2,6 +2,7 @@ #include #include #include +#include using namespace std::string_literals; @@ -197,7 +198,7 @@ SolverOptions::SolverOptions(py::dict &py_opts) } // Output upsampling - upsampling_tolerance = 0.0; + upsampling_tolerance = std::numeric_limits::infinity(); if (py_opts.contains("upsampling_tolerance") && !py_opts["upsampling_tolerance"].is_none()) { upsampling_tolerance = py_opts["upsampling_tolerance"].cast(); if (upsampling_tolerance <= 0.0) { diff --git a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp index c569338144..77bb46f8b6 100644 --- a/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp +++ b/packages/pybammsolvers/src/pybammsolvers/idaklu_source/Options.hpp @@ -59,7 +59,7 @@ struct SolverOptions { sunrealtype t_no_progress; // Knot reduction (streaming spline compression) double hermite_reduction_factor; // >= 1.0; total error budget is M * solver_tol (threshold = N*(M-1)^2) - // > 0 splits steps until upsampling_fcn is linear to within it; None is 0 (off) + // Split steps until upsampling_fcn is linear to within it; +inf (None) is off double upsampling_tolerance; // Newton solver for algebraic initial conditions double newton_step_tol; From 7b6649bf88969ae6d76639d064ed5e33c29cfe03 Mon Sep 17 00:00:00 2001 From: Marc Berliner <34451391+MarcBerliner@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:11:39 -0400 Subject: [PATCH 10/10] refactor: move the Solution.y/yp deprecation to a follow-on PR --- CHANGELOG.md | 1 - .../creating_models/1-an-ode-model.ipynb | 4 +- .../models/unsteady-heat-equation.ipynb | 4 +- .../3d_eqation_cylindric_non_zero_flux.py | 2 +- .../3d_examples/3d_equation_cylindric.py | 2 +- .../3d_examples/3d_equation_prismatic.py | 2 +- .../compare_comsol/compare_comsol_DFN.py | 2 +- .../pybamm/src/pybamm/models/base_model.py | 3 +- .../src/pybamm/simulation/simulation.py | 2 +- .../src/pybamm/solvers/casadi_solver.py | 17 +- .../pybamm/src/pybamm/solvers/solution.py | 19 +- packages/pybamm/tests/__init__.py | 1 - .../tests/integration/test_simulations.py | 10 +- .../integration/test_solvers/test_diffsl.py | 4 +- .../integration/test_solvers/test_idaklu.py | 8 +- packages/pybamm/tests/shared.py | 8 - .../pybamm/tests/unit/test_base_simulation.py | 4 +- .../test_simulation_with_experiment.py | 10 +- .../unit/test_models/test_model_to_json.py | 2 +- .../test_serialisation/test_round_trip.py | 2 +- packages/pybamm/tests/unit/test_simulation.py | 18 +- .../test_solvers/test_algebraic_solver.py | 52 ++--- .../test_casadi_algebraic_solver.py | 8 +- .../unit/test_solvers/test_casadi_solver.py | 205 +++++------------- .../test_solvers/test_composite_solver.py | 4 +- .../unit/test_solvers/test_dummy_solver.py | 7 +- .../unit/test_solvers/test_idaklu_solver.py | 84 +++---- .../test_solvers/test_nonlinear_solver.py | 26 +-- .../unit/test_solvers/test_observation.py | 2 +- .../unit/test_solvers/test_scipy_solver.py | 54 ++--- .../tests/unit/test_solvers/test_solution.py | 65 ++---- .../test_scikit_finite_element.py | 6 +- .../test_scikit_finite_element_3d.py | 11 +- 33 files changed, 215 insertions(+), 434 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dd7e3d24f..9c5385b5e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,6 @@ - `FiniteVolume2D` is deprecated in favour of `FiniteVolumeUnstructured` and raises a `DeprecationWarning` when created. Remaining gaps before removal are tracked in [#5938](https://github.com/pybamm-team/PyBaMM/issues/5938). ([#5939](https://github.com/pybamm-team/PyBaMM/pull/5939)) - `pybamm.Magnitude` is deprecated and will be removed in 27.1; use `pybamm.Component(symbol, 0)` / `pybamm.Component(symbol, 1)` for the lr/tb components of a `VectorField`. ([#5943](https://github.com/pybamm-team/PyBaMM/pull/5943)) -- `Solution.y` and `Solution.yp` are deprecated: they hold only the stored states, which need not match `Solution.t`. Use `solution[name]` for variables, or `all_ys` and `all_yps` for the raw states. ([#5998](https://github.com/pybamm-team/PyBaMM/pull/5998)) ## Features diff --git a/docs/source/examples/notebooks/creating_models/1-an-ode-model.ipynb b/docs/source/examples/notebooks/creating_models/1-an-ode-model.ipynb index b2a8161e1d..a87dd15b39 100644 --- a/docs/source/examples/notebooks/creating_models/1-an-ode-model.ipynb +++ b/docs/source/examples/notebooks/creating_models/1-an-ode-model.ipynb @@ -213,7 +213,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "After solving, we can extract the variables from the solution. These are automatically post-processed so that the solutions can be called at any time $t$ using interpolation. The times at which the model was solved at are stored in `solution.t`." + "After solving, we can extract the variables from the solution. These are automatically post-processed so that the solutions can be called at any time $t$ using interpolation. The times at which the model was solved at are stored in `solution.t` and the statevectors at those times are stored in `solution.y`" ] }, { @@ -222,7 +222,7 @@ "metadata": {}, "outputs": [], "source": [ - "t_sol = solution.t # get solution times\n", + "t_sol, y_sol = solution.t, solution.y # get solution times and states\n", "x = solution[\"x\"] # extract and process x from the solution\n", "y = solution[\"y\"] # extract and process y from the solution" ] diff --git a/docs/source/examples/notebooks/models/unsteady-heat-equation.ipynb b/docs/source/examples/notebooks/models/unsteady-heat-equation.ipynb index 0f71917b35..a7dae64a21 100644 --- a/docs/source/examples/notebooks/models/unsteady-heat-equation.ipynb +++ b/docs/source/examples/notebooks/models/unsteady-heat-equation.ipynb @@ -260,7 +260,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Now that the model has been discretised we are ready to solve. We must first choose a solver to use. For this model we choose the Scipy ODE solver, but other solvers are available in PyBaMM (see [here](https://docs.pybamm.org/en/latest/source/api/solvers/index.html)). To solve the model, we use the method `solver.solve` which takes in a model and an array of times at which we would like the solution to be returned. Ths solution is then stored in the `solution` object. The times can be accessed with `solution.t`." + "Now that the model has been discretised we are ready to solve. We must first choose a solver to use. For this model we choose the Scipy ODE solver, but other solvers are available in PyBaMM (see [here](https://docs.pybamm.org/en/latest/source/api/solvers/index.html)). To solve the model, we use the method `solver.solve` which takes in a model and an array of times at which we would like the solution to be returned. Ths solution is then stored in the `solution` object. The times and states can be accessed with `solver.t` and `solver.y`." ] }, { @@ -278,7 +278,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "After solving, we can process variables using the class `pybamm.ProcessedVariable`. This returns a callable object which can be evaluated at any time and position by means of interpolating the solution. Processed variables provide a convinient way of comparing the solution to solutions from different models, or to exact solutions. Since all of the variables are names with informative strings, the user doesn't need to keep track of where they are stored in the state vector. This is particularly useful in complex models with lots of variables, and is automatically handled by the solution dictionary." + "After solving, we can process variables using the class `pybamm.ProcessedVariable`. This returns a callable object which can be evaluated at any time and position by means of interpolating the solution. Processed variables provide a convinient way of comparing the solution to solutions from different models, or to exact solutions. Since all of the variables are names with informative strings, the user doesn't need to keep track of where they are stored in the state vector `solution.y`. This is particularly useful in complex models with lots of variables, and is automatically handled by the solution dictionary." ] }, { diff --git a/examples/scripts/3d_examples/3d_eqation_cylindric_non_zero_flux.py b/examples/scripts/3d_examples/3d_eqation_cylindric_non_zero_flux.py index d120f094f2..5409771cd6 100644 --- a/examples/scripts/3d_examples/3d_eqation_cylindric_non_zero_flux.py +++ b/examples/scripts/3d_examples/3d_eqation_cylindric_non_zero_flux.py @@ -65,7 +65,7 @@ solution = solver.solve(model) nodes = mesh["current collector"].nodes -T_solution = solution.all_ys[0].flatten() +T_solution = solution.y.flatten() print(f"Number of FEM nodes: {nodes.shape[0]}") print(f"Temperature range: [{T_solution.min():.2f}, {T_solution.max():.2f}]°C") diff --git a/examples/scripts/3d_examples/3d_equation_cylindric.py b/examples/scripts/3d_examples/3d_equation_cylindric.py index 7a6de3706c..e7713209f9 100644 --- a/examples/scripts/3d_examples/3d_equation_cylindric.py +++ b/examples/scripts/3d_examples/3d_equation_cylindric.py @@ -66,7 +66,7 @@ solution = solver.solve(model) nodes = mesh["current collector"].nodes -T_solution = solution.all_ys[0].flatten() +T_solution = solution.y.flatten() print(f"Number of FEM nodes: {nodes.shape[0]}") print(f"Temperature range: [{T_solution.min():.2f}, {T_solution.max():.2f}]°C") diff --git a/examples/scripts/3d_examples/3d_equation_prismatic.py b/examples/scripts/3d_examples/3d_equation_prismatic.py index 989b13b3cd..ae60db1aff 100644 --- a/examples/scripts/3d_examples/3d_equation_prismatic.py +++ b/examples/scripts/3d_examples/3d_equation_prismatic.py @@ -61,7 +61,7 @@ solution = solver.solve(model) nodes = mesh["current collector"].nodes -T_solution = solution.all_ys[0].flatten() +T_solution = solution.y.flatten() print(f"Number of FEM nodes: {nodes.shape[0]}") print(f"Temperature range: [{T_solution.min():.2f}, {T_solution.max():.2f}]°C") diff --git a/examples/scripts/compare_comsol/compare_comsol_DFN.py b/examples/scripts/compare_comsol/compare_comsol_DFN.py index 965f4f477f..13959aab55 100644 --- a/examples/scripts/compare_comsol/compare_comsol_DFN.py +++ b/examples/scripts/compare_comsol/compare_comsol_DFN.py @@ -120,7 +120,7 @@ def get_interp_fun(variable_name, domain): # Make new solution with same t and y # Update solution scales to match the pybamm model comsol_solution = pybamm.Solution( - pybamm_solution.all_ts, pybamm_solution.all_ys, comsol_model, {} + pybamm_solution.t, pybamm_solution.y, comsol_model, {} ) # plot diff --git a/packages/pybamm/src/pybamm/models/base_model.py b/packages/pybamm/src/pybamm/models/base_model.py index 6eacd0281c..81cfb26765 100644 --- a/packages/pybamm/src/pybamm/models/base_model.py +++ b/packages/pybamm/src/pybamm/models/base_model.py @@ -1434,8 +1434,7 @@ def _find_matching_variable(var, solution_model): def _extract_from_y_slices(var, solution_var, solution_model, solution): solution_y_slice = solution_model.y_slices[solution_var][0] - y = solution.all_ys[-1] - y_last = y[:, -1] if y.ndim > 1 else y + y_last = solution.y[:, -1] if solution.y.ndim > 1 else solution.y # Validate slice bounds slice_stop = ( diff --git a/packages/pybamm/src/pybamm/simulation/simulation.py b/packages/pybamm/src/pybamm/simulation/simulation.py index ec6c8e41f1..74a4c6dcb2 100644 --- a/packages/pybamm/src/pybamm/simulation/simulation.py +++ b/packages/pybamm/src/pybamm/simulation/simulation.py @@ -491,7 +491,7 @@ def _decode_combined_step_termination(self, step_solution, step, model, inputs): y = ( step_solution.y_event if step_solution.y_event is not None - else step_solution.all_ys[-1][:, -1] + else step_solution.y[:, -1] ) try: diff --git a/packages/pybamm/src/pybamm/solvers/casadi_solver.py b/packages/pybamm/src/pybamm/solvers/casadi_solver.py index 375080abff..40576d0b06 100644 --- a/packages/pybamm/src/pybamm/solvers/casadi_solver.py +++ b/packages/pybamm/src/pybamm/solvers/casadi_solver.py @@ -218,7 +218,10 @@ def _integrate_single(self, model, t_eval, inputs_dict, y0): # create integrator once, without grid, # to avoid having to create several times self.create_integrator(model, y0, inputs) - # Keep every segment's states CasADi, even if y0 is an ndarray + # Initialize solution. Coerce y0 to a CasADi DM so that when + # subsequent CasADi integration segments are appended, the + # concatenated ``solution.y`` stays a CasADi type. When the + # root method is NonlinearSolver, y0 arrives as an ndarray. y0_init = y0 if isinstance(y0, casadi.DM | casadi.MX) else casadi.DM(y0) solution = pybamm.Solution( np.array([t]), @@ -392,9 +395,7 @@ def f(idx, f_eval=f_eval, event=event): # We take away 1e-5 to deal with the case where the event sits # exactly on zero, as can happen when the event switch is used # (fast with events mode) - f_eval[idx] = ( - event(sol.t[idx], sol.all_ys[0][:, idx], inputs) - 1e-5 - ) + f_eval[idx] = event(sol.t[idx], sol.y[:, idx], inputs) - 1e-5 return f_eval[idx] def integer_bisect(): @@ -434,7 +435,7 @@ def integer_bisect(): t_event = np.nanmin(t_events) # create interpolant to evaluate y in the current integration # window - y_sol = interp1d(sol.t, sol.all_ys[0], kind="linear") + y_sol = interp1d(sol.t, sol.y, kind="linear") y_event = y_sol(t_event) closest_event_idx = event_idx[np.nanargmin(t_events)] @@ -466,7 +467,7 @@ def integer_bisect(): self.create_integrator(model, y0, inputs, t_window_event_dense) use_grid = True - y0 = coarse_solution.all_ys[0][:, event_idx_lower] + y0 = coarse_solution.y[:, event_idx_lower] dense_step_sol = self._run_integrator( model, y0, @@ -486,12 +487,12 @@ def integer_bisect(): # This is extremely rare, it's difficult to find a test that triggers this # hence no coverage check t_event = coarse_solution.t[event_idx_lower + 1] - y_event = coarse_solution.all_ys[0][:, event_idx_lower + 1].full().flatten() + y_event = coarse_solution.y[:, event_idx_lower + 1].full().flatten() # Return solution truncated at the first coarse event time # Also assign t_event t_sol = coarse_solution.t[: event_idx_lower + 1] - y_sol = coarse_solution.all_ys[0][:, : event_idx_lower + 1] + y_sol = coarse_solution.y[:, : event_idx_lower + 1] solution = pybamm.Solution( t_sol, y_sol, diff --git a/packages/pybamm/src/pybamm/solvers/solution.py b/packages/pybamm/src/pybamm/solvers/solution.py index 952d9ede40..12793bfdef 100644 --- a/packages/pybamm/src/pybamm/solvers/solution.py +++ b/packages/pybamm/src/pybamm/solvers/solution.py @@ -6,7 +6,6 @@ import json import numbers import pickle -import warnings from dataclasses import astuple, dataclass from itertools import chain @@ -602,28 +601,14 @@ def t_eval(self) -> np.ndarray: @property def y(self): - """The stored states, concatenated (deprecated).""" - warnings.warn( - "`Solution.y` is deprecated and will be removed in a future release; " - "it holds only the stored states, which need not match `Solution.t`. " - "Use `solution[name]` for variables, or `all_ys` for the raw states.", - DeprecationWarning, - stacklevel=2, - ) + """Values of the solution""" if self._y is None: self._y = self._concatenate_states(self.all_ys) return self._y @property def yp(self): - """The stored state time derivatives, concatenated (deprecated).""" - warnings.warn( - "`Solution.yp` is deprecated and will be removed in a future release; " - "it holds only the stored states, which need not match `Solution.t`. " - "Use `all_yps` for the raw state derivatives.", - DeprecationWarning, - stacklevel=2, - ) + """Time derivatives of the solution""" if self.hermite_interpolation and self._yp is None: self._yp = self._concatenate_states(self.all_yps) return self._yp diff --git a/packages/pybamm/tests/__init__.py b/packages/pybamm/tests/__init__.py index ad991f2b11..0ad83917cb 100644 --- a/packages/pybamm/tests/__init__.py +++ b/packages/pybamm/tests/__init__.py @@ -23,7 +23,6 @@ ) from .shared import ( - concatenated_states, DummyDiscretisationClass, get_mesh_for_testing, get_3d_mesh_for_testing, diff --git a/packages/pybamm/tests/integration/test_simulations.py b/packages/pybamm/tests/integration/test_simulations.py index b6567498a6..6add2e592f 100644 --- a/packages/pybamm/tests/integration/test_simulations.py +++ b/packages/pybamm/tests/integration/test_simulations.py @@ -47,15 +47,11 @@ def test_pickle_roundtrip_exact( sol_orig.t, sol_loaded.t, err_msg=f"{tag} sol.t mismatch" ) np.testing.assert_array_equal( - sol_orig.all_ys[0], - sol_loaded.all_ys[0], - err_msg=f"{tag} sol.all_ys[0] mismatch", + sol_orig.y, sol_loaded.y, err_msg=f"{tag} sol.y mismatch" ) - if sol_orig.all_yps[0] is not None: + if sol_orig.yp is not None: np.testing.assert_array_equal( - sol_orig.all_yps[0], - sol_loaded.all_yps[0], - err_msg=f"{tag} sol.all_yps[0] mismatch", + sol_orig.yp, sol_loaded.yp, err_msg=f"{tag} sol.yp mismatch" ) diff --git a/packages/pybamm/tests/integration/test_solvers/test_diffsl.py b/packages/pybamm/tests/integration/test_solvers/test_diffsl.py index a8521ec064..f65466506c 100644 --- a/packages/pybamm/tests/integration/test_solvers/test_diffsl.py +++ b/packages/pybamm/tests/integration/test_solvers/test_diffsl.py @@ -125,7 +125,9 @@ def test_models(self, model, inputs, request, timing_results): t_interp = np.linspace(t_eval[0], t_eval[1], 100) solver = pybamm.IDAKLUSolver(output_variables=[output_variable]) - solver.solve(model_disc, t_eval, t_interp=t_interp, inputs=pv_inputs) + _soln_pybamm = solver.solve( + model_disc, t_eval, t_interp=t_interp, inputs=pv_inputs + ).y t0 = time.perf_counter() voltage_pybamm = solver.solve( diff --git a/packages/pybamm/tests/integration/test_solvers/test_idaklu.py b/packages/pybamm/tests/integration/test_solvers/test_idaklu.py index c9be784420..12e4ee1b7e 100644 --- a/packages/pybamm/tests/integration/test_solvers/test_idaklu.py +++ b/packages/pybamm/tests/integration/test_solvers/test_idaklu.py @@ -84,7 +84,7 @@ def test_on_spme_sensitivities(self): sol_neg = solver.solve( model, t_eval, inputs={param_name: param_value - 0.5 * h}, t_interp=t_interp ) - dyda_fd = (sol_plus.all_ys[0] - sol_neg.all_ys[0]) / h + dyda_fd = (sol_plus.y - sol_neg.y) / h dyda_fd = dyda_fd.transpose().reshape(-1, 1) np.testing.assert_allclose( @@ -177,11 +177,11 @@ def test_interpolation(self): for sol in sols: # test that y[0] = to true solution true_solution = a_value * sol.t - np.testing.assert_allclose(sol.all_ys[0][0], true_solution) + np.testing.assert_allclose(sol.y[0], true_solution) # test that y[1:3] = to true solution true_solution = b_value * sol.t - np.testing.assert_allclose(sol.all_ys[0][1:3], true_solution) + np.testing.assert_allclose(sol.y[1:3], true_solution) def test_with_experiments(self): summary_vars = [] @@ -277,7 +277,7 @@ def test_multiple_initial_conditions_against_independent_solves(self): sol_ind = indep_sols[idx] np.testing.assert_allclose(sol_vec.t, sol_ind.t) - np.testing.assert_allclose(sol_vec.all_ys[0], sol_ind.all_ys[0]) + np.testing.assert_allclose(sol_vec.y, sol_ind.y) def test_outvars_with_experiments_multi_simulation(self): model = pybamm.lithium_ion.SPM() diff --git a/packages/pybamm/tests/shared.py b/packages/pybamm/tests/shared.py index a2197f52fa..e599288699 100644 --- a/packages/pybamm/tests/shared.py +++ b/packages/pybamm/tests/shared.py @@ -5,7 +5,6 @@ import re import socket -import casadi import numpy as np from scipy.sparse import eye @@ -623,10 +622,3 @@ def assert_symbolic_mesh_matches_numeric( numeric.jac(state).evaluate(0, y).toarray(), **tolerance, ) - - -def concatenated_states(states): - """Join a solution's per-segment ``all_ys`` or ``all_yps`` along time.""" - if isinstance(states[0], casadi.DM | casadi.MX): - return casadi.horzcat(*states) - return np.hstack(states) diff --git a/packages/pybamm/tests/unit/test_base_simulation.py b/packages/pybamm/tests/unit/test_base_simulation.py index 88cbcb989a..5995b739bd 100644 --- a/packages/pybamm/tests/unit/test_base_simulation.py +++ b/packages/pybamm/tests/unit/test_base_simulation.py @@ -68,9 +68,7 @@ def test_base_solve_simple_model(self): model.initial_conditions = {v: 1} sim = pybamm.BaseSimulation(model) sol = sim.solve([0, 1]) - np.testing.assert_allclose( - sol.all_ys[0][0], np.exp(-sol.t), rtol=1e-4, atol=1e-4 - ) + np.testing.assert_allclose(sol.y[0], np.exp(-sol.t), rtol=1e-4, atol=1e-4) def test_base_solve_with_crate(self): model = pybamm.lithium_ion.SPM() diff --git a/packages/pybamm/tests/unit/test_experiments/test_simulation_with_experiment.py b/packages/pybamm/tests/unit/test_experiments/test_simulation_with_experiment.py index 9a5a9a6d4d..a80dfb9a9a 100644 --- a/packages/pybamm/tests/unit/test_experiments/test_simulation_with_experiment.py +++ b/packages/pybamm/tests/unit/test_experiments/test_simulation_with_experiment.py @@ -1350,8 +1350,8 @@ def test_run_experiment_output_variables_repeated_step(self): full_solution, solution = solutions np.testing.assert_allclose( - solution.cycles[0].first_state.all_ys[0], - full_solution.cycles[0].first_state.all_ys[0], + solution.cycles[0].first_state.y, + full_solution.cycles[0].first_state.y, rtol=1e-6, ) change = "Change in throughput capacity [A.h]" @@ -2152,8 +2152,8 @@ def test_skipped_step_continuous(self): experiment_model_mode=experiment_model_mode, ) sim.solve(initial_soc=1) - previous_state = sim.solution.cycles[0].last_state.all_ys[0] - next_state = sim.solution.cycles[1].steps[-1].first_state.all_ys[0] + previous_state = sim.solution.cycles[0].last_state.y + next_state = sim.solution.cycles[1].steps[-1].first_state.y built_model = sim.steps_to_built_models[ sim.experiment.steps[0].basic_repr() @@ -2345,7 +2345,7 @@ def __init__(self, source): self.t_event = None self.y_event = None self.t = source.t - self.all_ys = source.all_ys + self.y = source.y self.all_inputs = source.all_inputs def __getitem__(self, key): diff --git a/packages/pybamm/tests/unit/test_models/test_model_to_json.py b/packages/pybamm/tests/unit/test_models/test_model_to_json.py index 458f69c312..f08ce1c55e 100644 --- a/packages/pybamm/tests/unit/test_models/test_model_to_json.py +++ b/packages/pybamm/tests/unit/test_models/test_model_to_json.py @@ -12,7 +12,7 @@ def _assert_solution_y_almost_equal(sol_orig, sol_loaded, key_output="Voltage [V]"): """Assert solution state vector has same shape and key output is almost equal.""" - np.testing.assert_array_equal(sol_orig.all_ys[0].shape, sol_loaded.all_ys[0].shape) + np.testing.assert_array_equal(sol_orig.y.shape, sol_loaded.y.shape) v_orig = sol_orig[key_output].data v_loaded = sol_loaded[key_output].data np.testing.assert_allclose(v_orig, v_loaded, rtol=5e-3, atol=1e-4) diff --git a/packages/pybamm/tests/unit/test_serialisation/test_round_trip.py b/packages/pybamm/tests/unit/test_serialisation/test_round_trip.py index 4fd3a8646f..d8f1d58fca 100644 --- a/packages/pybamm/tests/unit/test_serialisation/test_round_trip.py +++ b/packages/pybamm/tests/unit/test_serialisation/test_round_trip.py @@ -817,7 +817,7 @@ def test_solution_matches_original(self, model_cls): loaded_solution = loaded.default_solver.solve(loaded, t_eval) assert np.allclose( - original_solution.all_ys[0], loaded_solution.all_ys[0], rtol=1e-6, atol=1e-8 + original_solution.y, loaded_solution.y, rtol=1e-6, atol=1e-8 ), ( f"{model_cls.__name__} state trajectory diverged after a " "discretised round-trip" diff --git a/packages/pybamm/tests/unit/test_simulation.py b/packages/pybamm/tests/unit/test_simulation.py index e3105c903c..cd37f9173c 100644 --- a/packages/pybamm/tests/unit/test_simulation.py +++ b/packages/pybamm/tests/unit/test_simulation.py @@ -7,7 +7,7 @@ import pybamm from pybamm.solvers.base_solver import BaseSolver -from tests import concatenated_states, no_internet_connection +from tests import no_internet_connection class TestSimulation: @@ -18,9 +18,7 @@ def test_simple_model(self): model.initial_conditions = {v: 1} sim = pybamm.Simulation(model) sol = sim.solve([0, 1]) - np.testing.assert_allclose( - sol.all_ys[0][0], np.exp(-sol.t), rtol=1e-4, atol=1e-4 - ) + np.testing.assert_allclose(sol.y[0], np.exp(-sol.t), rtol=1e-4, atol=1e-4) def test_solve(self): sim = pybamm.Simulation(pybamm.lithium_ion.SPM()) @@ -175,7 +173,7 @@ def test_step(self): sim = pybamm.Simulation(model) sim.step(dt) # 1 step stores first 12 points - assert sim.solution.all_ys[0][0, :].size == 12 + assert sim.solution.y[0, :].size == 12 np.testing.assert_allclose( [sim.solution.t[0], sim.solution.t[-1]], np.array([0, dt]), @@ -185,7 +183,7 @@ def test_step(self): saved_sol = sim.solution sim.step(dt) # automatically append the next step - assert concatenated_states(sim.solution.all_ys)[0, :].size == 24 + assert sim.solution.y[0, :].size == 24 np.testing.assert_allclose( [sim.solution.t[0], sim.solution.t[-1]], np.array([0, 2 * dt]), @@ -194,7 +192,7 @@ def test_step(self): ) sim.step(dt, save=False) # now only store the two end step points - assert sim.solution.all_ys[0][0, :].size == 12 + assert sim.solution.y[0, :].size == 12 np.testing.assert_allclose( [sim.solution.t[0], sim.solution.t[-1]], np.array([2 * dt + 1e-9, 3 * dt]), @@ -203,7 +201,7 @@ def test_step(self): ) # Start from saved solution sim.step(dt, starting_solution=saved_sol) - assert concatenated_states(sim.solution.all_ys)[0, :].size == 24 + assert sim.solution.y[0, :].size == 24 np.testing.assert_allclose( [sim.solution.t[0], sim.solution.t[-1]], np.array([0, 2 * dt]), @@ -663,7 +661,7 @@ def test_step_with_inputs(self): dt, inputs={"Current function [A]": 1} ) # 1 step stores first 12 points assert sim.solution.t.size == 12 - assert sim.solution.all_ys[0][0, :].size == 12 + assert sim.solution.y[0, :].size == 12 assert sim.solution.t[0] == 0 assert sim.solution.t[-1] == dt np.testing.assert_array_equal( @@ -672,7 +670,7 @@ def test_step_with_inputs(self): sim.step( dt, inputs={"Current function [A]": 2} ) # automatically append the next step - assert concatenated_states(sim.solution.all_ys)[0, :].size == 24 + assert sim.solution.y[0, :].size == 24 np.testing.assert_allclose( [sim.solution.t[0], sim.solution.t[-1]], np.array([0, 2 * dt]), diff --git a/packages/pybamm/tests/unit/test_solvers/test_algebraic_solver.py b/packages/pybamm/tests/unit/test_solvers/test_algebraic_solver.py index 491dc6cf82..bcf950f05b 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_algebraic_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_algebraic_solver.py @@ -59,7 +59,7 @@ def algebraic_eval(self, t, y, inputs): solver = pybamm.AlgebraicSolver(extra_options={"maxiter": 100}) model = Model() solutions = solver._integrate(model, np.array([0])) - np.testing.assert_array_equal(solutions[0].all_ys[0], -2) + np.testing.assert_array_equal(solutions[0].y, -2) def test_root_find_fail(self): class Model(pybamm.BaseModel): @@ -118,7 +118,7 @@ def jac_algebraic_eval(self, t, y, inputs): solver = pybamm.AlgebraicSolver() solutions = solver._integrate(model, np.array([0])) - np.testing.assert_allclose(solutions[0].all_ys[0], sol, rtol=1e-7, atol=1e-6) + np.testing.assert_allclose(solutions[0].y, sol, rtol=1e-7, atol=1e-6) def test_model_solver(self): # Create model @@ -138,11 +138,11 @@ def test_model_solver(self): solver = pybamm.AlgebraicSolver() solution = solver.solve(model) np.testing.assert_array_equal( - model.get_processed_variable("var1").evaluate(t=None, y=solution.all_ys[0]), + model.get_processed_variable("var1").evaluate(t=None, y=solution.y), sol[:100], ) np.testing.assert_array_equal( - model.get_processed_variable("var2").evaluate(t=None, y=solution.all_ys[0]), + model.get_processed_variable("var2").evaluate(t=None, y=solution.y), sol[100:], ) @@ -151,15 +151,11 @@ def test_model_solver(self): solver.models_set_up = set() solution_no_jac = solver.solve(model) np.testing.assert_array_equal( - model.get_processed_variable("var1").evaluate( - t=None, y=solution_no_jac.all_ys[0] - ), + model.get_processed_variable("var1").evaluate(t=None, y=solution_no_jac.y), sol[:100], ) np.testing.assert_array_equal( - model.get_processed_variable("var2").evaluate( - t=None, y=solution_no_jac.all_ys[0] - ), + model.get_processed_variable("var2").evaluate(t=None, y=solution_no_jac.y), sol[100:], ) @@ -181,13 +177,13 @@ def test_model_solver_least_squares(self): solver = pybamm.AlgebraicSolver("lsq") solution = solver.solve(model) np.testing.assert_allclose( - model.get_processed_variable("var1").evaluate(t=None, y=solution.all_ys[0]), + model.get_processed_variable("var1").evaluate(t=None, y=solution.y), sol[:100], rtol=1e-7, atol=1e-6, ) np.testing.assert_allclose( - model.get_processed_variable("var2").evaluate(t=None, y=solution.all_ys[0]), + model.get_processed_variable("var2").evaluate(t=None, y=solution.y), sol[100:], rtol=1e-7, atol=1e-6, @@ -198,17 +194,13 @@ def test_model_solver_least_squares(self): solver = pybamm.AlgebraicSolver("lsq__trf") solution_no_jac = solver.solve(model) np.testing.assert_allclose( - model.get_processed_variable("var1").evaluate( - t=None, y=solution_no_jac.all_ys[0] - ), + model.get_processed_variable("var1").evaluate(t=None, y=solution_no_jac.y), sol[:100], rtol=1e-7, atol=1e-6, ) np.testing.assert_allclose( - model.get_processed_variable("var2").evaluate( - t=None, y=solution_no_jac.all_ys[0] - ), + model.get_processed_variable("var2").evaluate(t=None, y=solution_no_jac.y), sol[100:], rtol=1e-7, atol=1e-6, @@ -232,13 +224,13 @@ def test_model_solver_minimize(self): solver = pybamm.AlgebraicSolver("minimize", tol=1e-8) solution = solver.solve(model) np.testing.assert_allclose( - model.get_processed_variable("var1").evaluate(t=None, y=solution.all_ys[0]), + model.get_processed_variable("var1").evaluate(t=None, y=solution.y), sol[:100], rtol=1e-7, atol=1e-6, ) np.testing.assert_allclose( - model.get_processed_variable("var2").evaluate(t=None, y=solution.all_ys[0]), + model.get_processed_variable("var2").evaluate(t=None, y=solution.y), sol[100:], rtol=1e-7, atol=1e-6, @@ -249,17 +241,13 @@ def test_model_solver_minimize(self): solver = pybamm.AlgebraicSolver("minimize__BFGS") solution_no_jac = solver.solve(model) np.testing.assert_allclose( - model.get_processed_variable("var1").evaluate( - t=None, y=solution_no_jac.all_ys[0] - ), + model.get_processed_variable("var1").evaluate(t=None, y=solution_no_jac.y), sol[:100], rtol=1e-7, atol=1e-6, ) np.testing.assert_allclose( - model.get_processed_variable("var2").evaluate( - t=None, y=solution_no_jac.all_ys[0] - ), + model.get_processed_variable("var2").evaluate(t=None, y=solution_no_jac.y), sol[100:], rtol=1e-7, atol=1e-6, @@ -305,7 +293,7 @@ def test_model_solver_least_squares_with_bounds(self): solver = pybamm.AlgebraicSolver("lsq", tol=1e-5) solution = solver.solve(model) np.testing.assert_allclose( - model.get_processed_variable("var1").evaluate(t=None, y=solution.all_ys[0]), + model.get_processed_variable("var1").evaluate(t=None, y=solution.y), 3 * np.pi / 2, rtol=1e-3, atol=1e-2, @@ -324,7 +312,7 @@ def test_model_solver_minimize_with_bounds(self): solver = pybamm.AlgebraicSolver("minimize", tol=1e-16) solution = solver.solve(model) np.testing.assert_allclose( - model.get_processed_variable("var1").evaluate(t=None, y=solution.all_ys[0]), + model.get_processed_variable("var1").evaluate(t=None, y=solution.y), 3 * np.pi / 2, rtol=1e-5, atol=1e-4, @@ -348,16 +336,16 @@ def test_model_solver_with_time(self): solution = solver.solve(model, t_eval) sol = np.vstack((3 * t_eval, 6 * t_eval)) - np.testing.assert_array_equal(solution.all_ys[0], sol) + np.testing.assert_array_equal(solution.y, sol) np.testing.assert_array_equal( model.get_processed_variable("var1") - .evaluate(t=t_eval, y=solution.all_ys[0]) + .evaluate(t=t_eval, y=solution.y) .flatten(), sol[0, :], ) np.testing.assert_array_equal( model.get_processed_variable("var2") - .evaluate(t=t_eval, y=solution.all_ys[0]) + .evaluate(t=t_eval, y=solution.y) .flatten(), sol[1, :], ) @@ -376,4 +364,4 @@ def test_solve_with_input(self): # Solve solver = pybamm.AlgebraicSolver() solution = solver.solve(model, np.linspace(0, 1, 10), inputs={"value": 7}) - np.testing.assert_array_equal(solution.all_ys[0], -7) + np.testing.assert_array_equal(solution.y, -7) diff --git a/packages/pybamm/tests/unit/test_solvers/test_casadi_algebraic_solver.py b/packages/pybamm/tests/unit/test_solvers/test_casadi_algebraic_solver.py index f9617b83db..4aa36d92e3 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_casadi_algebraic_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_casadi_algebraic_solver.py @@ -34,7 +34,7 @@ def test_simple_root_find(self): # Solve solver = pybamm.CasadiAlgebraicSolver() solution = solver.solve(model, np.linspace(0, 1, 10)) - np.testing.assert_array_equal(solution.all_ys[0], -2) + np.testing.assert_array_equal(solution.y, -2) def test_algebraic_root_solver_reuse(self): # Create a model with an input parameter and @@ -140,7 +140,7 @@ def test_model_solver_with_time(self): solution = solver.solve(model, t_eval) sol = np.vstack((3 * t_eval, 6 * t_eval)) - np.testing.assert_allclose(solution.all_ys[0], sol, rtol=1e-7, atol=1e-6) + np.testing.assert_allclose(solution.y, sol, rtol=1e-7, atol=1e-6) np.testing.assert_allclose( solution["var1"].data.flatten(), sol[0, :], rtol=1e-7, atol=1e-6 ) @@ -166,7 +166,7 @@ def test_model_solver_with_time_not_changing(self): solution = solver.solve(model, t_eval) sol = np.vstack((3 + 0 * t_eval, 6 + 0 * t_eval)) - np.testing.assert_allclose(solution.all_ys[0], sol, rtol=1e-7, atol=1e-6) + np.testing.assert_allclose(solution.y, sol, rtol=1e-7, atol=1e-6) def test_model_solver_with_bounds(self): # Note: we need a better test case to test this functionality properly @@ -198,7 +198,7 @@ def test_solve_with_input(self): # Solve solver = pybamm.CasadiAlgebraicSolver() solution = solver.solve(model, np.linspace(0, 1, 10), inputs={"param": 7}) - np.testing.assert_array_equal(solution.all_ys[0], -7) + np.testing.assert_array_equal(solution.y, -7) def test_deprecated(self): with pytest.warns( diff --git a/packages/pybamm/tests/unit/test_solvers/test_casadi_solver.py b/packages/pybamm/tests/unit/test_solvers/test_casadi_solver.py index f81bbbf1b3..a941074446 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_casadi_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_casadi_solver.py @@ -4,11 +4,7 @@ import pytest import pybamm -from tests import ( - concatenated_states, - get_discretisation_for_testing, - get_mesh_for_testing, -) +from tests import get_discretisation_for_testing, get_mesh_for_testing class TestCasadiSolver: @@ -52,7 +48,7 @@ def test_model_solver(self): solution = solver.solve(model_disc, t_eval) np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( - solution.all_ys[0].full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) # Safe mode (enforce events that won't be triggered) @@ -62,7 +58,7 @@ def test_model_solver(self): solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( - solution.all_ys[0].full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) # Fast with events @@ -71,7 +67,7 @@ def test_model_solver(self): solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( - solution.all_ys[0].full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) def test_without_grid(self): @@ -89,10 +85,7 @@ def test_without_grid(self): solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( - concatenated_states(solution.all_ys).full()[0], - np.exp(0.1 * solution.t), - rtol=1e-6, - atol=1e-5, + solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) # DAE model @@ -109,16 +102,10 @@ def test_without_grid(self): solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( - concatenated_states(solution.all_ys).full()[0], - np.exp(0.1 * t_eval), - rtol=1e-6, - atol=1e-5, + solution.y.full()[0], np.exp(0.1 * t_eval), rtol=1e-6, atol=1e-5 ) np.testing.assert_allclose( - concatenated_states(solution.all_ys).full()[1], - np.ones_like(t_eval), - rtol=1e-6, - atol=1e-5, + solution.y.full()[1], np.ones_like(t_eval), rtol=1e-6, atol=1e-5 ) # DAE model, errors @@ -149,7 +136,7 @@ def test_model_solver_python(self): solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( - solution.all_ys[0].full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 + solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) pybamm.set_logging_level("WARNING") @@ -230,27 +217,15 @@ def test_model_solver_events(self): solver = pybamm.CasadiSolver(mode="safe", rtol=1e-8, atol=1e-8) t_eval = np.linspace(0, 5, 100) solution = solver.solve(model, t_eval) - np.testing.assert_array_less( - concatenated_states(solution.all_ys).full()[0, :-1], 1.5 - ) - np.testing.assert_array_less( - concatenated_states(solution.all_ys).full()[-1, :-1], 2.5 - ) + np.testing.assert_array_less(solution.y.full()[0, :-1], 1.5) + np.testing.assert_array_less(solution.y.full()[-1, :-1], 2.5) np.testing.assert_equal(solution.t_event[0], solution.t[-1]) - np.testing.assert_array_equal( - solution.y_event[:, 0], concatenated_states(solution.all_ys).full()[:, -1] - ) + np.testing.assert_array_equal(solution.y_event[:, 0], solution.y.full()[:, -1]) np.testing.assert_allclose( - concatenated_states(solution.all_ys).full()[0], - np.exp(0.1 * solution.t), - rtol=1e-6, - atol=1e-5, + solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) np.testing.assert_allclose( - concatenated_states(solution.all_ys).full()[-1], - 2 * np.exp(0.1 * solution.t), - rtol=1e-6, - atol=1e-5, + solution.y.full()[-1], 2 * np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) # Solve using "safe" mode with debug off @@ -258,27 +233,15 @@ def test_model_solver_events(self): solver = pybamm.CasadiSolver(mode="safe", rtol=1e-8, atol=1e-8, dt_max=1) t_eval = np.linspace(0, 5, 100) solution = solver.solve(model, t_eval) - np.testing.assert_array_less( - concatenated_states(solution.all_ys).full()[0], 1.5 - ) - np.testing.assert_array_less( - concatenated_states(solution.all_ys).full()[-1], 2.5 + 1e-10 - ) + np.testing.assert_array_less(solution.y.full()[0], 1.5) + np.testing.assert_array_less(solution.y.full()[-1], 2.5 + 1e-10) # test the last entry is exactly 2.5 + np.testing.assert_allclose(solution.y[-1, -1], 2.5, rtol=1e-3, atol=1e-2) np.testing.assert_allclose( - concatenated_states(solution.all_ys)[-1, -1], 2.5, rtol=1e-3, atol=1e-2 - ) - np.testing.assert_allclose( - concatenated_states(solution.all_ys).full()[0], - np.exp(0.1 * solution.t), - rtol=1e-6, - atol=1e-5, + solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) np.testing.assert_allclose( - concatenated_states(solution.all_ys).full()[-1], - 2 * np.exp(0.1 * solution.t), - rtol=1e-6, - atol=1e-5, + solution.y.full()[-1], 2 * np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) pybamm.settings.debug_mode = True @@ -286,23 +249,13 @@ def test_model_solver_events(self): solver = pybamm.CasadiSolver(dt_max=0, rtol=1e-8, atol=1e-8) t_eval = np.linspace(0, 5, 100) solution = solver.solve(model, t_eval) - np.testing.assert_array_less( - concatenated_states(solution.all_ys).full()[0], 1.5 - ) - np.testing.assert_array_less( - concatenated_states(solution.all_ys).full()[-1], 2.5 + 1e-10 - ) + np.testing.assert_array_less(solution.y.full()[0], 1.5) + np.testing.assert_array_less(solution.y.full()[-1], 2.5 + 1e-10) np.testing.assert_allclose( - concatenated_states(solution.all_ys).full()[0], - np.exp(0.1 * solution.t), - rtol=1e-6, - atol=1e-5, + solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) np.testing.assert_allclose( - concatenated_states(solution.all_ys).full()[-1], - 2 * np.exp(0.1 * solution.t), - rtol=1e-6, - atol=1e-5, + solution.y.full()[-1], 2 * np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) # Solve using "fast with events" mode @@ -322,27 +275,17 @@ def test_model_solver_events(self): solver = pybamm.CasadiSolver(mode="fast with events", rtol=1e-8, atol=1e-8) t_eval = np.linspace(0, 5, 100) solution = solver.solve(model, t_eval) - np.testing.assert_array_less( - concatenated_states(solution.all_ys).full()[0, :-1], 1.5 - ) - np.testing.assert_array_less( - concatenated_states(solution.all_ys).full()[-1, :-1], 2.5 - ) + np.testing.assert_array_less(solution.y.full()[0, :-1], 1.5) + np.testing.assert_array_less(solution.y.full()[-1, :-1], 2.5) np.testing.assert_equal(solution.t_event[0], solution.t[-1]) np.testing.assert_allclose( solution.y_event[:, 0].flatten(), [1.25, 2.5], rtol=1e-6, atol=1e-5 ) np.testing.assert_allclose( - concatenated_states(solution.all_ys).full()[0], - np.exp(0.1 * solution.t), - rtol=1e-6, - atol=1e-5, + solution.y.full()[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) np.testing.assert_allclose( - concatenated_states(solution.all_ys).full()[-1], - 2 * np.exp(0.1 * solution.t), - rtol=1e-6, - atol=1e-5, + solution.y.full()[-1], 2 * np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5 ) # Test when an event returns nan @@ -356,12 +299,8 @@ def test_model_solver_events(self): ] solver = pybamm.CasadiSolver(rtol=1e-8, atol=1e-8) solution = solver.solve(model, t_eval) - np.testing.assert_array_less( - concatenated_states(solution.all_ys).full()[0], 1.02 + 1e-10 - ) - np.testing.assert_allclose( - concatenated_states(solution.all_ys)[0, -1], 1.02, rtol=1e-3, atol=1e-2 - ) + np.testing.assert_array_less(solution.y.full()[0], 1.02 + 1e-10) + np.testing.assert_allclose(solution.y[0, -1], 1.02, rtol=1e-3, atol=1e-2) def test_model_step(self): # Create model @@ -383,7 +322,7 @@ def test_model_step(self): step_sol = solver.step(None, model, dt) np.testing.assert_array_equal(step_sol.t, [0, dt]) np.testing.assert_allclose( - step_sol.all_ys[0].full()[0], np.exp(0.1 * step_sol.t), rtol=1e-7, atol=1e-6 + step_sol.y.full()[0], np.exp(0.1 * step_sol.t), rtol=1e-7, atol=1e-6 ) # Step again (return 5 points) @@ -392,20 +331,14 @@ def test_model_step(self): step_sol_2.t, np.array([0, 1, np.nextafter(1, np.inf), 1.25, 1.5, 1.75, 2]) ) np.testing.assert_allclose( - concatenated_states(step_sol_2.all_ys).full()[0], - np.exp(0.1 * step_sol_2.t), - rtol=1e-7, - atol=1e-6, + step_sol_2.y.full()[0], np.exp(0.1 * step_sol_2.t), rtol=1e-7, atol=1e-6 ) # Check steps give same solution as solve t_eval = step_sol.t solution = solver.solve(model, t_eval) np.testing.assert_allclose( - solution.all_ys[0].full()[0], - step_sol.all_ys[0].full()[0], - rtol=1e-7, - atol=1e-6, + solution.y.full()[0], step_sol.y.full()[0], rtol=1e-7, atol=1e-6 ) def test_model_step_with_input(self): @@ -423,9 +356,7 @@ def test_model_step_with_input(self): dt = 0.1 step_sol = solver.step(None, model, dt, npts=5, inputs={"a": 0.1}) np.testing.assert_array_equal(step_sol.t, np.linspace(0, dt, 5)) - np.testing.assert_allclose( - step_sol.all_ys[0].full()[0], np.exp(0.1 * step_sol.t) - ) + np.testing.assert_allclose(step_sol.y.full()[0], np.exp(0.1 * step_sol.t)) # Step again with different inputs step_sol_2 = solver.step(step_sol, model, dt, npts=5, inputs={"a": -1}) @@ -440,7 +371,7 @@ def test_model_step_with_input(self): np.array([0.1, 0.1, 0.1, 0.1, 0.1, -1, -1, -1, -1, -1]), ) np.testing.assert_allclose( - concatenated_states(step_sol_2.all_ys).full()[0], + step_sol_2.y.full()[0], np.concatenate( [ np.exp(0.1 * step_sol_2.t[:5]), @@ -472,25 +403,20 @@ def test_model_step_events(self): while time < end_time: step_solution = step_solver.step(step_solution, model, dt=dt, npts=10) time += dt - np.testing.assert_array_less( - concatenated_states(step_solution.all_ys).full()[0, :-1], 1.5 - ) - np.testing.assert_array_less( - concatenated_states(step_solution.all_ys).full()[-1, :-1], 2.5 - ) + np.testing.assert_array_less(step_solution.y.full()[0, :-1], 1.5) + np.testing.assert_array_less(step_solution.y.full()[-1, :-1], 2.5) np.testing.assert_equal(step_solution.t_event[0], step_solution.t[-1]) np.testing.assert_array_equal( - step_solution.y_event[:, 0], - concatenated_states(step_solution.all_ys).full()[:, -1], + step_solution.y_event[:, 0], step_solution.y.full()[:, -1] ) np.testing.assert_allclose( - concatenated_states(step_solution.all_ys).full()[0], + step_solution.y.full()[0], np.exp(0.1 * step_solution.t), rtol=1e-6, atol=1e-5, ) np.testing.assert_allclose( - concatenated_states(step_solution.all_ys).full()[-1], + step_solution.y.full()[-1], 2 * np.exp(0.1 * step_solution.t), rtol=1e-5, atol=1e-4, @@ -515,9 +441,7 @@ def test_model_solver_with_inputs(self): solution = solver.solve(model, t_eval, inputs={"rate": 0.1}) assert len(solution.t) < len(t_eval) np.testing.assert_allclose( - concatenated_states(solution.all_ys).full()[0], - np.exp(-0.1 * solution.t), - rtol=1e-04, + solution.y.full()[0], np.exp(-0.1 * solution.t), rtol=1e-04 ) # Without grid @@ -526,16 +450,12 @@ def test_model_solver_with_inputs(self): solution = solver.solve(model, t_eval, inputs={"rate": 0.1}) assert len(solution.t) < len(t_eval) np.testing.assert_allclose( - concatenated_states(solution.all_ys).full()[0], - np.exp(-0.1 * solution.t), - rtol=1e-04, + solution.y.full()[0], np.exp(-0.1 * solution.t), rtol=1e-04 ) solution = solver.solve(model, t_eval, inputs={"rate": 1.1}) assert len(solution.t) < len(t_eval) np.testing.assert_allclose( - concatenated_states(solution.all_ys).full()[0], - np.exp(-1.1 * solution.t), - rtol=1e-04, + solution.y.full()[0], np.exp(-1.1 * solution.t), rtol=1e-04 ) def test_model_solver_dae_inputs_in_initial_conditions(self): @@ -561,13 +481,13 @@ def test_model_solver_dae_inputs_in_initial_conditions(self): for i, ipts in enumerate(inputs_list): np.testing.assert_equal(solutions[i]["var1"](0), ipts["ic 1"]) np.testing.assert_allclose( - solutions[i].all_ys[0].full()[0], + solutions[i].y.full()[0], ipts["ic 1"] * np.exp(ipts["rate"] * solutions[i].t), rtol=1e-6, atol=1e-5, ) np.testing.assert_allclose( - solutions[i].all_ys[0].full()[-1], + solutions[i].y.full()[-1], ipts["ic 1"] * np.exp(ipts["rate"] * solutions[i].t), rtol=1e-6, atol=1e-5, @@ -578,16 +498,10 @@ def test_model_solver_dae_inputs_in_initial_conditions(self): model, t_eval, inputs={"rate": -0.1, "ic 1": 1, "ic 2": 3} ) np.testing.assert_allclose( - solution.all_ys[0].full()[0], - 1 * np.exp(-0.1 * solution.t), - rtol=1e-6, - atol=1e-5, + solution.y.full()[0], 1 * np.exp(-0.1 * solution.t), rtol=1e-6, atol=1e-5 ) np.testing.assert_allclose( - solution.all_ys[0].full()[-1], - 1 * np.exp(-0.1 * solution.t), - rtol=1e-6, - atol=1e-5, + solution.y.full()[-1], 1 * np.exp(-0.1 * solution.t), rtol=1e-6, atol=1e-5 ) def test_model_solver_with_non_identity_mass(self): @@ -613,12 +527,8 @@ def test_model_solver_with_non_identity_mass(self): t_eval = np.linspace(0, 1, 100) solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) - np.testing.assert_allclose( - solution.all_ys[0].full()[0], np.exp(0.1 * solution.t) - ) - np.testing.assert_allclose( - solution.all_ys[0].full()[-1], 2 * np.exp(0.1 * solution.t) - ) + np.testing.assert_allclose(solution.y.full()[0], np.exp(0.1 * solution.t)) + np.testing.assert_allclose(solution.y.full()[-1], 2 * np.exp(0.1 * solution.t)) def test_dae_solver_algebraic_model(self): model = pybamm.BaseModel() @@ -712,30 +622,19 @@ def test_modulo_non_smooth_events(self): while time < end_time: step_solution = step_solver.step(step_solution, model, dt=dt, npts=10) time += dt - np.testing.assert_array_less( - concatenated_states(step_solution.all_ys)[0, :-1], 0.55 - ) - np.testing.assert_array_less( - concatenated_states(step_solution.all_ys)[-1, :-1], 1.2 - ) + np.testing.assert_array_less(step_solution.y[0, :-1], 0.55) + np.testing.assert_array_less(step_solution.y[-1, :-1], 1.2) np.testing.assert_equal(step_solution.t_event[0], step_solution.t[-1]) np.testing.assert_array_equal( - step_solution.y_event[:, 0], - concatenated_states(step_solution.all_ys).full()[:, -1], + step_solution.y_event[:, 0], step_solution.y.full()[:, -1] ) var1_soln = (step_solution.t % a) ** 2 / 2 + a**2 / 2 * (step_solution.t // a) var2_soln = 2 * var1_soln np.testing.assert_allclose( - concatenated_states(step_solution.all_ys).full()[0], - var1_soln, - rtol=1e-5, - atol=1e-4, + step_solution.y.full()[0], var1_soln, rtol=1e-5, atol=1e-4 ) np.testing.assert_allclose( - concatenated_states(step_solution.all_ys).full()[-1], - var2_soln, - rtol=1e-5, - atol=1e-4, + step_solution.y.full()[-1], var2_soln, rtol=1e-5, atol=1e-4 ) def test_solver_interpolation_warning(self): diff --git a/packages/pybamm/tests/unit/test_solvers/test_composite_solver.py b/packages/pybamm/tests/unit/test_solvers/test_composite_solver.py index dfed6f8c95..76538f760f 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_composite_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_composite_solver.py @@ -39,7 +39,7 @@ def test_first_solver_succeeds(self): composite = pybamm.CompositeSolver([solver1, solver2]) solution = composite.solve(model) - np.testing.assert_allclose(solution.all_ys[0], 3, rtol=1e-6) + np.testing.assert_allclose(solution.y, 3, rtol=1e-6) def test_fallback_to_second_solver(self): # Create a model that the first solver will fail on @@ -62,7 +62,7 @@ def solve(self, *args, **kwargs): composite = pybamm.CompositeSolver([failing_solver, working_solver]) solution = composite.solve(model) - np.testing.assert_allclose(solution.all_ys[0], 3, rtol=1e-6) + np.testing.assert_allclose(solution.y, 3, rtol=1e-6) def test_all_solvers_fail(self): model = pybamm.BaseModel() diff --git a/packages/pybamm/tests/unit/test_solvers/test_dummy_solver.py b/packages/pybamm/tests/unit/test_solvers/test_dummy_solver.py index ecb593e758..7ab28823fd 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_dummy_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_dummy_solver.py @@ -4,7 +4,6 @@ import numpy as np import pybamm -from tests import concatenated_states class TestDummySolver: @@ -20,7 +19,7 @@ def test_dummy_solver(self): t_eval = np.linspace(0, 1) sol = solver.solve(model, t_eval) np.testing.assert_array_equal(sol.t, t_eval) - np.testing.assert_array_equal(sol.all_ys[0], np.zeros((1, t_eval.size))) + np.testing.assert_array_equal(sol.y, np.zeros((1, t_eval.size))) np.testing.assert_array_equal(sol["v"].data, np.ones(t_eval.size)) def test_dummy_solver_step(self): @@ -41,7 +40,5 @@ def test_dummy_solver_step(self): # if t_eval is [0,1,2,3] # sol.t should be [0,1,1+eps,2,2+eps,3] i.e. size 2n-2 np.testing.assert_array_equal(len(sol.t), t_eval.size * 2 - 2) - np.testing.assert_array_equal( - concatenated_states(sol.all_ys), np.zeros((1, sol.t.size)) - ) + np.testing.assert_array_equal(sol.y, np.zeros((1, sol.t.size))) np.testing.assert_array_equal(sol["v"].data, np.ones(sol.t.size)) diff --git a/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py b/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py index d2b219abd1..b3a6acf11d 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_idaklu_solver.py @@ -141,17 +141,17 @@ def test_ida_roberts_klu(self): np.testing.assert_allclose(solution.t[-1], 2.0, rtol=1e-7, atol=1e-6) # test that final value is the event value - np.testing.assert_allclose(solution.all_ys[0][0, -1], 0.2, rtol=1e-7, atol=1e-6) + np.testing.assert_allclose(solution.y[0, -1], 0.2, rtol=1e-7, atol=1e-6) # test that y[1] remains constant np.testing.assert_allclose( - solution.all_ys[0][1, :], np.ones(solution.t.shape), rtol=1e-7, atol=1e-6 + solution.y[1, :], np.ones(solution.t.shape), rtol=1e-7, atol=1e-6 ) # test that y[0] = to true solution true_solution = 0.1 * solution.t np.testing.assert_allclose( - solution.all_ys[0][0, :], true_solution, rtol=1e-7, atol=1e-6 + solution.y[0, :], true_solution, rtol=1e-7, atol=1e-6 ) def test_multiple_inputs(self): @@ -189,7 +189,7 @@ def test_multiple_inputs(self): print("checking solution", inputs, solution.all_inputs) np.testing.assert_array_equal(solution.t, t_interp) np.testing.assert_allclose( - solution.all_ys[0][0], + solution.y[0], 2 * np.exp(-inputs["rate"] * solution.t), atol=1e-4, rtol=1e-4, @@ -300,7 +300,7 @@ def test_model_events(self): t_interp, ) np.testing.assert_allclose( - solution.all_ys[0][0], + solution.y[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5, @@ -320,7 +320,7 @@ def test_model_events(self): solution = solver.solve(model_disc, t_eval, t_interp=t_interp) np.testing.assert_array_equal(solution.t, t_interp) np.testing.assert_allclose( - solution.all_ys[0][0], + solution.y[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5, @@ -336,7 +336,7 @@ def test_model_events(self): solution = solver.solve(model_disc, t_eval, t_interp=t_interp) assert len(solution.t) < len(t_interp) np.testing.assert_allclose( - solution.all_ys[0][0], + solution.y[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5, @@ -363,18 +363,18 @@ def test_model_events(self): ) t_eval = np.array([0, 5]) solution = solver.solve(model, t_eval) - np.testing.assert_array_less(solution.all_ys[0][0, :-1], 1.5) - np.testing.assert_array_less(solution.all_ys[0][-1, :-1], 2.5) + np.testing.assert_array_less(solution.y[0, :-1], 1.5) + np.testing.assert_array_less(solution.y[-1, :-1], 2.5) np.testing.assert_equal(solution.t_event[0], solution.t[-1]) - np.testing.assert_array_equal(solution.y_event[:, 0], solution.all_ys[0][:, -1]) + np.testing.assert_array_equal(solution.y_event[:, 0], solution.y[:, -1]) np.testing.assert_allclose( - solution.all_ys[0][0], + solution.y[0], np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5, ) np.testing.assert_allclose( - solution.all_ys[0][-1], + solution.y[-1], 2 * np.exp(0.1 * solution.t), rtol=1e-6, atol=1e-5, @@ -412,7 +412,7 @@ def test_input_params(self): # test that y[3] remains constant np.testing.assert_allclose( - sol.all_ys[0][3], + sol.y[3], np.ones(sol.t.shape), rtol=1e-7, atol=1e-6, @@ -421,7 +421,7 @@ def test_input_params(self): # test that y[0] = to true solution true_solution = a_value * sol.t np.testing.assert_allclose( - sol.all_ys[0][0], + sol.y[0], true_solution, rtol=1e-7, atol=1e-6, @@ -430,7 +430,7 @@ def test_input_params(self): # test that y[1:3] = to true solution true_solution = b_value * sol.t np.testing.assert_allclose( - sol.all_ys[0][1:3], + sol.y[1:3], true_solution, rtol=1e-7, atol=1e-6, @@ -468,7 +468,7 @@ def test_ida_roberts_klu_sensitivities(self): # test that y[1] remains constant np.testing.assert_allclose( - sol.all_ys[0][1, :], + sol.y[1, :], np.ones(sol.t.shape), rtol=1e-7, atol=1e-6, @@ -477,7 +477,7 @@ def test_ida_roberts_klu_sensitivities(self): # test that y[0] = to true solution true_solution = a_value * sol.t np.testing.assert_allclose( - sol.all_ys[0][0, :], + sol.y[0, :], true_solution, rtol=1e-7, atol=1e-6, @@ -498,7 +498,7 @@ def test_ida_roberts_klu_sensitivities(self): # test that y[1] remains constant np.testing.assert_allclose( - sol.all_ys[0][1, :], + sol.y[1, :], np.ones(sol.t.shape), rtol=1e-7, atol=1e-6, @@ -507,7 +507,7 @@ def test_ida_roberts_klu_sensitivities(self): # test that y[0] = to true solution true_solution = a_value * sol.t np.testing.assert_allclose( - sol.all_ys[0][0, :], + sol.y[0, :], true_solution, rtol=1e-7, atol=1e-6, @@ -524,7 +524,7 @@ def test_ida_roberts_klu_sensitivities(self): sol_neg = solver.solve( model, t_eval, inputs={"a": a_value - 0.5 * h}, t_interp=t_interp ) - dyda_fd = (sol_plus.all_ys[0] - sol_neg.all_ys[0]) / h + dyda_fd = (sol_plus.y - sol_neg.y) / h dyda_fd = dyda_fd.transpose().reshape(-1, 1) np.testing.assert_allclose( @@ -566,7 +566,7 @@ def test_ida_roberts_consistent_initialization(self): # u(t0) = 0, v(t0) = 1 (corrected from v=2 by Newton IC solver) np.testing.assert_allclose( - sol.all_ys[0][:, 0], + sol.y[:, 0], [0, 1], rtol=1e-7, atol=1e-6, @@ -608,7 +608,7 @@ def test_sensitivities_with_events(self): # test that y[1] remains constant np.testing.assert_allclose( - sol.all_ys[0][1, :], + sol.y[1, :], np.ones(sol.t.shape), rtol=1e-7, atol=1e-6, @@ -617,7 +617,7 @@ def test_sensitivities_with_events(self): # test that y[0] = to true solution true_solution = a_value * sol.t np.testing.assert_allclose( - sol.all_ys[0][0, :], + sol.y[0, :], true_solution, rtol=1e-7, atol=1e-6, @@ -641,10 +641,8 @@ def test_sensitivities_with_events(self): inputs={"a": a_value - 0.5 * h, "b": b_value}, t_interp=t_interp, ) - max_index = min(sol_plus.all_ys[0].shape[1], sol_neg.all_ys[0].shape[1]) - 1 - dyda_fd = ( - sol_plus.all_ys[0][:, :max_index] - sol_neg.all_ys[0][:, :max_index] - ) / h + max_index = min(sol_plus.y.shape[1], sol_neg.y.shape[1]) - 1 + dyda_fd = (sol_plus.y[:, :max_index] - sol_neg.y[:, :max_index]) / h dyda_fd = dyda_fd.transpose().reshape(-1, 1) np.testing.assert_allclose( @@ -666,10 +664,8 @@ def test_sensitivities_with_events(self): inputs={"a": a_value, "b": b_value - 0.5 * h}, t_interp=t_interp, ) - max_index = min(sol_plus.all_ys[0].shape[1], sol_neg.all_ys[0].shape[1]) - 1 - dydb_fd = ( - sol_plus.all_ys[0][:, :max_index] - sol_neg.all_ys[0][:, :max_index] - ) / h + max_index = min(sol_plus.y.shape[1], sol_neg.y.shape[1]) - 1 + dydb_fd = (sol_plus.y[:, :max_index] - sol_neg.y[:, :max_index]) / h dydb_fd = dydb_fd.transpose().reshape(-1, 1) np.testing.assert_allclose( @@ -742,12 +738,12 @@ def test_dae_solver_algebraic_model(self): solver = pybamm.IDAKLUSolver() t_eval = [0, 1] solution = solver.solve(model, t_eval) - np.testing.assert_array_equal(solution.all_ys[0], -1) + np.testing.assert_array_equal(solution.y, -1) # change initial_conditions and re-solve (to test if ics_only works) model.concatenated_initial_conditions = pybamm.Vector(np.array([[1]])) solution = solver.solve(model, t_eval) - np.testing.assert_array_equal(solution.all_ys[0], -1) + np.testing.assert_array_equal(solution.y, -1) def test_banded(self): model = pybamm.lithium_ion.SPM() @@ -772,9 +768,7 @@ def test_banded(self): solver_banded = pybamm.IDAKLUSolver(options=options) soln_banded = solver_banded.solve(model, t_eval, t_interp=t_interp) - np.testing.assert_allclose( - soln.all_ys[0], soln_banded.all_ys[0], rtol=1e-6, atol=1e-5 - ) + np.testing.assert_allclose(soln.y, soln_banded.y, rtol=1e-6, atol=1e-5) @pytest.mark.parametrize( ("num_threads", "n_inputs"), @@ -1008,9 +1002,7 @@ def test_setup_options(self): if works: soln = solver.solve(model, t_eval, t_interp=t_interp) - np.testing.assert_allclose( - soln.all_ys[0], soln_base.all_ys[0], rtol=1e-5, atol=1e-4 - ) + np.testing.assert_allclose(soln.y, soln_base.y, rtol=1e-5, atol=1e-4) else: with pytest.raises(ValueError): _ = solver.solve(model, t_eval, t_interp=t_interp) @@ -1068,14 +1060,12 @@ def test_solver_options(self): solver = pybamm.IDAKLUSolver(rtol=1e-6, atol=1e-6, options=options) soln = solver.solve(model, t_eval) # Hermite upsample y - itp = CubicHermiteSpline(soln.t, soln.all_ys[0], soln.all_yps[0], axis=1) + itp = CubicHermiteSpline(soln.t, soln.y, soln.yp, axis=1) y_upsampled = itp(t_interp) # Asserts assert all(v == solver.options[k] for k, v in options.items()) - np.testing.assert_allclose( - y_upsampled, soln_base.all_ys[0], rtol=1e-5, atol=1e-4 - ) + np.testing.assert_allclose(y_upsampled, soln_base.y, rtol=1e-5, atol=1e-4) options_fail = { "max_order_bdf": -1, @@ -1682,8 +1672,8 @@ def test_model_solver_with_non_identity_mass(self): t_interp = np.linspace(t_eval[0], t_eval[-1], 100) solution = solver.solve(model, t_eval, t_interp=t_interp) np.testing.assert_array_equal(solution.t, t_interp) - np.testing.assert_allclose(solution.all_ys[0][0], np.exp(0.1 * solution.t)) - np.testing.assert_allclose(solution.all_ys[0][-1], 2 * np.exp(0.1 * solution.t)) + np.testing.assert_allclose(solution.y[0], np.exp(0.1 * solution.t)) + np.testing.assert_allclose(solution.y[-1], 2 * np.exp(0.1 * solution.t)) def test_multiple_initial_conditions_single_variable(self): model = pybamm.BaseModel() @@ -2557,9 +2547,7 @@ def test_solve_with_a_sequence_atol(self, sequence): expected = pybamm.IDAKLUSolver(atol=np.array([1e-3, 1e-9])).solve(model, t_eval) solution = pybamm.IDAKLUSolver(atol=sequence([1e-3, 1e-9])).solve(model, t_eval) assert solution.solver_statistics == expected.solver_statistics - np.testing.assert_allclose( - solution.all_ys[0], expected.all_ys[0], rtol=1e-12, atol=0 - ) + np.testing.assert_allclose(solution.y, expected.y, rtol=1e-12, atol=0) def test_per_state_atol_survives_a_config_round_trip(self): model = self._two_state_model() diff --git a/packages/pybamm/tests/unit/test_solvers/test_nonlinear_solver.py b/packages/pybamm/tests/unit/test_solvers/test_nonlinear_solver.py index ded8d84f1b..5afbb50abd 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_nonlinear_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_nonlinear_solver.py @@ -53,7 +53,7 @@ def test_simple_root_find(self): solver = pybamm.NonlinearSolver() solution = solver.solve(model, np.linspace(0, 1, 10)) - np.testing.assert_array_almost_equal(solution.all_ys[0], -2, decimal=8) + np.testing.assert_array_almost_equal(solution.y, -2, decimal=8) def test_solve_with_input(self): var = pybamm.Variable("var") @@ -70,7 +70,7 @@ def test_solve_with_input(self): np.linspace(0, 1, 10), inputs={"param": 7}, ) - np.testing.assert_array_almost_equal(solution.all_ys[0], -7, decimal=8) + np.testing.assert_array_almost_equal(solution.y, -7, decimal=8) def test_model_solver_with_time(self): model = pybamm.BaseModel() @@ -88,7 +88,7 @@ def test_model_solver_with_time(self): solution = solver.solve(model, t_eval) sol = np.vstack((3 * t_eval, 6 * t_eval)) - np.testing.assert_allclose(solution.all_ys[0], sol, rtol=1e-7, atol=1e-6) + np.testing.assert_allclose(solution.y, sol, rtol=1e-7, atol=1e-6) np.testing.assert_allclose( solution["var1"].data.flatten(), sol[0, :], @@ -118,7 +118,7 @@ def test_model_solver_with_time_not_changing(self): solution = solver.solve(model, t_eval) sol = np.vstack((3 + 0 * t_eval, 6 + 0 * t_eval)) - np.testing.assert_allclose(solution.all_ys[0], sol, rtol=1e-7, atol=1e-6) + np.testing.assert_allclose(solution.y, sol, rtol=1e-7, atol=1e-6) def test_solver_caching(self): var = pybamm.Variable("var") @@ -148,7 +148,7 @@ def test_pickle_round_trip(self): solver = pybamm.NonlinearSolver() sol_pre = solver.solve(model, [0]) - np.testing.assert_array_almost_equal(sol_pre.all_ys[0], -3, decimal=8) + np.testing.assert_array_almost_equal(sol_pre.y, -3, decimal=8) # Sanity: live C++ solver was built and attached to the model. assert model.algebraic_root_solver @@ -161,7 +161,7 @@ def test_pickle_round_trip(self): assert not restored_model.algebraic_root_solver sol_post = restored_solver.solve(restored_model, [0]) - np.testing.assert_array_almost_equal(sol_post.all_ys[0], -3, decimal=8) + np.testing.assert_array_almost_equal(sol_post.y, -3, decimal=8) assert restored_model.algebraic_root_solver def test_sparse_and_dense_modes(self): @@ -181,7 +181,7 @@ def test_sparse_and_dense_modes(self): sol_s = pybamm.NonlinearSolver(use_sparse=True).solve(model_sparse, t_eval) sol_d = pybamm.NonlinearSolver(use_sparse=False).solve(model_dense, t_eval) - np.testing.assert_allclose(sol_s.all_ys[0], sol_d.all_ys[0], atol=1e-10) + np.testing.assert_allclose(sol_s.y, sol_d.y, atol=1e-10) def test_klu_pivot_tolerance(self): model = pybamm.BaseModel() @@ -197,10 +197,8 @@ def test_klu_pivot_tolerance(self): ) for value in [0.001, 1.0] ] - np.testing.assert_allclose(solutions[0].all_ys[0], 2, rtol=1e-6) - np.testing.assert_allclose( - solutions[1].all_ys[0], solutions[0].all_ys[0], rtol=1e-10 - ) + np.testing.assert_allclose(solutions[0].y, 2, rtol=1e-6) + np.testing.assert_allclose(solutions[1].y, solutions[0].y, rtol=1e-10) with pytest.raises(pybamm.SolverError, match="klu_pivot_tolerance"): pybamm.NonlinearSolver(options={"klu_pivot_tolerance": 2.0}) @@ -235,9 +233,7 @@ def test_matches_casadi_algebraic_solver(self): inputs=inputs, ) - np.testing.assert_allclose( - sol_newton.all_ys[0], sol_casadi.all_ys[0], rtol=1e-3, atol=1e-5 - ) + np.testing.assert_allclose(sol_newton.y, sol_casadi.y, rtol=1e-3, atol=1e-5) def test_used_in_composite_solver(self): model = pybamm.BaseModel() @@ -256,7 +252,7 @@ def test_used_in_composite_solver(self): ] ) solution = composite.solve(model) - np.testing.assert_allclose(solution.all_ys[0], 7, rtol=1e-6) + np.testing.assert_allclose(solution.y, 7, rtol=1e-6) def test_convergence_failure_raises(self): var = pybamm.Variable("var") diff --git a/packages/pybamm/tests/unit/test_solvers/test_observation.py b/packages/pybamm/tests/unit/test_solvers/test_observation.py index 00dbca79b0..71ef100907 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_observation.py +++ b/packages/pybamm/tests/unit/test_solvers/test_observation.py @@ -32,7 +32,7 @@ def _split(solution, *boundaries): return [ pybamm.Solution( solution.t[start:end], - solution.all_ys[0][:, start:end], + solution.y[:, start:end], model, {}, all_yps=solution.all_yps[0][:, start:end], diff --git a/packages/pybamm/tests/unit/test_solvers/test_scipy_solver.py b/packages/pybamm/tests/unit/test_solvers/test_scipy_solver.py index afeef8a36d..99779c68ac 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_scipy_solver.py +++ b/packages/pybamm/tests/unit/test_solvers/test_scipy_solver.py @@ -6,7 +6,7 @@ import pytest import pybamm -from tests import concatenated_states, get_mesh_for_testing +from tests import get_mesh_for_testing class TestScipySolver: @@ -63,7 +63,7 @@ def test_model_solver_python_and_jax(self): t_eval = np.linspace(0, 1, 80) solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) - np.testing.assert_allclose(solution.all_ys[0][0], np.exp(0.1 * solution.t)) + np.testing.assert_allclose(solution.y[0], np.exp(0.1 * solution.t)) # Test time assert solution.total_time == solution.solve_time + solution.set_up_time @@ -119,13 +119,9 @@ def test_model_solver_with_event_python(self): solution = solver.solve(model, t_eval) assert len(solution.t) < len(t_eval) np.testing.assert_array_equal(solution.t[:-1], t_eval[: len(solution.t) - 1]) - np.testing.assert_allclose( - concatenated_states(solution.all_ys)[0], np.exp(-0.1 * solution.t) - ) + np.testing.assert_allclose(solution.y[0], np.exp(-0.1 * solution.t)) np.testing.assert_equal(solution.t_event[0], solution.t[-1]) - np.testing.assert_array_equal( - solution.y_event[:, 0], concatenated_states(solution.all_ys)[:, -1] - ) + np.testing.assert_array_equal(solution.y_event[:, 0], solution.y[:, -1]) # Test event in solution variables_and_events np.testing.assert_allclose( @@ -172,7 +168,7 @@ def jacobian(t, y): solution = solver.solve(model, t_eval) np.testing.assert_array_equal(solution.t, t_eval) - T, Y = solution.t, solution.all_ys[0] + T, Y = solution.t, solution.y np.testing.assert_allclose( model.get_processed_variable("var1").evaluate(T, Y), np.ones((N, T.size)) * np.exp(T[np.newaxis, :]), @@ -208,7 +204,7 @@ def test_model_step_python(self): step_sol = solver.step(None, model, dt) np.testing.assert_array_equal(step_sol.t, [0, dt]) np.testing.assert_allclose( - step_sol.all_ys[0][0], np.exp(0.1 * step_sol.t), rtol=1e-7, atol=1e-6 + step_sol.y[0], np.exp(0.1 * step_sol.t), rtol=1e-7, atol=1e-6 ) # Step again (return 5 points) @@ -217,18 +213,13 @@ def test_model_step_python(self): step_sol_2.t, np.array([0, 1, np.nextafter(1, np.inf), 1.25, 1.5, 1.75, 2]) ) np.testing.assert_allclose( - concatenated_states(step_sol_2.all_ys)[0], - np.exp(0.1 * step_sol_2.t), - rtol=1e-7, - atol=1e-6, + step_sol_2.y[0], np.exp(0.1 * step_sol_2.t), rtol=1e-7, atol=1e-6 ) # Check steps give same solution as solve t_eval = step_sol.t solution = solver.solve(model, t_eval) - np.testing.assert_allclose( - solution.all_ys[0][0], step_sol.all_ys[0][0], rtol=1e-7, atol=1e-6 - ) + np.testing.assert_allclose(solution.y[0], step_sol.y[0], rtol=1e-7, atol=1e-6) def test_step_different_model(self): disc = pybamm.Discretisation() @@ -258,7 +249,7 @@ def test_step_different_model(self): step_sol1 = solver.step(None, model1, dt) np.testing.assert_array_equal(step_sol1.t, [0, dt]) np.testing.assert_allclose( - step_sol1.all_ys[0][0], np.exp(0.1 * step_sol1.t), rtol=1e-7, atol=1e-6 + step_sol1.y[0], np.exp(0.1 * step_sol1.t), rtol=1e-7, atol=1e-6 ) # Step again, the model has changed so this raises an error @@ -287,9 +278,7 @@ def test_model_solver_with_inputs(self): solution = solver.solve(model, t_eval, inputs={"rate": 0.1}) assert len(solution.t) < len(t_eval) np.testing.assert_array_equal(solution.t[:-1], t_eval[: len(solution.t) - 1]) - np.testing.assert_allclose( - concatenated_states(solution.all_ys)[0], np.exp(-0.1 * solution.t) - ) + np.testing.assert_allclose(solution.y[0], np.exp(-0.1 * solution.t)) def test_model_solver_multiple_inputs_happy_path(self, subtests): for convert_to_format in ["python", "casadi"]: @@ -317,7 +306,7 @@ def test_model_solver_multiple_inputs_happy_path(self, subtests): solution = solutions[i] np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( - solution.all_ys[0][0], np.exp(-0.01 * (i + 1) * solution.t) + solution.y[0], np.exp(-0.01 * (i + 1) * solution.t) ) def test_model_solver_multiple_inputs_initial_conditions(self): @@ -381,9 +370,7 @@ def test_model_solver_with_event_with_casadi(self): np.testing.assert_array_equal( solution.t[:-1], t_eval[: len(solution.t) - 1] ) - np.testing.assert_allclose( - concatenated_states(solution.all_ys)[0], np.exp(-0.1 * solution.t) - ) + np.testing.assert_allclose(solution.y[0], np.exp(-0.1 * solution.t)) def test_model_solver_with_inputs_with_casadi(self): # Create model @@ -408,9 +395,7 @@ def test_model_solver_with_inputs_with_casadi(self): solution = solver.solve(model, t_eval, inputs={"rate": 0.1}) assert len(solution.t) < len(t_eval) np.testing.assert_array_equal(solution.t[:-1], t_eval[: len(solution.t) - 1]) - np.testing.assert_allclose( - concatenated_states(solution.all_ys)[0], np.exp(-0.1 * solution.t) - ) + np.testing.assert_allclose(solution.y[0], np.exp(-0.1 * solution.t)) def test_model_solver_inputs_in_initial_conditions(self): # Create model @@ -426,7 +411,7 @@ def test_model_solver_inputs_in_initial_conditions(self): solutions = solver.solve(model, t_eval, inputs=inputs_list) for i, ic in enumerate(inputs_list): np.testing.assert_allclose( - solutions[i].all_ys[0][0], + solutions[i].y[0], ic["ic 1"] * np.exp(ic["rate"] * solutions[i].t), rtol=1e-6, atol=1e-5, @@ -436,7 +421,7 @@ def test_model_solver_inputs_in_initial_conditions(self): # Solve again with different initial conditions solution = solver.solve(model, t_eval, inputs={"rate": -0.1, "ic 1": 1}) np.testing.assert_allclose( - solution.all_ys[0][0], 1 * np.exp(-0.1 * solution.t), rtol=1e-6, atol=1e-5 + solution.y[0], 1 * np.exp(-0.1 * solution.t), rtol=1e-6, atol=1e-5 ) def test_model_solver_manually_update_initial_conditions(self): @@ -451,7 +436,7 @@ def test_model_solver_manually_update_initial_conditions(self): t_eval = np.linspace(0, 5, 100) solution = solver.solve(model, t_eval) np.testing.assert_allclose( - solution.all_ys[0][0], 1 * np.exp(-solution.t), rtol=1e-6, atol=1e-5 + solution.y[0], 1 * np.exp(-solution.t), rtol=1e-6, atol=1e-5 ) # Change initial conditions and solve again @@ -461,7 +446,7 @@ def test_model_solver_manually_update_initial_conditions(self): solver = pybamm.ScipySolver(rtol=1e-8, atol=1e-8) solution = solver.solve(model, t_eval) np.testing.assert_allclose( - solution.all_ys[0][0], 2 * np.exp(-solution.t), rtol=1e-6, atol=1e-5 + solution.y[0], 2 * np.exp(-solution.t), rtol=1e-6, atol=1e-5 ) def test_scale_and_reference(self): @@ -480,10 +465,7 @@ def test_scale_and_reference(self): model.concatenated_initial_conditions.evaluate(), 1, rtol=1e-7, atol=1e-6 ) np.testing.assert_allclose( - solution.all_ys[0][0], - (solution["var1"].data - 1) / 2, - rtol=1e-15, - atol=1e-14, + solution.y[0], (solution["var1"].data - 1) / 2, rtol=1e-15, atol=1e-14 ) def test_deprecated(self): diff --git a/packages/pybamm/tests/unit/test_solvers/test_solution.py b/packages/pybamm/tests/unit/test_solvers/test_solution.py index c6ca2524a8..11403e04ab 100644 --- a/packages/pybamm/tests/unit/test_solvers/test_solution.py +++ b/packages/pybamm/tests/unit/test_solvers/test_solution.py @@ -18,7 +18,7 @@ import pybamm from pybamm.solvers.solution import _DEFAULT_SOLUTION_OPTIONS, make_cycle_solution -from tests import concatenated_states, get_discretisation_for_testing +from tests import get_discretisation_for_testing class _PicklerBeforeVariableObservers(pickle.Pickler): @@ -45,7 +45,7 @@ def test_init(self): y = np.tile(t, (20, 1)) sol = pybamm.Solution(t, y, pybamm.BaseModel(), {}) np.testing.assert_array_equal(sol.t, t) - np.testing.assert_array_equal(sol.all_ys[0], y) + np.testing.assert_array_equal(sol.y, y) assert sol.t_event is None assert sol.y_event is None assert sol.termination == "final time" @@ -93,12 +93,12 @@ def test_yp(self): # Without yps, yp should be None sol_no_yp = pybamm.Solution(t, y, pybamm.BaseModel(), {}) assert sol_no_yp.hermite_interpolation is False - assert sol_no_yp.all_yps is None + assert sol_no_yp.yp is None # With yps, yp should return the concatenated time derivatives sol_with_yp = pybamm.Solution(t, y, pybamm.BaseModel(), {}, all_yps=yp) assert sol_with_yp.hermite_interpolation is True - np.testing.assert_array_equal(sol_with_yp.all_yps[0], yp) + np.testing.assert_array_equal(sol_with_yp.yp, yp) def test_sensitivities(self): t = np.linspace(0, 1) @@ -334,7 +334,7 @@ def test_add_solutions(self): assert sol_sum.integration_time == 0.8 np.testing.assert_array_equal(sol_sum.t, np.concatenate([t1, t2[1:]])) np.testing.assert_array_equal( - concatenated_states(sol_sum.all_ys), np.concatenate([y1, y2[:, 1:]], axis=1) + sol_sum.y, np.concatenate([y1, y2[:, 1:]], axis=1) ) np.testing.assert_array_equal(sol_sum.all_inputs, [{"a": 1}, {"a": 2}]) assert sol_sum.all_inputs_stacked[0] is sol1.all_inputs_stacked[0] @@ -605,12 +605,8 @@ def test_add_solutions_different_models(self): # Test np.testing.assert_array_equal(sol_sum.t, np.concatenate([t1, t2[1:]])) - with ( - pytest.warns(DeprecationWarning), - pytest.raises( - pybamm.SolverError, - match=r"The solution is made up from different models", - ), + with pytest.raises( + pybamm.SolverError, match=r"The solution is made up from different models" ): sol_sum.y @@ -697,15 +693,6 @@ def test_copy(self): assert sol_copy.solve_time == sol1.solve_time assert sol_copy.integration_time == sol1.integration_time - def test_states_are_deprecated(self): - t = np.linspace(0, 1) - y = np.tile(t, (2, 1)) - solution = pybamm.Solution(t, y, pybamm.BaseModel(), {}, all_yps=2 * y) - with pytest.warns(DeprecationWarning, match=r"`Solution\.y` is deprecated"): - np.testing.assert_array_equal(solution.y, y) - with pytest.warns(DeprecationWarning, match=r"`Solution\.yp` is deprecated"): - np.testing.assert_array_equal(solution.yp, 2 * y) - def test_t_additive(self): model = pybamm.BaseModel() t1, t2, t3 = np.linspace(0, 1), np.linspace(1, 2), np.linspace(2, 3) @@ -931,13 +918,9 @@ def test_first_state_of_output_variables_solve_is_consistent(self): for output_variables in (None, ["u"]) ) + np.testing.assert_allclose(solution.first_state.y, [[1], [2]], rtol=1e-6) np.testing.assert_allclose( - solution.first_state.all_ys[0], [[1], [2]], rtol=1e-6 - ) - np.testing.assert_allclose( - solution.first_state.all_ys[0], - full_solution.first_state.all_ys[0], - rtol=1e-6, + solution.first_state.y, full_solution.first_state.y, rtol=1e-6 ) def test_first_last_state_empty_y(self): @@ -985,17 +968,11 @@ def test_cycles(self): assert isinstance(sol.cycles[0], pybamm.Solution) np.testing.assert_array_equal(sol.cycles[0].t, sol.t[:len_cycle_1]) - np.testing.assert_array_equal( - concatenated_states(sol.cycles[0].all_ys), - concatenated_states(sol.all_ys)[:, :len_cycle_1], - ) + np.testing.assert_array_equal(sol.cycles[0].y, sol.y[:, :len_cycle_1]) assert isinstance(sol.cycles[1], pybamm.Solution) np.testing.assert_array_equal(sol.cycles[1].t, sol.t[len_cycle_1:]) - np.testing.assert_allclose( - concatenated_states(sol.cycles[1].all_ys), - concatenated_states(sol.all_ys)[:, len_cycle_1:], - ) + np.testing.assert_allclose(sol.cycles[1].y, sol.y[:, len_cycle_1:]) def test_solutions_reject_undefined_attributes(self): # An experiment keeps several solutions per step, so none carries a __dict__ @@ -1030,9 +1007,7 @@ def test_pickle_keeps_slots(self): first_state = solution.first_state loaded = pickle.loads(pickle.dumps(solution)) np.testing.assert_array_equal(loaded.t, t) - np.testing.assert_array_equal( - loaded.first_state.all_ys[0], first_state.all_ys[0] - ) + np.testing.assert_array_equal(loaded.first_state.y, first_state.y) def test_total_time(self): sol = pybamm.Solution(np.array([0]), np.array([[1, 2]]), pybamm.BaseModel(), {}) @@ -1988,9 +1963,7 @@ def test_from_sub_solutions_matches_repeated_add(self): folded = functools.reduce(operator.add, [s.copy() for s in sols]) np.testing.assert_array_equal(batch.t, folded.t) - np.testing.assert_array_equal( - concatenated_states(batch.all_ys), concatenated_states(folded.all_ys) - ) + np.testing.assert_array_equal(batch.y, folded.y) assert [len(s) for s in batch.all_ts] == [len(s) for s in folded.all_ts] assert len(batch.sub_solutions) == len(folded.sub_solutions) assert batch.all_inputs == folded.all_inputs @@ -2114,9 +2087,7 @@ def test_from_sub_solutions_hermite_matches_add(self): folded = functools.reduce(operator.add, [s.copy() for s in sols]) assert out.hermite_interpolation - np.testing.assert_array_equal( - concatenated_states(out.all_yps), concatenated_states(folded.all_yps) - ) + np.testing.assert_array_equal(out.yp, folded.yp) def test_from_sub_solutions_mixed_hermite_is_not_hermite(self): # If any segment lacks yps, the fold is non-hermite (mirrors __add__'s AND). @@ -2327,9 +2298,7 @@ def test_from_sub_solutions_skipped_duplicate_preserves_hermite(self): assert out.hermite_interpolation == folded.hermite_interpolation assert out.hermite_interpolation is True - np.testing.assert_array_equal( - concatenated_states(out.all_yps), concatenated_states(folded.all_yps) - ) + np.testing.assert_array_equal(out.yp, folded.yp) def test_join_trailing_last_state_keeps_event_sensitivities(self): # Simulation carries last_state over a cycle whose steps are all infeasible; @@ -2486,7 +2455,5 @@ def __reduce__(self): assert not hasattr(loaded, "label") assert loaded.solver_statistics is None np.testing.assert_array_equal(loaded.t, solution.t) - np.testing.assert_array_equal( - loaded.first_state.all_ys[0], first_state.all_ys[0] - ) + np.testing.assert_array_equal(loaded.first_state.y, first_state.y) assert loaded.last_state.t[0] == pytest.approx(solution.t[-1]) diff --git a/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element.py b/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element.py index 4985b13afa..980293b05f 100644 --- a/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element.py +++ b/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element.py @@ -524,9 +524,7 @@ def test_pure_neumann_poisson(self): z = mesh["current collector"].coordinates[1, :][:, np.newaxis] u_exact = z**2 / 2 - 1 / 6 - np.testing.assert_allclose( - solution.all_ys[0][:-1], u_exact, rtol=1e-2, atol=1e-1 - ) + np.testing.assert_allclose(solution.y[:-1], u_exact, rtol=1e-2, atol=1e-1) def test_dirichlet_bcs(self): # manufactured solution u = a*z^2 + b*z + c @@ -564,7 +562,7 @@ def test_dirichlet_bcs(self): z = mesh["current collector"].edges["z"][:, np.newaxis] u_exact = a * z**2 + b * z + c np.testing.assert_allclose( - solution.all_ys[0][0 : len(z)], u_exact, rtol=1e-7, atol=1e-6 + solution.y[0 : len(z)], u_exact, rtol=1e-7, atol=1e-6 ) def test_disc_spatial_var(self): diff --git a/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element_3d.py b/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element_3d.py index d86f11ebdc..a30805c870 100644 --- a/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element_3d.py +++ b/packages/pybamm/tests/unit/test_spatial_methods/test_scikit_finite_element_3d.py @@ -212,7 +212,7 @@ def test_3d_thermal_equation_analytical(self): disc.process_model(model) solver = pybamm.AlgebraicSolver() solution = solver.solve(model) - T_numerical = solution.all_ys[0].flatten() + T_numerical = solution.y.flatten() # Define the analytical solution: T(x) = 0.5 * x * (L - x) x_coords = mesh["current collector"].nodes[:, 0] @@ -488,7 +488,7 @@ def test_pure_neumann_poisson_3d(self): solver = pybamm.AlgebraicSolver() solution = solver.solve(model) - u_numerical = solution.all_ys[0][:-1] + u_numerical = solution.y[:-1] z_coords = mesh["current collector"].nodes[:, 2] @@ -535,10 +535,7 @@ def test_dirichlet_bcs_3d(self): u_exact_at_nodes = a_val * x_coords**2 + b_val * x_coords + k_val np.testing.assert_allclose( - solution.all_ys[0].flatten(), - u_exact_at_nodes.flatten(), - rtol=1e-2, - atol=1e-2, + solution.y.flatten(), u_exact_at_nodes.flatten(), rtol=1e-2, atol=1e-2 ) def test_scalar_field_discretization(self): @@ -662,7 +659,7 @@ def test_laplacian_cylinder_manufactured(self): disc.process_model(model) solver = pybamm.AlgebraicSolver() solution = solver.solve(model) - u_numerical = solution.all_ys[0].flatten() + u_numerical = solution.y.flatten() nodes = mesh["current collector"].nodes x_nodes, y_nodes, z_nodes = nodes.T r_nodes = np.sqrt(x_nodes**2 + y_nodes**2)