From 4f7fed029c77b4f9f7753ed09580ef9a9dfbd41b Mon Sep 17 00:00:00 2001 From: Caitlin Parke Date: Wed, 21 Aug 2024 19:55:18 -0400 Subject: [PATCH 01/23] added phase-dependent particle to LAM --- .../full_battery_models/base_battery_model.py | 4 +- .../lithium_ion/base_lithium_ion_model.py | 44 ++++++------ .../active_material/loss_active_material.py | 52 ++++++++------ .../submodels/particle/base_particle.py | 6 +- .../particle_mechanics/base_mechanics.py | 69 ++++++++++++------- .../parameters/lithium_ion_parameters.py | 43 +++++++----- 6 files changed, 129 insertions(+), 89 deletions(-) diff --git a/src/pybamm/models/full_battery_models/base_battery_model.py b/src/pybamm/models/full_battery_models/base_battery_model.py index 8a2e443338..c27a252436 100644 --- a/src/pybamm/models/full_battery_models/base_battery_model.py +++ b/src/pybamm/models/full_battery_models/base_battery_model.py @@ -623,8 +623,8 @@ def __init__(self, extra_options): options["surface form"] != "false" and options["particle size"] == "single" and options["particle"] == "Fickian diffusion" - and options["particle mechanics"] == "none" - and options["loss of active material"] == "none" + # and options["particle mechanics"] == "none" + # and options["loss of active material"] == "none" ): raise pybamm.OptionError( "If there are multiple particle phases: 'surface form' cannot be " diff --git a/src/pybamm/models/full_battery_models/lithium_ion/base_lithium_ion_model.py b/src/pybamm/models/full_battery_models/lithium_ion/base_lithium_ion_model.py index 6db56b74c4..4b8b165b23 100644 --- a/src/pybamm/models/full_battery_models/lithium_ion/base_lithium_ion_model.py +++ b/src/pybamm/models/full_battery_models/lithium_ion/base_lithium_ion_model.py @@ -364,29 +364,31 @@ def set_crack_submodel(self): for domain in self.options.whole_cell_domains: if domain != "separator": domain = domain.split()[0].lower() - crack = getattr(self.options, domain)["particle mechanics"] - if crack == "none": - self.submodels[f"{domain} particle mechanics"] = ( - pybamm.particle_mechanics.NoMechanics( - self.param, domain, options=self.options, phase="primary" + phases = self.options.phases[domain] + for phase in phases: + crack = getattr(self.options, domain)["particle mechanics"] + if crack == "none": + self.submodels[f"{domain} {phase}particle mechanics"] = ( + pybamm.particle_mechanics.NoMechanics( + self.param, domain, options=self.options, phase=phase + ) ) - ) - elif crack == "swelling only": - self.submodels[f"{domain} particle mechanics"] = ( - pybamm.particle_mechanics.SwellingOnly( - self.param, domain, options=self.options, phase="primary" + elif crack == "swelling only": + self.submodels[f"{domain} {phase}particle mechanics"] = ( + pybamm.particle_mechanics.SwellingOnly( + self.param, domain, options=self.options, phase=phase + ) ) - ) - elif crack == "swelling and cracking": - self.submodels[f"{domain} particle mechanics"] = ( - pybamm.particle_mechanics.CrackPropagation( - self.param, - domain, - self.x_average, - options=self.options, - phase="primary", + elif crack == "swelling and cracking": + self.submodels[f"{domain} {phase}particle mechanics"] = ( + pybamm.particle_mechanics.CrackPropagation( + self.param, + domain, + self.x_average, + options=self.options, + phase=phase, + ) ) - ) def set_active_material_submodel(self): for domain in ["negative", "positive"]: @@ -400,7 +402,7 @@ def set_active_material_submodel(self): ) else: submod = pybamm.active_material.LossActiveMaterial( - self.param, domain, self.options, self.x_average + self.param, domain, self.options, self.x_average, phase ) self.submodels[f"{domain} {phase} active material"] = submod diff --git a/src/pybamm/models/submodels/active_material/loss_active_material.py b/src/pybamm/models/submodels/active_material/loss_active_material.py index 6f027d89e6..0838de2400 100644 --- a/src/pybamm/models/submodels/active_material/loss_active_material.py +++ b/src/pybamm/models/submodels/active_material/loss_active_material.py @@ -23,34 +23,35 @@ class LossActiveMaterial(BaseModel): """ - def __init__(self, param, domain, options, x_average): - super().__init__(param, domain, options=options) + def __init__(self, param, domain, options, x_average, phase): + super().__init__(param, domain, options=options, phase=phase) pybamm.citations.register("Reniers2019") self.x_average = x_average def get_fundamental_variables(self): domain, Domain = self.domain_Domain + phase = self.phase_name if self.x_average is True: eps_solid_xav = pybamm.Variable( - f"X-averaged {domain} electrode active material volume fraction", + f"X-averaged {domain} electrode {phase}active material volume fraction", domain="current collector", ) eps_solid = pybamm.PrimaryBroadcast(eps_solid_xav, f"{domain} electrode") else: eps_solid = pybamm.Variable( - f"{Domain} electrode active material volume fraction", + f"{Domain} electrode {phase}active material volume fraction", domain=f"{domain} electrode", auxiliary_domains={"secondary": "current collector"}, ) variables = self._get_standard_active_material_variables(eps_solid) lli_due_to_lam = pybamm.Variable( - "Loss of lithium due to loss of active material " + f"Loss of lithium due to loss of {phase}active material " f"in {domain} electrode [mol]" ) variables.update( { - "Loss of lithium due to loss of active material " + f"Loss of lithium due to loss of {phase}active material " f"in {domain} electrode [mol]": lli_due_to_lam } ) @@ -58,6 +59,7 @@ def get_fundamental_variables(self): def get_coupled_variables(self, variables): domain, Domain = self.domain_Domain + phase_name = self.phase_name deps_solid_dt = 0 lam_option = getattr(getattr(self.options, domain), self.phase)[ @@ -68,17 +70,17 @@ def get_coupled_variables(self, variables): # This is loss of active material model by mechanical effects if self.x_average is True: stress_t_surf = variables[ - f"X-averaged {domain} particle surface tangential stress [Pa]" + f"X-averaged {domain} {phase_name}particle surface tangential stress [Pa]" ] stress_r_surf = variables[ - f"X-averaged {domain} particle surface radial stress [Pa]" + f"X-averaged {domain} {phase_name}particle surface radial stress [Pa]" ] else: stress_t_surf = variables[ - f"{Domain} particle surface tangential stress [Pa]" + f"{Domain} {phase_name}particle surface tangential stress [Pa]" ] stress_r_surf = variables[ - f"{Domain} particle surface radial stress [Pa]" + f"{Domain} {phase_name}particle surface radial stress [Pa]" ] beta_LAM = self.domain_param.beta_LAM @@ -100,12 +102,12 @@ def get_coupled_variables(self, variables): beta_LAM_sei = self.domain_param.beta_LAM_sei if self.x_average is True: a_j_sei = variables[ - f"X-averaged {domain} electrode SEI " + f"X-averaged {domain} electrode {phase_name}SEI " "volumetric interfacial current density [A.m-3]" ] else: a_j_sei = variables[ - f"{Domain} electrode SEI volumetric " + f"{Domain} electrode {phase_name}SEI volumetric " "interfacial current density [A.m-3]" ] @@ -131,19 +133,22 @@ def get_coupled_variables(self, variables): def set_rhs(self, variables): domain, Domain = self.domain_Domain + phase_name = self.phase_name if self.x_average is True: eps_solid = variables[ - f"X-averaged {domain} electrode active material volume fraction" + f"X-averaged {domain} electrode {phase_name}active material volume fraction" ] deps_solid_dt = variables[ - f"X-averaged {domain} electrode active material " + f"X-averaged {domain} electrode {phase_name}active material " "volume fraction change [s-1]" ] else: - eps_solid = variables[f"{Domain} electrode active material volume fraction"] + eps_solid = variables[ + f"{Domain} electrode {phase_name}active material volume fraction" + ] deps_solid_dt = variables[ - f"{Domain} electrode active material volume fraction change [s-1]" + f"{Domain} electrode {phase_name}active material volume fraction change [s-1]" ] # Loss of lithium due to loss of active material @@ -151,11 +156,13 @@ def set_rhs(self, variables): # simulations using adaptive inter-cycle extrapolation algorithm." # Journal of The Electrochemical Society 168.12 (2021): 120531. lli_due_to_lam = variables[ - "Loss of lithium due to loss of active material " + f"Loss of lithium due to loss of {phase_name}active material " f"in {domain} electrode [mol]" ] # Multiply by mol.m-3 * m3 to get mol - c_s_av = variables[f"Average {domain} particle concentration [mol.m-3]"] + c_s_av = variables[ + f"Average {domain} {phase_name}particle concentration [mol.m-3]" + ] V = self.domain_param.L * self.param.A_cc self.rhs = { @@ -166,20 +173,23 @@ def set_rhs(self, variables): def set_initial_conditions(self, variables): domain, Domain = self.domain_Domain + phase_name = self.phase_name eps_solid_init = self.domain_param.prim.epsilon_s if self.x_average is True: eps_solid_xav = variables[ - f"X-averaged {domain} electrode active material volume fraction" + f"X-averaged {domain} electrode {phase_name}active material volume fraction" ] self.initial_conditions = {eps_solid_xav: pybamm.x_average(eps_solid_init)} else: - eps_solid = variables[f"{Domain} electrode active material volume fraction"] + eps_solid = variables[ + f"{Domain} electrode {phase_name}active material volume fraction" + ] self.initial_conditions = {eps_solid: eps_solid_init} lli_due_to_lam = variables[ - "Loss of lithium due to loss of active material " + f"Loss of lithium due to loss of {phase_name}active material " f"in {domain} electrode [mol]" ] self.initial_conditions[lli_due_to_lam] = pybamm.Scalar(0) diff --git a/src/pybamm/models/submodels/particle/base_particle.py b/src/pybamm/models/submodels/particle/base_particle.py index dab48b5f79..fe37d2ff2e 100644 --- a/src/pybamm/models/submodels/particle/base_particle.py +++ b/src/pybamm/models/submodels/particle/base_particle.py @@ -57,9 +57,9 @@ def _get_effective_diffusivity(self, c, T, current): if stress_option == "true": # Ai2019 eq [12] sto = c / phase_param.c_max - Omega = pybamm.r_average(domain_param.Omega(sto, T)) - E = pybamm.r_average(domain_param.E(sto, T)) - nu = domain_param.nu + Omega = pybamm.r_average(phase_param.Omega(sto, T)) + E = pybamm.r_average(phase_param.E(sto, T)) + nu = phase_param.nu theta_M = Omega / (param.R * T) * (2 * Omega * E) / (9 * (1 - nu)) stress_factor = 1 + theta_M * (c - domain_param.c_0) else: diff --git a/src/pybamm/models/submodels/particle_mechanics/base_mechanics.py b/src/pybamm/models/submodels/particle_mechanics/base_mechanics.py index 4e25becbab..3bcddb2bd6 100644 --- a/src/pybamm/models/submodels/particle_mechanics/base_mechanics.py +++ b/src/pybamm/models/submodels/particle_mechanics/base_mechanics.py @@ -38,30 +38,37 @@ def _get_standard_variables(self, l_cr): def _get_mechanical_results(self, variables): domain_param = self.domain_param domain, Domain = self.domain_Domain + phase_name = self.phase_name + phase_param = self.phase_param - c_s_rav = variables[f"R-averaged {domain} particle concentration [mol.m-3]"] - sto_rav = variables[f"R-averaged {domain} particle concentration"] - c_s_surf = variables[f"{Domain} particle surface concentration [mol.m-3]"] + c_s_rav = variables[ + f"R-averaged {domain} {phase_name}particle concentration [mol.m-3]" + ] + sto_rav = variables[f"R-averaged {domain} {phase_name}particle concentration"] + c_s_surf = variables[ + f"{Domain} {phase_name}particle surface concentration [mol.m-3]" + ] T_xav = variables["X-averaged cell temperature [K]"] - phase_name = self.phase_name T = pybamm.PrimaryBroadcast( variables[f"{Domain} electrode temperature [K]"], [f"{domain} {phase_name}particle"], ) - eps_s = variables[f"{Domain} electrode active material volume fraction"] + eps_s = variables[ + f"{Domain} electrode {phase_name}active material volume fraction" + ] # use a tangential approximation for omega - sto = variables[f"{Domain} particle concentration"] - Omega = pybamm.r_average(domain_param.Omega(sto, T)) - R0 = domain_param.prim.R + sto = variables[f"{Domain} {phase_name}particle concentration"] + Omega = pybamm.r_average(phase_param.Omega(sto, T)) + R0 = phase_param.R c_0 = domain_param.c_0 - E0 = pybamm.r_average(domain_param.E(sto, T)) - nu = domain_param.nu + E0 = pybamm.r_average(phase_param.E(sto, T)) + nu = phase_param.nu L0 = domain_param.L - sto_init = pybamm.r_average(domain_param.prim.c_init / domain_param.prim.c_max) + sto_init = pybamm.r_average(phase_param.c_init / phase_param.c_max) v_change = pybamm.x_average( - eps_s * domain_param.prim.t_change(sto_rav) - ) - pybamm.x_average(eps_s * domain_param.prim.t_change(sto_init)) + eps_s * phase_param.t_change(sto_rav) + ) - pybamm.x_average(eps_s * phase_param.t_change(sto_init)) electrode_thickness_change = self.param.n_electrodes_parallel * v_change * L0 # Ai2019 eq [10] @@ -81,31 +88,43 @@ def _get_mechanical_results(self, variables): variables.update( { - f"{Domain} particle surface radial stress [Pa]": stress_r_surf, - f"{Domain} particle surface tangential stress [Pa]": stress_t_surf, - f"{Domain} particle surface displacement [m]": disp_surf, - f"X-averaged {domain} particle surface " + f"{Domain} {phase_name}particle surface radial stress [Pa]": stress_r_surf, + f"{Domain} {phase_name}particle surface tangential stress [Pa]": stress_t_surf, + f"{Domain} {phase_name}particle surface displacement [m]": disp_surf, + f"X-averaged {domain} {phase_name}particle surface " "radial stress [Pa]": stress_r_surf_av, - f"X-averaged {domain} particle surface " + f"X-averaged {domain} {phase_name}particle surface " "tangential stress [Pa]": stress_t_surf_av, - f"X-averaged {domain} particle surface displacement [m]": disp_surf_av, - f"{Domain} electrode thickness change [m]": electrode_thickness_change, + f"X-averaged {domain} {phase_name}particle surface displacement [m]": disp_surf_av, + f"{Domain} electrode {phase_name}thickness change [m]": electrode_thickness_change, } ) if ( - "Negative electrode thickness change [m]" in variables - and "Positive electrode thickness change [m]" in variables + f"Negative electrode {phase_name}thickness change [m]" in variables + and f"Positive electrode {phase_name}thickness change [m]" in variables ): # thermal expansion # Ai2019 eq [13] thermal_expansion = self.param.alpha_T_cell * (T_xav - self.param.T_ref) # calculate total cell thickness change - neg_thickness_change = variables["Negative electrode thickness change [m]"] - pos_thickness_change = variables["Positive electrode thickness change [m]"] - variables["Cell thickness change [m]"] = ( + neg_thickness_change = variables[ + f"Negative electrode {phase_name}thickness change [m]" + ] + pos_thickness_change = variables[ + f"Positive electrode {phase_name}thickness change [m]" + ] + variables[f"Cell {phase_name}thickness change [m]"] = ( neg_thickness_change + pos_thickness_change + thermal_expansion ) + if ( + "Cell primary thickness change [m]" in variables + and "Cell secondary thickness change [m]" in variables + ): + variables["Cell thickness change [m]"] = ( + variables["Cell primary thickness change [m]"] + + variables["Cell secondary thickness change [m]"] + ) return variables diff --git a/src/pybamm/parameters/lithium_ion_parameters.py b/src/pybamm/parameters/lithium_ion_parameters.py index f5a76c6d48..7f874c7160 100644 --- a/src/pybamm/parameters/lithium_ion_parameters.py +++ b/src/pybamm/parameters/lithium_ion_parameters.py @@ -269,7 +269,6 @@ def _set_parameters(self): self.tau_s = self.geo.tau_s # Mechanical parameters - self.nu = pybamm.Parameter(f"{Domain} electrode Poisson's ratio") self.c_0 = pybamm.Parameter( f"{Domain} electrode reference concentration for free of deformation " "[mol.m-3]" @@ -313,22 +312,6 @@ def C_dl(self, T): f"{Domain} electrode double-layer capacity [F.m-2]", inputs ) - def Omega(self, sto, T): - """Dimensional partial molar volume of Li in solid solution [m3.mol-1]""" - Domain = self.domain.capitalize() - inputs = {f"{Domain} particle stoichiometry": sto, "Temperature [K]": T} - return pybamm.FunctionParameter( - f"{Domain} electrode partial molar volume [m3.mol-1]", inputs - ) - - def E(self, sto, T): - """Dimensional Young's modulus""" - Domain = self.domain.capitalize() - inputs = {f"{Domain} particle stoichiometry": sto, "Temperature [K]": T} - return pybamm.FunctionParameter( - f"{Domain} electrode Young's modulus [Pa]", inputs - ) - def sigma(self, T): """Dimensional electrical conductivity in electrode""" inputs = {"Temperature [K]": T} @@ -538,6 +521,9 @@ def _set_parameters(self): if self.options["particle shape"] == "spherical": self.a_typ = 3 * pybamm.xyz_average(self.epsilon_s) / self.R_typ + # Mechanical property + self.nu = pybamm.Parameter(f"{pref}{Domain} electrode Poisson's ratio") + def D(self, c_s, T, lithiation=None): """ Dimensional diffusivity in particle. In the parameter sets this is defined as @@ -803,3 +789,26 @@ def t_change(self, sto): "surface concentration [mol.m-3]": self.c_max, }, ) + + def Omega(self, sto, T): + """Dimensional partial molar volume of Li in solid solution [m3.mol-1]""" + domain, Domain = self.domain_Domain + inputs = { + f"{self.phase_prefactor} particle stoichiometry": sto, + "Temperature [K]": T, + } + return pybamm.FunctionParameter( + f"{self.phase_prefactor}{Domain} electrode partial molar volume [m3.mol-1]", + inputs, + ) + + def E(self, sto, T): + """Dimensional Young's modulus""" + domain, Domain = self.domain_Domain + inputs = { + f"{self.phase_prefactor} particle stoichiometry": sto, + "Temperature [K]": T, + } + return pybamm.FunctionParameter( + f"{self.phase_prefactor}{Domain} electrode Young's modulus [Pa]", inputs + ) From 0b0171f509654cbe429ffba6c94a1ec8cc24ee91 Mon Sep 17 00:00:00 2001 From: Caitlin Parke Date: Wed, 28 Aug 2024 13:07:22 -0400 Subject: [PATCH 02/23] adding tests, still debugging --- .../active_material/loss_active_material.py | 26 ++++ .../base_lithium_ion_tests.py | 133 ++++++++++++++++++ 2 files changed, 159 insertions(+) diff --git a/src/pybamm/models/submodels/active_material/loss_active_material.py b/src/pybamm/models/submodels/active_material/loss_active_material.py index 0838de2400..c093a7e2cb 100644 --- a/src/pybamm/models/submodels/active_material/loss_active_material.py +++ b/src/pybamm/models/submodels/active_material/loss_active_material.py @@ -49,6 +49,32 @@ def get_fundamental_variables(self): f"Loss of lithium due to loss of {phase}active material " f"in {domain} electrode [mol]" ) + if phase == "": + variables.update( + { + f"Loss of lithium due to loss of {phase}active material " + f"in {domain} electrode [mol]": lli_due_to_lam + } + ) + else: + total_lli_due_to_lam = ( + f"Loss of lithium due to loss of active material " + f"in {domain} electrode [mol]" + ) + variables.update( + { + f"Loss of lithium due to loss of {phase}active material " + f"in {domain} electrode [mol]": lli_due_to_lam, + total_lli_due_to_lam: pybamm.Variable( + f"Loss of lithium due to loss of primary active material " + f"in {domain} electrode [mol]" + ) + + pybamm.Variable( + f"Loss of lithium due to loss of secondary active material " + f"in {domain} electrode [mol]" + ), + } + ) variables.update( { f"Loss of lithium due to loss of {phase}active material " diff --git a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py index eddf2aa1e4..11b1d27c49 100644 --- a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py +++ b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py @@ -375,3 +375,136 @@ def temp_drive_cycle(y, z, t): model = self.model() modeltest = tests.StandardModelTest(model, parameter_values=parameter_values) modeltest.test_all(skip_output_tests=True) + + def test_composite_stress_driven_LAM(self): + options = { + "particle phases": ("2", "1"), + "open-circuit potential": (("single", "current sigmoid"), "single"), + "loss of active material": "stress-driven", + } + + # taken from Ai2020 + def graphite_volume_change_Ai2020(sto, c_s_max): + p1 = 145.907 + p2 = -681.229 + p3 = 1334.442 + p4 = -1415.710 + p5 = 873.906 + p6 = -312.528 + p7 = 60.641 + p8 = -5.706 + p9 = 0.386 + p10 = -4.966e-05 + t_change = ( + p1 * sto**9 + + p2 * sto**8 + + p3 * sto**7 + + p4 * sto**6 + + p5 * sto**5 + + p6 * sto**4 + + p7 * sto**3 + + p8 * sto**2 + + p9 * sto + + p10 + ) + return t_change + + # taken from Ai2020 + def lico2_volume_change_Ai2020(sto, c_s_max): + omega = pybamm.Parameter( + "Positive electrode partial molar volume [m3.mol-1]" + ) + t_change = omega * c_s_max * sto + return t_change + + # use Chen2020 composite and add Ai2020 stress-driven parameters + parameter_values = pybamm.ParameterValues("Chen2020_composite") + second = 0.9 + parameter_values.update( + {"Negative electrode LAM constant proportional term [s-1]": 1e-4 / 3600}, + check_already_exists=False, + ) + parameter_values.update( + {"Positive electrode LAM constant proportional term [s-1]": 1e-4 / 3600}, + check_already_exists=False, + ) + parameter_values.update( + {"Primary: Negative electrode partial molar volume [m3.mol-1]": 3.1e-06}, + check_already_exists=False, + ) + parameter_values.update( + {"Primary: Negative electrode Young's modulus [Pa]": 15000000000.0}, + check_already_exists=False, + ) + parameter_values.update( + {"Primary: Negative electrode Poisson's ratio": 0.3}, + check_already_exists=False, + ) + parameter_values.update( + {"Negative electrode critical stress [Pa]": 60000000.0}, + check_already_exists=False, + ) + parameter_values.update( + {"Negative electrode LAM constant exponential term": 2.0}, + check_already_exists=False, + ) + parameter_values.update( + { + "Secondary: Negative electrode partial molar volume [m3.mol-1]": 3.1e-06 + * second + }, + check_already_exists=False, + ) + parameter_values.update( + { + "Secondary: Negative electrode Young's modulus [Pa]": 15000000000.0 + * second + }, + check_already_exists=False, + ) + parameter_values.update( + {"Secondary: Negative electrode Poisson's ratio": 0.3 * second}, + check_already_exists=False, + ) + parameter_values.update( + { + "Negative electrode reference concentration for free of deformation [mol.m-3]": 0.0 + }, + check_already_exists=False, + ) + parameter_values.update( + {"Negative electrode volume change": graphite_volume_change_Ai2020}, + check_already_exists=False, + ) + + parameter_values.update( + {"Positive electrode partial molar volume [m3.mol-1]": -7.28e-07}, + check_already_exists=False, + ) + parameter_values.update( + {"Positive electrode Young's modulus [Pa]": 375000000000.0}, + check_already_exists=False, + ) + parameter_values.update( + {"Positive electrode Poisson's ratio": 0.2}, check_already_exists=False + ) + parameter_values.update( + {"Positive electrode critical stress [Pa]": 375000000.0}, + check_already_exists=False, + ) + parameter_values.update( + {"Positive electrode LAM constant exponential term": 2.0}, + check_already_exists=False, + ) + parameter_values.update( + { + "Positive electrode reference concentration for free of deformation [mol.m-3]": 0.0 + }, + check_already_exists=False, + ) + parameter_values.update( + {"Positive electrode volume change": lico2_volume_change_Ai2020}, + check_already_exists=False, + ) + + self.run_basic_processing_test(options, parameter_values=parameter_values) From 719823e66df18588721053ed93c66e2340bea6ad Mon Sep 17 00:00:00 2001 From: Caitlin Parke Date: Thu, 29 Aug 2024 14:41:51 -0400 Subject: [PATCH 03/23] added more phase-dependent pars --- .../constant_active_material.py | 3 +- .../active_material/loss_active_material.py | 35 +++---------------- .../active_material/total_active_material.py | 1 + .../parameters/lithium_ion_parameters.py | 30 ++++++++-------- 4 files changed, 23 insertions(+), 46 deletions(-) diff --git a/src/pybamm/models/submodels/active_material/constant_active_material.py b/src/pybamm/models/submodels/active_material/constant_active_material.py index 3237775f1c..e978168e9a 100644 --- a/src/pybamm/models/submodels/active_material/constant_active_material.py +++ b/src/pybamm/models/submodels/active_material/constant_active_material.py @@ -23,6 +23,7 @@ class Constant(BaseModel): def get_fundamental_variables(self): domain = self.domain + phase = self.phase_name eps_solid = self.phase_param.epsilon_s deps_solid_dt = pybamm.FullBroadcast( 0, f"{domain} electrode", "current collector" @@ -35,7 +36,7 @@ def get_fundamental_variables(self): variables.update( { - "Loss of lithium due to loss of active material " + f"Loss of lithium due to loss of {phase}active material " f"in {domain} electrode [mol]": pybamm.Scalar(0) } ) diff --git a/src/pybamm/models/submodels/active_material/loss_active_material.py b/src/pybamm/models/submodels/active_material/loss_active_material.py index c093a7e2cb..2eb78f506f 100644 --- a/src/pybamm/models/submodels/active_material/loss_active_material.py +++ b/src/pybamm/models/submodels/active_material/loss_active_material.py @@ -49,32 +49,7 @@ def get_fundamental_variables(self): f"Loss of lithium due to loss of {phase}active material " f"in {domain} electrode [mol]" ) - if phase == "": - variables.update( - { - f"Loss of lithium due to loss of {phase}active material " - f"in {domain} electrode [mol]": lli_due_to_lam - } - ) - else: - total_lli_due_to_lam = ( - f"Loss of lithium due to loss of active material " - f"in {domain} electrode [mol]" - ) - variables.update( - { - f"Loss of lithium due to loss of {phase}active material " - f"in {domain} electrode [mol]": lli_due_to_lam, - total_lli_due_to_lam: pybamm.Variable( - f"Loss of lithium due to loss of primary active material " - f"in {domain} electrode [mol]" - ) - + pybamm.Variable( - f"Loss of lithium due to loss of secondary active material " - f"in {domain} electrode [mol]" - ), - } - ) + variables.update( { f"Loss of lithium due to loss of {phase}active material " @@ -109,9 +84,9 @@ def get_coupled_variables(self, variables): f"{Domain} {phase_name}particle surface radial stress [Pa]" ] - beta_LAM = self.domain_param.beta_LAM - stress_critical = self.domain_param.stress_critical - m_LAM = self.domain_param.m_LAM + beta_LAM = self.phase_param.beta_LAM + stress_critical = self.phase_param.stress_critical + m_LAM = self.phase_param.m_LAM stress_h_surf = (stress_r_surf + 2 * stress_t_surf) / 3 # compressive stress make no contribution @@ -125,7 +100,7 @@ def get_coupled_variables(self, variables): deps_solid_dt += j_stress_LAM if "reaction" in lam_option: - beta_LAM_sei = self.domain_param.beta_LAM_sei + beta_LAM_sei = self.phase_param.beta_LAM_sei if self.x_average is True: a_j_sei = variables[ f"X-averaged {domain} electrode {phase_name}SEI " diff --git a/src/pybamm/models/submodels/active_material/total_active_material.py b/src/pybamm/models/submodels/active_material/total_active_material.py index 5e1d7e2f92..f86486ff53 100644 --- a/src/pybamm/models/submodels/active_material/total_active_material.py +++ b/src/pybamm/models/submodels/active_material/total_active_material.py @@ -34,6 +34,7 @@ def get_coupled_variables(self, variables): f"{Domain} electrode {{}}active material volume fraction change [s-1]", f"X-averaged {domain} electrode {{}}active material " "volume fraction change [s-1]", + f"Loss of lithium due to loss of {{}}active material in {domain} electrode [mol]", ]: sumvar = sum( variables[variable_template.format(phase + " ")] for phase in phases diff --git a/src/pybamm/parameters/lithium_ion_parameters.py b/src/pybamm/parameters/lithium_ion_parameters.py index 7f874c7160..a5f0ec635a 100644 --- a/src/pybamm/parameters/lithium_ion_parameters.py +++ b/src/pybamm/parameters/lithium_ion_parameters.py @@ -282,20 +282,6 @@ def _set_parameters(self): self.b_cr = pybamm.Parameter(f"{Domain} electrode Paris' law constant b") self.m_cr = pybamm.Parameter(f"{Domain} electrode Paris' law constant m") - # Loss of active material parameters - self.m_LAM = pybamm.Parameter( - f"{Domain} electrode LAM constant exponential term" - ) - self.beta_LAM = pybamm.Parameter( - f"{Domain} electrode LAM constant proportional term [s-1]" - ) - self.stress_critical = pybamm.Parameter( - f"{Domain} electrode critical stress [Pa]" - ) - self.beta_LAM_sei = pybamm.Parameter( - f"{Domain} electrode reaction-driven LAM factor [m3.mol-1]" - ) - # Utilisation parameters self.u_init = pybamm.Parameter( f"Initial {domain} electrode interface utilisation" @@ -524,6 +510,20 @@ def _set_parameters(self): # Mechanical property self.nu = pybamm.Parameter(f"{pref}{Domain} electrode Poisson's ratio") + # Loss of active material parameters + self.m_LAM = pybamm.Parameter( + f"{pref}{Domain} electrode LAM constant exponential term" + ) + self.beta_LAM = pybamm.Parameter( + f"{pref}{Domain} electrode LAM constant proportional term [s-1]" + ) + self.stress_critical = pybamm.Parameter( + f"{pref}{Domain} electrode critical stress [Pa]" + ) + self.beta_LAM_sei = pybamm.Parameter( + f"{pref}{Domain} electrode reaction-driven LAM factor [m3.mol-1]" + ) + def D(self, c_s, T, lithiation=None): """ Dimensional diffusivity in particle. In the parameter sets this is defined as @@ -782,7 +782,7 @@ def t_change(self, sto): """ domain, Domain = self.domain_Domain return pybamm.FunctionParameter( - f"{Domain} electrode volume change", + f"{self.phase_prefactor}{Domain} electrode volume change", { "Particle stoichiometry": sto, f"{self.phase_prefactor}Maximum {domain} particle " From d01dd8094a8dc5cf2e1bab5f012805e38b374f60 Mon Sep 17 00:00:00 2001 From: Caitlin Parke Date: Thu, 5 Sep 2024 14:25:34 -0400 Subject: [PATCH 04/23] fixed phase volume fraction for reaction-driven --- .../models/submodels/active_material/loss_active_material.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pybamm/models/submodels/active_material/loss_active_material.py b/src/pybamm/models/submodels/active_material/loss_active_material.py index 2eb78f506f..ffba064d03 100644 --- a/src/pybamm/models/submodels/active_material/loss_active_material.py +++ b/src/pybamm/models/submodels/active_material/loss_active_material.py @@ -176,7 +176,7 @@ def set_initial_conditions(self, variables): domain, Domain = self.domain_Domain phase_name = self.phase_name - eps_solid_init = self.domain_param.prim.epsilon_s + eps_solid_init = self.phase_param.epsilon_s if self.x_average is True: eps_solid_xav = variables[ From d33d3bc13534d15c82c471736cbfe8610466e566 Mon Sep 17 00:00:00 2001 From: Caitlin Parke Date: Fri, 6 Sep 2024 14:05:39 -0400 Subject: [PATCH 05/23] Total SEI thickness calculation added --- .../models/submodels/active_material/total_active_material.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pybamm/models/submodels/active_material/total_active_material.py b/src/pybamm/models/submodels/active_material/total_active_material.py index f86486ff53..867c929632 100644 --- a/src/pybamm/models/submodels/active_material/total_active_material.py +++ b/src/pybamm/models/submodels/active_material/total_active_material.py @@ -35,6 +35,7 @@ def get_coupled_variables(self, variables): f"X-averaged {domain} electrode {{}}active material " "volume fraction change [s-1]", f"Loss of lithium due to loss of {{}}active material in {domain} electrode [mol]", + f"{Domain} total {{}}SEI thickness [m]", ]: sumvar = sum( variables[variable_template.format(phase + " ")] for phase in phases From 63e30d47ed0df0803c9c994a7a8c8466165d189e Mon Sep 17 00:00:00 2001 From: Caitlin Parke Date: Fri, 6 Sep 2024 15:14:12 -0400 Subject: [PATCH 06/23] Updated tests to fix testing errors for new pars --- .../test_models/standard_output_tests.py | 2 +- .../base_lithium_ion_tests.py | 107 ++++-------------- 2 files changed, 26 insertions(+), 83 deletions(-) diff --git a/tests/integration/test_models/standard_output_tests.py b/tests/integration/test_models/standard_output_tests.py index 83b88c0ff0..ca5e27607d 100644 --- a/tests/integration/test_models/standard_output_tests.py +++ b/tests/integration/test_models/standard_output_tests.py @@ -449,7 +449,7 @@ def test_conservation(self): # this seems to be linked to using constant concentration but not sure why decimal = 12 elif self.model.options["particle phases"] != "1": - decimal = 13 + decimal = 9 elif "current-driven" in self.model.options["loss of active material"]: # current driven LAM model doesn't perfectly conserve lithium, not sure why decimal = 9 diff --git a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py index 2a8dc21c02..7c5ccf0967 100644 --- a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py +++ b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py @@ -419,92 +419,35 @@ def lico2_volume_change_Ai2020(sto, c_s_max): # use Chen2020 composite and add Ai2020 stress-driven parameters parameter_values = pybamm.ParameterValues("Chen2020_composite") - second = 0.9 - parameter_values.update( - {"Negative electrode LAM constant proportional term [s-1]": 1e-4 / 3600}, - check_already_exists=False, - ) - parameter_values.update( - {"Positive electrode LAM constant proportional term [s-1]": 1e-4 / 3600}, - check_already_exists=False, - ) - parameter_values.update( - {"Primary: Negative electrode partial molar volume [m3.mol-1]": 3.1e-06}, - check_already_exists=False, - ) - parameter_values.update( - {"Primary: Negative electrode Young's modulus [Pa]": 15000000000.0}, - check_already_exists=False, - ) - parameter_values.update( - {"Primary: Negative electrode Poisson's ratio": 0.3}, - check_already_exists=False, - ) - parameter_values.update( - {"Negative electrode critical stress [Pa]": 60000000.0}, - check_already_exists=False, - ) - parameter_values.update( - {"Negative electrode LAM constant exponential term": 2.0}, - check_already_exists=False, - ) - parameter_values.update( - { - "Secondary: Negative electrode partial molar volume [m3.mol-1]": 3.1e-06 - * second - }, - check_already_exists=False, - ) - parameter_values.update( - { - "Secondary: Negative electrode Young's modulus [Pa]": 15000000000.0 - * second - }, - check_already_exists=False, - ) - parameter_values.update( - {"Secondary: Negative electrode Poisson's ratio": 0.3 * second}, - check_already_exists=False, - ) - parameter_values.update( - { - "Negative electrode reference concentration for free of deformation [mol.m-3]": 0.0 - }, - check_already_exists=False, - ) - parameter_values.update( - {"Negative electrode volume change": graphite_volume_change_Ai2020}, - check_already_exists=False, - ) - - parameter_values.update( - {"Positive electrode partial molar volume [m3.mol-1]": -7.28e-07}, - check_already_exists=False, - ) - parameter_values.update( - {"Positive electrode Young's modulus [Pa]": 375000000000.0}, - check_already_exists=False, - ) - parameter_values.update( - {"Positive electrode Poisson's ratio": 0.2}, check_already_exists=False - ) - parameter_values.update( - {"Positive electrode critical stress [Pa]": 375000000.0}, - check_already_exists=False, - ) - parameter_values.update( - {"Positive electrode LAM constant exponential term": 2.0}, - check_already_exists=False, - ) parameter_values.update( { - "Positive electrode reference concentration for free of deformation [mol.m-3]": 0.0 + "Primary: Negative electrode LAM constant proportional term [s-1]": 1e-4 + / 3600, + "Secondary: Negative electrode LAM constant proportional term [s-1]": 1e-4 + / 3600, + "Positive electrode LAM constant proportional term [s-1]": 1e-4 / 3600, + "Primary: Negative electrode partial molar volume [m3.mol-1]": 3.1e-06, + "Primary: Negative electrode Young's modulus [Pa]": 15000000000.0, + "Primary: Negative electrode Poisson's ratio": 0.3, + "Primary: Negative electrode critical stress [Pa]": 60000000.0, + "Secondary: Negative electrode critical stress [Pa]": 60000000.0, + "Primary: Negative electrode LAM constant exponential term": 2.0, + "Secondary: Negative electrode LAM constant exponential term": 2.0, + "Secondary: Negative electrode partial molar volume [m3.mol-1]": 3.1e-06, + "Secondary: Negative electrode Young's modulus [Pa]": 15000000000.0, + "Secondary: Negative electrode Poisson's ratio": 0.3, + "Negative electrode reference concentration for free of deformation [mol.m-3]": 0.0, + "Primary: Negative electrode volume change": graphite_volume_change_Ai2020, + "Secondary: Negative electrode volume change": graphite_volume_change_Ai2020, + "Positive electrode partial molar volume [m3.mol-1]": -7.28e-07, + "Positive electrode Young's modulus [Pa]": 375000000000.0, + "Positive electrode Poisson's ratio": 0.2, + "Positive electrode critical stress [Pa]": 375000000.0, + "Positive electrode LAM constant exponential term": 2.0, + "Positive electrode reference concentration for free of deformation [mol.m-3]": 0.0, + "Positive electrode volume change": lico2_volume_change_Ai2020, }, check_already_exists=False, ) - parameter_values.update( - {"Positive electrode volume change": lico2_volume_change_Ai2020}, - check_already_exists=False, - ) self.run_basic_processing_test(options, parameter_values=parameter_values) From fb7fe352c75d2116d6260d7d557da94192eba3bb Mon Sep 17 00:00:00 2001 From: Caitlin Parke Date: Fri, 6 Sep 2024 15:51:00 -0400 Subject: [PATCH 07/23] added reaction-driven composite test --- .../base_lithium_ion_tests.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py index 7c5ccf0967..767a35d7f2 100644 --- a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py +++ b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py @@ -451,3 +451,79 @@ def lico2_volume_change_Ai2020(sto, c_s_max): ) self.run_basic_processing_test(options, parameter_values=parameter_values) + + def test_composite_reaction_driven_LAM(self): + options = { + "particle phases": ("2", "1"), + "open-circuit potential": (("single", "current sigmoid"), "single"), + "loss of active material": "reaction-driven", + } + + # taken from Ai2020 + def graphite_volume_change_Ai2020(sto, c_s_max): + p1 = 145.907 + p2 = -681.229 + p3 = 1334.442 + p4 = -1415.710 + p5 = 873.906 + p6 = -312.528 + p7 = 60.641 + p8 = -5.706 + p9 = 0.386 + p10 = -4.966e-05 + t_change = ( + p1 * sto**9 + + p2 * sto**8 + + p3 * sto**7 + + p4 * sto**6 + + p5 * sto**5 + + p6 * sto**4 + + p7 * sto**3 + + p8 * sto**2 + + p9 * sto + + p10 + ) + return t_change + + # taken from Ai2020 + def lico2_volume_change_Ai2020(sto, c_s_max): + omega = pybamm.Parameter( + "Positive electrode partial molar volume [m3.mol-1]" + ) + t_change = omega * c_s_max * sto + return t_change + + # use Chen2020 composite and add Ai2020 stress-driven parameters + parameter_values = pybamm.ParameterValues("Chen2020_composite") + parameter_values.update( + { + "Primary: Negative electrode LAM constant proportional term [s-1]": 1e-4 + / 3600, + "Secondary: Negative electrode LAM constant proportional term [s-1]": 1e-4 + / 3600, + "Positive electrode LAM constant proportional term [s-1]": 1e-4 / 3600, + "Primary: Negative electrode partial molar volume [m3.mol-1]": 3.1e-06, + "Primary: Negative electrode Young's modulus [Pa]": 15000000000.0, + "Primary: Negative electrode Poisson's ratio": 0.3, + "Primary: Negative electrode critical stress [Pa]": 60000000.0, + "Secondary: Negative electrode critical stress [Pa]": 60000000.0, + "Primary: Negative electrode LAM constant exponential term": 2.0, + "Secondary: Negative electrode LAM constant exponential term": 2.0, + "Secondary: Negative electrode partial molar volume [m3.mol-1]": 3.1e-06, + "Secondary: Negative electrode Young's modulus [Pa]": 15000000000.0, + "Secondary: Negative electrode Poisson's ratio": 0.3, + "Negative electrode reference concentration for free of deformation [mol.m-3]": 0.0, + "Primary: Negative electrode volume change": graphite_volume_change_Ai2020, + "Secondary: Negative electrode volume change": graphite_volume_change_Ai2020, + "Positive electrode partial molar volume [m3.mol-1]": -7.28e-07, + "Positive electrode Young's modulus [Pa]": 375000000000.0, + "Positive electrode Poisson's ratio": 0.2, + "Positive electrode critical stress [Pa]": 375000000.0, + "Positive electrode LAM constant exponential term": 2.0, + "Positive electrode reference concentration for free of deformation [mol.m-3]": 0.0, + "Positive electrode volume change": lico2_volume_change_Ai2020, + }, + check_already_exists=False, + ) + + self.run_basic_processing_test(options, parameter_values=parameter_values) From 10a2254a37e0b87cf860353de7c6cfaed5c4814d Mon Sep 17 00:00:00 2001 From: Robert Timms <43040151+rtimms@users.noreply.github.com> Date: Sun, 8 Sep 2024 17:31:55 -0700 Subject: [PATCH 08/23] #4405 make dUdT and volume change functions of sto only (#4427) * #4405 make dUdT and volume change functions of sto only * #4405 changelog --- CHANGELOG.md | 1 + .../parameterization/parameterization.ipynb | 2 +- .../input/parameters/lithium_ion/Ai2020.py | 35 +++++++++---------- .../input/parameters/lithium_ion/Chen2020.py | 8 ++--- .../lithium_ion/Chen2020_composite.py | 12 +++---- .../input/parameters/lithium_ion/Ecker2015.py | 16 ++++----- .../Ecker2015_graphite_halfcell.py | 8 ++--- .../parameters/lithium_ion/Marquis2019.py | 33 +++++++++-------- .../parameters/lithium_ion/Mohtat2020.py | 24 ++++++------- .../parameters/lithium_ion/NCA_Kim2011.py | 14 ++++---- .../input/parameters/lithium_ion/OKane2022.py | 23 ++++++------ .../OKane2022_graphite_SiOx_halfcell.py | 10 +++--- .../parameters/lithium_ion/ORegan2022.py | 24 ++++++------- .../input/parameters/lithium_ion/Prada2013.py | 6 ++-- .../parameters/lithium_ion/Ramadass2004.py | 33 +++++++++-------- .../input/parameters/lithium_ion/Xu2019.py | 4 +-- src/pybamm/parameters/bpx.py | 31 ++++------------ .../parameters/lithium_ion_parameters.py | 10 ++---- tests/unit/test_parameters/test_bpx.py | 6 ++-- .../test_parameter_sets/test_Ai2020.py | 8 ++--- .../test_LCO_Ramadass2004.py | 4 +-- .../test_LGM50_ORegan2022.py | 4 +-- .../test_parameter_sets/test_OKane2022.py | 4 +-- .../test_OKane2022_negative_halfcell.py | 2 +- 24 files changed, 155 insertions(+), 167 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7764a3b24a..0446b5125a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Removed the `start_step_offset` setting and disabled minimum `dt` warnings for drive cycles with the (`IDAKLUSolver`). ([#4416](https://github.com/pybamm-team/PyBaMM/pull/4416)) ## Breaking changes +- The parameters "... electrode OCP entropic change [V.K-1]" and "... electrode volume change" are now expected to be functions of stoichiometry only instead of functions of both stoichiometry and maximum concentration ([#4427](https://github.com/pybamm-team/PyBaMM/pull/4427)) - Renamed `set_events` function to `add_events_from` to better reflect its purpose. ([#4421](https://github.com/pybamm-team/PyBaMM/pull/4421)) # [v24.9.0](https://github.com/pybamm-team/PyBaMM/tree/v24.9.0) - 2024-09-03 diff --git a/docs/source/examples/notebooks/parameterization/parameterization.ipynb b/docs/source/examples/notebooks/parameterization/parameterization.ipynb index dabb5e5f76..a6ff34c772 100644 --- a/docs/source/examples/notebooks/parameterization/parameterization.ipynb +++ b/docs/source/examples/notebooks/parameterization/parameterization.ipynb @@ -586,7 +586,7 @@ "outputs": [ { "data": { - "text/plain": "{'Ideal gas constant [J.K-1.mol-1]': 8.314462618,\n 'Faraday constant [C.mol-1]': 96485.33212,\n 'Negative electrode thickness [m]': 0.0001,\n 'Separator thickness [m]': 2.5e-05,\n 'Positive electrode thickness [m]': 0.0001,\n 'Electrode height [m]': 0.137,\n 'Electrode width [m]': 0.207,\n 'Nominal cell capacity [A.h]': 0.680616,\n 'Current function [A]': 0.680616,\n 'Maximum concentration in negative electrode [mol.m-3]': 24983.2619938437,\n 'Negative particle diffusivity [m2.s-1]': ,\n 'Negative electrode OCP [V]': ,\n 'Negative electrode porosity': 0.3,\n 'Negative electrode active material volume fraction': 0.6,\n 'Negative particle radius [m]': 1e-05,\n 'Negative electrode Bruggeman coefficient (electrolyte)': 1.5,\n 'Negative electrode Bruggeman coefficient (electrode)': 1.5,\n 'Negative electrode exchange-current density [A.m-2]': ,\n 'Negative electrode OCP entropic change [V.K-1]': ,\n 'Maximum concentration in positive electrode [mol.m-3]': 51217.9257309275,\n 'Positive particle diffusivity [m2.s-1]': ,\n 'Positive electrode OCP [V]': ,\n 'Positive electrode porosity': 0.3,\n 'Positive electrode active material volume fraction': 0.5,\n 'Positive particle radius [m]': 1e-05,\n 'Positive electrode Bruggeman coefficient (electrolyte)': 1.5,\n 'Positive electrode Bruggeman coefficient (electrode)': 1.5,\n 'Positive electrode exchange-current density [A.m-2]': ,\n 'Positive electrode OCP entropic change [V.K-1]': ,\n 'Separator porosity': 1.0,\n 'Separator Bruggeman coefficient (electrolyte)': 1.5,\n 'Initial concentration in electrolyte [mol.m-3]': 1000.0,\n 'Reference temperature [K]': 298.15,\n 'Ambient temperature [K]': 298.15,\n 'Number of electrodes connected in parallel to make a cell': 1.0,\n 'Number of cells connected in series to make a battery': 1.0,\n 'Lower voltage cut-off [V]': 3.105,\n 'Upper voltage cut-off [V]': 4.1,\n 'Initial concentration in negative electrode [mol.m-3]': 19986.609595075,\n 'Initial concentration in positive electrode [mol.m-3]': 30730.7554385565}" + "text/plain": "{'Ideal gas constant [J.K-1.mol-1]': 8.314462618,\n 'Faraday constant [C.mol-1]': 96485.33212,\n 'Negative electrode thickness [m]': 0.0001,\n 'Separator thickness [m]': 2.5e-05,\n 'Positive electrode thickness [m]': 0.0001,\n 'Electrode height [m]': 0.137,\n 'Electrode width [m]': 0.207,\n 'Nominal cell capacity [A.h]': 0.680616,\n 'Current function [A]': 0.680616,\n 'Maximum concentration in negative electrode [mol.m-3]': 24983.2619938437,\n 'Negative particle diffusivity [m2.s-1]': ,\n 'Negative electrode OCP [V]': ,\n 'Negative electrode porosity': 0.3,\n 'Negative electrode active material volume fraction': 0.6,\n 'Negative particle radius [m]': 1e-05,\n 'Negative electrode Bruggeman coefficient (electrolyte)': 1.5,\n 'Negative electrode Bruggeman coefficient (electrode)': 1.5,\n 'Negative electrode exchange-current density [A.m-2]': ,\n 'Negative electrode OCP entropic change [V.K-1]': ,\n 'Maximum concentration in positive electrode [mol.m-3]': 51217.9257309275,\n 'Positive particle diffusivity [m2.s-1]': ,\n 'Positive electrode OCP [V]': ,\n 'Positive electrode porosity': 0.3,\n 'Positive electrode active material volume fraction': 0.5,\n 'Positive particle radius [m]': 1e-05,\n 'Positive electrode Bruggeman coefficient (electrolyte)': 1.5,\n 'Positive electrode Bruggeman coefficient (electrode)': 1.5,\n 'Positive electrode exchange-current density [A.m-2]': ,\n 'Positive electrode OCP entropic change [V.K-1]': ,\n 'Separator porosity': 1.0,\n 'Separator Bruggeman coefficient (electrolyte)': 1.5,\n 'Initial concentration in electrolyte [mol.m-3]': 1000.0,\n 'Reference temperature [K]': 298.15,\n 'Ambient temperature [K]': 298.15,\n 'Number of electrodes connected in parallel to make a cell': 1.0,\n 'Number of cells connected in series to make a battery': 1.0,\n 'Lower voltage cut-off [V]': 3.105,\n 'Upper voltage cut-off [V]': 4.1,\n 'Initial concentration in negative electrode [mol.m-3]': 19986.609595075,\n 'Initial concentration in positive electrode [mol.m-3]': 30730.7554385565}" }, "execution_count": 61, "metadata": {}, diff --git a/src/pybamm/input/parameters/lithium_ion/Ai2020.py b/src/pybamm/input/parameters/lithium_ion/Ai2020.py index b45c04fa7f..f578d59fa5 100644 --- a/src/pybamm/input/parameters/lithium_ion/Ai2020.py +++ b/src/pybamm/input/parameters/lithium_ion/Ai2020.py @@ -5,7 +5,7 @@ def graphite_diffusivity_Dualfoil1998(sto, T): """ - Graphite diffusivity as a function of stochiometry [1, 2, 3]. + Graphite diffusivity as a function of stoichiometry [1, 2, 3]. References ---------- @@ -20,7 +20,7 @@ def graphite_diffusivity_Dualfoil1998(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature, [K] @@ -72,10 +72,10 @@ def graphite_electrolyte_exchange_current_density_Dualfoil1998( return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def graphite_entropy_Enertech_Ai2020_function(sto, c_s_max): +def graphite_entropy_Enertech_Ai2020_function(sto): """ Lithium Cobalt Oxide (LiCO2) entropic change in open-circuit potential (OCP) at - a temperature of 298.15K as a function of the stochiometry. The fit is taken + a temperature of 298.15K as a function of the stoichiometry. The fit is taken from Ref [1], which is only accurate for 0.43 < sto < 0.9936. @@ -89,7 +89,7 @@ def graphite_entropy_Enertech_Ai2020_function(sto, c_s_max): Parameters ---------- sto: double - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) Returns ------- @@ -126,9 +126,9 @@ def graphite_entropy_Enertech_Ai2020_function(sto, c_s_max): return du_dT -def graphite_volume_change_Ai2020(sto, c_s_max): +def graphite_volume_change_Ai2020(sto): """ - Graphite particle volume change as a function of stochiometry [1, 2]. + Graphite particle volume change as a function of stoichiometry [1, 2]. References ---------- @@ -143,7 +143,7 @@ def graphite_volume_change_Ai2020(sto, c_s_max): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry, dimensionless + Electrode stoichiometry, dimensionless should be R-averaged particle concentration c_s_max : :class:`pybamm.Symbol` Maximum particle concentration [mol.m-3] @@ -214,7 +214,7 @@ def graphite_cracking_rate_Ai2020(T_dim): def lico2_diffusivity_Dualfoil1998(sto, T): """ - LiCo2 diffusivity as a function of stochiometry, in this case the + LiCo2 diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from Dualfoil [1]. References @@ -224,7 +224,7 @@ def lico2_diffusivity_Dualfoil1998(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature, [K] @@ -273,10 +273,10 @@ def lico2_electrolyte_exchange_current_density_Dualfoil1998(c_e, c_s_surf, c_s_m return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def lico2_entropic_change_Ai2020_function(sto, c_s_max): +def lico2_entropic_change_Ai2020_function(sto): """ Lithium Cobalt Oxide (LiCO2) entropic change in open-circuit potential (OCP) at - a temperature of 298.15K as a function of the stochiometry. The fit is taken + a temperature of 298.15K as a function of the stoichiometry. The fit is taken from Ref [1], which is only accurate for 0.43 < sto < 0.9936. @@ -290,7 +290,7 @@ def lico2_entropic_change_Ai2020_function(sto, c_s_max): Parameters ---------- sto: double - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) Returns ------- @@ -323,9 +323,9 @@ def lico2_entropic_change_Ai2020_function(sto, c_s_max): return du_dT -def lico2_volume_change_Ai2020(sto, c_s_max): +def lico2_volume_change_Ai2020(sto): """ - lico2 particle volume change as a function of stochiometry [1, 2]. + lico2 particle volume change as a function of stoichiometry [1, 2]. References ---------- @@ -340,10 +340,8 @@ def lico2_volume_change_Ai2020(sto, c_s_max): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry, dimensionless + Electrode stoichiometry, dimensionless should be R-averaged particle concentration - c_s_max : :class:`pybamm.Symbol` - Maximum particle concentration [mol.m-3] Returns ------- @@ -351,6 +349,7 @@ def lico2_volume_change_Ai2020(sto, c_s_max): volume change, dimensionless, normalised by particle volume """ omega = pybamm.Parameter("Positive electrode partial molar volume [m3.mol-1]") + c_s_max = pybamm.Parameter("Maximum concentration in positive electrode [mol.m-3]") t_change = omega * c_s_max * sto return t_change diff --git a/src/pybamm/input/parameters/lithium_ion/Chen2020.py b/src/pybamm/input/parameters/lithium_ion/Chen2020.py index 5a7460871b..b3655513a1 100644 --- a/src/pybamm/input/parameters/lithium_ion/Chen2020.py +++ b/src/pybamm/input/parameters/lithium_ion/Chen2020.py @@ -4,7 +4,7 @@ def graphite_LGM50_ocp_Chen2020(sto): """ - LG M50 Graphite open-circuit potential as a function of stochiometry, fit taken + LG M50 Graphite open-circuit potential as a function of stoichiometry, fit taken from [1]. References @@ -17,7 +17,7 @@ def graphite_LGM50_ocp_Chen2020(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -75,7 +75,7 @@ def graphite_LGM50_electrolyte_exchange_current_density_Chen2020( def nmc_LGM50_ocp_Chen2020(sto): """ - LG M50 NMC open-circuit potential as a function of stochiometry, fit taken + LG M50 NMC open-circuit potential as a function of stoichiometry, fit taken from [1]. References @@ -88,7 +88,7 @@ def nmc_LGM50_ocp_Chen2020(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- diff --git a/src/pybamm/input/parameters/lithium_ion/Chen2020_composite.py b/src/pybamm/input/parameters/lithium_ion/Chen2020_composite.py index 58b6211072..69b622a7c5 100644 --- a/src/pybamm/input/parameters/lithium_ion/Chen2020_composite.py +++ b/src/pybamm/input/parameters/lithium_ion/Chen2020_composite.py @@ -43,7 +43,7 @@ def graphite_LGM50_electrolyte_exchange_current_density_Chen2020( def silicon_ocp_lithiation_Mark2016(sto): """ silicon Open-circuit Potential (OCP) as a a function of the - stochiometry. The fit is taken from the Enertech cell [1], which is only accurate + stoichiometry. The fit is taken from the Enertech cell [1], which is only accurate for 0 < sto < 1. References @@ -55,7 +55,7 @@ def silicon_ocp_lithiation_Mark2016(sto): Parameters ---------- sto: double - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) Returns ------- @@ -87,7 +87,7 @@ def silicon_ocp_lithiation_Mark2016(sto): def silicon_ocp_delithiation_Mark2016(sto): """ silicon Open-circuit Potential (OCP) as a a function of the - stochiometry. The fit is taken from the Enertech cell [1], which is only accurate + stoichiometry. The fit is taken from the Enertech cell [1], which is only accurate for 0 < sto < 1. References @@ -99,7 +99,7 @@ def silicon_ocp_delithiation_Mark2016(sto): Parameters ---------- sto: double - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) Returns ------- @@ -170,7 +170,7 @@ def silicon_LGM50_electrolyte_exchange_current_density_Chen2020( def nmc_LGM50_ocp_Chen2020(sto): """ - LG M50 NMC open-circuit potential as a function of stochiometry, fit taken + LG M50 NMC open-circuit potential as a function of stoichiometry, fit taken from [1]. References @@ -183,7 +183,7 @@ def nmc_LGM50_ocp_Chen2020(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- diff --git a/src/pybamm/input/parameters/lithium_ion/Ecker2015.py b/src/pybamm/input/parameters/lithium_ion/Ecker2015.py index 32cc631293..05fbbb2fd7 100644 --- a/src/pybamm/input/parameters/lithium_ion/Ecker2015.py +++ b/src/pybamm/input/parameters/lithium_ion/Ecker2015.py @@ -4,7 +4,7 @@ def graphite_diffusivity_Ecker2015(sto, T): """ - Graphite diffusivity as a function of stochiometry [1, 2, 3]. + Graphite diffusivity as a function of stoichiometry [1, 2, 3]. References ---------- @@ -21,7 +21,7 @@ def graphite_diffusivity_Ecker2015(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -42,7 +42,7 @@ def graphite_diffusivity_Ecker2015(sto, T): def graphite_ocp_Ecker2015(sto): """ - Graphite OCP as a function of stochiometry [1, 2, 3]. + Graphite OCP as a function of stoichiometry [1, 2, 3]. References ---------- @@ -59,7 +59,7 @@ def graphite_ocp_Ecker2015(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -152,7 +152,7 @@ def graphite_electrolyte_exchange_current_density_Ecker2015(c_e, c_s_surf, c_s_m def nco_diffusivity_Ecker2015(sto, T): """ - NCO diffusivity as a function of stochiometry [1, 2, 3]. + NCO diffusivity as a function of stoichiometry [1, 2, 3]. References ---------- @@ -169,7 +169,7 @@ def nco_diffusivity_Ecker2015(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -190,7 +190,7 @@ def nco_diffusivity_Ecker2015(sto, T): def nco_ocp_Ecker2015(sto): """ - NCO OCP as a function of stochiometry [1, 2, 3]. + NCO OCP as a function of stoichiometry [1, 2, 3]. References ---------- @@ -207,7 +207,7 @@ def nco_ocp_Ecker2015(sto): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ diff --git a/src/pybamm/input/parameters/lithium_ion/Ecker2015_graphite_halfcell.py b/src/pybamm/input/parameters/lithium_ion/Ecker2015_graphite_halfcell.py index 365bb6386c..267f55e774 100644 --- a/src/pybamm/input/parameters/lithium_ion/Ecker2015_graphite_halfcell.py +++ b/src/pybamm/input/parameters/lithium_ion/Ecker2015_graphite_halfcell.py @@ -34,7 +34,7 @@ def li_metal_electrolyte_exchange_current_density_Xu2019(c_e, c_Li, T): def graphite_diffusivity_Ecker2015(sto, T): """ - Graphite diffusivity as a function of stochiometry [1, 2, 3]. + Graphite diffusivity as a function of stoichiometry [1, 2, 3]. References ---------- @@ -51,7 +51,7 @@ def graphite_diffusivity_Ecker2015(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -72,7 +72,7 @@ def graphite_diffusivity_Ecker2015(sto, T): def graphite_ocp_Ecker2015(sto): """ - Graphite OCP as a function of stochiometry [1, 2, 3]. + Graphite OCP as a function of stoichiometry [1, 2, 3]. References ---------- @@ -89,7 +89,7 @@ def graphite_ocp_Ecker2015(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- diff --git a/src/pybamm/input/parameters/lithium_ion/Marquis2019.py b/src/pybamm/input/parameters/lithium_ion/Marquis2019.py index b1f63e6ff7..16591eac2d 100644 --- a/src/pybamm/input/parameters/lithium_ion/Marquis2019.py +++ b/src/pybamm/input/parameters/lithium_ion/Marquis2019.py @@ -4,7 +4,7 @@ def graphite_mcmb2528_diffusivity_Dualfoil1998(sto, T): """ - Graphite MCMB 2528 diffusivity as a function of stochiometry, in this case the + Graphite MCMB 2528 diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from Dualfoil [1]. References @@ -14,7 +14,7 @@ def graphite_mcmb2528_diffusivity_Dualfoil1998(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -34,7 +34,7 @@ def graphite_mcmb2528_diffusivity_Dualfoil1998(sto, T): def graphite_mcmb2528_ocp_Dualfoil1998(sto): """ Graphite MCMB 2528 Open-circuit Potential (OCP) as a function of the - stochiometry. The fit is taken from Dualfoil [1]. Dualfoil states that the data + stoichiometry. The fit is taken from Dualfoil [1]. Dualfoil states that the data was measured by Chris Bogatu at Telcordia and PolyStor materials, 2000. However, we could not find any other records of this measurment. @@ -93,10 +93,10 @@ def graphite_electrolyte_exchange_current_density_Dualfoil1998( return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def graphite_entropic_change_Moura2016(sto, c_s_max): +def graphite_entropic_change_Moura2016(sto): """ Graphite entropic change in open-circuit potential (OCP) at a temperature of - 298.15K as a function of the stochiometry taken from Scott Moura's FastDFN code + 298.15K as a function of the stoichiometry taken from Scott Moura's FastDFN code [1]. References @@ -106,9 +106,12 @@ def graphite_entropic_change_Moura2016(sto, c_s_max): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ + # Original parametrization was expressed in terms of c_s_max, but we want to + # express it in terms of stoichiometry only + c_s_max = 24983.2619938437 du_dT = ( -1.5 * (120.0 / c_s_max) * np.exp(-120 * sto) + (0.0351 / (0.083 * c_s_max)) * ((np.cosh((sto - 0.286) / 0.083)) ** (-2)) @@ -126,7 +129,7 @@ def graphite_entropic_change_Moura2016(sto, c_s_max): def lico2_diffusivity_Dualfoil1998(sto, T): """ - LiCo2 diffusivity as a function of stochiometry, in this case the + LiCo2 diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from Dualfoil [1]. References @@ -136,7 +139,7 @@ def lico2_diffusivity_Dualfoil1998(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -155,7 +158,7 @@ def lico2_diffusivity_Dualfoil1998(sto, T): def lico2_ocp_Dualfoil1998(sto): """ Lithium Cobalt Oxide (LiCO2) Open-circuit Potential (OCP) as a a function of the - stochiometry. The fit is taken from Dualfoil [1]. Dualfoil states that the data + stoichiometry. The fit is taken from Dualfoil [1]. Dualfoil states that the data was measured by Oscar Garcia 2001 using Quallion electrodes for 0.5 < sto < 0.99 and by Marc Doyle for sto<0.4 (for unstated electrodes). We could not find any other records of the Garcia measurements. Doyles fits can be found in his @@ -170,7 +173,7 @@ def lico2_ocp_Dualfoil1998(sto): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ @@ -222,10 +225,10 @@ def lico2_electrolyte_exchange_current_density_Dualfoil1998(c_e, c_s_surf, c_s_m return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def lico2_entropic_change_Moura2016(sto, c_s_max): +def lico2_entropic_change_Moura2016(sto): """ Lithium Cobalt Oxide (LiCO2) entropic change in open-circuit potential (OCP) at - a temperature of 298.15K as a function of the stochiometry. The fit is taken + a temperature of 298.15K as a function of the stoichiometry. The fit is taken from Scott Moura's FastDFN code [1]. References @@ -235,13 +238,15 @@ def lico2_entropic_change_Moura2016(sto, c_s_max): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ # Since the equation for LiCo2 from this ref. has the stretch factor, # should this too? If not, the "bumps" in the OCV don't line up. stretch = 1.062 sto = stretch * sto - + # Original parametrization was expressed in terms of c_s_max, but we want to + # express it in terms of stoichiometry only + c_s_max = 51217.9257309275 du_dT = ( 0.07645 * (-54.4806 / c_s_max) * ((1.0 / np.cosh(30.834 - 54.4806 * sto)) ** 2) + 2.1581 * (-50.294 / c_s_max) * ((np.cosh(52.294 - 50.294 * sto)) ** (-2)) diff --git a/src/pybamm/input/parameters/lithium_ion/Mohtat2020.py b/src/pybamm/input/parameters/lithium_ion/Mohtat2020.py index 9923d9d308..0176c3f6a0 100644 --- a/src/pybamm/input/parameters/lithium_ion/Mohtat2020.py +++ b/src/pybamm/input/parameters/lithium_ion/Mohtat2020.py @@ -4,7 +4,7 @@ def graphite_diffusivity_PeymanMPM(sto, T): """ - Graphite diffusivity as a function of stochiometry, in this case the + Graphite diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from Peyman MPM. References @@ -14,7 +14,7 @@ def graphite_diffusivity_PeymanMPM(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -34,7 +34,7 @@ def graphite_diffusivity_PeymanMPM(sto, T): def graphite_ocp_PeymanMPM(sto): """ Graphite Open-circuit Potential (OCP) as a function of the - stochiometry. The fit is taken from Peyman MPM [1]. + stoichiometry. The fit is taken from Peyman MPM [1]. References ---------- @@ -89,10 +89,10 @@ def graphite_electrolyte_exchange_current_density_PeymanMPM(c_e, c_s_surf, c_s_m return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def graphite_entropic_change_PeymanMPM(sto, c_s_max): +def graphite_entropic_change_PeymanMPM(sto): """ Graphite entropic change in open-circuit potential (OCP) at a temperature of - 298.15K as a function of the stochiometry taken from [1] + 298.15K as a function of the stoichiometry taken from [1] References ---------- @@ -102,7 +102,7 @@ def graphite_entropic_change_PeymanMPM(sto, c_s_max): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ @@ -121,7 +121,7 @@ def graphite_entropic_change_PeymanMPM(sto, c_s_max): def NMC_diffusivity_PeymanMPM(sto, T): """ - NMC diffusivity as a function of stochiometry, in this case the + NMC diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from Peyman MPM. References @@ -131,7 +131,7 @@ def NMC_diffusivity_PeymanMPM(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -151,7 +151,7 @@ def NMC_diffusivity_PeymanMPM(sto, T): def NMC_ocp_PeymanMPM(sto): """ Nickel Managanese Cobalt Oxide (NMC) Open-circuit Potential (OCP) as a - function of the stochiometry. The fit is taken from Peyman MPM. + function of the stoichiometry. The fit is taken from Peyman MPM. References ---------- @@ -160,7 +160,7 @@ def NMC_ocp_PeymanMPM(sto): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ @@ -209,7 +209,7 @@ def NMC_electrolyte_exchange_current_density_PeymanMPM(c_e, c_s_surf, c_s_max, T return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def NMC_entropic_change_PeymanMPM(sto, c_s_max): +def NMC_entropic_change_PeymanMPM(sto): """ Nickel Manganese Cobalt (NMC) entropic change in open-circuit potential (OCP) at a temperature of 298.15K as a function of the OCP. The fit is taken from [1]. @@ -224,7 +224,7 @@ def NMC_entropic_change_PeymanMPM(sto, c_s_max): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ diff --git a/src/pybamm/input/parameters/lithium_ion/NCA_Kim2011.py b/src/pybamm/input/parameters/lithium_ion/NCA_Kim2011.py index 7d0478b6d0..1af610f58a 100644 --- a/src/pybamm/input/parameters/lithium_ion/NCA_Kim2011.py +++ b/src/pybamm/input/parameters/lithium_ion/NCA_Kim2011.py @@ -16,7 +16,7 @@ def graphite_diffusivity_Kim2011(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -35,7 +35,7 @@ def graphite_diffusivity_Kim2011(sto, T): def graphite_ocp_Kim2011(sto): """ - Graphite Open-circuit Potential (OCP) as a function of the stochiometry [1]. + Graphite Open-circuit Potential (OCP) as a function of the stoichiometry [1]. References ---------- @@ -92,7 +92,7 @@ def graphite_electrolyte_exchange_current_density_Kim2011(c_e, c_s_surf, c_s_max """ i0_ref = 36 # reference exchange current density at 100% SOC - sto = 0.36 # stochiometry at 100% SOC + sto = 0.36 # stoichiometry at 100% SOC c_s_n_ref = sto * c_s_max # reference electrode concentration c_e_ref = pybamm.Parameter("Initial concentration in electrolyte [mol.m-3]") alpha = 0.5 # charge transfer coefficient @@ -111,7 +111,7 @@ def graphite_electrolyte_exchange_current_density_Kim2011(c_e, c_s_surf, c_s_max def nca_diffusivity_Kim2011(sto, T): """ - NCA diffusivity as a function of stochiometry [1]. + NCA diffusivity as a function of stoichiometry [1]. References ---------- @@ -123,7 +123,7 @@ def nca_diffusivity_Kim2011(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -168,7 +168,7 @@ def nca_electrolyte_exchange_current_density_Kim2011(c_e, c_s_surf, c_s_max, T): Exchange-current density [A.m-2] """ i0_ref = 4 # reference exchange current density at 100% SOC - sto = 0.41 # stochiometry at 100% SOC + sto = 0.41 # stoichiometry at 100% SOC c_s_ref = sto * c_s_max # reference electrode concentration c_e_ref = pybamm.Parameter("Initial concentration in electrolyte [mol.m-3]") alpha = 0.5 # charge transfer coefficient @@ -252,7 +252,7 @@ def electrolyte_conductivity_Kim2011(c_e, T): def nca_ocp_Kim2011(sto): """ - Graphite Open Circuit Potential (OCP) as a function of the stochiometry [1]. + Graphite Open Circuit Potential (OCP) as a function of the stoichiometry [1]. References ---------- diff --git a/src/pybamm/input/parameters/lithium_ion/OKane2022.py b/src/pybamm/input/parameters/lithium_ion/OKane2022.py index b1e852dbdf..4ccb72bf62 100644 --- a/src/pybamm/input/parameters/lithium_ion/OKane2022.py +++ b/src/pybamm/input/parameters/lithium_ion/OKane2022.py @@ -96,7 +96,7 @@ def SEI_limited_dead_lithium_OKane2022(L_sei): def graphite_LGM50_diffusivity_Chen2020(sto, T): """ - LG M50 Graphite diffusivity as a function of stochiometry, in this case the + LG M50 Graphite diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from [1]. References @@ -109,7 +109,7 @@ def graphite_LGM50_diffusivity_Chen2020(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -165,9 +165,9 @@ def graphite_LGM50_electrolyte_exchange_current_density_Chen2020( return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def graphite_volume_change_Ai2020(sto, c_s_max): +def graphite_volume_change_Ai2020(sto): """ - Graphite particle volume change as a function of stochiometry [1, 2]. + Graphite particle volume change as a function of stoichiometry [1, 2]. References ---------- @@ -182,7 +182,7 @@ def graphite_volume_change_Ai2020(sto, c_s_max): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry, dimensionless + Electrode stoichiometry, dimensionless should be R-averaged particle concentration Returns ------- @@ -260,7 +260,7 @@ def nmc_LGM50_diffusivity_Chen2020(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -279,7 +279,7 @@ def nmc_LGM50_diffusivity_Chen2020(sto, T): def nmc_LGM50_ocp_Chen2020(sto): """ - LG M50 NMC open-circuit potential as a function of stochiometry, fit taken + LG M50 NMC open-circuit potential as a function of stoichiometry, fit taken from [1]. References @@ -292,7 +292,7 @@ def nmc_LGM50_ocp_Chen2020(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -344,9 +344,9 @@ def nmc_LGM50_electrolyte_exchange_current_density_Chen2020(c_e, c_s_surf, c_s_m return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def volume_change_Ai2020(sto, c_s_max): +def volume_change_Ai2020(sto): """ - Particle volume change as a function of stochiometry [1, 2]. + Particle volume change as a function of stoichiometry [1, 2]. References ---------- @@ -361,7 +361,7 @@ def volume_change_Ai2020(sto, c_s_max): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry, dimensionless + Electrode stoichiometry, dimensionless should be R-averaged particle concentration Returns ------- @@ -369,6 +369,7 @@ def volume_change_Ai2020(sto, c_s_max): volume change, dimensionless, normalised by particle volume """ omega = pybamm.Parameter("Positive electrode partial molar volume [m3.mol-1]") + c_s_max = pybamm.Parameter("Maximum concentration in positive electrode [mol.m-3]") t_change = omega * c_s_max * sto return t_change diff --git a/src/pybamm/input/parameters/lithium_ion/OKane2022_graphite_SiOx_halfcell.py b/src/pybamm/input/parameters/lithium_ion/OKane2022_graphite_SiOx_halfcell.py index 35533ba80e..c343dd23f4 100644 --- a/src/pybamm/input/parameters/lithium_ion/OKane2022_graphite_SiOx_halfcell.py +++ b/src/pybamm/input/parameters/lithium_ion/OKane2022_graphite_SiOx_halfcell.py @@ -126,7 +126,7 @@ def SEI_limited_dead_lithium_OKane2022(L_sei): def graphite_LGM50_diffusivity_Chen2020(sto, T): """ - LG M50 Graphite diffusivity as a function of stochiometry, in this case the + LG M50 Graphite diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from [1]. References @@ -139,7 +139,7 @@ def graphite_LGM50_diffusivity_Chen2020(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -195,9 +195,9 @@ def graphite_LGM50_electrolyte_exchange_current_density_Chen2020( return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def graphite_volume_change_Ai2020(sto, c_s_max): +def graphite_volume_change_Ai2020(sto): """ - Graphite particle volume change as a function of stochiometry [1, 2]. + Graphite particle volume change as a function of stoichiometry [1, 2]. References ---------- @@ -212,7 +212,7 @@ def graphite_volume_change_Ai2020(sto, c_s_max): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry, dimensionless + Electrode stoichiometry, dimensionless should be R-averaged particle concentration Returns ------- diff --git a/src/pybamm/input/parameters/lithium_ion/ORegan2022.py b/src/pybamm/input/parameters/lithium_ion/ORegan2022.py index 3ca5f6824c..45c8cdbcbd 100644 --- a/src/pybamm/input/parameters/lithium_ion/ORegan2022.py +++ b/src/pybamm/input/parameters/lithium_ion/ORegan2022.py @@ -233,7 +233,7 @@ def copper_thermal_conductivity_CRC(T): def graphite_LGM50_diffusivity_ORegan2022(sto, T): """ - LG M50 Graphite diffusivity as a function of stochiometry, in this case the + LG M50 Graphite diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from [1]. References @@ -245,7 +245,7 @@ def graphite_LGM50_diffusivity_ORegan2022(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -292,7 +292,7 @@ def graphite_LGM50_diffusivity_ORegan2022(sto, T): def graphite_LGM50_ocp_Chen2020(sto): """ - LG M50 Graphite open-circuit potential as a function of stochiometry, fit taken + LG M50 Graphite open-circuit potential as a function of stoichiometry, fit taken from [1]. References @@ -305,7 +305,7 @@ def graphite_LGM50_ocp_Chen2020(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -439,10 +439,10 @@ def graphite_LGM50_thermal_conductivity_ORegan2022(T): return lambda_wet -def graphite_LGM50_entropic_change_ORegan2022(sto, c_s_max): +def graphite_LGM50_entropic_change_ORegan2022(sto): """ LG M50 Graphite entropic change in open-circuit potential (OCP) at a temperature of - 298.15K as a function of the stochiometry. The fit is taken from [1]. + 298.15K as a function of the stoichiometry. The fit is taken from [1]. References ---------- @@ -453,7 +453,7 @@ def graphite_LGM50_entropic_change_ORegan2022(sto, c_s_max): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -525,7 +525,7 @@ def nmc_LGM50_diffusivity_ORegan2022(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -579,7 +579,7 @@ def nmc_LGM50_ocp_Chen2020(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -712,10 +712,10 @@ def nmc_LGM50_thermal_conductivity_ORegan2022(T): return lambda_wet -def nmc_LGM50_entropic_change_ORegan2022(sto, c_s_max): +def nmc_LGM50_entropic_change_ORegan2022(sto): """ LG M50 NMC 811 entropic change in open-circuit potential (OCP) at a temperature of - 298.15K as a function of the stochiometry. The fit is taken from [1]. + 298.15K as a function of the stoichiometry. The fit is taken from [1]. References ---------- @@ -726,7 +726,7 @@ def nmc_LGM50_entropic_change_ORegan2022(sto, c_s_max): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- diff --git a/src/pybamm/input/parameters/lithium_ion/Prada2013.py b/src/pybamm/input/parameters/lithium_ion/Prada2013.py index 0ba56516ab..f27ba23bdd 100644 --- a/src/pybamm/input/parameters/lithium_ion/Prada2013.py +++ b/src/pybamm/input/parameters/lithium_ion/Prada2013.py @@ -4,7 +4,7 @@ def graphite_LGM50_ocp_Chen2020(sto): """ - LG M50 Graphite open-circuit potential as a function of stochiometry, fit taken + LG M50 Graphite open-circuit potential as a function of stoichiometry, fit taken from [1]. Prada2013 doesn't give an OCP for graphite, so we use this instead. References @@ -17,7 +17,7 @@ def graphite_LGM50_ocp_Chen2020(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -86,7 +86,7 @@ def LFP_ocp_Afshar2017(sto): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ diff --git a/src/pybamm/input/parameters/lithium_ion/Ramadass2004.py b/src/pybamm/input/parameters/lithium_ion/Ramadass2004.py index 879a5f55c6..a1e24da7e3 100644 --- a/src/pybamm/input/parameters/lithium_ion/Ramadass2004.py +++ b/src/pybamm/input/parameters/lithium_ion/Ramadass2004.py @@ -4,7 +4,7 @@ def graphite_mcmb2528_diffusivity_Dualfoil1998(sto, T): """ - Graphite MCMB 2528 diffusivity as a function of stochiometry, in this case the + Graphite MCMB 2528 diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from Dualfoil [1]. References @@ -14,7 +14,7 @@ def graphite_mcmb2528_diffusivity_Dualfoil1998(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -34,7 +34,7 @@ def graphite_mcmb2528_diffusivity_Dualfoil1998(sto, T): def graphite_ocp_Ramadass2004(sto): """ Graphite Open-circuit Potential (OCP) as a function of the - stochiometry (theta?). The fit is taken from Ramadass 2004. + stoichiometry (theta?). The fit is taken from Ramadass 2004. References ---------- @@ -92,10 +92,10 @@ def graphite_electrolyte_exchange_current_density_Ramadass2004( return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def graphite_entropic_change_Moura2016(sto, c_s_max): +def graphite_entropic_change_Moura2016(sto): """ Graphite entropic change in open-circuit potential (OCP) at a temperature of - 298.15K as a function of the stochiometry taken from Scott Moura's FastDFN code + 298.15K as a function of the stoichiometry taken from Scott Moura's FastDFN code [1]. References @@ -105,9 +105,12 @@ def graphite_entropic_change_Moura2016(sto, c_s_max): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ + # Original parametrization was expressed in terms of c_s_max, but we want to + # express it in terms of stoichiometry only + c_s_max = 24983.2619938437 du_dT = ( -1.5 * (120.0 / c_s_max) * np.exp(-120 * sto) + (0.0351 / (0.083 * c_s_max)) * ((np.cosh((sto - 0.286) / 0.083)) ** (-2)) @@ -125,7 +128,7 @@ def graphite_entropic_change_Moura2016(sto, c_s_max): def lico2_diffusivity_Ramadass2004(sto, T): """ - LiCo2 diffusivity as a function of stochiometry, in this case the + LiCo2 diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from Ramadass 2004. References @@ -137,7 +140,7 @@ def lico2_diffusivity_Ramadass2004(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -156,7 +159,7 @@ def lico2_diffusivity_Ramadass2004(sto, T): def lico2_ocp_Ramadass2004(sto): """ Lithium Cobalt Oxide (LiCO2) Open-circuit Potential (OCP) as a a function of the - stochiometry. The fit is taken from Ramadass 2004. Stretch is considered the + stoichiometry. The fit is taken from Ramadass 2004. Stretch is considered the overhang area negative electrode / area positive electrode, in Ramadass 2002. References @@ -168,7 +171,7 @@ def lico2_ocp_Ramadass2004(sto): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ @@ -228,10 +231,10 @@ def lico2_electrolyte_exchange_current_density_Ramadass2004(c_e, c_s_surf, c_s_m return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def lico2_entropic_change_Moura2016(sto, c_s_max): +def lico2_entropic_change_Moura2016(sto): """ Lithium Cobalt Oxide (LiCO2) entropic change in open-circuit potential (OCP) at - a temperature of 298.15K as a function of the stochiometry. The fit is taken + a temperature of 298.15K as a function of the stoichiometry. The fit is taken from Scott Moura's FastDFN code [1]. References @@ -241,13 +244,15 @@ def lico2_entropic_change_Moura2016(sto, c_s_max): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ # Since the equation for LiCo2 from this ref. has the stretch factor, # should this too? If not, the "bumps" in the OCV don't line up. stretch = 1.062 sto = stretch * sto - + # Original parametrization was expressed in terms of c_s_max, but we want to + # express it in terms of stoichiometry only + c_s_max = 51217.9257309275 du_dT = ( 0.07645 * (-54.4806 / c_s_max) * ((1.0 / np.cosh(30.834 - 54.4806 * sto)) ** 2) + 2.1581 * (-50.294 / c_s_max) * ((np.cosh(52.294 - 50.294 * sto)) ** (-2)) diff --git a/src/pybamm/input/parameters/lithium_ion/Xu2019.py b/src/pybamm/input/parameters/lithium_ion/Xu2019.py index edf3bd40b0..caee487339 100644 --- a/src/pybamm/input/parameters/lithium_ion/Xu2019.py +++ b/src/pybamm/input/parameters/lithium_ion/Xu2019.py @@ -36,7 +36,7 @@ def li_metal_electrolyte_exchange_current_density_Xu2019(c_e, c_Li, T): def nmc_ocp_Xu2019(sto): """ Nickel Managanese Cobalt Oxide (NMC) Open-circuit Potential (OCP) as a - function of the stochiometry, from [1]. + function of the stoichiometry, from [1]. References ---------- @@ -48,7 +48,7 @@ def nmc_ocp_Xu2019(sto): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ diff --git a/src/pybamm/parameters/bpx.py b/src/pybamm/parameters/bpx.py index 7485e805b9..df380ad627 100644 --- a/src/pybamm/parameters/bpx.py +++ b/src/pybamm/parameters/bpx.py @@ -228,12 +228,6 @@ def _bpx_to_param_dict(bpx: BPX) -> dict: def _arrhenius(Ea, T): return exp(Ea / constants.R * (1 / T_ref - 1 / T)) - def _entropic_change(sto, c_s_max, dUdT, constant=False): - if constant: - return dUdT - else: - return dUdT(sto) - # reaction rates in pybamm exchange current is defined j0 = k * sqrt(ce * cs * # (cs-cs_max)) in BPX exchange current is defined j0 = F * k_norm * sqrt((ce/ce0) * # (cs/cs_max) * (1-cs/cs_max)) @@ -284,25 +278,10 @@ def _conductivity(c_e, T, Ea, sigma_ref, constant=False): ) # entropic change - dUdT = pybamm_dict[ - phase_domain_pre_name + "entropic change coefficient [V.K-1]" - ] - if callable(dUdT): + dUdT = pybamm_dict[phase_domain_pre_name + "OCP entropic change [V.K-1]"] + if isinstance(dUdT, tuple): pybamm_dict[phase_domain_pre_name + "OCP entropic change [V.K-1]"] = ( - partial(_entropic_change, dUdT=dUdT) - ) - elif isinstance(dUdT, tuple): - pybamm_dict[phase_domain_pre_name + "OCP entropic change [V.K-1]"] = ( - partial( - _entropic_change, - dUdT=partial( - _interpolant_func, name=dUdT[0], x=dUdT[1][0], y=dUdT[1][1] - ), - ) - ) - else: - pybamm_dict[phase_domain_pre_name + "OCP entropic change [V.K-1]"] = ( - partial(_entropic_change, dUdT=dUdT, constant=True) + partial(_interpolant_func, name=dUdT[0], x=dUdT[1][0], y=dUdT[1][1]) ) # reaction rate @@ -440,6 +419,10 @@ def _get_pybamm_name(pybamm_name, domain): pybamm_name = domain.short_pre_name + pybamm_name_lower elif pybamm_name.startswith("OCP"): pybamm_name = domain.pre_name + pybamm_name + elif pybamm_name.startswith("Entropic change"): + pybamm_name = domain.pre_name + pybamm_name.replace( + "Entropic change coefficient", "OCP entropic change" + ) elif pybamm_name.startswith("Cation transference number"): pybamm_name = pybamm_name elif domain.pre_name != "": diff --git a/src/pybamm/parameters/lithium_ion_parameters.py b/src/pybamm/parameters/lithium_ion_parameters.py index f5a76c6d48..e372cab4a4 100644 --- a/src/pybamm/parameters/lithium_ion_parameters.py +++ b/src/pybamm/parameters/lithium_ion_parameters.py @@ -669,11 +669,9 @@ def dUdT(self, sto): "MSMR" formulation, stoichiometry is explicitly defined as a function of U and T, and dUdT is only used to calculate the reversible heat generation term. """ - domain, Domain = self.domain_Domain + Domain = self.domain.capitalize() inputs = { f"{Domain} particle stoichiometry": sto, - f"{self.phase_prefactor}Maximum {domain} particle " - "surface concentration [mol.m-3]": self.c_max, } return pybamm.FunctionParameter( f"{self.phase_prefactor}{Domain} electrode OCP entropic change [V.K-1]", @@ -794,12 +792,10 @@ def t_change(self, sto): """ Volume change for the electrode; sto should be R-averaged """ - domain, Domain = self.domain_Domain + Domain = self.domain.capitalize() return pybamm.FunctionParameter( f"{Domain} electrode volume change", { - "Particle stoichiometry": sto, - f"{self.phase_prefactor}Maximum {domain} particle " - "surface concentration [mol.m-3]": self.c_max, + f"{Domain} particle stoichiometry": sto, }, ) diff --git a/tests/unit/test_parameters/test_bpx.py b/tests/unit/test_parameters/test_bpx.py index ab4c25f97a..3cc7cfebf1 100644 --- a/tests/unit/test_parameters/test_bpx.py +++ b/tests/unit/test_parameters/test_bpx.py @@ -203,7 +203,7 @@ def check_constant_output(func): D = param[f"{electrode} particle diffusivity [m2.s-1]"] dUdT = param[f"{electrode} electrode OCP entropic change [V.K-1]"] check_constant_output(D) - check_constant_output(dUdT) + self.assertEqual(dUdT, 1) kappa = param["Electrolyte conductivity [S.m-1]"] De = param["Electrolyte diffusivity [m2.s-1]"] @@ -260,9 +260,7 @@ def test_table_data(self): self.assertIsInstance(D, pybamm.Interpolant) OCP = param[f"{electrode} electrode OCP [V]"](c) self.assertIsInstance(OCP, pybamm.Interpolant) - dUdT = param[f"{electrode} electrode OCP entropic change [V.K-1]"]( - c, 10000 - ) + dUdT = param[f"{electrode} electrode OCP entropic change [V.K-1]"](c) self.assertIsInstance(dUdT, pybamm.Interpolant) def test_bpx_soc_error(self): diff --git a/tests/unit/test_parameters/test_parameter_sets/test_Ai2020.py b/tests/unit/test_parameters/test_parameter_sets/test_Ai2020.py index f7302330bf..05fe8f68fa 100644 --- a/tests/unit/test_parameters/test_parameter_sets/test_Ai2020.py +++ b/tests/unit/test_parameters/test_parameter_sets/test_Ai2020.py @@ -22,10 +22,10 @@ def test_functions(self): 0.6098, ), "Positive electrode OCP entropic change [V.K-1]": ( - [sto, c_p_max], + [sto], -2.1373e-4, ), - "Positive electrode volume change": ([sto, c_p_max], -1.8179e-2), + "Positive electrode volume change": ([sto], -1.8179e-2), # Negative electrode "Negative electrode cracking rate": ([T], 3.9e-20), "Negative particle diffusivity [m2.s-1]": ([sto, T], 3.9e-14), @@ -34,10 +34,10 @@ def test_functions(self): 0.4172, ), "Negative electrode OCP entropic change [V.K-1]": ( - [sto, c_n_max], + [sto], -1.1033e-4, ), - "Negative electrode volume change": ([sto, c_n_max], 5.1921e-2), + "Negative electrode volume change": ([sto], 5.1921e-2), } for name, value in fun_test.items(): diff --git a/tests/unit/test_parameters/test_parameter_sets/test_LCO_Ramadass2004.py b/tests/unit/test_parameters/test_parameter_sets/test_LCO_Ramadass2004.py index e6c4b04fdf..287c4e97d8 100644 --- a/tests/unit/test_parameters/test_parameter_sets/test_LCO_Ramadass2004.py +++ b/tests/unit/test_parameters/test_parameter_sets/test_LCO_Ramadass2004.py @@ -21,7 +21,7 @@ def test_functions(self): 1.4517, ), "Positive electrode OCP entropic change [V.K-1]": ( - [sto, c_p_max], + [sto], -3.4664e-5, ), "Positive electrode OCP [V]": ([sto], 4.1249), @@ -32,7 +32,7 @@ def test_functions(self): 2.2007, ), "Negative electrode OCP entropic change [V.K-1]": ( - [sto, c_n_max], + [sto], -1.5079e-5, ), "Negative electrode OCP [V]": ([sto], 0.1215), diff --git a/tests/unit/test_parameters/test_parameter_sets/test_LGM50_ORegan2022.py b/tests/unit/test_parameters/test_parameter_sets/test_LGM50_ORegan2022.py index 05a38b6245..8a7e401d66 100644 --- a/tests/unit/test_parameters/test_parameter_sets/test_LGM50_ORegan2022.py +++ b/tests/unit/test_parameters/test_parameter_sets/test_LGM50_ORegan2022.py @@ -15,7 +15,7 @@ def test_functions(self): fun_test = { # Positive electrode "Positive electrode OCP entropic change [V.K-1]": ( - [0.5, c_p_max], + [0.5], -9.7940e-07, ), "Positive electrode specific heat capacity [J.kg-1.K-1]": ( @@ -32,7 +32,7 @@ def test_functions(self): "Positive electrode thermal conductivity [W.m-1.K-1]": ([T], 0.8047), # Negative electrode "Negative electrode OCP entropic change [V.K-1]": ( - [0.5, c_n_max], + [0.5], -2.6460e-07, ), "Negative electrode specific heat capacity [J.kg-1.K-1]": ( diff --git a/tests/unit/test_parameters/test_parameter_sets/test_OKane2022.py b/tests/unit/test_parameters/test_parameter_sets/test_OKane2022.py index e34f837b38..014b467715 100644 --- a/tests/unit/test_parameters/test_parameter_sets/test_OKane2022.py +++ b/tests/unit/test_parameters/test_parameter_sets/test_OKane2022.py @@ -27,7 +27,7 @@ def test_functions(self): 0.33947, ), "Negative electrode cracking rate": ([T], 3.9e-20), - "Negative electrode volume change": ([sto, 33133], 0.0897), + "Negative electrode volume change": ([sto], 0.0897), # Positive electrode "Positive particle diffusivity [m2.s-1]": ([sto, T], 4e-15), "Positive electrode exchange-current density [A.m-2]": ( @@ -36,7 +36,7 @@ def test_functions(self): ), "Positive electrode OCP [V]": ([sto], 3.5682), "Positive electrode cracking rate": ([T], 3.9e-20), - "Positive electrode volume change": ([sto, 63104], 0.70992), + "Positive electrode volume change": ([sto], 0.70992), } for name, value in fun_test.items(): diff --git a/tests/unit/test_parameters/test_parameter_sets/test_OKane2022_negative_halfcell.py b/tests/unit/test_parameters/test_parameter_sets/test_OKane2022_negative_halfcell.py index bf39457dc4..beebeb35e3 100644 --- a/tests/unit/test_parameters/test_parameter_sets/test_OKane2022_negative_halfcell.py +++ b/tests/unit/test_parameters/test_parameter_sets/test_OKane2022_negative_halfcell.py @@ -26,7 +26,7 @@ def test_functions(self): 0.33947, ), "Positive electrode cracking rate": ([T], 3.9e-20), - "Positive electrode volume change": ([sto, 33133], 0.0897), + "Positive electrode volume change": ([sto], 0.0897), } for name, value in fun_test.items(): From 0cc6f72654277a8728b02088880954bce6a6f49b Mon Sep 17 00:00:00 2001 From: Robert Timms <43040151+rtimms@users.noreply.github.com> Date: Sun, 8 Sep 2024 18:35:05 -0700 Subject: [PATCH 09/23] allow tol and inputs to be passes to get_initial_ocps (#4426) --- .../lithium_ion/electrode_soh.py | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh.py b/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh.py index a5710dc986..c5b6a9b911 100644 --- a/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh.py +++ b/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh.py @@ -657,6 +657,8 @@ def get_initial_stoichiometries(self, initial_value, tol=1e-6, inputs=None): The tolerance for the solver used to compute the initial stoichiometries. A lower value results in higher precision but may increase computation time. Default is 1e-6. + inputs : dict, optional + A dictionary of input parameters passed to the model. Returns ------- @@ -727,6 +729,11 @@ def get_min_max_stoichiometries(self, inputs=None): Calculate min/max stoichiometries given voltage limits, open-circuit potentials, etc defined by parameter_values + Parameters + ---------- + inputs : dict, optional + A dictionary of input parameters passed to the model. + Returns ------- x_0, x_100, y_100, y_0 @@ -751,7 +758,7 @@ def get_min_max_stoichiometries(self, inputs=None): sol = self.solve(all_inputs) return [sol["x_0"], sol["x_100"], sol["y_100"], sol["y_0"]] - def get_initial_ocps(self, initial_value, tol=1e-6): + def get_initial_ocps(self, initial_value, tol=1e-6, inputs=None): """ Calculate initial open-circuit potentials to start off the simulation at a particular state of charge, given voltage limits, open-circuit potentials, etc @@ -760,9 +767,14 @@ def get_initial_ocps(self, initial_value, tol=1e-6): Parameters ---------- initial_value : float - Target SOC, must be between 0 and 1. + Target initial value. + If integer, interpreted as SOC, must be between 0 and 1. + If string e.g. "4 V", interpreted as voltage, + must be between V_min and V_max. tol: float, optional Tolerance for the solver used in calculating initial stoichiometries. + inputs : dict, optional + A dictionary of input parameters passed to the model. Returns ------- @@ -771,7 +783,7 @@ def get_initial_ocps(self, initial_value, tol=1e-6): """ parameter_values = self.parameter_values param = self.param - x, y = self.get_initial_stoichiometries(initial_value, tol) + x, y = self.get_initial_stoichiometries(initial_value, tol, inputs=inputs) if self.options["open-circuit potential"] == "MSMR": msmr_pot_model = _get_msmr_potential_model( self.parameter_values, self.param @@ -783,8 +795,8 @@ def get_initial_ocps(self, initial_value, tol=1e-6): Up = sol["Up"].data[0] else: T_ref = parameter_values["Reference temperature [K]"] - Un = parameter_values.evaluate(param.n.prim.U(x, T_ref)) - Up = parameter_values.evaluate(param.p.prim.U(y, T_ref)) + Un = parameter_values.evaluate(param.n.prim.U(x, T_ref), inputs=inputs) + Up = parameter_values.evaluate(param.p.prim.U(y, T_ref), inputs=inputs) return Un, Up def get_min_max_ocps(self): @@ -871,6 +883,8 @@ def get_initial_stoichiometries( The tolerance for the solver used to compute the initial stoichiometries. A lower value results in higher precision but may increase computation time. Default is 1e-6. + inputs : dict, optional + A dictionary of input parameters passed to the model. Returns ------- @@ -918,6 +932,8 @@ def get_initial_ocps( param=None, known_value="cyclable lithium capacity", options=None, + tol=1e-6, + inputs=None, ): """ Calculate initial open-circuit potentials to start off the simulation at a @@ -942,6 +958,10 @@ def get_initial_ocps( options : dict-like, optional A dictionary of options to be passed to the model, see :class:`pybamm.BatteryModelOptions`. + tol: float, optional + Tolerance for the solver used in calculating initial open-circuit potentials. + inputs : dict, optional + A dictionary of input parameters passed to the model. Returns ------- @@ -949,7 +969,7 @@ def get_initial_ocps( The initial electrode OCPs that give the desired initial state of charge """ esoh_solver = ElectrodeSOHSolver(parameter_values, param, known_value, options) - return esoh_solver.get_initial_ocps(initial_value) + return esoh_solver.get_initial_ocps(initial_value, tol, inputs=inputs) def get_min_max_ocps( From 1d94c3ed8988b5caa038ec00d9a744fa23ee682b Mon Sep 17 00:00:00 2001 From: Caitlin Parke Date: Mon, 9 Sep 2024 11:43:10 -0400 Subject: [PATCH 10/23] Updated tests, citatation for LAM phase --- .../test_lithium_ion/test_newman_tobias.py | 6 ++++++ tests/unit/test_citations.py | 2 +- .../test_lithium_ion/base_lithium_ion_tests.py | 17 +++++++++++++++++ .../test_lithium_ion/test_newman_tobias.py | 4 ++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py index 7d457fd7dc..121bc3a018 100644 --- a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py +++ b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py @@ -26,3 +26,9 @@ def test_composite_graphite_silicon(self): def test_composite_graphite_silicon_sei(self): pass # skip this test + + def test_composite_reaction_driven_LAM(self): + pass # skip this test + + def test_composite_stress_driven_LAM(self): + pass # skip this test diff --git a/tests/unit/test_citations.py b/tests/unit/test_citations.py index 7133cf234a..c87912490f 100644 --- a/tests/unit/test_citations.py +++ b/tests/unit/test_citations.py @@ -296,7 +296,7 @@ def test_reniers_2019(self): citations._reset() assert "Reniers2019" not in citations._papers_to_cite - pybamm.active_material.LossActiveMaterial(None, "negative", None, True) + pybamm.active_material.LossActiveMaterial(None, "negative", None, True, None) assert "Reniers2019" in citations._papers_to_cite assert "Reniers2019" in citations._citation_tags.keys() diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py index 9c093c0c65..7b690257dc 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py @@ -588,3 +588,20 @@ def test_well_posed_composite_different_degradation(self): "lithium plating": (("none", "irreversible"), "none"), } self.check_well_posedness(options) + + def test_well_posed_composite_LAM(self): + # phases with LAM degradation + options = { + "particle phases": ("2", "1"), + "open-circuit potential": (("single", "current sigmoid"), "single"), + "SEI": "solvent-diffusion limited", + "loss of active material": "reaction-driven", + } + self.check_well_posedness(options) + + options = { + "particle phases": ("2", "1"), + "open-circuit potential": (("single", "current sigmoid"), "single"), + "loss of active material": "stress-driven", + } + self.check_well_posedness(options) diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py index c979474e13..ea641ee7cc 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py @@ -39,3 +39,7 @@ def test_well_posed_composite_diffusion_hysteresis(self): @pytest.mark.skip(reason="Test currently not implemented") def test_well_posed_composite_different_degradation(self): pass # skip this test + + @pytest.mark.skip(reason="Test currently not implemented") + def test_well_posed_composite_LAM(self): + pass # skip this test From fcbe75615f4c057fbcf024506d8ad3e28cdfe091 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 9 Sep 2024 22:10:10 +0100 Subject: [PATCH 11/23] chore: update pre-commit hooks (#4430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.3 → v0.6.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.3...v0.6.4) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 43928bbc56..985cd0291a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.6.3" + rev: "v0.6.4" hooks: - id: ruff args: [--fix, --show-fixes] From b9e7d10cca21aabc5a8bfa210f477a42e2e23fe4 Mon Sep 17 00:00:00 2001 From: Caitlin Parke Date: Tue, 10 Sep 2024 10:51:10 -0400 Subject: [PATCH 12/23] updated the LAM notebook and changelog --- CHANGELOG.md | 4 + .../models/loss_of_active_materials.ipynb | 336 +++++++++++++++++- .../active_material/total_active_material.py | 1 - 3 files changed, 322 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5203229bd3..cdead760dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## Optimizations - Removed the `start_step_offset` setting and disabled minimum `dt` warnings for drive cycles with the (`IDAKLUSolver`). ([#4416](https://github.com/pybamm-team/PyBaMM/pull/4416)) +## Features + +- Added phase-dependent particle options to LAM #4369 + # [v24.9.0](https://github.com/pybamm-team/PyBaMM/tree/v24.9.0) - 2024-09-03 ## Features diff --git a/docs/source/examples/notebooks/models/loss_of_active_materials.ipynb b/docs/source/examples/notebooks/models/loss_of_active_materials.ipynb index ad428e6791..dccf003b40 100644 --- a/docs/source/examples/notebooks/models/loss_of_active_materials.ipynb +++ b/docs/source/examples/notebooks/models/loss_of_active_materials.ipynb @@ -30,7 +30,7 @@ "output_type": "stream", "text": [ "At t = 57.3387, , mxstep steps taken before reaching tout.\n", - "At t = 57.3387 and h = 7.05477e-15, the corrector convergence failed repeatedly or with |h| = hmin.\n", + "At t = 57.3387, , mxstep steps taken before reaching tout.\n", "At t = 57.3387, , mxstep steps taken before reaching tout.\n", "At t = 57.3387, , mxstep steps taken before reaching tout.\n" ] @@ -83,12 +83,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "6b19474c3912495eb75217e009760637", + "model_id": "c0457801238749319c233087de2acbe7", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "interactive(children=(FloatSlider(value=0.0, description='t', max=2.329196798170269, step=0.02329196798170269)…" + "interactive(children=(FloatSlider(value=0.0, description='t', max=2.3291967981693755, step=0.02329196798169375…" ] }, "metadata": {}, @@ -97,7 +97,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 2, @@ -137,11 +137,11 @@ "output_type": "stream", "text": [ "At t = 57.3387, , mxstep steps taken before reaching tout.\n", - "At t = 57.3387 and h = 7.05477e-15, the corrector convergence failed repeatedly or with |h| = hmin.\n", + "At t = 57.3387, , mxstep steps taken before reaching tout.\n", "At t = 57.3387, , mxstep steps taken before reaching tout.\n", "At t = 57.3387, , mxstep steps taken before reaching tout.\n", "At t = 57.3307, , mxstep steps taken before reaching tout.\n", - "At t = 57.3307, , mxstep steps taken before reaching tout.\n", + "At t = 57.3307 and h = 3.45325e-14, the corrector convergence failed repeatedly or with |h| = hmin.\n", "At t = 57.3307, , mxstep steps taken before reaching tout.\n", "At t = 57.3307, , mxstep steps taken before reaching tout.\n", "At t = 57.2504, , mxstep steps taken before reaching tout.\n", @@ -153,12 +153,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "789a681c8c574bb8b3d3016a844dd9a2", + "model_id": "fb6f9d424ab747a7b2ce16bfb13b3935", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "interactive(children=(FloatSlider(value=0.0, description='t', max=2.329196798170269, step=0.02329196798170269)…" + "interactive(children=(FloatSlider(value=0.0, description='t', max=2.3291967981693755, step=0.02329196798169375…" ] }, "metadata": {}, @@ -167,7 +167,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 3, @@ -225,12 +225,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "ad36439975754b29bbbef1bd94379408", + "model_id": "d87e2825497b480199979327f55acf61", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "interactive(children=(FloatSlider(value=0.0, description='t', max=1.8531298311682403, step=0.01853129831168240…" + "interactive(children=(FloatSlider(value=0.0, description='t', max=1.85353350947348, step=0.0185353350947348), …" ] }, "metadata": {}, @@ -239,7 +239,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 4, @@ -248,6 +248,16 @@ } ], "source": [ + "import pybamm\n", + "\n", + "experiment = pybamm.Experiment(\n", + " [\n", + " \"Discharge at 1C until 3 V\",\n", + " \"Rest for 600 seconds\",\n", + " \"Charge at 1C until 4.2 V\",\n", + " \"Hold at 4.199 V for 600 seconds\",\n", + " ]\n", + ")\n", "model = pybamm.lithium_ion.DFN(\n", " options={\n", " \"SEI\": \"solvent-diffusion limited\",\n", @@ -255,7 +265,7 @@ " }\n", ")\n", "param = pybamm.ParameterValues(\"Chen2020\")\n", - "param.update({\"Negative electrode reaction-driven LAM factor [m3.mol-1]\": 1e-3})\n", + "param.update({\"Negative electrode reaction-driven LAM factor [m3.mol-1]\": 1e-4})\n", "sim = pybamm.Simulation(\n", " model,\n", " experiment=experiment,\n", @@ -300,12 +310,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "91ea043e10d342049929095e48e98c5e", + "model_id": "9869cd81346d49518b2882aafef7c677", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "interactive(children=(FloatSlider(value=0.0, description='t', max=1.8506629989989005, step=0.01850662998998900…" + "interactive(children=(FloatSlider(value=0.0, description='t', max=1.8506629988943608, step=0.01850662998894360…" ] }, "metadata": {}, @@ -314,7 +324,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 5, @@ -358,6 +368,296 @@ ")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## LAM with composite electrode\n", + "The LAM submodel is also compatible with multiple phases within an electrode for both stress- and reaction-driven loss of active material. Currently, there is no single parameter set that combines both LAM degradation and composite materials. The following examples use the Chen2020 composite parameter set with LAM parameters taken from the Ai2020 parameter set. " + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "# Volume change functions from Ai2020 parameters\n", + "\n", + "\n", + "def graphite_volume_change_Ai2020(sto, c_s_max):\n", + " p1 = 145.907\n", + " p2 = -681.229\n", + " p3 = 1334.442\n", + " p4 = -1415.710\n", + " p5 = 873.906\n", + " p6 = -312.528\n", + " p7 = 60.641\n", + " p8 = -5.706\n", + " p9 = 0.386\n", + " p10 = -4.966e-05\n", + " t_change = (\n", + " p1 * sto**9\n", + " + p2 * sto**8\n", + " + p3 * sto**7\n", + " + p4 * sto**6\n", + " + p5 * sto**5\n", + " + p6 * sto**4\n", + " + p7 * sto**3\n", + " + p8 * sto**2\n", + " + p9 * sto\n", + " + p10\n", + " )\n", + " return t_change\n", + "\n", + "\n", + "def lico2_volume_change_Ai2020(sto, c_s_max):\n", + " omega = pybamm.Parameter(\"Positive electrode partial molar volume [m3.mol-1]\")\n", + " t_change = omega * c_s_max * sto\n", + " return t_change" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Stress-driven composite anode\n", + "The secondary phase LAM parameters have been adjusted from the Ai2020 by about 10% to show less degradation in that phase. The model is set up in the same way the single-phase simulation is but with additional parameters." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "options = {\n", + " \"particle phases\": (\"2\", \"1\"),\n", + " \"open-circuit potential\": ((\"single\", \"current sigmoid\"), \"single\"),\n", + " \"loss of active material\": \"stress-driven\",\n", + "}\n", + "\n", + "model = pybamm.lithium_ion.SPM(options)\n", + "parameter_values = pybamm.ParameterValues(\"Chen2020_composite\")\n", + "second = 0.1\n", + "parameter_values.update(\n", + " {\n", + " \"Primary: Negative electrode LAM constant proportional term [s-1]\": 1e-4 / 3600,\n", + " \"Secondary: Negative electrode LAM constant proportional term [s-1]\": 1e-4\n", + " / 3600\n", + " * second,\n", + " \"Positive electrode LAM constant proportional term [s-1]\": 1e-4 / 3600,\n", + " \"Primary: Negative electrode partial molar volume [m3.mol-1]\": 3.1e-06,\n", + " \"Primary: Negative electrode Young's modulus [Pa]\": 15000000000.0,\n", + " \"Primary: Negative electrode Poisson's ratio\": 0.3,\n", + " \"Primary: Negative electrode critical stress [Pa]\": 60000000.0,\n", + " \"Secondary: Negative electrode critical stress [Pa]\": 60000000.0,\n", + " \"Primary: Negative electrode LAM constant exponential term\": 2.0,\n", + " \"Secondary: Negative electrode LAM constant exponential term\": 2.0,\n", + " \"Secondary: Negative electrode partial molar volume [m3.mol-1]\": 3.1e-06\n", + " * second,\n", + " \"Secondary: Negative electrode Young's modulus [Pa]\": 15000000000.0 * second,\n", + " \"Secondary: Negative electrode Poisson's ratio\": 0.3 * second,\n", + " \"Negative electrode reference concentration for free of deformation [mol.m-3]\": 0.0,\n", + " \"Primary: Negative electrode volume change\": graphite_volume_change_Ai2020,\n", + " \"Secondary: Negative electrode volume change\": graphite_volume_change_Ai2020,\n", + " \"Positive electrode partial molar volume [m3.mol-1]\": -7.28e-07,\n", + " \"Positive electrode Young's modulus [Pa]\": 375000000000.0,\n", + " \"Positive electrode Poisson's ratio\": 0.2,\n", + " \"Positive electrode critical stress [Pa]\": 375000000.0,\n", + " \"Positive electrode LAM constant exponential term\": 2.0,\n", + " \"Positive electrode reference concentration for free of deformation [mol.m-3]\": 0.0,\n", + " \"Positive electrode volume change\": lico2_volume_change_Ai2020,\n", + " },\n", + " check_already_exists=False,\n", + ")\n", + "\n", + "# sim = pybamm.Simulation(model, parameter_values=parameter_values)\n", + "# sim.solve([0, 4500])\n", + "experiment = pybamm.Experiment(\n", + " [\n", + " \"Discharge at 1C until 3 V\",\n", + " \"Rest for 600 seconds\",\n", + " \"Charge at 1C until 4.2 V\",\n", + " \"Hold at 4.199 V for 600 seconds\",\n", + " ]\n", + ")\n", + "sim = pybamm.Simulation(\n", + " model,\n", + " experiment=experiment,\n", + " parameter_values=parameter_values,\n", + " discretisation_kwargs={\"remove_independent_variables_from_rhs\": True},\n", + ")\n", + "solution = sim.solve(calc_esoh=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The two phase LAM model can be compared between the cathode and two anode phases." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "15ff88ca947842a0828b787da358eb72", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "interactive(children=(FloatSlider(value=0.0, description='t', max=2.1702864080208446, step=0.02170286408020844…" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pybamm.dynamic_plot(\n", + " sim,\n", + " [\n", + " \"Voltage [V]\",\n", + " \"Current [A]\",\n", + " [\n", + " \"Average negative primary particle concentration\",\n", + " \"Average negative secondary particle concentration\",\n", + " \"Average positive particle concentration\",\n", + " ],\n", + " \"X-averaged negative electrode primary active material volume fraction\",\n", + " \"X-averaged positive electrode active material volume fraction\",\n", + " \"X-averaged negative electrode secondary active material volume fraction\",\n", + " \"Sum of x-averaged positive electrode volumetric interfacial current densities [A.m-3]\",\n", + " \"Sum of x-averaged negative electrode volumetric interfacial current densities [A.m-3]\",\n", + " \"X-averaged positive particle surface tangential stress [Pa]\",\n", + " \"X-averaged negative primary particle surface tangential stress [Pa]\",\n", + " \"X-averaged negative secondary particle surface tangential stress [Pa]\",\n", + " ],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reaction-driven composite anode\n", + "The same process is repeated for the reaction-driven LAM degradation." + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "49a86b84f21d4b06b7d71b33c9fc4b0d", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "interactive(children=(FloatSlider(value=0.0, description='t', max=2.081773444877257, step=0.02081773444877257)…" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "options = {\n", + " \"particle phases\": (\"2\", \"1\"),\n", + " \"open-circuit potential\": ((\"single\", \"current sigmoid\"), \"single\"),\n", + " \"SEI\": \"solvent-diffusion limited\",\n", + " \"loss of active material\": \"reaction-driven\",\n", + "}\n", + "\n", + "model = pybamm.lithium_ion.SPM(options)\n", + "parameter_values = pybamm.ParameterValues(\"Chen2020_composite\")\n", + "second = 0.9\n", + "\n", + "parameter_values.update(\n", + " {\n", + " \"Primary: Negative electrode partial molar volume [m3.mol-1]\": 3.1e-06,\n", + " \"Primary: Negative electrode Young's modulus [Pa]\": 15000000000.0,\n", + " \"Primary: Negative electrode Poisson's ratio\": 0.3,\n", + " \"Negative electrode critical stress [Pa]\": 60000000.0,\n", + " \"Negative electrode LAM constant exponential term\": 2.0,\n", + " \"Secondary: Negative electrode partial molar volume [m3.mol-1]\": 3.1e-06\n", + " * second,\n", + " \"Secondary: Negative electrode Young's modulus [Pa]\": 15000000000.0 * second,\n", + " \"Secondary: Negative electrode Poisson's ratio\": 0.3 * second,\n", + " \"Negative electrode reference concentration for free of deformation [mol.m-3]\": 0.0,\n", + " \"Primary: Negative electrode volume change\": graphite_volume_change_Ai2020,\n", + " \"Secondary: Negative electrode volume change\": graphite_volume_change_Ai2020,\n", + " \"Positive electrode partial molar volume [m3.mol-1]\": -7.28e-07,\n", + " \"Positive electrode Young's modulus [Pa]\": 375000000000.0,\n", + " \"Positive electrode Poisson's ratio\": 0.2,\n", + " \"Positive electrode critical stress [Pa]\": 375000000.0,\n", + " \"Positive electrode LAM constant exponential term\": 2.0,\n", + " \"Positive electrode reference concentration for free of deformation [mol.m-3]\": 0.0,\n", + " \"Positive electrode volume change\": lico2_volume_change_Ai2020,\n", + " \"Primary: Negative electrode reaction-driven LAM factor [m3.mol-1]\": 1e-9,\n", + " \"Secondary: Negative electrode reaction-driven LAM factor [m3.mol-1]\": 10,\n", + " },\n", + " check_already_exists=False,\n", + ")\n", + "\n", + "# Changing secondary SEI solvent diffusivity to show different degradation between phases\n", + "parameter_values.update(\n", + " {\n", + " \"Secondary: Outer SEI solvent diffusivity [m2.s-1]\": 2.5000000000000002e-24,\n", + " }\n", + ")\n", + "\n", + "# sim = pybamm.Simulation(model, parameter_values=parameter_values)\n", + "# sim.solve([0, 4100])\n", + "sim = pybamm.Simulation(\n", + " model,\n", + " experiment=experiment,\n", + " parameter_values=parameter_values,\n", + " solver=pybamm.CasadiSolver(\"fast with events\"),\n", + ")\n", + "solution = sim.solve(calc_esoh=False)\n", + "\n", + "sim.plot(\n", + " [\n", + " \"Voltage [V]\",\n", + " \"Current [A]\",\n", + " \"Sum of x-averaged negative electrode volumetric interfacial current densities [A.m-3]\",\n", + " \"X-averaged negative electrode primary active material volume fraction\",\n", + " \"X-averaged negative electrode secondary active material volume fraction\",\n", + " \"Negative total primary SEI thickness [m]\",\n", + " \"Negative total secondary SEI thickness [m]\",\n", + " ]\n", + ")" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -369,7 +669,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -417,7 +717,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.8" + "version": "3.11.9" }, "toc": { "base_numbering": 1, diff --git a/src/pybamm/models/submodels/active_material/total_active_material.py b/src/pybamm/models/submodels/active_material/total_active_material.py index 867c929632..f86486ff53 100644 --- a/src/pybamm/models/submodels/active_material/total_active_material.py +++ b/src/pybamm/models/submodels/active_material/total_active_material.py @@ -35,7 +35,6 @@ def get_coupled_variables(self, variables): f"X-averaged {domain} electrode {{}}active material " "volume fraction change [s-1]", f"Loss of lithium due to loss of {{}}active material in {domain} electrode [mol]", - f"{Domain} total {{}}SEI thickness [m]", ]: sumvar = sum( variables[variable_template.format(phase + " ")] for phase in phases From 361b094f4730fb534b3cde3f13ac0abd0cf040cc Mon Sep 17 00:00:00 2001 From: Caitlin Parke Date: Tue, 10 Sep 2024 14:48:17 -0400 Subject: [PATCH 13/23] updating to develop branch --- .../models/loss_of_active_materials.ipynb | 63 ++++++++++--------- .../full_battery_models/base_battery_model.py | 3 +- .../base_lithium_ion_tests.py | 10 +-- 3 files changed, 41 insertions(+), 35 deletions(-) diff --git a/docs/source/examples/notebooks/models/loss_of_active_materials.ipynb b/docs/source/examples/notebooks/models/loss_of_active_materials.ipynb index dccf003b40..1ce1cca826 100644 --- a/docs/source/examples/notebooks/models/loss_of_active_materials.ipynb +++ b/docs/source/examples/notebooks/models/loss_of_active_materials.ipynb @@ -83,7 +83,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "c0457801238749319c233087de2acbe7", + "model_id": "ccfc7ae873d1492197fa7b554339a3d7", "version_major": 2, "version_minor": 0 }, @@ -97,7 +97,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 2, @@ -153,7 +153,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "fb6f9d424ab747a7b2ce16bfb13b3935", + "model_id": "b34472112ae344da92ccc8af5178c64b", "version_major": 2, "version_minor": 0 }, @@ -167,7 +167,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 3, @@ -225,7 +225,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "d87e2825497b480199979327f55acf61", + "model_id": "60db1d0de494460493cc8edd5b61d4e7", "version_major": 2, "version_minor": 0 }, @@ -239,7 +239,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 4, @@ -310,7 +310,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "9869cd81346d49518b2882aafef7c677", + "model_id": "1dfb1de5ccde449c9eefcda1b1f44468", "version_major": 2, "version_minor": 0 }, @@ -324,7 +324,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 5, @@ -385,7 +385,7 @@ "# Volume change functions from Ai2020 parameters\n", "\n", "\n", - "def graphite_volume_change_Ai2020(sto, c_s_max):\n", + "def graphite_volume_change_Ai2020(sto):\n", " p1 = 145.907\n", " p2 = -681.229\n", " p3 = 1334.442\n", @@ -411,8 +411,9 @@ " return t_change\n", "\n", "\n", - "def lico2_volume_change_Ai2020(sto, c_s_max):\n", + "def lico2_volume_change_Ai2020(sto):\n", " omega = pybamm.Parameter(\"Positive electrode partial molar volume [m3.mol-1]\")\n", + " c_s_max = pybamm.Parameter(\"Maximum concentration in positive electrode [mol.m-3]\")\n", " t_change = omega * c_s_max * sto\n", " return t_change" ] @@ -427,7 +428,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 7, "metadata": {}, "outputs": [], "source": [ @@ -500,13 +501,13 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 8, "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "15ff88ca947842a0828b787da358eb72", + "model_id": "074bcadceb3e4fbd8cc786e798bb6508", "version_major": 2, "version_minor": 0 }, @@ -520,10 +521,10 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 22, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -561,13 +562,13 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 9, "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "49a86b84f21d4b06b7d71b33c9fc4b0d", + "model_id": "98a2b1762a3c43bcaa9ceff5a146d704", "version_major": 2, "version_minor": 0 }, @@ -581,10 +582,10 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 31, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -669,22 +670,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "[1] Weilong Ai, Ludwig Kraft, Johannes Sturm, Andreas Jossen, and Billy Wu. Electrochemical thermal-mechanical modelling of stress inhomogeneity in lithium-ion pouch cells. Journal of The Electrochemical Society, 167(1):013512, 2019. doi:10.1149/2.0122001JES.\n", - "[2] Joel A. E. Andersson, Joris Gillis, Greg Horn, James B. Rawlings, and Moritz Diehl. CasADi – A software framework for nonlinear optimization and optimal control. Mathematical Programming Computation, 11(1):1–36, 2019. doi:10.1007/s12532-018-0139-4.\n", - "[3] Chang-Hui Chen, Ferran Brosa Planella, Kieran O'Regan, Dominika Gastol, W. Dhammika Widanage, and Emma Kendrick. Development of Experimental Techniques for Parameterization of Multi-scale Lithium-ion Battery Models. Journal of The Electrochemical Society, 167(8):080534, 2020. doi:10.1149/1945-7111/ab9050.\n", - "[4] Rutooj Deshpande, Mark Verbrugge, Yang-Tse Cheng, John Wang, and Ping Liu. Battery cycle life prediction with coupled chemical degradation and fatigue mechanics. Journal of the Electrochemical Society, 159(10):A1730, 2012. doi:10.1149/2.049210jes.\n", - "[5] Marc Doyle, Thomas F. Fuller, and John Newman. Modeling of galvanostatic charge and discharge of the lithium/polymer/insertion cell. Journal of the Electrochemical society, 140(6):1526–1533, 1993. doi:10.1149/1.2221597.\n", - "[6] Charles R. Harris, K. Jarrod Millman, Stéfan J. van der Walt, Ralf Gommers, Pauli Virtanen, David Cournapeau, Eric Wieser, Julian Taylor, Sebastian Berg, Nathaniel J. Smith, and others. Array programming with NumPy. Nature, 585(7825):357–362, 2020. doi:10.1038/s41586-020-2649-2.\n", - "[7] Scott G. Marquis. Long-term degradation of lithium-ion batteries. PhD thesis, University of Oxford, 2020.\n", - "[8] Jorn M. Reniers, Grietus Mulder, and David A. Howey. Review and performance comparison of mechanical-chemical degradation models for lithium-ion batteries. Journal of The Electrochemical Society, 166(14):A3189, 2019. doi:10.1149/2.0281914jes.\n", - "[9] Valentin Sulzer, Scott G. Marquis, Robert Timms, Martin Robinson, and S. Jon Chapman. Python Battery Mathematical Modelling (PyBaMM). Journal of Open Research Software, 9(1):14, 2021. doi:10.5334/jors.309.\n", + "[1] Weilong Ai, Niall Kirkaldy, Yang Jiang, Gregory Offer, Huizhi Wang, and Billy Wu. A composite electrode model for lithium-ion batteries with silicon/graphite negative electrodes. Journal of Power Sources, 527:231142, 2022. URL: https://www.sciencedirect.com/science/article/pii/S0378775322001604, doi:https://doi.org/10.1016/j.jpowsour.2022.231142.\n", + "[2] Weilong Ai, Ludwig Kraft, Johannes Sturm, Andreas Jossen, and Billy Wu. Electrochemical thermal-mechanical modelling of stress inhomogeneity in lithium-ion pouch cells. Journal of The Electrochemical Society, 167(1):013512, 2019. doi:10.1149/2.0122001JES.\n", + "[3] Joel A. E. Andersson, Joris Gillis, Greg Horn, James B. Rawlings, and Moritz Diehl. CasADi – A software framework for nonlinear optimization and optimal control. Mathematical Programming Computation, 11(1):1–36, 2019. doi:10.1007/s12532-018-0139-4.\n", + "[4] Ferran Brosa Planella and W. Dhammika Widanage. Systematic derivation of a Single Particle Model with Electrolyte and Side Reactions (SPMe+SR) for degradation of lithium-ion batteries. Submitted for publication, ():, 2022. doi:.\n", + "[5] Von DAG Bruggeman. Berechnung verschiedener physikalischer konstanten von heterogenen substanzen. i. dielektrizitätskonstanten und leitfähigkeiten der mischkörper aus isotropen substanzen. Annalen der physik, 416(7):636–664, 1935.\n", + "[6] Chang-Hui Chen, Ferran Brosa Planella, Kieran O'Regan, Dominika Gastol, W. Dhammika Widanage, and Emma Kendrick. Development of Experimental Techniques for Parameterization of Multi-scale Lithium-ion Battery Models. Journal of The Electrochemical Society, 167(8):080534, 2020. doi:10.1149/1945-7111/ab9050.\n", + "[7] Rutooj Deshpande, Mark Verbrugge, Yang-Tse Cheng, John Wang, and Ping Liu. Battery cycle life prediction with coupled chemical degradation and fatigue mechanics. Journal of the Electrochemical Society, 159(10):A1730, 2012. doi:10.1149/2.049210jes.\n", + "[8] Marc Doyle, Thomas F. Fuller, and John Newman. Modeling of galvanostatic charge and discharge of the lithium/polymer/insertion cell. Journal of the Electrochemical society, 140(6):1526–1533, 1993. doi:10.1149/1.2221597.\n", + "[9] Charles R. Harris, K. Jarrod Millman, Stéfan J. van der Walt, Ralf Gommers, Pauli Virtanen, David Cournapeau, Eric Wieser, Julian Taylor, Sebastian Berg, Nathaniel J. Smith, and others. Array programming with NumPy. Nature, 585(7825):357–362, 2020. doi:10.1038/s41586-020-2649-2.\n", + "[10] Scott G. Marquis. Long-term degradation of lithium-ion batteries. PhD thesis, University of Oxford, 2020.\n", + "[11] Scott G. Marquis, Valentin Sulzer, Robert Timms, Colin P. Please, and S. Jon Chapman. An asymptotic derivation of a single particle model with electrolyte. Journal of The Electrochemical Society, 166(15):A3693–A3706, 2019. doi:10.1149/2.0341915jes.\n", + "[12] Jorn M. Reniers, Grietus Mulder, and David A. Howey. Review and performance comparison of mechanical-chemical degradation models for lithium-ion batteries. Journal of The Electrochemical Society, 166(14):A3189, 2019. doi:10.1149/2.0281914jes.\n", + "[13] Valentin Sulzer, Scott G. Marquis, Robert Timms, Martin Robinson, and S. Jon Chapman. Python Battery Mathematical Modelling (PyBaMM). Journal of Open Research Software, 9(1):14, 2021. doi:10.5334/jors.309.\n", "\n" ] } diff --git a/src/pybamm/models/full_battery_models/base_battery_model.py b/src/pybamm/models/full_battery_models/base_battery_model.py index 6cff673f67..555eac3d6c 100644 --- a/src/pybamm/models/full_battery_models/base_battery_model.py +++ b/src/pybamm/models/full_battery_models/base_battery_model.py @@ -618,8 +618,7 @@ def __init__(self, extra_options): options["surface form"] != "false" and options["particle size"] == "single" and options["particle"] == "Fickian diffusion" - # and options["particle mechanics"] == "none" - # and options["loss of active material"] == "none" + ): raise pybamm.OptionError( "If there are multiple particle phases: 'surface form' cannot be " diff --git a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py index 767a35d7f2..e4f9e51487 100644 --- a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py +++ b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py @@ -384,7 +384,7 @@ def test_composite_stress_driven_LAM(self): } # taken from Ai2020 - def graphite_volume_change_Ai2020(sto, c_s_max): + def graphite_volume_change_Ai2020(sto): p1 = 145.907 p2 = -681.229 p3 = 1334.442 @@ -410,10 +410,11 @@ def graphite_volume_change_Ai2020(sto, c_s_max): return t_change # taken from Ai2020 - def lico2_volume_change_Ai2020(sto, c_s_max): + def lico2_volume_change_Ai2020(sto): omega = pybamm.Parameter( "Positive electrode partial molar volume [m3.mol-1]" ) + c_s_max = pybamm.Parameter("Maximum concentration in positive electrode [mol.m-3]") t_change = omega * c_s_max * sto return t_change @@ -460,7 +461,7 @@ def test_composite_reaction_driven_LAM(self): } # taken from Ai2020 - def graphite_volume_change_Ai2020(sto, c_s_max): + def graphite_volume_change_Ai2020(sto): p1 = 145.907 p2 = -681.229 p3 = 1334.442 @@ -486,10 +487,11 @@ def graphite_volume_change_Ai2020(sto, c_s_max): return t_change # taken from Ai2020 - def lico2_volume_change_Ai2020(sto, c_s_max): + def lico2_volume_change_Ai2020(sto): omega = pybamm.Parameter( "Positive electrode partial molar volume [m3.mol-1]" ) + c_s_max = pybamm.Parameter("Maximum concentration in positive electrode [mol.m-3]") t_change = omega * c_s_max * sto return t_change From a43ab161f8ea05b97852f7a37ef2207eca069831 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 18:49:26 +0000 Subject: [PATCH 14/23] style: pre-commit fixes --- .../models/full_battery_models/base_battery_model.py | 1 - .../test_lithium_ion/base_lithium_ion_tests.py | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pybamm/models/full_battery_models/base_battery_model.py b/src/pybamm/models/full_battery_models/base_battery_model.py index 1e22495e37..568a7d2815 100644 --- a/src/pybamm/models/full_battery_models/base_battery_model.py +++ b/src/pybamm/models/full_battery_models/base_battery_model.py @@ -618,7 +618,6 @@ def __init__(self, extra_options): options["surface form"] != "false" and options["particle size"] == "single" and options["particle"] == "Fickian diffusion" - ): raise pybamm.OptionError( "If there are multiple particle phases: 'surface form' cannot be " diff --git a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py index e4f9e51487..7c176249fd 100644 --- a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py +++ b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py @@ -414,7 +414,9 @@ def lico2_volume_change_Ai2020(sto): omega = pybamm.Parameter( "Positive electrode partial molar volume [m3.mol-1]" ) - c_s_max = pybamm.Parameter("Maximum concentration in positive electrode [mol.m-3]") + c_s_max = pybamm.Parameter( + "Maximum concentration in positive electrode [mol.m-3]" + ) t_change = omega * c_s_max * sto return t_change @@ -491,7 +493,9 @@ def lico2_volume_change_Ai2020(sto): omega = pybamm.Parameter( "Positive electrode partial molar volume [m3.mol-1]" ) - c_s_max = pybamm.Parameter("Maximum concentration in positive electrode [mol.m-3]") + c_s_max = pybamm.Parameter( + "Maximum concentration in positive electrode [mol.m-3]" + ) t_change = omega * c_s_max * sto return t_change From 7449d5d1076757c504c58d7cff5912e58251d869 Mon Sep 17 00:00:00 2001 From: Caitlin Parke Date: Tue, 10 Sep 2024 14:55:27 -0400 Subject: [PATCH 15/23] precommit --- .../models/full_battery_models/base_battery_model.py | 4 +--- .../test_lithium_ion/base_lithium_ion_tests.py | 8 ++++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/pybamm/models/full_battery_models/base_battery_model.py b/src/pybamm/models/full_battery_models/base_battery_model.py index 1e22495e37..f4943a1b2b 100644 --- a/src/pybamm/models/full_battery_models/base_battery_model.py +++ b/src/pybamm/models/full_battery_models/base_battery_model.py @@ -618,13 +618,11 @@ def __init__(self, extra_options): options["surface form"] != "false" and options["particle size"] == "single" and options["particle"] == "Fickian diffusion" - ): raise pybamm.OptionError( "If there are multiple particle phases: 'surface form' cannot be " "'false', 'particle size' must be 'single', 'particle' must be " - "'Fickian diffusion'. Also the following must " - "be 'none': 'particle mechanics', 'loss of active material'" + "'Fickian diffusion'." ) if options["surface temperature"] == "lumped": diff --git a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py index e4f9e51487..7c176249fd 100644 --- a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py +++ b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py @@ -414,7 +414,9 @@ def lico2_volume_change_Ai2020(sto): omega = pybamm.Parameter( "Positive electrode partial molar volume [m3.mol-1]" ) - c_s_max = pybamm.Parameter("Maximum concentration in positive electrode [mol.m-3]") + c_s_max = pybamm.Parameter( + "Maximum concentration in positive electrode [mol.m-3]" + ) t_change = omega * c_s_max * sto return t_change @@ -491,7 +493,9 @@ def lico2_volume_change_Ai2020(sto): omega = pybamm.Parameter( "Positive electrode partial molar volume [m3.mol-1]" ) - c_s_max = pybamm.Parameter("Maximum concentration in positive electrode [mol.m-3]") + c_s_max = pybamm.Parameter( + "Maximum concentration in positive electrode [mol.m-3]" + ) t_change = omega * c_s_max * sto return t_change From 6a8e33919017e6d4228dc90b21f7c065689dad86 Mon Sep 17 00:00:00 2001 From: Caitlin Parke Date: Tue, 10 Sep 2024 21:31:27 -0400 Subject: [PATCH 16/23] fixed phase and domain thickness calcs --- .../particle_mechanics/base_mechanics.py | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/pybamm/models/submodels/particle_mechanics/base_mechanics.py b/src/pybamm/models/submodels/particle_mechanics/base_mechanics.py index 3bcddb2bd6..1301722da0 100644 --- a/src/pybamm/models/submodels/particle_mechanics/base_mechanics.py +++ b/src/pybamm/models/submodels/particle_mechanics/base_mechanics.py @@ -101,29 +101,26 @@ def _get_mechanical_results(self, variables): ) if ( - f"Negative electrode {phase_name}thickness change [m]" in variables - and f"Positive electrode {phase_name}thickness change [m]" in variables + f"{Domain} primary thickness change [m]" in variables + and f"{Domain} secondary thickness change [m]" in variables + ): + variables[f"{Domain} thickness change [m]"] = ( + variables[f"{Domain} primary thickness change [m]"] + + variables[f"{Domain} secondary thickness change [m]"] + ) + + if ( + "Negative electrode thickness change [m]" in variables + and "Positive electrode thickness change [m]" in variables ): # thermal expansion # Ai2019 eq [13] thermal_expansion = self.param.alpha_T_cell * (T_xav - self.param.T_ref) # calculate total cell thickness change - neg_thickness_change = variables[ - f"Negative electrode {phase_name}thickness change [m]" - ] - pos_thickness_change = variables[ - f"Positive electrode {phase_name}thickness change [m]" - ] - variables[f"Cell {phase_name}thickness change [m]"] = ( - neg_thickness_change + pos_thickness_change + thermal_expansion - ) - if ( - "Cell primary thickness change [m]" in variables - and "Cell secondary thickness change [m]" in variables - ): + neg_thickness_change = variables["Negative electrode thickness change [m]"] + pos_thickness_change = variables["Positive electrode thickness change [m]"] variables["Cell thickness change [m]"] = ( - variables["Cell primary thickness change [m]"] - + variables["Cell secondary thickness change [m]"] + neg_thickness_change + pos_thickness_change + thermal_expansion ) return variables From c2d8ac21d64890b616ba9b4510a4e8ce49dde703 Mon Sep 17 00:00:00 2001 From: Pradyot Ranjan <99216956+prady0t@users.noreply.github.com> Date: Fri, 13 Sep 2024 17:54:44 +0530 Subject: [PATCH 17/23] Migrating unittest to pytest 6 (#4354) * Migrating unittest to pytest 6 Signed-off-by: Pradyot Ranjan <99216956+pradyotRanjan@users.noreply.github.com> * style: pre-commit fixes * Removing errors Signed-off-by: Pradyot Ranjan <99216956+pradyotRanjan@users.noreply.github.com> * style: pre-commit fixes * Removing regex errors Signed-off-by: Pradyot Ranjan <99216956+pradyotRanjan@users.noreply.github.com> * style: pre-commit fixes * Replace square bracket with a curly one Co-authored-by: Arjun Verma * Fixing errors Signed-off-by: Pradyot Ranjan <99216956+pradyotRanjan@users.noreply.github.com> * style: pre-commit fixes * Removing failure Signed-off-by: Pradyot Ranjan <99216956+pradyotRanjan@users.noreply.github.com> * style: pre-commit fixes * Delete test_spm_2024_09_13-PM03_52.json --------- Signed-off-by: Pradyot Ranjan <99216956+pradyotRanjan@users.noreply.github.com> Co-authored-by: Pradyot Ranjan <99216956+pradyotRanjan@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Co-authored-by: Arjun Verma Co-authored-by: Saransh Chopra --- .../full_battery_models/base_battery_model.py | 2 +- .../test_symbolic_diff.py | 78 ++- .../test_unary_operators.py | 421 ++++++++--------- .../test_expression_tree/test_variable.py | 80 ++-- .../test_geometry/test_battery_geometry.py | 133 +++--- tests/unit/test_meshes/test_meshes.py | 210 +++------ .../test_one_dimensional_submesh.py | 248 ++++------ .../test_meshes/test_scikit_fem_submesh.py | 151 +++--- tests/unit/test_models/test_base_model.py | 445 ++++++++---------- .../test_equivalent_circuit/test_thevenin.py | 40 +- .../test_lead_acid/test_loqs.py | 63 +-- .../test_lithium_ion/test_electrode_soh.py | 213 ++++----- .../test_lithium_ion/test_mpm.py | 70 ++- tests/unit/test_parameters/test_bpx.py | 42 +- .../test_parameters/test_current_functions.py | 15 +- .../test_parameters/test_ecm_parameters.py | 25 +- .../test_lead_acid_parameters.py | 64 ++- .../test_lithium_ion_parameters.py | 13 +- 18 files changed, 967 insertions(+), 1346 deletions(-) diff --git a/src/pybamm/models/full_battery_models/base_battery_model.py b/src/pybamm/models/full_battery_models/base_battery_model.py index f4943a1b2b..5340d685e3 100644 --- a/src/pybamm/models/full_battery_models/base_battery_model.py +++ b/src/pybamm/models/full_battery_models/base_battery_model.py @@ -749,7 +749,7 @@ def print_options(self): Print the possible options with the ones currently selected """ for key, value in self.items(): - print(f"{key!r}: {value!r} (possible: {self.possible_options[key]!r})") + print(rf"{key!r}: {value!r} (possible: {self.possible_options[key]!r})") def print_detailed_options(self): """ diff --git a/tests/unit/test_expression_tree/test_symbolic_diff.py b/tests/unit/test_expression_tree/test_symbolic_diff.py index fb08740305..e03953e667 100644 --- a/tests/unit/test_expression_tree/test_symbolic_diff.py +++ b/tests/unit/test_expression_tree/test_symbolic_diff.py @@ -2,13 +2,13 @@ # Tests for the symbolic differentiation methods # +import pytest import numpy as np import pybamm -import unittest from numpy import testing -class TestSymbolicDifferentiation(unittest.TestCase): +class TestSymbolicDifferentiation: def test_advanced(self): a = pybamm.StateVector(slice(0, 1)) b = pybamm.StateVector(slice(1, 2)) @@ -16,14 +16,14 @@ def test_advanced(self): # func = (a * 2 + 5 * (-b)) / (a * b) - self.assertEqual(func.diff(a).evaluate(y=y), 1 / 5) - self.assertEqual(func.diff(b).evaluate(y=y), -2 / 9) + assert func.diff(a).evaluate(y=y) == 1 / 5 + assert func.diff(b).evaluate(y=y) == -2 / 9 # func = a * b**a testing.assert_array_almost_equal( func.diff(a).evaluate(y=y)[0], 3**5 * (5 * np.log(3) + 1) ) - self.assertEqual(func.diff(b).evaluate(y=y), 5**2 * 3**4) + assert func.diff(b).evaluate(y=y) == 5**2 * 3**4 def test_advanced_functions(self): a = pybamm.StateVector(slice(0, 1)) @@ -32,84 +32,68 @@ def test_advanced_functions(self): # func = a * pybamm.exp(b) - self.assertAlmostEqual(func.diff(a).evaluate(y=y)[0], np.exp(3)) + assert func.diff(a).evaluate(y=y)[0] == pytest.approx(np.exp(3)) func = pybamm.exp(a + 2 * b + a * b) + a * pybamm.exp(b) - self.assertEqual( - func.diff(a).evaluate(y=y), (4 * np.exp(3 * 5 + 5 + 2 * 3) + np.exp(3)) - ) - self.assertEqual( - func.diff(b).evaluate(y=y), np.exp(3) * (7 * np.exp(3 * 5 + 5 + 3) + 5) - ) + assert func.diff(a).evaluate(y=y) == (4 * np.exp(3 * 5 + 5 + 2 * 3) + np.exp(3)) + assert func.diff(b).evaluate(y=y) == np.exp(3) * (7 * np.exp(3 * 5 + 5 + 3) + 5) # func = pybamm.sin(pybamm.cos(a * 4) / 2) * pybamm.cos(4 * pybamm.exp(b / 3)) - self.assertEqual( - func.diff(a).evaluate(y=y), - -2 * np.sin(20) * np.cos(np.cos(20) / 2) * np.cos(4 * np.exp(1)), - ) - self.assertEqual( - func.diff(b).evaluate(y=y), - -4 / 3 * np.exp(1) * np.sin(4 * np.exp(1)) * np.sin(np.cos(20) / 2), - ) + assert func.diff(a).evaluate(y=y) == -2 * np.sin(20) * np.cos( + np.cos(20) / 2 + ) * np.cos(4 * np.exp(1)) + assert func.diff(b).evaluate(y=y) == -4 / 3 * np.exp(1) * np.sin( + 4 * np.exp(1) + ) * np.sin(np.cos(20) / 2) # func = pybamm.sin(a * b) - self.assertEqual(func.diff(a).evaluate(y=y), 3 * np.cos(15)) + assert func.diff(a).evaluate(y=y) == 3 * np.cos(15) def test_diff_zero(self): a = pybamm.StateVector(slice(0, 1)) b = pybamm.StateVector(slice(1, 2)) func = (a * 2 + 5 * (-a)) / (a * a) - self.assertEqual(func.diff(b), pybamm.Scalar(0)) - self.assertNotEqual(func.diff(a), pybamm.Scalar(0)) + assert func.diff(b) == pybamm.Scalar(0) + assert func.diff(a) != pybamm.Scalar(0) def test_diff_state_vector_dot(self): a = pybamm.StateVectorDot(slice(0, 1)) b = pybamm.StateVector(slice(1, 2)) - self.assertEqual(a.diff(a), pybamm.Scalar(1)) - self.assertEqual(a.diff(b), pybamm.Scalar(0)) + assert a.diff(a) == pybamm.Scalar(1) + assert a.diff(b) == pybamm.Scalar(0) def test_diff_heaviside(self): a = pybamm.Scalar(1) b = pybamm.StateVector(slice(0, 1)) func = (a < b) * (2 * b) - self.assertEqual(func.diff(b).evaluate(y=np.array([2])), 2) - self.assertEqual(func.diff(b).evaluate(y=np.array([-2])), 0) + assert func.diff(b).evaluate(y=np.array([2])) == 2 + assert func.diff(b).evaluate(y=np.array([-2])) == 0 def test_diff_modulo(self): a = pybamm.Scalar(3) b = pybamm.StateVector(slice(0, 1)) func = (a % b) * (b**2) - self.assertEqual(func.diff(b).evaluate(y=np.array([2])), 0) - self.assertEqual(func.diff(b).evaluate(y=np.array([5])), 30) - self.assertEqual(func.diff(b).evaluate(y=np.array([-2])), 12) + assert func.diff(b).evaluate(y=np.array([2])) == 0 + assert func.diff(b).evaluate(y=np.array([5])) == 30 + assert func.diff(b).evaluate(y=np.array([-2])) == 12 def test_diff_maximum_minimum(self): a = pybamm.Scalar(1) b = pybamm.StateVector(slice(0, 1)) func = pybamm.minimum(a, b**3) - self.assertEqual(func.diff(b).evaluate(y=np.array([10])), 0) - self.assertEqual(func.diff(b).evaluate(y=np.array([2])), 0) - self.assertEqual(func.diff(b).evaluate(y=np.array([-2])), 3 * (-2) ** 2) + assert func.diff(b).evaluate(y=np.array([10])) == 0 + assert func.diff(b).evaluate(y=np.array([2])) == 0 + assert func.diff(b).evaluate(y=np.array([-2])) == 3 * (-2) ** 2 func = pybamm.maximum(a, b**3) - self.assertEqual(func.diff(b).evaluate(y=np.array([10])), 3 * 10**2) - self.assertEqual(func.diff(b).evaluate(y=np.array([2])), 3 * 2**2) - self.assertEqual(func.diff(b).evaluate(y=np.array([-2])), 0) + assert func.diff(b).evaluate(y=np.array([10])) == 3 * 10**2 + assert func.diff(b).evaluate(y=np.array([2])) == 3 * 2**2 + assert func.diff(b).evaluate(y=np.array([-2])) == 0 def test_exceptions(self): a = pybamm.Symbol("a") b = pybamm.Symbol("b") - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): a._diff(b) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_expression_tree/test_unary_operators.py b/tests/unit/test_expression_tree/test_unary_operators.py index 39cd05cf1d..812cbd8f6b 100644 --- a/tests/unit/test_expression_tree/test_unary_operators.py +++ b/tests/unit/test_expression_tree/test_unary_operators.py @@ -1,9 +1,7 @@ # # Tests for the Unary Operator classes # -import unittest - -import unittest.mock as mock +import pytest import numpy as np from scipy.sparse import diags @@ -15,49 +13,48 @@ import pybamm -class TestUnaryOperators(unittest.TestCase): +class TestUnaryOperators: def test_unary_operator(self): a = pybamm.Symbol("a", domain=["test"]) un = pybamm.UnaryOperator("unary test", a) - self.assertEqual(un.children[0].name, a.name) - self.assertEqual(un.domain, a.domain) + assert un.children[0].name == a.name + assert un.domain == a.domain # with number a = pybamm.InputParameter("a") absval = pybamm.AbsoluteValue(-a) - self.assertEqual(absval.evaluate(inputs={"a": 10}), 10) + assert absval.evaluate(inputs={"a": 10}) == 10 - def test_negation(self): + def test_negation(self, mocker): a = pybamm.Symbol("a") nega = pybamm.Negate(a) - self.assertEqual(nega.name, "-") - self.assertEqual(nega.children[0].name, a.name) + assert nega.name == "-" + assert nega.children[0].name == a.name b = pybamm.Scalar(4) negb = pybamm.Negate(b) - self.assertEqual(negb.evaluate(), -4) + assert negb.evaluate() == -4 # Test broadcast gets switched broad_a = pybamm.PrimaryBroadcast(a, "test") neg_broad = -broad_a - self.assertEqual(neg_broad, pybamm.PrimaryBroadcast(nega, "test")) + assert neg_broad == pybamm.PrimaryBroadcast(nega, "test") broad_a = pybamm.FullBroadcast(a, "test", "test2") neg_broad = -broad_a - self.assertEqual(neg_broad, pybamm.FullBroadcast(nega, "test", "test2")) + assert neg_broad == pybamm.FullBroadcast(nega, "test", "test2") # Test recursion broad_a = pybamm.PrimaryBroadcast(pybamm.PrimaryBroadcast(a, "test"), "test2") neg_broad = -broad_a - self.assertEqual( - neg_broad, - pybamm.PrimaryBroadcast(pybamm.PrimaryBroadcast(nega, "test"), "test2"), + assert neg_broad == pybamm.PrimaryBroadcast( + pybamm.PrimaryBroadcast(nega, "test"), "test2" ) # Test from_json input_json = { "name": "-", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": [], "secondary": [], @@ -66,39 +63,38 @@ def test_negation(self): }, "children": [a], } - self.assertEqual(pybamm.Negate._from_json(input_json), nega) + assert pybamm.Negate._from_json(input_json) == nega - def test_absolute(self): + def test_absolute(self, mocker): a = pybamm.Symbol("a") absa = pybamm.AbsoluteValue(a) - self.assertEqual(absa.name, "abs") - self.assertEqual(absa.children[0].name, a.name) + assert absa.name == "abs" + assert absa.children[0].name == a.name b = pybamm.Scalar(-4) absb = pybamm.AbsoluteValue(b) - self.assertEqual(absb.evaluate(), 4) + assert absb.evaluate() == 4 # Test broadcast gets switched broad_a = pybamm.PrimaryBroadcast(a, "test") abs_broad = abs(broad_a) - self.assertEqual(abs_broad, pybamm.PrimaryBroadcast(absa, "test")) + assert abs_broad == pybamm.PrimaryBroadcast(absa, "test") broad_a = pybamm.FullBroadcast(a, "test", "test2") abs_broad = abs(broad_a) - self.assertEqual(abs_broad, pybamm.FullBroadcast(absa, "test", "test2")) + assert abs_broad == pybamm.FullBroadcast(absa, "test", "test2") # Test recursion broad_a = pybamm.PrimaryBroadcast(pybamm.PrimaryBroadcast(a, "test"), "test2") abs_broad = abs(broad_a) - self.assertEqual( - abs_broad, - pybamm.PrimaryBroadcast(pybamm.PrimaryBroadcast(absa, "test"), "test2"), + assert abs_broad == pybamm.PrimaryBroadcast( + pybamm.PrimaryBroadcast(absa, "test"), "test2" ) # Test from_json input_json = { "name": "abs", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": [], "secondary": [], @@ -107,24 +103,23 @@ def test_absolute(self): }, "children": [a], } - self.assertEqual(pybamm.AbsoluteValue._from_json(input_json), absa) + assert pybamm.AbsoluteValue._from_json(input_json) == absa def test_smooth_absolute_value(self): a = pybamm.StateVector(slice(0, 1)) expr = pybamm.smooth_absolute_value(a, 10) - self.assertAlmostEqual(expr.evaluate(y=np.array([1]))[0, 0], 1) - self.assertEqual(expr.evaluate(y=np.array([0])), 0) - self.assertAlmostEqual(expr.evaluate(y=np.array([-1]))[0, 0], 1) - self.assertEqual( - str(expr), - "y[0:1] * (exp(10.0 * y[0:1]) - exp(-10.0 * y[0:1])) " - "/ (exp(10.0 * y[0:1]) + exp(-10.0 * y[0:1]))", + assert expr.evaluate(y=np.array([1]))[0, 0] == pytest.approx(1) + assert expr.evaluate(y=np.array([0])) == 0 + assert expr.evaluate(y=np.array([-1]))[0, 0] == pytest.approx(1) + assert ( + str(expr) == "y[0:1] * (exp(10.0 * y[0:1]) - exp(-10.0 * y[0:1])) " + "/ (exp(10.0 * y[0:1]) + exp(-10.0 * y[0:1]))" ) def test_sign(self): b = pybamm.Scalar(-4) signb = pybamm.sign(b) - self.assertEqual(signb.evaluate(), -1) + assert signb.evaluate() == -1 A = diags(np.linspace(-1, 1, 5)) b = pybamm.Matrix(A) @@ -134,40 +129,37 @@ def test_sign(self): ) broad = pybamm.PrimaryBroadcast(-4, "test domain") - self.assertEqual(pybamm.sign(broad), pybamm.PrimaryBroadcast(-1, "test domain")) + assert pybamm.sign(broad) == pybamm.PrimaryBroadcast(-1, "test domain") conc = pybamm.Concatenation(broad, pybamm.PrimaryBroadcast(2, "another domain")) - self.assertEqual( - pybamm.sign(conc), - pybamm.Concatenation( - pybamm.PrimaryBroadcast(-1, "test domain"), - pybamm.PrimaryBroadcast(1, "another domain"), - ), + assert pybamm.sign(conc) == pybamm.Concatenation( + pybamm.PrimaryBroadcast(-1, "test domain"), + pybamm.PrimaryBroadcast(1, "another domain"), ) # Test from_json - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): # signs are always scalar/array types in a discretised model pybamm.Sign._from_json({}) - def test_floor(self): + def test_floor(self, mocker): a = pybamm.Symbol("a") floora = pybamm.Floor(a) - self.assertEqual(floora.name, "floor") - self.assertEqual(floora.children[0].name, a.name) + assert floora.name == "floor" + assert floora.children[0].name == a.name b = pybamm.Scalar(3.5) floorb = pybamm.Floor(b) - self.assertEqual(floorb.evaluate(), 3) + assert floorb.evaluate() == 3 c = pybamm.Scalar(-3.2) floorc = pybamm.Floor(c) - self.assertEqual(floorc.evaluate(), -4) + assert floorc.evaluate() == -4 # Test from_json input_json = { "name": "floor", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": [], "secondary": [], @@ -176,26 +168,26 @@ def test_floor(self): }, "children": [a], } - self.assertEqual(pybamm.Floor._from_json(input_json), floora) + assert pybamm.Floor._from_json(input_json) == floora - def test_ceiling(self): + def test_ceiling(self, mocker): a = pybamm.Symbol("a") ceila = pybamm.Ceiling(a) - self.assertEqual(ceila.name, "ceil") - self.assertEqual(ceila.children[0].name, a.name) + assert ceila.name == "ceil" + assert ceila.children[0].name == a.name b = pybamm.Scalar(3.5) ceilb = pybamm.Ceiling(b) - self.assertEqual(ceilb.evaluate(), 4) + assert ceilb.evaluate() == 4 c = pybamm.Scalar(-3.2) ceilc = pybamm.Ceiling(c) - self.assertEqual(ceilc.evaluate(), -3) + assert ceilc.evaluate() == -3 # Test from_json input_json = { "name": "ceil", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": [], "secondary": [], @@ -204,81 +196,77 @@ def test_ceiling(self): }, "children": [a], } - self.assertEqual(pybamm.Ceiling._from_json(input_json), ceila) + assert pybamm.Ceiling._from_json(input_json) == ceila def test_gradient(self): # gradient of scalar symbol should fail a = pybamm.Symbol("a") - with self.assertRaisesRegex( - pybamm.DomainError, "Cannot take gradient of 'a' since its domain is empty" + with pytest.raises( + pybamm.DomainError, + match="Cannot take gradient of 'a' since its domain is empty", ): pybamm.Gradient(a) # gradient of variable evaluating on edges should fail a = pybamm.PrimaryBroadcastToEdges(pybamm.Scalar(1), "test") - with self.assertRaisesRegex(TypeError, "evaluates on edges"): + with pytest.raises(TypeError, match="evaluates on edges"): pybamm.Gradient(a) # gradient of broadcast should return broadcasted zero a = pybamm.PrimaryBroadcast(pybamm.Variable("a"), "test domain") grad = pybamm.grad(a) - self.assertEqual(grad, pybamm.PrimaryBroadcastToEdges(0, "test domain")) + assert grad == pybamm.PrimaryBroadcastToEdges(0, "test domain") # gradient of a secondary broadcast moves the secondary out of the gradient a = pybamm.Symbol("a", domain="test domain") a_broad = pybamm.SecondaryBroadcast(a, "another domain") grad = pybamm.grad(a_broad) - self.assertEqual( - grad, pybamm.SecondaryBroadcast(pybamm.grad(a), "another domain") - ) + assert grad == pybamm.SecondaryBroadcast(pybamm.grad(a), "another domain") # otherwise gradient should work a = pybamm.Symbol("a", domain="test domain") grad = pybamm.Gradient(a) - self.assertEqual(grad.children[0].name, a.name) - self.assertEqual(grad.domain, a.domain) + assert grad.children[0].name == a.name + assert grad.domain == a.domain def test_div(self): # divergence of scalar symbol should fail a = pybamm.Symbol("a") - with self.assertRaisesRegex( + with pytest.raises( pybamm.DomainError, - "Cannot take divergence of 'a' since its domain is empty", + match="Cannot take divergence of 'a' since its domain is empty", ): pybamm.Divergence(a) # divergence of variable evaluating on edges should fail a = pybamm.PrimaryBroadcast(pybamm.Scalar(1), "test") - with self.assertRaisesRegex(TypeError, "evaluate on edges"): + with pytest.raises(TypeError, match="evaluate on edges"): pybamm.Divergence(a) # divergence of broadcast should return broadcasted zero a = pybamm.PrimaryBroadcastToEdges(pybamm.Variable("a"), "test domain") div = pybamm.div(a) - self.assertEqual(div, pybamm.PrimaryBroadcast(0, "test domain")) + assert div == pybamm.PrimaryBroadcast(0, "test domain") a = pybamm.PrimaryBroadcastToEdges( pybamm.Variable("a", "some domain"), "test domain" ) div = pybamm.div(a) - self.assertEqual( - div, - pybamm.PrimaryBroadcast( - pybamm.PrimaryBroadcast(0, "some domain"), "test domain" - ), + assert div == pybamm.PrimaryBroadcast( + pybamm.PrimaryBroadcast(0, "some domain"), "test domain" ) # otherwise divergence should work a = pybamm.Symbol("a", domain="test domain") div = pybamm.Divergence(pybamm.Gradient(a)) - self.assertEqual(div.domain, a.domain) + assert div.domain == a.domain # check div commutes with negation a = pybamm.Symbol("a", domain="test domain") div = pybamm.div(-pybamm.Gradient(a)) - self.assertEqual(div, (-pybamm.Divergence(pybamm.Gradient(a)))) + assert div == (-pybamm.Divergence(pybamm.Gradient(a))) div = pybamm.div(-a * pybamm.Gradient(a)) - self.assertEqual(div, (-pybamm.Divergence(a * pybamm.Gradient(a)))) + assert div == (-pybamm.Divergence(a * pybamm.Gradient(a))) # div = pybamm.div(a * -pybamm.Gradient(a)) # self.assertEqual(div, (-pybamm.Divergence(a * pybamm.Gradient(a)))) @@ -288,9 +276,9 @@ def test_integral(self): a = pybamm.Symbol("a", domain=["negative electrode"]) x = pybamm.SpatialVariable("x", ["negative electrode"]) inta = pybamm.Integral(a, x) - self.assertEqual(inta.name, "integral dx ['negative electrode']") - self.assertEqual(inta.children[0].name, a.name) - self.assertEqual(inta.integration_variable[0], x) + assert inta.name == "integral dx ['negative electrode']" + assert inta.children[0].name == a.name + assert inta.integration_variable[0] == x assert_domain_equal(inta.domains, {}) # space integral with secondary domain a_sec = pybamm.Symbol( @@ -393,18 +381,18 @@ def test_integral(self): y = pybamm.SpatialVariable("y", ["current collector"]) z = pybamm.SpatialVariable("z", ["current collector"]) inta = pybamm.Integral(b, [y, z]) - self.assertEqual(inta.name, "integral dy dz ['current collector']") - self.assertEqual(inta.children[0].name, b.name) - self.assertEqual(inta.integration_variable[0], y) - self.assertEqual(inta.integration_variable[1], z) - self.assertEqual(inta.domain, []) + assert inta.name == "integral dy dz ['current collector']" + assert inta.children[0].name == b.name + assert inta.integration_variable[0] == y + assert inta.integration_variable[1] == z + assert inta.domain == [] # Indefinite inta = pybamm.IndefiniteIntegral(a, x) - self.assertEqual(inta.name, "a integrated w.r.t x on ['negative electrode']") - self.assertEqual(inta.children[0].name, a.name) - self.assertEqual(inta.integration_variable[0], x) - self.assertEqual(inta.domain, ["negative electrode"]) + assert inta.name == "a integrated w.r.t x on ['negative electrode']" + assert inta.children[0].name == a.name + assert inta.integration_variable[0] == x + assert inta.domain == ["negative electrode"] inta_sec = pybamm.IndefiniteIntegral(a_sec, x) assert_domain_equal( inta_sec.domains, @@ -412,22 +400,20 @@ def test_integral(self): ) # backward indefinite integral inta = pybamm.BackwardIndefiniteIntegral(a, x) - self.assertEqual( - inta.name, "a integrated backward w.r.t x on ['negative electrode']" - ) + assert inta.name == "a integrated backward w.r.t x on ['negative electrode']" # expected errors a = pybamm.Symbol("a", domain=["negative electrode"]) x = pybamm.SpatialVariable("x", ["separator"]) y = pybamm.Variable("y") z = pybamm.SpatialVariable("z", ["negative electrode"]) - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.Integral(a, x) - with self.assertRaisesRegex(TypeError, "integration_variable must be"): + with pytest.raises(TypeError, match="integration_variable must be"): pybamm.Integral(a, y) - with self.assertRaisesRegex( + with pytest.raises( NotImplementedError, - "Indefinite integral only implemented w.r.t. one variable", + match="Indefinite integral only implemented w.r.t. one variable", ): pybamm.IndefiniteIntegral(a, [x, y]) @@ -436,166 +422,166 @@ def test_index(self): y_test = np.array([1, 2, 3, 4, 5]) # with integer ind = pybamm.Index(vec, 3) - self.assertIsInstance(ind, pybamm.Index) - self.assertEqual(ind.slice, slice(3, 4)) - self.assertEqual(ind.evaluate(y=y_test), 4) + assert isinstance(ind, pybamm.Index) + assert ind.slice == slice(3, 4) + assert ind.evaluate(y=y_test) == 4 # with -1 ind = pybamm.Index(vec, -1) - self.assertIsInstance(ind, pybamm.Index) - self.assertEqual(ind.slice, slice(-1, None)) - self.assertEqual(ind.evaluate(y=y_test), 5) - self.assertEqual(ind.name, "Index[-1]") + assert isinstance(ind, pybamm.Index) + assert ind.slice == slice(-1, None) + assert ind.evaluate(y=y_test) == 5 + assert ind.name == "Index[-1]" # with slice ind = pybamm.Index(vec, slice(1, 3)) - self.assertIsInstance(ind, pybamm.Index) - self.assertEqual(ind.slice, slice(1, 3)) + assert isinstance(ind, pybamm.Index) + assert ind.slice == slice(1, 3) np.testing.assert_array_equal(ind.evaluate(y=y_test), np.array([[2], [3]])) # with only stop slice ind = pybamm.Index(vec, slice(3)) - self.assertIsInstance(ind, pybamm.Index) - self.assertEqual(ind.slice, slice(3)) + assert isinstance(ind, pybamm.Index) + assert ind.slice == slice(3) np.testing.assert_array_equal(ind.evaluate(y=y_test), np.array([[1], [2], [3]])) # errors - with self.assertRaisesRegex(TypeError, "index must be integer or slice"): + with pytest.raises(TypeError, match="index must be integer or slice"): pybamm.Index(vec, 0.0) debug_mode = pybamm.settings.debug_mode pybamm.settings.debug_mode = True - with self.assertRaisesRegex(ValueError, "slice size exceeds child size"): + with pytest.raises(ValueError, match="slice size exceeds child size"): pybamm.Index(vec, 5) pybamm.settings.debug_mode = debug_mode def test_evaluate_at(self): a = pybamm.Symbol("a", domain=["negative electrode"]) f = pybamm.EvaluateAt(a, 1) - self.assertEqual(f.position, 1) + assert f.position == 1 def test_upwind_downwind(self): # upwind of scalar symbol should fail a = pybamm.Symbol("a") - with self.assertRaisesRegex( - pybamm.DomainError, "Cannot upwind 'a' since its domain is empty" + with pytest.raises( + pybamm.DomainError, match="Cannot upwind 'a' since its domain is empty" ): pybamm.Upwind(a) # upwind of variable evaluating on edges should fail a = pybamm.PrimaryBroadcastToEdges(pybamm.Scalar(1), "test") - with self.assertRaisesRegex(TypeError, "evaluate on nodes"): + with pytest.raises(TypeError, match="evaluate on nodes"): pybamm.Upwind(a) # otherwise upwind should work a = pybamm.Symbol("a", domain="test domain") upwind = pybamm.upwind(a) - self.assertIsInstance(upwind, pybamm.Upwind) - self.assertEqual(upwind.children[0].name, a.name) - self.assertEqual(upwind.domain, a.domain) + assert isinstance(upwind, pybamm.Upwind) + assert upwind.children[0].name == a.name + assert upwind.domain == a.domain # also test downwind a = pybamm.Symbol("a", domain="test domain") downwind = pybamm.downwind(a) - self.assertIsInstance(downwind, pybamm.Downwind) - self.assertEqual(downwind.children[0].name, a.name) - self.assertEqual(downwind.domain, a.domain) + assert isinstance(downwind, pybamm.Downwind) + assert downwind.children[0].name == a.name + assert downwind.domain == a.domain def test_diff(self): a = pybamm.StateVector(slice(0, 1)) y = np.array([5]) # negation - self.assertEqual((-a).diff(a).evaluate(y=y), -1) - self.assertEqual((-a).diff(-a).evaluate(), 1) + assert (-a).diff(a).evaluate(y=y) == -1 + assert (-a).diff(-a).evaluate() == 1 # absolute value - self.assertEqual((a**3).diff(a).evaluate(y=y), 3 * 5**2) - self.assertEqual((abs(a**3)).diff(a).evaluate(y=y), 3 * 5**2) - self.assertEqual((a**3).diff(a).evaluate(y=-y), 3 * 5**2) - self.assertEqual((abs(a**3)).diff(a).evaluate(y=-y), -3 * 5**2) + assert (a**3).diff(a).evaluate(y=y) == 3 * 5**2 + assert (abs(a**3)).diff(a).evaluate(y=y) == 3 * 5**2 + assert (a**3).diff(a).evaluate(y=-y) == 3 * 5**2 + assert (abs(a**3)).diff(a).evaluate(y=-y) == -3 * 5**2 # sign - self.assertEqual((pybamm.sign(a)).diff(a).evaluate(y=y), 0) + assert (pybamm.sign(a)).diff(a).evaluate(y=y) == 0 # floor - self.assertEqual((pybamm.Floor(a)).diff(a).evaluate(y=y), 0) + assert (pybamm.Floor(a)).diff(a).evaluate(y=y) == 0 # ceil - self.assertEqual((pybamm.Ceiling(a)).diff(a).evaluate(y=y), 0) + assert (pybamm.Ceiling(a)).diff(a).evaluate(y=y) == 0 # spatial operator (not implemented) spatial_a = pybamm.SpatialOperator("name", a) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_a.diff(a) def test_printing(self): a = pybamm.Symbol("a", domain="test") - self.assertEqual(str(-a), "-a") + assert str(-a) == "-a" grad = pybamm.Gradient(a) - self.assertEqual(grad.name, "grad") - self.assertEqual(str(grad), "grad(a)") + assert grad.name == "grad" + assert str(grad) == "grad(a)" def test_eq(self): a = pybamm.Scalar(4) un1 = pybamm.UnaryOperator("test", a) un2 = pybamm.UnaryOperator("test", a) un3 = pybamm.UnaryOperator("new test", a) - self.assertEqual(un1, un2) - self.assertNotEqual(un1, un3) + assert un1 == un2 + assert un1 != un3 a = pybamm.Scalar(4) un4 = pybamm.UnaryOperator("test", a) - self.assertEqual(un1, un4) + assert un1 == un4 d = pybamm.Scalar(42) un5 = pybamm.UnaryOperator("test", d) - self.assertNotEqual(un1, un5) + assert un1 != un5 def test_delta_function(self): a = pybamm.Symbol("a") delta_a = pybamm.DeltaFunction(a, "right", "some domain") - self.assertEqual(delta_a.side, "right") - self.assertEqual(delta_a.child, a) - self.assertEqual(delta_a.domain, ["some domain"]) - self.assertFalse(delta_a.evaluates_on_edges("primary")) + assert delta_a.side == "right" + assert delta_a.child == a + assert delta_a.domain == ["some domain"] + assert not delta_a.evaluates_on_edges("primary") a = pybamm.Symbol("a", domain="some domain") delta_a = pybamm.DeltaFunction(a, "left", "another domain") - self.assertEqual(delta_a.side, "left") + assert delta_a.side == "left" assert_domain_equal( delta_a.domains, {"primary": ["another domain"], "secondary": ["some domain"]}, ) - with self.assertRaisesRegex( - pybamm.DomainError, "Delta function domain cannot be None" + with pytest.raises( + pybamm.DomainError, match="Delta function domain cannot be None" ): delta_a = pybamm.DeltaFunction(a, "right", None) def test_boundary_operators(self): a = pybamm.Symbol("a", domain="some domain") boundary_a = pybamm.BoundaryOperator("boundary", a, "right") - self.assertEqual(boundary_a.side, "right") - self.assertEqual(boundary_a.child, a) + assert boundary_a.side == "right" + assert boundary_a.child == a def test_evaluates_on_edges(self): a = pybamm.StateVector(slice(0, 10), domain="test") - self.assertFalse(pybamm.Index(a, slice(1)).evaluates_on_edges("primary")) - self.assertFalse(pybamm.Laplacian(a).evaluates_on_edges("primary")) - self.assertFalse(pybamm.GradientSquared(a).evaluates_on_edges("primary")) - self.assertFalse(pybamm.BoundaryIntegral(a).evaluates_on_edges("primary")) - self.assertTrue(pybamm.Upwind(a).evaluates_on_edges("primary")) - self.assertTrue(pybamm.Downwind(a).evaluates_on_edges("primary")) + assert not pybamm.Index(a, slice(1)).evaluates_on_edges("primary") + assert not pybamm.Laplacian(a).evaluates_on_edges("primary") + assert not pybamm.GradientSquared(a).evaluates_on_edges("primary") + assert not pybamm.BoundaryIntegral(a).evaluates_on_edges("primary") + assert pybamm.Upwind(a).evaluates_on_edges("primary") + assert pybamm.Downwind(a).evaluates_on_edges("primary") def test_boundary_value(self): a = pybamm.Scalar(1) boundary_a = pybamm.boundary_value(a, "right") - self.assertEqual(boundary_a, a) + assert boundary_a == a boundary_broad_a = pybamm.boundary_value( pybamm.PrimaryBroadcast(a, ["negative electrode"]), "left" ) - self.assertEqual(boundary_broad_a.evaluate(), np.array([1])) + assert boundary_broad_a.evaluate() == np.array([1]) a = pybamm.Symbol("a", domain=["separator"]) boundary_a = pybamm.boundary_value(a, "right") - self.assertIsInstance(boundary_a, pybamm.BoundaryValue) - self.assertEqual(boundary_a.side, "right") + assert isinstance(boundary_a, pybamm.BoundaryValue) + assert boundary_a.side == "right" assert_domain_equal(boundary_a.domains, {}) # test with secondary domain a_sec = pybamm.Symbol( @@ -627,7 +613,7 @@ def test_boundary_value(self): }, ) boundary_a_quat = pybamm.boundary_value(a_quat, "right") - self.assertEqual(boundary_a_quat.domain, ["current collector"]) + assert boundary_a_quat.domain == ["current collector"] assert_domain_equal( boundary_a_quat.domains, { @@ -639,26 +625,26 @@ def test_boundary_value(self): # error if boundary value on tabs and domain is not "current collector" var = pybamm.Variable("var", domain=["negative electrode"]) - with self.assertRaisesRegex(pybamm.ModelError, "Can only take boundary"): + with pytest.raises(pybamm.ModelError, match="Can only take boundary"): pybamm.boundary_value(var, "negative tab") pybamm.boundary_value(var, "positive tab") # boundary value of symbol that evaluates on edges raises error symbol_on_edges = pybamm.PrimaryBroadcastToEdges(1, "domain") - with self.assertRaisesRegex( + with pytest.raises( ValueError, - "Can't take the boundary value of a symbol that evaluates on edges", + match="Can't take the boundary value of a symbol that evaluates on edges", ): pybamm.boundary_value(symbol_on_edges, "right") def test_boundary_gradient(self): var = pybamm.Variable("var", domain=["negative electrode"]) grad = pybamm.boundary_gradient(var, "right") - self.assertIsInstance(grad, pybamm.BoundaryGradient) + assert isinstance(grad, pybamm.BoundaryGradient) zero = pybamm.PrimaryBroadcast(0, ["negative electrode"]) grad = pybamm.boundary_gradient(zero, "right") - self.assertEqual(grad, 0) + assert grad == 0 def test_unary_simplifications(self): a = pybamm.Scalar(0) @@ -666,25 +652,25 @@ def test_unary_simplifications(self): d = pybamm.Scalar(-1) # negate - self.assertIsInstance((-a), pybamm.Scalar) - self.assertEqual((-a).evaluate(), 0) - self.assertIsInstance((-b), pybamm.Scalar) - self.assertEqual((-b).evaluate(), -1) + assert isinstance((-a), pybamm.Scalar) + assert (-a).evaluate() == 0 + assert isinstance((-b), pybamm.Scalar) + assert (-b).evaluate() == -1 # absolute value - self.assertIsInstance((abs(a)), pybamm.Scalar) - self.assertEqual((abs(a)).evaluate(), 0) - self.assertIsInstance((abs(d)), pybamm.Scalar) - self.assertEqual((abs(d)).evaluate(), 1) + assert isinstance((abs(a)), pybamm.Scalar) + assert (abs(a)).evaluate() == 0 + assert isinstance((abs(d)), pybamm.Scalar) + assert (abs(d)).evaluate() == 1 def test_not_constant(self): a = pybamm.NotConstant(pybamm.Scalar(1)) - self.assertEqual(a.name, "not_constant") - self.assertEqual(a.domain, []) - self.assertEqual(a.evaluate(), 1) - self.assertEqual(a.jac(pybamm.StateVector(slice(0, 1))).evaluate(), 0) - self.assertFalse(a.is_constant()) - self.assertFalse((2 * a).is_constant()) + assert a.name == "not_constant" + assert a.domain == [] + assert a.evaluate() == 1 + assert a.jac(pybamm.StateVector(slice(0, 1))).evaluate() == 0 + assert not a.is_constant() + assert not (2 * a).is_constant() def test_to_equation(self): a = pybamm.Symbol("a", domain="negative particle") @@ -695,62 +681,57 @@ def test_to_equation(self): # Test print_name pybamm.Floor.print_name = "test" - self.assertEqual(pybamm.Floor(-2.5).to_equation(), sympy.Symbol("test")) + assert pybamm.Floor(-2.5).to_equation() == sympy.Symbol("test") # Test Negate value = 4 - self.assertEqual(pybamm.Negate(value).to_equation(), -value) + assert pybamm.Negate(value).to_equation() == -value # Test AbsoluteValue - self.assertEqual(pybamm.AbsoluteValue(-value).to_equation(), value) + assert pybamm.AbsoluteValue(-value).to_equation() == value # Test Gradient - self.assertEqual(pybamm.Gradient(a).to_equation(), sympy_Gradient("a")) + assert pybamm.Gradient(a).to_equation() == sympy_Gradient("a") # Test Divergence - self.assertEqual( - pybamm.Divergence(pybamm.Gradient(a)).to_equation(), - sympy_Divergence(sympy_Gradient("a")), + assert pybamm.Divergence(pybamm.Gradient(a)).to_equation() == sympy_Divergence( + sympy_Gradient("a") ) # Test BoundaryValue - self.assertEqual( - pybamm.BoundaryValue(one, "right").to_equation(), sympy.Symbol("1") + assert pybamm.BoundaryValue(one, "right").to_equation() == sympy.Symbol("1") + assert pybamm.BoundaryValue(a, "right").to_equation() == sympy.Symbol( + "a^{surf}" ) - self.assertEqual( - pybamm.BoundaryValue(a, "right").to_equation(), sympy.Symbol("a^{surf}") + assert pybamm.BoundaryValue(b, "positive tab").to_equation() == sympy.Symbol( + str(b) ) - self.assertEqual( - pybamm.BoundaryValue(b, "positive tab").to_equation(), sympy.Symbol(str(b)) - ) - self.assertEqual( - pybamm.BoundaryValue(c, "left").to_equation(), - sympy.Symbol(r"c^{\mathtt{\text{left}}}"), + assert pybamm.BoundaryValue(c, "left").to_equation() == sympy.Symbol( + r"c^{\mathtt{\text{left}}}" ) # Test Integral xn = pybamm.SpatialVariable("xn", ["negative electrode"]) - self.assertEqual( - pybamm.Integral(d, xn).to_equation(), - sympy.Integral("d", sympy.Symbol("xn")), + assert pybamm.Integral(d, xn).to_equation() == sympy.Integral( + "d", sympy.Symbol("xn") ) def test_explicit_time_integral(self): expr = pybamm.ExplicitTimeIntegral(pybamm.Parameter("param"), pybamm.Scalar(1)) - self.assertEqual(expr.child, pybamm.Parameter("param")) - self.assertEqual(expr.initial_condition, pybamm.Scalar(1)) - self.assertEqual(expr.name, "explicit time integral") - self.assertEqual(expr.create_copy(), expr) - self.assertFalse(expr.is_constant()) + assert expr.child == pybamm.Parameter("param") + assert expr.initial_condition == pybamm.Scalar(1) + assert expr.name == "explicit time integral" + assert expr.create_copy() == expr + assert not expr.is_constant() - def test_to_from_json(self): + def test_to_from_json(self, mocker): # UnaryOperator a = pybamm.Symbol("a", domain=["test"]) un = pybamm.UnaryOperator("unary test", a) un_json = { "name": "unary test", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": ["test"], "secondary": [], @@ -759,10 +740,10 @@ def test_to_from_json(self): }, } - self.assertEqual(un.to_json(), un_json) + assert un.to_json() == un_json un_json["children"] = [a] - self.assertEqual(pybamm.UnaryOperator._from_json(un_json), un) + assert pybamm.UnaryOperator._from_json(un_json) == un # Index vec = pybamm.StateVector(slice(0, 5)) @@ -770,41 +751,31 @@ def test_to_from_json(self): ind_json = { "name": "Index[3]", - "id": mock.ANY, + "id": mocker.ANY, "index": {"start": 3, "stop": 4, "step": None}, "check_size": False, } - self.assertEqual(ind.to_json(), ind_json) + assert ind.to_json() == ind_json ind_json["children"] = [vec] - self.assertEqual(pybamm.Index._from_json(ind_json), ind) + assert pybamm.Index._from_json(ind_json) == ind # SpatialOperator spatial_vec = pybamm.SpatialOperator("name", vec) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_vec.to_json() - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.SpatialOperator._from_json({}) # ExplicitTimeIntegral expr = pybamm.ExplicitTimeIntegral(pybamm.Parameter("param"), pybamm.Scalar(1)) - expr_json = {"name": "explicit time integral", "id": mock.ANY} + expr_json = {"name": "explicit time integral", "id": mocker.ANY} - self.assertEqual(expr.to_json(), expr_json) + assert expr.to_json() == expr_json expr_json["children"] = [pybamm.Parameter("param")] expr_json["initial_condition"] = [pybamm.Scalar(1)] - self.assertEqual(pybamm.ExplicitTimeIntegral._from_json(expr_json), expr) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert pybamm.ExplicitTimeIntegral._from_json(expr_json) == expr diff --git a/tests/unit/test_expression_tree/test_variable.py b/tests/unit/test_expression_tree/test_variable.py index fb17968ca8..25c7955cc0 100644 --- a/tests/unit/test_expression_tree/test_variable.py +++ b/tests/unit/test_expression_tree/test_variable.py @@ -2,7 +2,7 @@ # Tests for the Variable class # -import unittest +import pytest import numpy as np @@ -10,97 +10,85 @@ import sympy -class TestVariable(unittest.TestCase): +class TestVariable: def test_variable_init(self): a = pybamm.Variable("a") - self.assertEqual(a.name, "a") - self.assertEqual(a.domain, []) + assert a.name == "a" + assert a.domain == [] a = pybamm.Variable("a", domain=["test"]) - self.assertEqual(a.domain[0], "test") - self.assertRaises(TypeError, pybamm.Variable("a", domain="test")) - self.assertEqual(a.scale, 1) - self.assertEqual(a.reference, 0) + assert a.domain[0] == "test" + assert a.scale == 1 + assert a.reference == 0 a = pybamm.Variable("a", scale=2, reference=-1) - self.assertEqual(a.scale, 2) - self.assertEqual(a.reference, -1) + assert a.scale == 2 + assert a.reference == -1 def test_variable_diff(self): a = pybamm.Variable("a") b = pybamm.Variable("b") - self.assertIsInstance(a.diff(a), pybamm.Scalar) - self.assertEqual(a.diff(a).evaluate(), 1) - self.assertIsInstance(a.diff(b), pybamm.Scalar) - self.assertEqual(a.diff(b).evaluate(), 0) + assert isinstance(a.diff(a), pybamm.Scalar) + assert a.diff(a).evaluate() == 1 + assert isinstance(a.diff(b), pybamm.Scalar) + assert a.diff(b).evaluate() == 0 def test_variable_eq(self): a1 = pybamm.Variable("a", domain=["negative electrode"]) a2 = pybamm.Variable("a", domain=["negative electrode"]) - self.assertEqual(a1, a2) + assert a1 == a2 a3 = pybamm.Variable("b", domain=["negative electrode"]) a4 = pybamm.Variable("a", domain=["positive electrode"]) - self.assertNotEqual(a1, a3) - self.assertNotEqual(a1, a4) + assert a1 != a3 + assert a1 != a4 def test_variable_bounds(self): var = pybamm.Variable("var") - self.assertEqual(var.bounds, (-np.inf, np.inf)) + assert var.bounds == (-np.inf, np.inf) var = pybamm.Variable("var", bounds=(0, 1)) - self.assertEqual(var.bounds, (0, 1)) + assert var.bounds == (0, 1) - with self.assertRaisesRegex(ValueError, "Invalid bounds"): + with pytest.raises(ValueError, match="Invalid bounds"): pybamm.Variable("var", bounds=(1, 0)) - with self.assertRaisesRegex(ValueError, "Invalid bounds"): + with pytest.raises(ValueError, match="Invalid bounds"): pybamm.Variable("var", bounds=(1, 1)) def test_to_equation(self): # Test print_name func = pybamm.Variable("test_string") func.print_name = "test" - self.assertEqual(func.to_equation(), sympy.Symbol("test")) + assert func.to_equation() == sympy.Symbol("test") # Test name - self.assertEqual(pybamm.Variable("name").to_equation(), sympy.Symbol("name")) + assert pybamm.Variable("name").to_equation() == sympy.Symbol("name") def test_to_json_error(self): func = pybamm.Variable("test_string") - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): func.to_json() -class TestVariableDot(unittest.TestCase): +class TestVariableDot: def test_variable_init(self): a = pybamm.VariableDot("a'") - self.assertEqual(a.name, "a'") - self.assertEqual(a.domain, []) + assert a.name == "a'" + assert a.domain == [] a = pybamm.VariableDot("a", domain=["test"]) - self.assertEqual(a.domain[0], "test") - self.assertRaises(TypeError, pybamm.Variable("a", domain="test")) + assert a.domain[0] == "test" def test_variable_id(self): a1 = pybamm.VariableDot("a", domain=["negative electrode"]) a2 = pybamm.VariableDot("a", domain=["negative electrode"]) - self.assertEqual(a1, a2) + assert a1 == a2 a3 = pybamm.VariableDot("b", domain=["negative electrode"]) a4 = pybamm.VariableDot("a", domain=["positive electrode"]) - self.assertNotEqual(a1, a3) - self.assertNotEqual(a1, a4) + assert a1 != a3 + assert a1 != a4 def test_variable_diff(self): a = pybamm.VariableDot("a") b = pybamm.Variable("b") - self.assertIsInstance(a.diff(a), pybamm.Scalar) - self.assertEqual(a.diff(a).evaluate(), 1) - self.assertIsInstance(a.diff(b), pybamm.Scalar) - self.assertEqual(a.diff(b).evaluate(), 0) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert isinstance(a.diff(a), pybamm.Scalar) + assert a.diff(a).evaluate() == 1 + assert isinstance(a.diff(b), pybamm.Scalar) + assert a.diff(b).evaluate() == 0 diff --git a/tests/unit/test_geometry/test_battery_geometry.py b/tests/unit/test_geometry/test_battery_geometry.py index 38e1ce1908..c73ef89af1 100644 --- a/tests/unit/test_geometry/test_battery_geometry.py +++ b/tests/unit/test_geometry/test_battery_geometry.py @@ -1,25 +1,27 @@ # # Tests for the base model class # +import pytest import pybamm -import unittest -class TestBatteryGeometry(unittest.TestCase): - def test_geometry_keys(self): - for cc_dimension in [0, 1, 2]: - geometry = pybamm.battery_geometry( - options={ - "particle size": "distribution", - "dimensionality": cc_dimension, - }, +class TestBatteryGeometry: + @pytest.fixture(params=[0, 1, 2]) + def geometry(self, request): + geometry = pybamm.battery_geometry( + options={ + "particle size": "distribution", + "dimensionality": request.param, + }, + ) + return geometry + + def test_geometry_keys(self, geometry): + for domain_geoms in geometry.values(): + assert all( + isinstance(spatial_var, str) for spatial_var in domain_geoms.keys() ) - for domain_geoms in geometry.values(): - all( - self.assertIsInstance(spatial_var, str) - for spatial_var in domain_geoms.keys() - ) geometry.print_parameter_info() def test_geometry(self): @@ -31,60 +33,60 @@ def test_geometry(self): "dimensionality": cc_dimension, }, ) - self.assertIsInstance(geometry, pybamm.Geometry) - self.assertIn("negative electrode", geometry) - self.assertIn("negative particle", geometry) - self.assertIn("negative particle size", geometry) - self.assertEqual(geometry["negative electrode"]["x_n"]["min"], 0) - self.assertEqual(geometry["negative electrode"]["x_n"]["max"], geo.n.L) + assert isinstance(geometry, pybamm.Geometry) + assert "negative electrode" in geometry + assert "negative particle" in geometry + assert "negative particle size" in geometry + assert geometry["negative electrode"]["x_n"]["min"] == 0 + assert geometry["negative electrode"]["x_n"]["max"] == geo.n.L if cc_dimension == 1: - self.assertIn("tabs", geometry["current collector"]) + assert "tabs" in geometry["current collector"] geometry = pybamm.battery_geometry(include_particles=False) - self.assertNotIn("negative particle", geometry) + assert "negative particle" not in geometry geometry = pybamm.battery_geometry() - self.assertNotIn("negative particle size", geometry) + assert "negative particle size" not in geometry geometry = pybamm.battery_geometry(form_factor="cylindrical") - self.assertEqual(geometry["current collector"]["r_macro"]["position"], 1) + assert geometry["current collector"]["r_macro"]["position"] == 1 geometry = pybamm.battery_geometry( form_factor="cylindrical", options={"dimensionality": 1} ) - self.assertEqual(geometry["current collector"]["r_macro"]["min"], geo.r_inner) - self.assertEqual(geometry["current collector"]["r_macro"]["max"], 1) + assert geometry["current collector"]["r_macro"]["min"] == geo.r_inner + assert geometry["current collector"]["r_macro"]["max"] == 1 options = {"particle phases": "2"} geometry = pybamm.battery_geometry(options=options) geo = pybamm.GeometricParameters(options=options) - self.assertEqual(geometry["negative primary particle"]["r_n_prim"]["min"], 0) - self.assertEqual( - geometry["negative primary particle"]["r_n_prim"]["max"], geo.n.prim.R_typ + assert geometry["negative primary particle"]["r_n_prim"]["min"] == 0 + assert ( + geometry["negative primary particle"]["r_n_prim"]["max"] == geo.n.prim.R_typ ) - self.assertEqual(geometry["negative secondary particle"]["r_n_sec"]["min"], 0) - self.assertEqual( - geometry["negative secondary particle"]["r_n_sec"]["max"], geo.n.sec.R_typ + assert geometry["negative secondary particle"]["r_n_sec"]["min"] == 0 + assert ( + geometry["negative secondary particle"]["r_n_sec"]["max"] == geo.n.sec.R_typ ) - self.assertEqual(geometry["positive primary particle"]["r_p_prim"]["min"], 0) - self.assertEqual( - geometry["positive primary particle"]["r_p_prim"]["max"], geo.p.prim.R_typ + assert geometry["positive primary particle"]["r_p_prim"]["min"] == 0 + assert ( + geometry["positive primary particle"]["r_p_prim"]["max"] == geo.p.prim.R_typ ) - self.assertEqual(geometry["positive secondary particle"]["r_p_sec"]["min"], 0) - self.assertEqual( - geometry["positive secondary particle"]["r_p_sec"]["max"], geo.p.sec.R_typ + assert geometry["positive secondary particle"]["r_p_sec"]["min"] == 0 + assert ( + geometry["positive secondary particle"]["r_p_sec"]["max"] == geo.p.sec.R_typ ) def test_geometry_error(self): - with self.assertRaisesRegex(pybamm.GeometryError, "Invalid current"): + with pytest.raises(pybamm.GeometryError, match="Invalid current"): pybamm.battery_geometry( form_factor="cylindrical", options={"dimensionality": 2} ) - with self.assertRaisesRegex(pybamm.GeometryError, "Invalid form"): + with pytest.raises(pybamm.GeometryError, match="Invalid form"): pybamm.battery_geometry(form_factor="triangle") -class TestReadParameters(unittest.TestCase): +class TestReadParameters: # This is the most complicated geometry and should test the parameters are # all returned for the deepest dict def test_read_parameters(self): @@ -103,37 +105,22 @@ def test_read_parameters(self): geometry = pybamm.battery_geometry(options={"dimensionality": 2}) - self.assertEqual( - set([x.name for x in geometry.parameters]), - set( - [ - x.name - for x in [ - L_n, - L_s, - L_p, - L_y, - L_z, - tab_n_y, - tab_n_z, - L_tab_n, - tab_p_y, - tab_p_z, - L_tab_p, - ] + assert set([x.name for x in geometry.parameters]) == set( + [ + x.name + for x in [ + L_n, + L_s, + L_p, + L_y, + L_z, + tab_n_y, + tab_n_z, + L_tab_n, + tab_p_y, + tab_p_z, + L_tab_p, ] - ), - ) - self.assertTrue( - all(isinstance(x, pybamm.Parameter) for x in geometry.parameters) + ] ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert all(isinstance(x, pybamm.Parameter) for x in geometry.parameters) diff --git a/tests/unit/test_meshes/test_meshes.py b/tests/unit/test_meshes/test_meshes.py index 2f3bffddfb..eecc1a8911 100644 --- a/tests/unit/test_meshes/test_meshes.py +++ b/tests/unit/test_meshes/test_meshes.py @@ -2,9 +2,9 @@ # Test for the Finite Volume Mesh class # +import pytest import pybamm import numpy as np -import unittest def get_param(): @@ -19,7 +19,19 @@ def get_param(): ) -class TestMesh(unittest.TestCase): +class TestMesh: + @pytest.fixture(scope="class") + def submesh_types(self): + submesh_types = { + "negative electrode": pybamm.Uniform1DSubMesh, + "separator": pybamm.Uniform1DSubMesh, + "positive electrode": pybamm.Uniform1DSubMesh, + "negative particle": pybamm.Uniform1DSubMesh, + "positive particle": pybamm.Uniform1DSubMesh, + "current collector": pybamm.SubMesh0D, + } + return submesh_types + def test_mesh_creation_no_parameters(self): r = pybamm.SpatialVariable( "r", domain=["negative particle"], coord_sys="spherical polar" @@ -36,17 +48,17 @@ def test_mesh_creation_no_parameters(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check geometry - self.assertEqual(mesh.geometry, geometry) + assert mesh.geometry == geometry # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) # errors if old format @@ -55,12 +67,12 @@ def test_mesh_creation_no_parameters(self): "primary": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} } } - with self.assertRaisesRegex( - pybamm.GeometryError, "Geometry should no longer be given keys" + with pytest.raises( + pybamm.GeometryError, match="Geometry should no longer be given keys" ): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) - def test_mesh_creation(self): + def test_mesh_creation(self, submesh_types): param = get_param() geometry = pybamm.battery_geometry() @@ -68,52 +80,32 @@ def test_mesh_creation(self): var_pts = {"x_n": 10, "x_s": 10, "x_p": 12, "r_n": 5, "r_p": 6} - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } - # create mesh mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check geometry - self.assertEqual(mesh.geometry, geometry) + assert mesh.geometry == geometry # check boundary locations - self.assertEqual(mesh["negative electrode"].edges[0], 0) - self.assertAlmostEqual(mesh["positive electrode"].edges[-1], 0.6) + assert mesh["negative electrode"].edges[0] == 0 + assert mesh["positive electrode"].edges[-1] == pytest.approx(0.6) # check internal boundary locations - self.assertEqual( - mesh["negative electrode"].edges[-1], mesh["separator"].edges[0] - ) - self.assertEqual( - mesh["positive electrode"].edges[0], mesh["separator"].edges[-1] - ) + assert mesh["negative electrode"].edges[-1] == mesh["separator"].edges[0] + assert mesh["positive electrode"].edges[0] == mesh["separator"].edges[-1] for domain in mesh.base_domains: if domain != "current collector": - self.assertEqual(len(mesh[domain].edges), len(mesh[domain].nodes) + 1) + assert len(mesh[domain].edges) == len(mesh[domain].nodes) + 1 - def test_init_failure(self): + def test_init_failure(self, submesh_types): geometry = pybamm.battery_geometry() - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } - with self.assertRaisesRegex(KeyError, "Points not given"): + + with pytest.raises(KeyError, match="Points not given"): pybamm.Mesh(geometry, submesh_types, {}) var_pts = {"x_n": 10, "x_s": 10, "x_p": 12} geometry = pybamm.battery_geometry(options={"dimensionality": 1}) - with self.assertRaisesRegex(KeyError, "Points not given"): + with pytest.raises(KeyError, match="Points not given"): pybamm.Mesh(geometry, submesh_types, var_pts) # Not processing geometry parameters @@ -121,26 +113,17 @@ def test_init_failure(self): var_pts = {"x_n": 10, "x_s": 10, "x_p": 12, "r_n": 5, "r_p": 6} - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } - - with self.assertRaisesRegex(pybamm.DiscretisationError, "Parameter values"): + with pytest.raises(pybamm.DiscretisationError, match="Parameter values"): pybamm.Mesh(geometry, submesh_types, var_pts) # Geometry has an unrecognized variable type geometry["negative electrode"] = { "x_n": {"min": 0, "max": pybamm.Variable("var")} } - with self.assertRaisesRegex(NotImplementedError, "for symbol var"): + with pytest.raises(NotImplementedError, match="for symbol var"): pybamm.Mesh(geometry, submesh_types, var_pts) - def test_mesh_sizes(self): + def test_mesh_sizes(self, submesh_types): param = get_param() geometry = pybamm.battery_geometry() @@ -148,27 +131,19 @@ def test_mesh_sizes(self): # provide mesh properties var_pts = {"x_n": 10, "x_s": 10, "x_p": 12, "r_n": 5, "r_p": 6} - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } # create mesh mesh = pybamm.Mesh(geometry, submesh_types, var_pts) - self.assertEqual(mesh["negative electrode"].npts, var_pts["x_n"]) - self.assertEqual(mesh["separator"].npts, var_pts["x_s"]) - self.assertEqual(mesh["positive electrode"].npts, var_pts["x_p"]) + assert mesh["negative electrode"].npts == var_pts["x_n"] + assert mesh["separator"].npts == var_pts["x_s"] + assert mesh["positive electrode"].npts == var_pts["x_p"] - self.assertEqual(len(mesh["negative electrode"].edges) - 1, var_pts["x_n"]) - self.assertEqual(len(mesh["separator"].edges) - 1, var_pts["x_s"]) - self.assertEqual(len(mesh["positive electrode"].edges) - 1, var_pts["x_p"]) + assert len(mesh["negative electrode"].edges) - 1 == var_pts["x_n"] + assert len(mesh["separator"].edges) - 1 == var_pts["x_s"] + assert len(mesh["positive electrode"].edges) - 1 == var_pts["x_p"] - def test_mesh_sizes_using_standard_spatial_vars(self): + def test_mesh_sizes_using_standard_spatial_vars(self, submesh_types): param = get_param() geometry = pybamm.battery_geometry() @@ -177,27 +152,19 @@ def test_mesh_sizes_using_standard_spatial_vars(self): # provide mesh properties var = pybamm.standard_spatial_vars var_pts = {var.x_n: 10, var.x_s: 10, var.x_p: 12, var.r_n: 5, var.r_p: 6} - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } # create mesh mesh = pybamm.Mesh(geometry, submesh_types, var_pts) - self.assertEqual(mesh["negative electrode"].npts, var_pts[var.x_n]) - self.assertEqual(mesh["separator"].npts, var_pts[var.x_s]) - self.assertEqual(mesh["positive electrode"].npts, var_pts[var.x_p]) + assert mesh["negative electrode"].npts == var_pts[var.x_n] + assert mesh["separator"].npts == var_pts[var.x_s] + assert mesh["positive electrode"].npts == var_pts[var.x_p] - self.assertEqual(len(mesh["negative electrode"].edges) - 1, var_pts[var.x_n]) - self.assertEqual(len(mesh["separator"].edges) - 1, var_pts[var.x_s]) - self.assertEqual(len(mesh["positive electrode"].edges) - 1, var_pts[var.x_p]) + assert len(mesh["negative electrode"].edges) - 1 == var_pts[var.x_n] + assert len(mesh["separator"].edges) - 1 == var_pts[var.x_s] + assert len(mesh["positive electrode"].edges) - 1 == var_pts[var.x_p] - def test_combine_submeshes(self): + def test_combine_submeshes(self, submesh_types): param = get_param() geometry = pybamm.battery_geometry() @@ -205,22 +172,14 @@ def test_combine_submeshes(self): # provide mesh properties var_pts = {"x_n": 10, "x_s": 10, "x_p": 12, "r_n": 5, "r_p": 6} - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } # create mesh mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # create submesh submesh = mesh[("negative electrode", "separator")] - self.assertEqual(submesh.edges[0], 0) - self.assertEqual(submesh.edges[-1], mesh["separator"].edges[-1]) + assert submesh.edges[0] == 0 + assert submesh.edges[-1] == mesh["separator"].edges[-1] np.testing.assert_almost_equal( submesh.nodes - np.concatenate( @@ -228,8 +187,8 @@ def test_combine_submeshes(self): ), 0, ) - self.assertEqual(submesh.internal_boundaries, [0.1]) - with self.assertRaises(pybamm.DomainError): + assert submesh.internal_boundaries == [0.1] + with pytest.raises(pybamm.DomainError): mesh.combine_submeshes("negative electrode", "positive electrode") # test errors @@ -241,15 +200,15 @@ def test_combine_submeshes(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) - with self.assertRaisesRegex(pybamm.DomainError, "trying"): + with pytest.raises(pybamm.DomainError, match="trying"): mesh.combine_submeshes("negative electrode", "negative particle") - with self.assertRaisesRegex( - ValueError, "Submesh domains being combined cannot be empty" + with pytest.raises( + ValueError, match="Submesh domains being combined cannot be empty" ): mesh.combine_submeshes() - def test_ghost_cells(self): + def test_ghost_cells(self, submesh_types): param = get_param() geometry = pybamm.battery_geometry() @@ -257,14 +216,6 @@ def test_ghost_cells(self): # provide mesh properties var_pts = {"x_n": 10, "x_s": 10, "x_p": 12, "r_n": 5, "r_p": 6} - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } # create mesh mesh = pybamm.Mesh(geometry, submesh_types, var_pts) @@ -282,7 +233,7 @@ def test_ghost_cells(self): mesh["positive electrode"].edges[-1], ) - def test_mesh_coord_sys(self): + def test_mesh_coord_sys(self, submesh_types): param = get_param() geometry = pybamm.battery_geometry() @@ -290,21 +241,12 @@ def test_mesh_coord_sys(self): var_pts = {"x_n": 10, "x_s": 10, "x_p": 12, "r_n": 5, "r_p": 6} - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } - # create mesh mesh = pybamm.Mesh(geometry, submesh_types, var_pts) for submesh in mesh.values(): if not isinstance(submesh, pybamm.SubMesh0D): - self.assertTrue(submesh.coord_sys in pybamm.KNOWN_COORD_SYS) + assert submesh.coord_sys in pybamm.KNOWN_COORD_SYS def test_unimplemented_meshes(self): var_pts = {"x_n": 10, "y": 10} @@ -315,10 +257,10 @@ def test_unimplemented_meshes(self): } } submesh_types = {"negative electrode": pybamm.Uniform1DSubMesh} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.Mesh(geometry, submesh_types, var_pts) - def test_1plus1D_tabs_left_right(self): + def test_1plus1_d_tabs_left_right(self): param = pybamm.ParameterValues( values={ "Electrode width [m]": 0.4, @@ -349,12 +291,12 @@ def test_1plus1D_tabs_left_right(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # negative tab should be "left" - self.assertEqual(mesh["current collector"].tabs["negative tab"], "left") + assert mesh["current collector"].tabs["negative tab"] == "left" # positive tab should be "right" - self.assertEqual(mesh["current collector"].tabs["positive tab"], "right") + assert mesh["current collector"].tabs["positive tab"] == "right" - def test_1plus1D_tabs_right_left(self): + def test_1plus1_d_tabs_right_left(self): param = pybamm.ParameterValues( values={ "Electrode width [m]": 0.4, @@ -385,10 +327,10 @@ def test_1plus1D_tabs_right_left(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # negative tab should be "right" - self.assertEqual(mesh["current collector"].tabs["negative tab"], "right") + assert mesh["current collector"].tabs["negative tab"] == "right" # positive tab should be "left" - self.assertEqual(mesh["current collector"].tabs["positive tab"], "left") + assert mesh["current collector"].tabs["positive tab"] == "left" def test_to_json(self): r = pybamm.SpatialVariable( @@ -412,20 +354,10 @@ def test_to_json(self): "base_domains": ["negative particle"], } - self.assertEqual(mesh_json, expected_json) + assert mesh_json == expected_json -class TestMeshGenerator(unittest.TestCase): +class TestMeshGenerator: def test_init_name(self): mesh_generator = pybamm.MeshGenerator(pybamm.SubMesh0D) - self.assertEqual(mesh_generator.__repr__(), "Generator for SubMesh0D") - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert mesh_generator.__repr__() == "Generator for SubMesh0D" diff --git a/tests/unit/test_meshes/test_one_dimensional_submesh.py b/tests/unit/test_meshes/test_one_dimensional_submesh.py index 82429e475c..94895b6c4b 100644 --- a/tests/unit/test_meshes/test_one_dimensional_submesh.py +++ b/tests/unit/test_meshes/test_one_dimensional_submesh.py @@ -1,20 +1,36 @@ +import pytest import pybamm -import unittest import numpy as np -class TestSubMesh1D(unittest.TestCase): +@pytest.fixture() +def r(): + r = pybamm.SpatialVariable( + "r", domain=["negative particle"], coord_sys="spherical polar" + ) + return r + + +@pytest.fixture() +def geometry(r): + geometry = { + "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} + } + return geometry + + +class TestSubMesh1D: def test_tabs(self): edges = np.linspace(0, 1, 10) tabs = {"negative": {"z_centre": 0}, "positive": {"z_centre": 1}} mesh = pybamm.SubMesh1D(edges, None, tabs=tabs) - self.assertEqual(mesh.tabs["negative tab"], "left") - self.assertEqual(mesh.tabs["positive tab"], "right") + assert mesh.tabs["negative tab"] == "left" + assert mesh.tabs["positive tab"] == "right" def test_exceptions(self): edges = np.linspace(0, 1, 10) tabs = {"negative": {"z_centre": 0.2}, "positive": {"z_centre": 1}} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.SubMesh1D(edges, None, tabs=tabs) def test_to_json(self): @@ -41,28 +57,20 @@ def test_to_json(self): "tabs": {"negative tab": "left", "positive tab": "right"}, } - self.assertEqual(mesh_json, expected_json) + assert mesh_json == expected_json # check tabs work new_mesh = pybamm.Uniform1DSubMesh._from_json(mesh_json) - self.assertEqual(mesh.tabs, new_mesh.tabs) + assert mesh.tabs == new_mesh.tabs -class TestUniform1DSubMesh(unittest.TestCase): +class TestUniform1DSubMesh: def test_exceptions(self): lims = {"a": 1, "b": 2} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.Uniform1DSubMesh(lims, None) - def test_symmetric_mesh_creation_no_parameters(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" - ) - - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } - + def test_symmetric_mesh_creation_no_parameters(self, r, geometry): submesh_types = {"negative particle": pybamm.Uniform1DSubMesh} var_pts = {r: 20} @@ -70,27 +78,19 @@ def test_symmetric_mesh_creation_no_parameters(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, - ) - - -class TestExponential1DSubMesh(unittest.TestCase): - def test_symmetric_mesh_creation_no_parameters_even(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } +class TestExponential1DSubMesh: + def test_symmetric_mesh_creation_no_parameters_even(self, r, geometry): submesh_params = {"side": "symmetric"} submesh_types = { "negative particle": pybamm.MeshGenerator( @@ -103,25 +103,17 @@ def test_symmetric_mesh_creation_no_parameters_even(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) - def test_symmetric_mesh_creation_no_parameters_odd(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" - ) - - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } - + def test_symmetric_mesh_creation_no_parameters_odd(self, r, geometry): submesh_params = {"side": "symmetric", "stretch": 1.5} submesh_types = { "negative particle": pybamm.MeshGenerator( @@ -134,25 +126,17 @@ def test_symmetric_mesh_creation_no_parameters_odd(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, - ) - - def test_left_mesh_creation_no_parameters(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } - + def test_left_mesh_creation_no_parameters(self, r, geometry): submesh_params = {"side": "left"} submesh_types = { "negative particle": pybamm.MeshGenerator( @@ -165,25 +149,17 @@ def test_left_mesh_creation_no_parameters(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) - def test_right_mesh_creation_no_parameters(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" - ) - - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } - + def test_right_mesh_creation_no_parameters(self, r, geometry): submesh_params = {"side": "right", "stretch": 2} submesh_types = { "negative particle": pybamm.MeshGenerator( @@ -196,27 +172,19 @@ def test_right_mesh_creation_no_parameters(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, - ) - - -class TestChebyshev1DSubMesh(unittest.TestCase): - def test_mesh_creation_no_parameters(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } +class TestChebyshev1DSubMesh: + def test_mesh_creation_no_parameters(self, r, geometry): submesh_types = {"negative particle": pybamm.Chebyshev1DSubMesh} var_pts = {r: 20} @@ -224,18 +192,18 @@ def test_mesh_creation_no_parameters(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) -class TestUser1DSubMesh(unittest.TestCase): +class TestUser1DSubMesh: def test_exceptions(self): edges = np.array([0, 0.3, 1]) submesh_params = {"edges": edges} @@ -244,35 +212,27 @@ def test_exceptions(self): # error if npts+1 != len(edges) lims = {"x_n": {"min": 0, "max": 1}} npts = {"x_n": 10} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # error if lims[0] not equal to edges[0] lims = {"x_n": {"min": 0.1, "max": 1}} npts = {"x_n": len(edges) - 1} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # error if lims[-1] not equal to edges[-1] lims = {"x_n": {"min": 0, "max": 10}} npts = {"x_n": len(edges) - 1} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # no user points mesh = pybamm.MeshGenerator(pybamm.UserSupplied1DSubMesh) - with self.assertRaisesRegex(pybamm.GeometryError, "User mesh requires"): + with pytest.raises(pybamm.GeometryError, match="User mesh requires"): mesh(None, None) - def test_mesh_creation_no_parameters(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" - ) - - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } - + def test_mesh_creation_no_parameters(self, r, geometry): edges = np.array([0, 0.3, 1]) submesh_params = {"edges": edges} submesh_types = { @@ -286,18 +246,18 @@ def test_mesh_creation_no_parameters(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) -class TestSpectralVolume1DSubMesh(unittest.TestCase): +class TestSpectralVolume1DSubMesh: def test_exceptions(self): edges = np.array([0, 0.3, 1]) submesh_params = {"edges": edges} @@ -306,30 +266,22 @@ def test_exceptions(self): # error if npts+1 != len(edges) lims = {"x_n": {"min": 0, "max": 1}} npts = {"x_n": 10} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # error if lims[0] not equal to edges[0] lims = {"x_n": {"min": 0.1, "max": 1}} npts = {"x_n": len(edges) - 1} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # error if lims[-1] not equal to edges[-1] lims = {"x_n": {"min": 0, "max": 10}} npts = {"x_n": len(edges) - 1} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) - def test_mesh_creation_no_parameters(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" - ) - - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } - + def test_mesh_creation_no_parameters(self, r, geometry): edges = np.array([0, 0.3, 1]) order = 3 submesh_params = {"edges": edges, "order": order} @@ -344,15 +296,15 @@ def test_mesh_creation_no_parameters(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].sv_nodes), var_pts[r]) - self.assertEqual(len(mesh["negative particle"].nodes), order * var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, + assert len(mesh["negative particle"].sv_nodes) == var_pts[r] + assert len(mesh["negative particle"].nodes) == order * var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) # check Chebyshev subdivision locations @@ -360,7 +312,7 @@ def test_mesh_creation_no_parameters(self): mesh["negative particle"].edges.tolist(), [0, 0.075, 0.225, 0.3, 0.475, 0.825, 1], ): - self.assertAlmostEqual(a, b) + assert a == pytest.approx(b) # test uniform submesh creation submesh_params = {"order": order} @@ -377,14 +329,4 @@ def test_mesh_creation_no_parameters(self): mesh["negative particle"].edges.tolist(), [0.0, 0.125, 0.375, 0.5, 0.625, 0.875, 1.0], ): - self.assertAlmostEqual(a, b) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert a == pytest.approx(b) diff --git a/tests/unit/test_meshes/test_scikit_fem_submesh.py b/tests/unit/test_meshes/test_scikit_fem_submesh.py index 07e7dd016a..30c45510e4 100644 --- a/tests/unit/test_meshes/test_scikit_fem_submesh.py +++ b/tests/unit/test_meshes/test_scikit_fem_submesh.py @@ -2,12 +2,13 @@ # Test for the scikit-fem Finite Element Mesh class # +import pytest import pybamm -import unittest import numpy as np -def get_param(): +@pytest.fixture() +def param(): return pybamm.ParameterValues( { "Electrode width [m]": 0.4, @@ -25,9 +26,8 @@ def get_param(): ) -class TestScikitFiniteElement2DSubMesh(unittest.TestCase): - def test_mesh_creation(self): - param = get_param() +class TestScikitFiniteElement2DSubMesh: + def test_mesh_creation(self, param): geometry = pybamm.battery_geometry( include_particles=False, options={"dimensionality": 2} ) @@ -46,23 +46,19 @@ def test_mesh_creation(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative electrode"].edges[0], 0) - self.assertEqual(mesh["positive electrode"].edges[-1], 1) + assert mesh["negative electrode"].edges[0] == 0 + assert mesh["positive electrode"].edges[-1] == 1 # check internal boundary locations - self.assertEqual( - mesh["negative electrode"].edges[-1], mesh["separator"].edges[0] - ) - self.assertEqual( - mesh["positive electrode"].edges[0], mesh["separator"].edges[-1] - ) + assert mesh["negative electrode"].edges[-1] == mesh["separator"].edges[0] + assert mesh["positive electrode"].edges[0] == mesh["separator"].edges[-1] for domain in mesh.base_domains: if domain == "current collector": # NOTE: only for degree 1 npts = var_pts["y"] * var_pts["z"] - self.assertEqual(mesh[domain].npts, npts) + assert mesh[domain].npts == npts else: - self.assertEqual(len(mesh[domain].edges), len(mesh[domain].nodes) + 1) + assert len(mesh[domain].edges) == len(mesh[domain].nodes) + 1 def test_init_failure(self): submesh_types = { @@ -74,26 +70,26 @@ def test_init_failure(self): geometry = pybamm.battery_geometry( include_particles=False, options={"dimensionality": 2} ) - with self.assertRaises(KeyError): + with pytest.raises(KeyError): pybamm.Mesh(geometry, submesh_types, {}) var_pts = {"x_n": 10, "x_s": 10, "x_p": 10, "y": 10, "z": 10} # there are parameters in the variables that need to be processed - with self.assertRaisesRegex( + with pytest.raises( pybamm.DiscretisationError, - "Parameter values have not yet been set for geometry", + match="Parameter values have not yet been set for geometry", ): pybamm.Mesh(geometry, submesh_types, var_pts) lims = {"x_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.ScikitUniform2DSubMesh(lims, None) lims = { "x_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, "x_p": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, } - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.ScikitUniform2DSubMesh(lims, None) lims = { @@ -106,7 +102,7 @@ def test_init_failure(self): "y": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, z: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, } - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.ScikitUniform2DSubMesh(lims, npts) def test_tab_error(self): @@ -142,7 +138,7 @@ def test_tab_error(self): include_particles=False, options={"dimensionality": 2} ) param.process_geometry(geometry) - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.Mesh(geometry, submesh_types, var_pts) def test_tab_left_right(self): @@ -180,8 +176,7 @@ def test_tab_left_right(self): param.process_geometry(geometry) pybamm.Mesh(geometry, submesh_types, var_pts) - def test_to_json(self): - param = get_param() + def test_to_json(self, param): geometry = pybamm.battery_geometry( include_particles=False, options={"dimensionality": 2} ) @@ -216,7 +211,7 @@ def test_to_json(self): ], } - self.assertEqual(mesh_json, expected_json) + assert mesh_json == expected_json # test Uniform2DSubMesh serialisation @@ -276,7 +271,7 @@ def test_to_json(self): }, } - self.assertEqual(submesh, expected_submesh) + assert submesh == expected_submesh new_submesh = pybamm.ScikitUniform2DSubMesh._from_json(submesh) @@ -284,10 +279,8 @@ def test_to_json(self): np.testing.assert_array_equal(x, y) -class TestScikitFiniteElementChebyshev2DSubMesh(unittest.TestCase): - def test_mesh_creation(self): - param = get_param() - +class TestScikitFiniteElementChebyshev2DSubMesh: + def test_mesh_creation(self, param): geometry = pybamm.battery_geometry( include_particles=False, options={"dimensionality": 2} ) @@ -306,28 +299,24 @@ def test_mesh_creation(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative electrode"].edges[0], 0) - self.assertEqual(mesh["positive electrode"].edges[-1], 1) + assert mesh["negative electrode"].edges[0] == 0 + assert mesh["positive electrode"].edges[-1] == 1 # check internal boundary locations - self.assertEqual( - mesh["negative electrode"].edges[-1], mesh["separator"].edges[0] - ) - self.assertEqual( - mesh["positive electrode"].edges[0], mesh["separator"].edges[-1] - ) + assert mesh["negative electrode"].edges[-1] == mesh["separator"].edges[0] + assert mesh["positive electrode"].edges[0] == mesh["separator"].edges[-1] for domain in mesh.base_domains: if domain == "current collector": # NOTE: only for degree 1 npts = var_pts["y"] * var_pts["z"] - self.assertEqual(mesh[domain].npts, npts) + assert mesh[domain].npts == npts else: - self.assertEqual(len(mesh[domain].edges), len(mesh[domain].nodes) + 1) + assert len(mesh[domain].edges) == len(mesh[domain].nodes) + 1 def test_init_failure(self): # only one lim lims = {"x_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.ScikitChebyshev2DSubMesh(lims, None) # different coord_sys @@ -335,7 +324,7 @@ def test_init_failure(self): "r_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, "z": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, } - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.ScikitChebyshev2DSubMesh(lims, None) # not y and z @@ -343,14 +332,12 @@ def test_init_failure(self): "x_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, "z": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, } - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.ScikitChebyshev2DSubMesh(lims, None) -class TestScikitExponential2DSubMesh(unittest.TestCase): - def test_mesh_creation(self): - param = get_param() - +class TestScikitExponential2DSubMesh: + def test_mesh_creation(self, param): geometry = pybamm.battery_geometry( include_particles=False, options={"dimensionality": 2} ) @@ -371,28 +358,24 @@ def test_mesh_creation(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative electrode"].edges[0], 0) - self.assertEqual(mesh["positive electrode"].edges[-1], 1) + assert mesh["negative electrode"].edges[0] == 0 + assert mesh["positive electrode"].edges[-1] == 1 # check internal boundary locations - self.assertEqual( - mesh["negative electrode"].edges[-1], mesh["separator"].edges[0] - ) - self.assertEqual( - mesh["positive electrode"].edges[0], mesh["separator"].edges[-1] - ) + assert mesh["negative electrode"].edges[-1] == mesh["separator"].edges[0] + assert mesh["positive electrode"].edges[0] == mesh["separator"].edges[-1] for domain in mesh.base_domains: if domain == "current collector": # NOTE: only for degree 1 npts = var_pts["y"] * var_pts["z"] - self.assertEqual(mesh[domain].npts, npts) + assert mesh[domain].npts == npts else: - self.assertEqual(len(mesh[domain].edges), len(mesh[domain].nodes) + 1) + assert len(mesh[domain].edges) == len(mesh[domain].nodes) + 1 def test_init_failure(self): # only one lim lims = {"x_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.ScikitExponential2DSubMesh(lims, None) # different coord_sys @@ -400,7 +383,7 @@ def test_init_failure(self): "r_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, "z": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, } - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.ScikitExponential2DSubMesh(lims, None) # not y and z @@ -408,18 +391,16 @@ def test_init_failure(self): "x_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, "z": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, } - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.ScikitExponential2DSubMesh(lims, None) # side not top - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.ScikitExponential2DSubMesh(None, None, side="bottom") -class TestScikitUser2DSubMesh(unittest.TestCase): - def test_mesh_creation(self): - param = get_param() - +class TestScikitUser2DSubMesh: + def test_mesh_creation(self, param): geometry = pybamm.battery_geometry( include_particles=False, options={"dimensionality": 2} ) @@ -444,23 +425,19 @@ def test_mesh_creation(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative electrode"].edges[0], 0) - self.assertEqual(mesh["positive electrode"].edges[-1], 1) + assert mesh["negative electrode"].edges[0] == 0 + assert mesh["positive electrode"].edges[-1] == 1 # check internal boundary locations - self.assertEqual( - mesh["negative electrode"].edges[-1], mesh["separator"].edges[0] - ) - self.assertEqual( - mesh["positive electrode"].edges[0], mesh["separator"].edges[-1] - ) + assert mesh["negative electrode"].edges[-1] == mesh["separator"].edges[0] + assert mesh["positive electrode"].edges[0] == mesh["separator"].edges[-1] for domain in mesh.base_domains: if domain == "current collector": # NOTE: only for degree 1 npts = var_pts["y"] * var_pts["z"] - self.assertEqual(mesh[domain].npts, npts) + assert mesh[domain].npts == npts else: - self.assertEqual(len(mesh[domain].edges), len(mesh[domain].nodes) + 1) + assert len(mesh[domain].edges) == len(mesh[domain].nodes) + 1 def test_exceptions(self): lims = {"y": {"min": 0, "max": 1}} @@ -469,48 +446,38 @@ def test_exceptions(self): submesh_params = {"y_edges": y_edges, "z_edges": z_edges} mesh = pybamm.MeshGenerator(pybamm.UserSupplied2DSubMesh, submesh_params) # test not enough lims - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, None) lims = {"y": {"min": 0, "max": 1}, "z": {"min": 0, "max": 1}} # error if len(edges) != npts npts = {"y": 10, "z": 3} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # error if lims[0] not equal to edges[0] lims = {"y": {"min": 0.1, "max": 1}, "z": {"min": 0, "max": 1}} npts = {"y": 3, "z": 3} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # error if lims[-1] not equal to edges[-1] lims = {"y": {"min": 0, "max": 1}, "z": {"min": 0, "max": 1.3}} npts = {"y": 3, "z": 3} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # error if different coordinate system lims = {"y": {"min": 0, "max": 1}, "r_n": {"min": 0, "max": 1}} npts = {"y": 3, "r_n": 3} - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): mesh(lims, npts) mesh = pybamm.MeshGenerator(pybamm.UserSupplied2DSubMesh) - with self.assertRaisesRegex(pybamm.GeometryError, "User mesh requires"): + with pytest.raises(pybamm.GeometryError, match="User mesh requires"): mesh(None, None) submesh_params = {"y_edges": np.array([0, 0.3, 1])} mesh = pybamm.MeshGenerator(pybamm.UserSupplied2DSubMesh, submesh_params) - with self.assertRaisesRegex(pybamm.GeometryError, "User mesh requires"): + with pytest.raises(pybamm.GeometryError, match="User mesh requires"): mesh(None, None) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_models/test_base_model.py b/tests/unit/test_models/test_base_model.py index d50cd66d2d..bfc7ad7473 100644 --- a/tests/unit/test_models/test_base_model.py +++ b/tests/unit/test_models/test_base_model.py @@ -2,10 +2,10 @@ # Tests for the base model class # +import pytest import os import platform import subprocess # nosec -import unittest from io import StringIO import sys @@ -16,7 +16,7 @@ import pybamm -class TestBaseModel(unittest.TestCase): +class TestBaseModel: def test_rhs_set_get(self): model = pybamm.BaseModel() rhs = { @@ -24,7 +24,7 @@ def test_rhs_set_get(self): pybamm.Symbol("d"): pybamm.Symbol("beta"), } model.rhs = rhs - self.assertEqual(rhs, model.rhs) + assert rhs == model.rhs # test domains rhs = { pybamm.Symbol("c", domain=["negative electrode"]): pybamm.Symbol( @@ -35,9 +35,9 @@ def test_rhs_set_get(self): ), } model.rhs = rhs - self.assertEqual(rhs, model.rhs) + assert rhs == model.rhs # non-matching domains should fail - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): model.rhs = { pybamm.Symbol("c", domain=["positive electrode"]): pybamm.Symbol( "alpha", domain=["negative electrode"] @@ -48,7 +48,7 @@ def test_algebraic_set_get(self): model = pybamm.BaseModel() algebraic = {pybamm.Symbol("b"): pybamm.Symbol("c") - pybamm.Symbol("a")} model.algebraic = algebraic - self.assertEqual(algebraic, model.algebraic) + assert algebraic == model.algebraic def test_initial_conditions_set_get(self): model = pybamm.BaseModel() @@ -57,22 +57,22 @@ def test_initial_conditions_set_get(self): pybamm.Symbol("d0"): pybamm.Symbol("delta"), } model.initial_conditions = initial_conditions - self.assertEqual(initial_conditions, model.initial_conditions) + assert initial_conditions == model.initial_conditions # Test number input c0 = pybamm.Symbol("c0") model.initial_conditions[c0] = 34 - self.assertIsInstance(model.initial_conditions[c0], pybamm.Scalar) - self.assertEqual(model.initial_conditions[c0].value, 34) + assert isinstance(model.initial_conditions[c0], pybamm.Scalar) + assert model.initial_conditions[c0].value == 34 # Variable in initial conditions should fail - with self.assertRaisesRegex( - TypeError, "Initial conditions cannot contain 'Variable' objects" + with pytest.raises( + TypeError, match="Initial conditions cannot contain 'Variable' objects" ): model.initial_conditions = {c0: pybamm.Variable("v")} # non-matching domains should fail - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): model.initial_conditions = { pybamm.Symbol("c", domain=["positive electrode"]): pybamm.Symbol( "alpha", domain=["negative electrode"] @@ -85,7 +85,7 @@ def test_boundary_conditions_set_get(self): "c": {"left": ("epsilon", "Dirichlet"), "right": ("eta", "Dirichlet")} } model.boundary_conditions = boundary_conditions - self.assertEqual(boundary_conditions, model.boundary_conditions) + assert boundary_conditions == model.boundary_conditions # Test number input c0 = pybamm.Symbol("c0") @@ -93,29 +93,29 @@ def test_boundary_conditions_set_get(self): "left": (-2, "Dirichlet"), "right": (4, "Dirichlet"), } - self.assertIsInstance(model.boundary_conditions[c0]["left"][0], pybamm.Scalar) - self.assertIsInstance(model.boundary_conditions[c0]["right"][0], pybamm.Scalar) - self.assertEqual(model.boundary_conditions[c0]["left"][0].value, -2) - self.assertEqual(model.boundary_conditions[c0]["right"][0].value, 4) - self.assertEqual(model.boundary_conditions[c0]["left"][1], "Dirichlet") - self.assertEqual(model.boundary_conditions[c0]["right"][1], "Dirichlet") + assert isinstance(model.boundary_conditions[c0]["left"][0], pybamm.Scalar) + assert isinstance(model.boundary_conditions[c0]["right"][0], pybamm.Scalar) + assert model.boundary_conditions[c0]["left"][0].value == -2 + assert model.boundary_conditions[c0]["right"][0].value == 4 + assert model.boundary_conditions[c0]["left"][1] == "Dirichlet" + assert model.boundary_conditions[c0]["right"][1] == "Dirichlet" # Check bad bc type bad_bcs = {c0: {"left": (-2, "bad type"), "right": (4, "bad type")}} - with self.assertRaisesRegex(pybamm.ModelError, "boundary condition"): + with pytest.raises(pybamm.ModelError, match="boundary condition"): model.boundary_conditions = bad_bcs def test_variables_set_get(self): model = pybamm.BaseModel() variables = {"c": "alpha", "d": "beta"} model.variables = variables - self.assertEqual(variables, model.variables) - self.assertEqual(model.variable_names(), list(variables.keys())) + assert variables == model.variables + assert model.variable_names() == list(variables.keys()) def test_jac_set_get(self): model = pybamm.BaseModel() model.jacobian = "test" - self.assertEqual(model.jacobian, "test") + assert model.jacobian == "test" def test_read_parameters(self): # Read parameters from different parts of the model @@ -143,18 +143,15 @@ def test_read_parameters(self): } # Test variables_and_events - self.assertIn("v+f+i", model.variables_and_events) - self.assertIn("Event: u=e", model.variables_and_events) + assert "v+f+i" in model.variables_and_events + assert "Event: u=e" in model.variables_and_events - self.assertEqual( - set([x.name for x in model.parameters]), - set([x.name for x in [a, b, c, d, e, f, g, h, i]]), + assert set([x.name for x in model.parameters]) == set( + [x.name for x in [a, b, c, d, e, f, g, h, i]] ) - self.assertTrue( - all( - isinstance(x, (pybamm.Parameter, pybamm.InputParameter)) - for x in model.parameters - ) + assert all( + isinstance(x, (pybamm.Parameter, pybamm.InputParameter)) + for x in model.parameters ) model.variables = { @@ -162,7 +159,8 @@ def test_read_parameters(self): } model.print_parameter_info() - def test_get_parameter_info(self): + @pytest.mark.parametrize("symbols", ["c", "d", "e", "f", "h", "i"]) + def test_get_parameter_info(self, symbols): model = pybamm.BaseModel() a = pybamm.InputParameter("a") b = pybamm.InputParameter("b", "test") @@ -187,17 +185,28 @@ def test_get_parameter_info(self): } parameter_info = model.get_parameter_info() - self.assertEqual(parameter_info["a"][1], "InputParameter") - self.assertEqual(parameter_info["b"][1], "InputParameter in ['test']") - self.assertIn("c", parameter_info) - self.assertIn("d", parameter_info) - self.assertIn("e", parameter_info) - self.assertIn("f", parameter_info) - self.assertEqual(parameter_info["g"][1], "Parameter") - self.assertIn("h", parameter_info) - self.assertIn("i", parameter_info) - - def test_get_parameter_info_submodel(self): + assert parameter_info["a"][1] == "InputParameter" + assert parameter_info["b"][1] == "InputParameter in ['test']" + assert symbols in parameter_info + assert parameter_info["g"][1] == "Parameter" + + @pytest.mark.parametrize( + "sub, key, parameter_value", + [ + ("sub1", "a", "InputParameter"), + ("sub1", "w", "InputParameter"), + ("sub1", "e", "InputParameter"), + ("sub1", "g", "Parameter"), + ("sub1", "x", "Parameter"), + ("sub1", "f", "InputParameter in ['test']"), + ("sub2", "b", "InputParameter in ['test']"), + ("sub2", "h", "Parameter"), + ("sub1", "c", "FunctionParameter with inputs(s) ''"), + ("sub2", "d", "FunctionParameter with inputs(s) ''"), + ("sub2", "i", "FunctionParameter with inputs(s) ''"), + ], + ) + def test_get_parameter_info_submodel(self, sub, key, parameter_value): submodel = pybamm.lithium_ion.SPM().submodels["electrolyte diffusion"] class SubModel1(pybamm.BaseSubModel): @@ -270,34 +279,15 @@ def set_initial_conditions(self, variables): expected_error_message = "Cannot use get_parameter_info" - with self.assertRaisesRegex(NotImplementedError, expected_error_message): + with pytest.raises(NotImplementedError, match=expected_error_message): submodel.get_parameter_info(by_submodel=True) - with self.assertRaisesRegex(NotImplementedError, expected_error_message): + with pytest.raises(NotImplementedError, match=expected_error_message): submodel.get_parameter_info(by_submodel=False) - self.assertIn("a", parameter_info["sub1"]) - self.assertIn("b", parameter_info["sub2"]) - self.assertEqual(parameter_info["sub1"]["a"][1], "InputParameter") - self.assertEqual(parameter_info["sub1"]["w"][1], "InputParameter") - self.assertEqual(parameter_info["sub1"]["e"][1], "InputParameter") - self.assertEqual(parameter_info["sub1"]["g"][1], "Parameter") - self.assertEqual(parameter_info["sub1"]["x"][1], "Parameter") - self.assertEqual(parameter_info["sub1"]["f"][1], "InputParameter in ['test']") - self.assertEqual(parameter_info["sub2"]["b"][1], "InputParameter in ['test']") - self.assertEqual(parameter_info["sub2"]["h"][1], "Parameter") - self.assertEqual( - parameter_info["sub1"]["c"][1], - "FunctionParameter with inputs(s) ''", - ) - self.assertEqual( - parameter_info["sub2"]["d"][1], - "FunctionParameter with inputs(s) ''", - ) - self.assertEqual( - parameter_info["sub2"]["i"][1], - "FunctionParameter with inputs(s) ''", - ) + assert "a" in parameter_info["sub1"] + assert "b" in parameter_info["sub2"] + assert parameter_info[sub][key][1] == parameter_value def test_print_parameter_info(self): model = pybamm.BaseModel() @@ -339,14 +329,31 @@ def test_print_parameter_info(self): sys.stdout = sys.__stdout__ result = captured_output.getvalue().strip() - self.assertIn("a", result) - self.assertIn("b", result) - self.assertIn("InputParameter", result) - self.assertIn("InputParameter in ['test']", result) - self.assertIn("Parameter", result) - self.assertIn("FunctionParameter with inputs(s) ''", result) - - def test_print_parameter_info_submodel(self): + assert "a" in result + assert "b" in result + assert "InputParameter" in result + assert "InputParameter in ['test']" in result + assert "Parameter" in result + assert "FunctionParameter with inputs(s) ''" in result + + @pytest.mark.parametrize( + "values", + [ + "'sub1' submodel parameters:", + "'sub2' submodel parameters:", + "Parameter", + "InputParameter", + "FunctionParameter with inputs(s) ''", + "InputParameter in ['test']", + "g", + "a", + "c", + "h", + "b", + "d", + ], + ) + def test_print_parameter_info_submodel(self, values): model = pybamm.BaseModel() a = pybamm.InputParameter("a") b = pybamm.InputParameter("b", "test") @@ -385,18 +392,7 @@ def test_print_parameter_info_submodel(self): sys.stdout = sys.__stdout__ result = captured_output.getvalue().strip() - self.assertIn("'sub1' submodel parameters:", result) - self.assertIn("'sub2' submodel parameters:", result) - self.assertIn("Parameter", result) - self.assertIn("InputParameter", result) - self.assertIn("FunctionParameter with inputs(s) ''", result) - self.assertIn("InputParameter in ['test']", result) - self.assertIn("g", result) - self.assertIn("a", result) - self.assertIn("c", result) - self.assertIn("h", result) - self.assertIn("b", result) - self.assertIn("d", result) + assert values in result def test_read_input_parameters(self): # Read input parameters from different parts of the model @@ -416,13 +412,10 @@ def test_read_input_parameters(self): model.events = [pybamm.Event("u=e", u - e)] model.variables = {"v+f": v + f} - self.assertEqual( - set([x.name for x in model.input_parameters]), - set([x.name for x in [a, b, c, d, e, f]]), - ) - self.assertTrue( - all(isinstance(x, pybamm.InputParameter) for x in model.input_parameters) + assert set([x.name for x in model.input_parameters]) == set( + [x.name for x in [a, b, c, d, e, f]] ) + assert all(isinstance(x, pybamm.InputParameter) for x in model.input_parameters) def test_update(self): # model @@ -452,19 +445,19 @@ def test_update(self): model.update(submodel) # check - self.assertEqual(model.rhs[d], submodel.rhs[d]) - self.assertEqual(model.initial_conditions[d], submodel.initial_conditions[d]) - self.assertEqual(model.boundary_conditions[d], submodel.boundary_conditions[d]) - self.assertEqual(model.variables["d"], submodel.variables["d"]) - self.assertEqual(model.rhs[c], rhs[c]) - self.assertEqual(model.initial_conditions[c], initial_conditions[c]) - self.assertEqual(model.boundary_conditions[c], boundary_conditions[c]) - self.assertEqual(model.variables["c"], variables["c"]) + assert model.rhs[d] == submodel.rhs[d] + assert model.initial_conditions[d] == submodel.initial_conditions[d] + assert model.boundary_conditions[d] == submodel.boundary_conditions[d] + assert model.variables["d"] == submodel.variables["d"] + assert model.rhs[c] == rhs[c] + assert model.initial_conditions[c] == initial_conditions[c] + assert model.boundary_conditions[c] == boundary_conditions[c] + assert model.variables["c"] == variables["c"] # update with conflicting submodel submodel2 = pybamm.BaseModel() submodel2.rhs = {d: pybamm.div(pybamm.grad(d)) - 1} - with self.assertRaises(pybamm.ModelError): + with pytest.raises(pybamm.ModelError): model.update(submodel2) # update with multiple submodels @@ -482,12 +475,12 @@ def test_update(self): model = pybamm.BaseModel() model.update(submodel1, submodel2) - self.assertEqual(model.rhs[d], submodel1.rhs[d]) - self.assertEqual(model.initial_conditions[d], submodel1.initial_conditions[d]) - self.assertEqual(model.boundary_conditions[d], submodel1.boundary_conditions[d]) - self.assertEqual(model.rhs[e], submodel2.rhs[e]) - self.assertEqual(model.initial_conditions[e], submodel2.initial_conditions[e]) - self.assertEqual(model.boundary_conditions[e], submodel2.boundary_conditions[e]) + assert model.rhs[d] == submodel1.rhs[d] + assert model.initial_conditions[d] == submodel1.initial_conditions[d] + assert model.boundary_conditions[d] == submodel1.boundary_conditions[d] + assert model.rhs[e] == submodel2.rhs[e] + assert model.initial_conditions[e] == submodel2.initial_conditions[e] + assert model.boundary_conditions[e] == submodel2.boundary_conditions[e] def test_new_copy(self): model = pybamm.BaseModel(name="a model") @@ -504,9 +497,9 @@ def test_new_copy(self): model.convert_to_format = "python" new_model = model.new_copy() - self.assertEqual(new_model.name, model.name) - self.assertEqual(new_model.use_jacobian, model.use_jacobian) - self.assertEqual(new_model.convert_to_format, model.convert_to_format) + assert new_model.name == model.name + assert new_model.use_jacobian == model.use_jacobian + assert new_model.convert_to_format == model.convert_to_format def test_check_no_repeated_keys(self): model = pybamm.BaseModel() @@ -515,7 +508,7 @@ def test_check_no_repeated_keys(self): model.rhs = {var: -1} var = pybamm.Variable("var") model.algebraic = {var: var} - with self.assertRaisesRegex(pybamm.ModelError, "Multiple equations specified"): + with pytest.raises(pybamm.ModelError, match="Multiple equations specified"): model.check_no_repeated_keys() def test_check_well_posedness_variables(self): @@ -540,26 +533,26 @@ def test_check_well_posedness_variables(self): # Underdetermined model - not enough differential equations model.rhs = {c: 5 * pybamm.div(pybamm.grad(d)) - 1} model.algebraic = {e: e - c - d} - with self.assertRaisesRegex(pybamm.ModelError, "underdetermined"): + with pytest.raises(pybamm.ModelError, match="underdetermined"): model.check_well_posedness() # Underdetermined model - not enough algebraic equations model.algebraic = {} - with self.assertRaisesRegex(pybamm.ModelError, "underdetermined"): + with pytest.raises(pybamm.ModelError, match="underdetermined"): model.check_well_posedness() # Overdetermined model - repeated keys model.algebraic = {c: c - d, d: e + d} - with self.assertRaisesRegex(pybamm.ModelError, "overdetermined"): + with pytest.raises(pybamm.ModelError, match="overdetermined"): model.check_well_posedness() # Overdetermined model - extra keys in algebraic model.rhs = {c: 5 * pybamm.div(pybamm.grad(d)) - 1, d: -d} model.algebraic = {e: c - d} - with self.assertRaisesRegex(pybamm.ModelError, "overdetermined"): + with pytest.raises(pybamm.ModelError, match="overdetermined"): model.check_well_posedness() model.rhs = {c: 1, d: -1} model.algebraic = {e: c - d} - with self.assertRaisesRegex(pybamm.ModelError, "overdetermined"): + with pytest.raises(pybamm.ModelError, match="overdetermined"): model.check_well_posedness() # After discretisation, don't check for overdetermined from extra algebraic keys @@ -568,7 +561,7 @@ def test_check_well_posedness_variables(self): # passes with post_discretisation=True model.check_well_posedness(post_discretisation=True) # fails with post_discretisation=False (default) - with self.assertRaisesRegex(pybamm.ModelError, "extra algebraic keys"): + with pytest.raises(pybamm.ModelError, match="extra algebraic keys"): model.check_well_posedness() # after discretisation, algebraic equation without a StateVector fails @@ -577,9 +570,9 @@ def test_check_well_posedness_variables(self): c: 1, d: pybamm.StateVector(slice(0, 15)) - pybamm.StateVector(slice(15, 30)), } - with self.assertRaisesRegex( + with pytest.raises( pybamm.ModelError, - "each algebraic equation must contain at least one StateVector", + match="each algebraic equation must contain at least one StateVector", ): model.check_well_posedness(post_discretisation=True) @@ -587,8 +580,8 @@ def test_check_well_posedness_variables(self): model = pybamm.BaseModel() model.rhs = {c: d.diff(pybamm.t), d: -1} model.initial_conditions = {c: 1, d: 1} - with self.assertRaisesRegex( - pybamm.ModelError, "time derivative of variable found" + with pytest.raises( + pybamm.ModelError, match="time derivative of variable found" ): model.check_well_posedness() @@ -596,8 +589,8 @@ def test_check_well_posedness_variables(self): model = pybamm.BaseModel() model.algebraic = {c: 2 * d - c, d: c * d.diff(pybamm.t) - d} model.initial_conditions = {c: 1, d: 1} - with self.assertRaisesRegex( - pybamm.ModelError, "time derivative of variable found" + with pytest.raises( + pybamm.ModelError, match="time derivative of variable found" ): model.check_well_posedness() @@ -605,8 +598,8 @@ def test_check_well_posedness_variables(self): model = pybamm.BaseModel() model.rhs = {c: d.diff(pybamm.t), d: -1} model.initial_conditions = {c: 1, d: 1} - with self.assertRaisesRegex( - pybamm.ModelError, "time derivative of variable found" + with pytest.raises( + pybamm.ModelError, match="time derivative of variable found" ): model.check_well_posedness() @@ -616,8 +609,8 @@ def test_check_well_posedness_variables(self): d: 5 * pybamm.StateVector(slice(0, 15)) - 1, c: 5 * pybamm.StateVectorDot(slice(0, 15)) - 1, } - with self.assertRaisesRegex( - pybamm.ModelError, "time derivative of state vector found" + with pytest.raises( + pybamm.ModelError, match="time derivative of state vector found" ): model.check_well_posedness(post_discretisation=True) @@ -625,8 +618,8 @@ def test_check_well_posedness_variables(self): model = pybamm.BaseModel() model.rhs = {c: 5 * pybamm.StateVectorDot(slice(0, 15)) - 1} model.initial_conditions = {c: 1} - with self.assertRaisesRegex( - pybamm.ModelError, "time derivative of state vector found" + with pytest.raises( + pybamm.ModelError, match="time derivative of state vector found" ): model.check_well_posedness(post_discretisation=True) @@ -651,7 +644,7 @@ def test_check_well_posedness_initial_boundary_conditions(self): # Model with bad initial conditions (expect assertion error) d = pybamm.Variable("d", domain=whole_cell) model.initial_conditions = {d: 3} - with self.assertRaisesRegex(pybamm.ModelError, "initial condition"): + with pytest.raises(pybamm.ModelError, match="initial condition"): model.check_well_posedness() # Algebraic well-posed model @@ -686,14 +679,14 @@ def test_check_well_posedness_output_variables(self): model.rhs = {c: -c} model.initial_conditions = {c: 1} model.variables = {"d": d} - with self.assertRaisesRegex(pybamm.ModelError, "No key set for variable"): + with pytest.raises(pybamm.ModelError, match="No key set for variable"): model.check_well_posedness() # check error is raised even if some modified form of d is in model.rhs two_d = 2 * d model.rhs[two_d] = -d model.initial_conditions[two_d] = 1 - with self.assertRaisesRegex(pybamm.ModelError, "No key set for variable"): + with pytest.raises(pybamm.ModelError, match="No key set for variable"): model.check_well_posedness() # add d to rhs, fine @@ -730,13 +723,13 @@ def test_export_casadi(self): var_fn = casadi.Function("var", [t, x, z, p], [var]) # Test that function values are as expected - self.assertEqual(x0_fn([0, 5]), 5) - self.assertEqual(z0_fn([0, 0]), 1) - self.assertEqual(rhs_fn(0, 3, 2, [7, 2]), -21) - self.assertEqual(alg_fn(0, 3, 2, [7, 2]), 1) + assert x0_fn([0, 5]) == 5 + assert z0_fn([0, 0]) == 1 + assert rhs_fn(0, 3, 2, [7, 2]) == -21 + assert alg_fn(0, 3, 2, [7, 2]) == 1 np.testing.assert_array_equal(np.array(jac_rhs_fn(5, 6, 7, [8, 9])), [[-8, 0]]) np.testing.assert_array_equal(np.array(jac_alg_fn(5, 6, 7, [8, 9])), [[1, -1]]) - self.assertEqual(var_fn(6, 3, 2, [7, 2]), -1) + assert var_fn(6, 3, 2, [7, 2]) == -1 # Now change the order of input parameters out = model.export_casadi_objects(["a+b"], input_parameter_order=["q", "p"]) @@ -756,18 +749,16 @@ def test_export_casadi(self): var_fn = casadi.Function("var", [t, x, z, p], [var]) # Test that function values are as expected - self.assertEqual(x0_fn([5, 0]), 5) - self.assertEqual(z0_fn([0, 0]), 1) - self.assertEqual(rhs_fn(0, 3, 2, [2, 7]), -21) - self.assertEqual(alg_fn(0, 3, 2, [2, 7]), 1) + assert x0_fn([5, 0]) == 5 + assert z0_fn([0, 0]) == 1 + assert rhs_fn(0, 3, 2, [2, 7]) == -21 + assert alg_fn(0, 3, 2, [2, 7]) == 1 np.testing.assert_array_equal(np.array(jac_rhs_fn(5, 6, 7, [9, 8])), [[-8, 0]]) np.testing.assert_array_equal(np.array(jac_alg_fn(5, 6, 7, [9, 8])), [[1, -1]]) - self.assertEqual(var_fn(6, 3, 2, [2, 7]), -1) + assert var_fn(6, 3, 2, [2, 7]) == -1 # Test fails if order not specified - with self.assertRaisesRegex( - ValueError, "input_parameter_order must be specified" - ): + with pytest.raises(ValueError, match="input_parameter_order must be specified"): model.export_casadi_objects(["a+b"]) # Fine if order is not specified if there is only one input parameter @@ -787,16 +778,16 @@ def test_export_casadi(self): # Test that function values are as expected # a + b - p = 3 + 2 - 7 = -2 - self.assertEqual(var_fn(6, 3, 2, [7]), -2) + assert var_fn(6, 3, 2, [7]) == -2 # Test fails if not discretised model = pybamm.lithium_ion.SPMe() - with self.assertRaisesRegex( - pybamm.DiscretisationError, "Cannot automatically discretise model" + with pytest.raises( + pybamm.DiscretisationError, match="Cannot automatically discretise model" ): model.export_casadi_objects(["Electrolyte concentration [mol.m-3]"]) - @unittest.skipIf(platform.system() == "Windows", "Skipped for Windows") + @pytest.mark.skipif(platform.system() == "Windows", reason="Skipped for Windows") def test_generate_casadi(self): model = pybamm.BaseModel() t = pybamm.t @@ -825,13 +816,13 @@ def test_generate_casadi(self): var_fn = casadi.external("variables", "./test.so") # Test that function values are as expected - self.assertEqual(x0_fn([2, 5]), 5) - self.assertEqual(z0_fn([0, 0]), 1) - self.assertEqual(rhs_fn(0, 3, 2, [7, 2]), -21) - self.assertEqual(alg_fn(0, 3, 2, [7, 2]), 1) + assert x0_fn([2, 5]) == 5 + assert z0_fn([0, 0]) == 1 + assert rhs_fn(0, 3, 2, [7, 2]) == -21 + assert alg_fn(0, 3, 2, [7, 2]) == 1 np.testing.assert_array_equal(np.array(jac_rhs_fn(5, 6, 7, [8, 9])), [[-8, 0]]) np.testing.assert_array_equal(np.array(jac_alg_fn(5, 6, 7, [8, 9])), [[1, -1]]) - self.assertEqual(var_fn(6, 3, 2, [7, 2]), -1) + assert var_fn(6, 3, 2, [7, 2]) == -1 # Remove generated files. os.remove("test.c") @@ -863,10 +854,10 @@ def test_set_initial_conditions(self): } # Test original initial conditions - self.assertEqual(model.initial_conditions[var_scalar].value, 1) - self.assertEqual(model.initial_conditions[var_1D].value, 1) - self.assertEqual(model.initial_conditions[var_2D].value, 1) - self.assertEqual(model.initial_conditions[var_concat].value, 1) + assert model.initial_conditions[var_scalar].value == 1 + assert model.initial_conditions[var_1D].value == 1 + assert model.initial_conditions[var_2D].value == 1 + assert model.initial_conditions[var_concat].value == 1 # Discretise geometry = { @@ -911,22 +902,22 @@ def test_set_initial_conditions(self): # Test new initial conditions (both in place and not) for mdl in [model, new_model]: var_scalar = mdl.variables["var_scalar"] - self.assertIsInstance(mdl.initial_conditions[var_scalar], pybamm.Vector) - self.assertEqual(mdl.initial_conditions[var_scalar].entries, 3) + assert isinstance(mdl.initial_conditions[var_scalar], pybamm.Vector) + assert mdl.initial_conditions[var_scalar].entries == 3 var_1D = mdl.variables["var_1D"] - self.assertIsInstance(mdl.initial_conditions[var_1D], pybamm.Vector) - self.assertEqual(mdl.initial_conditions[var_1D].shape, (10, 1)) + assert isinstance(mdl.initial_conditions[var_1D], pybamm.Vector) + assert mdl.initial_conditions[var_1D].shape == (10, 1) np.testing.assert_array_equal(mdl.initial_conditions[var_1D].entries, 3) var_2D = mdl.variables["var_2D"] - self.assertIsInstance(mdl.initial_conditions[var_2D], pybamm.Vector) - self.assertEqual(mdl.initial_conditions[var_2D].shape, (50, 1)) + assert isinstance(mdl.initial_conditions[var_2D], pybamm.Vector) + assert mdl.initial_conditions[var_2D].shape == (50, 1) np.testing.assert_array_equal(mdl.initial_conditions[var_2D].entries, 3) var_concat = mdl.variables["var_concat"] - self.assertIsInstance(mdl.initial_conditions[var_concat], pybamm.Vector) - self.assertEqual(mdl.initial_conditions[var_concat].shape, (20, 1)) + assert isinstance(mdl.initial_conditions[var_concat], pybamm.Vector) + assert mdl.initial_conditions[var_concat].shape == (20, 1) np.testing.assert_array_equal(mdl.initial_conditions[var_concat].entries, 3) # Test updating a discretised model (out-of-place) @@ -934,30 +925,26 @@ def test_set_initial_conditions(self): # Test new initial conditions var_scalar = next(iter(new_model_disc.initial_conditions.keys())) - self.assertIsInstance( - new_model_disc.initial_conditions[var_scalar], pybamm.Vector - ) - self.assertEqual(new_model_disc.initial_conditions[var_scalar].entries, 3) + assert isinstance(new_model_disc.initial_conditions[var_scalar], pybamm.Vector) + assert new_model_disc.initial_conditions[var_scalar].entries == 3 var_1D = list(new_model_disc.initial_conditions.keys())[1] - self.assertIsInstance(new_model_disc.initial_conditions[var_1D], pybamm.Vector) - self.assertEqual(new_model_disc.initial_conditions[var_1D].shape, (10, 1)) + assert isinstance(new_model_disc.initial_conditions[var_1D], pybamm.Vector) + assert new_model_disc.initial_conditions[var_1D].shape == (10, 1) np.testing.assert_array_equal( new_model_disc.initial_conditions[var_1D].entries, 3 ) var_2D = list(new_model_disc.initial_conditions.keys())[2] - self.assertIsInstance(new_model_disc.initial_conditions[var_2D], pybamm.Vector) - self.assertEqual(new_model_disc.initial_conditions[var_2D].shape, (50, 1)) + assert isinstance(new_model_disc.initial_conditions[var_2D], pybamm.Vector) + assert new_model_disc.initial_conditions[var_2D].shape == (50, 1) np.testing.assert_array_equal( new_model_disc.initial_conditions[var_2D].entries, 3 ) var_concat = list(new_model_disc.initial_conditions.keys())[3] - self.assertIsInstance( - new_model_disc.initial_conditions[var_concat], pybamm.Vector - ) - self.assertEqual(new_model_disc.initial_conditions[var_concat].shape, (20, 1)) + assert isinstance(new_model_disc.initial_conditions[var_concat], pybamm.Vector) + assert new_model_disc.initial_conditions[var_concat].shape == (20, 1) np.testing.assert_array_equal( new_model_disc.initial_conditions[var_concat].entries, 3 ) @@ -1008,22 +995,22 @@ def test_set_initial_conditions(self): # Test new initial conditions (both in place and not) var_scalar = new_model.variables["var_scalar"] - self.assertIsInstance(new_model.initial_conditions[var_scalar], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_scalar].entries, 3) + assert isinstance(new_model.initial_conditions[var_scalar], pybamm.Vector) + assert new_model.initial_conditions[var_scalar].entries == 3 var_1D = new_model.variables["var_1D"] - self.assertIsInstance(new_model.initial_conditions[var_1D], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_1D].shape, (10, 1)) + assert isinstance(new_model.initial_conditions[var_1D], pybamm.Vector) + assert new_model.initial_conditions[var_1D].shape == (10, 1) np.testing.assert_array_equal(new_model.initial_conditions[var_1D].entries, 3) var_2D = new_model.variables["var_2D"] - self.assertIsInstance(new_model.initial_conditions[var_2D], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_2D].shape, (50, 1)) + assert isinstance(new_model.initial_conditions[var_2D], pybamm.Vector) + assert new_model.initial_conditions[var_2D].shape == (50, 1) np.testing.assert_array_equal(new_model.initial_conditions[var_2D].entries, 3) var_concat = new_model.variables["var_concat"] - self.assertIsInstance(new_model.initial_conditions[var_concat], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_concat].shape, (20, 1)) + assert isinstance(new_model.initial_conditions[var_concat], pybamm.Vector) + assert new_model.initial_conditions[var_concat].shape == (20, 1) np.testing.assert_array_equal( new_model.initial_conditions[var_concat].entries, 3 ) @@ -1040,22 +1027,22 @@ def test_set_initial_conditions(self): # Test new initial conditions (both in place and not) var_scalar = new_model.variables["var_scalar"] - self.assertIsInstance(new_model.initial_conditions[var_scalar], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_scalar].entries, 5) + assert isinstance(new_model.initial_conditions[var_scalar], pybamm.Vector) + assert new_model.initial_conditions[var_scalar].entries == 5 var_1D = new_model.variables["var_1D"] - self.assertIsInstance(new_model.initial_conditions[var_1D], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_1D].shape, (10, 1)) + assert isinstance(new_model.initial_conditions[var_1D], pybamm.Vector) + assert new_model.initial_conditions[var_1D].shape == (10, 1) np.testing.assert_array_equal(new_model.initial_conditions[var_1D].entries, 5) var_2D = new_model.variables["var_2D"] - self.assertIsInstance(new_model.initial_conditions[var_2D], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_2D].shape, (50, 1)) + assert isinstance(new_model.initial_conditions[var_2D], pybamm.Vector) + assert new_model.initial_conditions[var_2D].shape == (50, 1) np.testing.assert_array_equal(new_model.initial_conditions[var_2D].entries, 5) var_concat = new_model.variables["var_concat"] - self.assertIsInstance(new_model.initial_conditions[var_concat], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_concat].shape, (20, 1)) + assert isinstance(new_model.initial_conditions[var_concat], pybamm.Vector) + assert new_model.initial_conditions[var_concat].shape == (20, 1) np.testing.assert_array_equal( new_model.initial_conditions[var_concat].entries, 5 ) @@ -1066,30 +1053,26 @@ def test_set_initial_conditions(self): # Test new initial conditions var_scalar = next(iter(new_model_disc.initial_conditions.keys())) - self.assertIsInstance( - new_model_disc.initial_conditions[var_scalar], pybamm.Vector - ) - self.assertEqual(new_model_disc.initial_conditions[var_scalar].entries, 5) + assert isinstance(new_model_disc.initial_conditions[var_scalar], pybamm.Vector) + assert new_model_disc.initial_conditions[var_scalar].entries == 5 var_1D = list(new_model_disc.initial_conditions.keys())[1] - self.assertIsInstance(new_model_disc.initial_conditions[var_1D], pybamm.Vector) - self.assertEqual(new_model_disc.initial_conditions[var_1D].shape, (10, 1)) + assert isinstance(new_model_disc.initial_conditions[var_1D], pybamm.Vector) + assert new_model_disc.initial_conditions[var_1D].shape == (10, 1) np.testing.assert_array_equal( new_model_disc.initial_conditions[var_1D].entries, 5 ) var_2D = list(new_model_disc.initial_conditions.keys())[2] - self.assertIsInstance(new_model_disc.initial_conditions[var_2D], pybamm.Vector) - self.assertEqual(new_model_disc.initial_conditions[var_2D].shape, (50, 1)) + assert isinstance(new_model_disc.initial_conditions[var_2D], pybamm.Vector) + assert new_model_disc.initial_conditions[var_2D].shape == (50, 1) np.testing.assert_array_equal( new_model_disc.initial_conditions[var_2D].entries, 5 ) var_concat = list(new_model_disc.initial_conditions.keys())[3] - self.assertIsInstance( - new_model_disc.initial_conditions[var_concat], pybamm.Vector - ) - self.assertEqual(new_model_disc.initial_conditions[var_concat].shape, (20, 1)) + assert isinstance(new_model_disc.initial_conditions[var_concat], pybamm.Vector) + assert new_model_disc.initial_conditions[var_concat].shape == (20, 1) np.testing.assert_array_equal( new_model_disc.initial_conditions[var_concat].entries, 5 ) @@ -1103,7 +1086,7 @@ def test_set_initial_condition_errors(self): var = pybamm.Scalar(1) model.rhs = {var: -var} model.initial_conditions = {var: 1} - with self.assertRaisesRegex(NotImplementedError, "Variable must have type"): + with pytest.raises(NotImplementedError, match="Variable must have type"): model.set_initial_conditions_from({}) var = pybamm.Variable( @@ -1116,9 +1099,7 @@ def test_set_initial_condition_errors(self): ) model.rhs = {var: -var} model.initial_conditions = {var: 1} - with self.assertRaisesRegex( - NotImplementedError, "Variable must be 0D, 1D, or 2D" - ): + with pytest.raises(NotImplementedError, match="Variable must be 0D, 1D, or 2D"): model.set_initial_conditions_from({"var": np.ones((5, 6, 7, 8))}) var_concat_neg = pybamm.Variable("var concat neg", domain="negative electrode") @@ -1126,8 +1107,8 @@ def test_set_initial_condition_errors(self): var_concat = pybamm.concatenation(var_concat_neg, var_concat_sep) model.algebraic = {var_concat: -var_concat} model.initial_conditions = {var_concat: 1} - with self.assertRaisesRegex( - NotImplementedError, "Variable in concatenation must be 1D" + with pytest.raises( + NotImplementedError, match="Variable in concatenation must be 1D" ): model.set_initial_conditions_from({"var concat neg": np.ones((5, 6, 7))}) @@ -1136,20 +1117,20 @@ def test_set_initial_condition_errors(self): var = pybamm.Variable("var") model.rhs = {var: -var} model.initial_conditions = {var: pybamm.Scalar(1)} - with self.assertRaisesRegex(pybamm.ModelError, "must appear in the solution"): + with pytest.raises(pybamm.ModelError, match="must appear in the solution"): model.set_initial_conditions_from({"wrong var": 2}) var = pybamm.concatenation( pybamm.Variable("var", "test"), pybamm.Variable("var2", "test2") ) model.rhs = {var: -var} model.initial_conditions = {var: pybamm.Scalar(1)} - with self.assertRaisesRegex(pybamm.ModelError, "must appear in the solution"): + with pytest.raises(pybamm.ModelError, match="must appear in the solution"): model.set_initial_conditions_from({"wrong var": 2}) def test_set_variables_error(self): var = pybamm.Variable("var") model = pybamm.BaseModel() - with self.assertRaisesRegex(ValueError, "not var"): + with pytest.raises(ValueError, match="not var"): model.variables = {"not var": var} def test_build_submodels(self): @@ -1202,23 +1183,23 @@ def get_coupled_variables(self, variables): "submodel 1": Submodel1(None, "negative"), "submodel 2": Submodel2(None, "negative"), } - self.assertFalse(model._built) + assert not model._built model.build_model() - self.assertTrue(model._built) + assert model._built u = model.variables["u"] v = model.variables["v"] - self.assertEqual(model.rhs[u].value, 2) - self.assertEqual(model.algebraic[v], -1.0 + v) + assert model.rhs[u].value == 2 + assert model.algebraic[v] == -1.0 + v def test_timescale_lengthscale_get_set_not_implemented(self): model = pybamm.BaseModel() - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): model.timescale - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): model.length_scales - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): model.timescale = 1 - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): model.length_scales = 1 def test_save_load_model(self): @@ -1284,7 +1265,7 @@ def test_save_load_model(self): testing.assert_array_equal(solution.all_ys, new_solution.all_ys) # raises warning if variables are saved without mesh - with self.assertWarns(pybamm.ModelWarning): + with pytest.warns(pybamm.ModelWarning): model_disc.save_model( filename="test_base_model", variables=model_disc.variables ) @@ -1297,13 +1278,3 @@ def test_save_load_model(self): new_model = pybamm.load_model("test_base_model.json") os.remove("test_base_model.json") - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_models/test_full_battery_models/test_equivalent_circuit/test_thevenin.py b/tests/unit/test_models/test_full_battery_models/test_equivalent_circuit/test_thevenin.py index 39bfaf1145..4f54db7035 100644 --- a/tests/unit/test_models/test_full_battery_models/test_equivalent_circuit/test_thevenin.py +++ b/tests/unit/test_models/test_full_battery_models/test_equivalent_circuit/test_thevenin.py @@ -3,10 +3,10 @@ # import pybamm -import unittest +import pytest -class TestThevenin(unittest.TestCase): +class TestThevenin: def test_standard_model(self): model = pybamm.equivalent_circuit.Thevenin() model.check_well_posedness() @@ -16,22 +16,18 @@ def test_default_properties(self): x = model.variables["x ECMD"] # test var_pts - self.assertEqual(model.default_var_pts, {x: 20}) + assert model.default_var_pts == {x: 20} # test geometry - self.assertEqual( - model.default_geometry, {"ECMD particle": {x: {"min": 0, "max": 1}}} - ) + assert model.default_geometry == {"ECMD particle": {x: {"min": 0, "max": 1}}} # test spatial methods - self.assertIsInstance( + assert isinstance( model.default_spatial_methods["ECMD particle"], pybamm.FiniteVolume ) # test submesh types - self.assertEqual( - model.default_submesh_types, {"ECMD particle": pybamm.Uniform1DSubMesh} - ) + assert model.default_submesh_types == {"ECMD particle": pybamm.Uniform1DSubMesh} def test_changing_number_of_rcs(self): options = {"number of rc elements": 0} @@ -50,7 +46,7 @@ def test_changing_number_of_rcs(self): model = pybamm.equivalent_circuit.Thevenin(options=options) model.check_well_posedness() - with self.assertRaisesRegex(pybamm.OptionError, "natural numbers"): + with pytest.raises(pybamm.OptionError, match="natural numbers"): options = {"number of rc elements": -1} model = pybamm.equivalent_circuit.Thevenin(options=options) model.check_well_posedness() @@ -114,35 +110,25 @@ def external_circuit_function(variables): def test_raise_option_error(self): options = {"not an option": "something"} - with self.assertRaisesRegex( - pybamm.OptionError, "Option 'not an option' not recognised" + with pytest.raises( + pybamm.OptionError, match="Option 'not an option' not recognised" ): pybamm.equivalent_circuit.Thevenin(options=options) def test_not_a_valid_option(self): options = {"operating mode": "not a valid option"} - with self.assertRaisesRegex( - pybamm.OptionError, "Option 'operating mode' must be one of" + with pytest.raises( + pybamm.OptionError, match="Option 'operating mode' must be one of" ): pybamm.equivalent_circuit.Thevenin(options=options) def test_get_default_parameters(self): model = pybamm.equivalent_circuit.Thevenin() values = model.default_parameter_values - self.assertIn("Initial SoC", list(values.keys())) + assert "Initial SoC" in list(values.keys()) values.process_model(model) def test_get_default_quick_plot_variables(self): model = pybamm.equivalent_circuit.Thevenin() variables = model.default_quick_plot_variables - self.assertIn("Current [A]", variables) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert "Current [A]" in variables diff --git a/tests/unit/test_models/test_full_battery_models/test_lead_acid/test_loqs.py b/tests/unit/test_models/test_full_battery_models/test_lead_acid/test_loqs.py index d68686936c..e1e3dce5f5 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lead_acid/test_loqs.py +++ b/tests/unit/test_models/test_full_battery_models/test_lead_acid/test_loqs.py @@ -3,10 +3,9 @@ # import pybamm -import unittest -class TestLeadAcidLOQS(unittest.TestCase): +class TestLeadAcidLOQS: def test_well_posed(self): options = {"thermal": "isothermal"} model = pybamm.lead_acid.LOQS(options) @@ -20,17 +19,15 @@ def test_well_posed(self): def test_default_geometry(self): options = {"thermal": "isothermal"} model = pybamm.lead_acid.LOQS(options) - self.assertNotIn("negative particle", model.default_geometry) - self.assertIsInstance(model.default_spatial_methods, dict) - self.assertIsInstance( + assert "negative particle" not in model.default_geometry + assert isinstance(model.default_spatial_methods, dict) + assert isinstance( model.default_spatial_methods["current collector"], pybamm.ZeroDimensionalSpatialMethod, ) - self.assertTrue( - issubclass( - model.default_submesh_types["current collector"], - pybamm.SubMesh0D, - ) + assert issubclass( + model.default_submesh_types["current collector"], + pybamm.SubMesh0D, ) def test_well_posed_with_convection(self): @@ -42,7 +39,7 @@ def test_well_posed_with_convection(self): model = pybamm.lead_acid.LOQS(options) model.check_well_posedness() - def test_well_posed_1plus1D(self): + def test_well_posed_1plus1_d(self): options = { "surface form": "differential", "current collector": "potential pair", @@ -50,17 +47,15 @@ def test_well_posed_1plus1D(self): } model = pybamm.lead_acid.LOQS(options) model.check_well_posedness() - self.assertIsInstance( + assert isinstance( model.default_spatial_methods["current collector"], pybamm.FiniteVolume ) - self.assertTrue( - issubclass( - model.default_submesh_types["current collector"], - pybamm.Uniform1DSubMesh, - ) + assert issubclass( + model.default_submesh_types["current collector"], + pybamm.Uniform1DSubMesh, ) - def test_well_posed_2plus1D(self): + def test_well_posed_2plus1_d(self): options = { "surface form": "differential", "current collector": "potential pair", @@ -68,19 +63,17 @@ def test_well_posed_2plus1D(self): } model = pybamm.lead_acid.LOQS(options) model.check_well_posedness() - self.assertIsInstance( + assert isinstance( model.default_spatial_methods["current collector"], pybamm.ScikitFiniteElement, ) - self.assertTrue( - issubclass( - model.default_submesh_types["current collector"], - pybamm.ScikitUniform2DSubMesh, - ) + assert issubclass( + model.default_submesh_types["current collector"], + pybamm.ScikitUniform2DSubMesh, ) -class TestLeadAcidLOQSWithSideReactions(unittest.TestCase): +class TestLeadAcidLOQSWithSideReactions: def test_well_posed_differential(self): options = {"surface form": "differential", "hydrolysis": "true"} model = pybamm.lead_acid.LOQS(options) @@ -92,7 +85,7 @@ def test_well_posed_algebraic(self): model.check_well_posedness() -class TestLeadAcidLOQSSurfaceForm(unittest.TestCase): +class TestLeadAcidLOQSSurfaceForm: def test_well_posed_differential(self): options = {"surface form": "differential"} model = pybamm.lead_acid.LOQS(options) @@ -103,7 +96,7 @@ def test_well_posed_algebraic(self): model = pybamm.lead_acid.LOQS(options) model.check_well_posedness() - def test_well_posed_1plus1D(self): + def test_well_posed_1plus1_d(self): options = { "surface form": "differential", "current collector": "potential pair", @@ -115,13 +108,13 @@ def test_well_posed_1plus1D(self): def test_default_geometry(self): options = {"surface form": "differential"} model = pybamm.lead_acid.LOQS(options) - self.assertIn("current collector", model.default_geometry) + assert "current collector" in model.default_geometry options.update({"current collector": "potential pair", "dimensionality": 1}) model = pybamm.lead_acid.LOQS(options) - self.assertIn("current collector", model.default_geometry) + assert "current collector" in model.default_geometry -class TestLeadAcidLOQSExternalCircuits(unittest.TestCase): +class TestLeadAcidLOQSExternalCircuits: def test_well_posed_voltage(self): options = {"operating mode": "voltage"} model = pybamm.lead_acid.LOQS(options) @@ -152,13 +145,3 @@ def test_well_posed_discharge_energy(self): options = {"calculate discharge energy": "true"} model = pybamm.lead_acid.LOQS(options) model.check_well_posedness() - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_electrode_soh.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_electrode_soh.py index 9f044b0566..80ff155369 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_electrode_soh.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_electrode_soh.py @@ -2,11 +2,23 @@ # Tests for the lithium-ion electrode-specific SOH model # +import pytest import pybamm -import unittest -class TestElectrodeSOH(unittest.TestCase): +# Fixture for TestElectrodeSOHMSMR, TestCalculateTheoreticalEnergy and TestGetInitialOCPMSMR class. +@pytest.fixture() +def options(): + options = { + "open-circuit potential": "MSMR", + "particle": "MSMR", + "number of MSMR reactions": ("6", "4"), + "intercalation kinetics": "MSMR", + } + return options + + +class TestElectrodeSOH: def test_known_solution(self): param = pybamm.LithiumIonParameters() parameter_values = pybamm.ParameterValues("Mohtat2020") @@ -24,16 +36,16 @@ def test_known_solution(self): # Solve the model and check outputs sol = esoh_solver.solve(inputs) - self.assertAlmostEqual(sol["Up(y_100) - Un(x_100)"], Vmax, places=5) - self.assertAlmostEqual(sol["Up(y_0) - Un(x_0)"], Vmin, places=5) - self.assertAlmostEqual(sol["Q_Li"], Q_Li, places=5) + assert sol["Up(y_100) - Un(x_100)"] == pytest.approx(Vmax, abs=1e-05) + assert sol["Up(y_0) - Un(x_0)"] == pytest.approx(Vmin, abs=1e-05) + assert sol["Q_Li"] == pytest.approx(Q_Li, abs=1e-05) # Solve with split esoh and check outputs ics = esoh_solver._set_up_solve(inputs) sol_split = esoh_solver._solve_split(inputs, ics) for key in sol: if key != "Maximum theoretical energy [W.h]": - self.assertAlmostEqual(sol[key], sol_split[key].data[0], places=5) + assert sol[key] == pytest.approx(sol_split[key].data[0], abs=1e-05) else: # theoretical_energy is not present in sol_split inputs = { @@ -41,7 +53,7 @@ def test_known_solution(self): for k in ["x_0", "y_0", "x_100", "y_100", "Q_p"] } energy = esoh_solver.theoretical_energy_integral(inputs) - self.assertAlmostEqual(sol[key], energy, places=5) + assert sol[key] == pytest.approx(energy, abs=1e-05) def test_known_solution_cell_capacity(self): param = pybamm.LithiumIonParameters() @@ -62,9 +74,9 @@ def test_known_solution_cell_capacity(self): # Solve the model and check outputs sol = esoh_solver.solve(inputs) - self.assertAlmostEqual(sol["Up(y_100) - Un(x_100)"], Vmax, places=5) - self.assertAlmostEqual(sol["Up(y_0) - Un(x_0)"], Vmin, places=5) - self.assertAlmostEqual(sol["Q"], Q, places=5) + assert sol["Up(y_100) - Un(x_100)"] == pytest.approx(Vmax, abs=1e-05) + assert sol["Up(y_0) - Un(x_0)"] == pytest.approx(Vmin, abs=1e-05) + assert sol["Q"] == pytest.approx(Q, abs=1e-05) def test_error(self): param = pybamm.LithiumIonParameters() @@ -79,7 +91,7 @@ def test_error(self): inputs = {"Q_Li": Q_Li, "Q_n": Q_n, "Q_p": Q_p} # Solve the model and check outputs - with self.assertRaisesRegex(ValueError, "outside the range"): + with pytest.raises(ValueError, match="outside the range"): esoh_solver.solve(inputs) Q_Li = parameter_values.evaluate(param.Q_Li_particles_init) @@ -93,8 +105,8 @@ def test_error(self): esoh_solver = pybamm.lithium_ion.ElectrodeSOHSolver(parameter_values, param) inputs = {"Q_n": Q_n, "Q_p": Q_p, "Q_Li": Q_Li} # Solver fails to find a solution but voltage limits are not violated - with self.assertRaisesRegex( - pybamm.SolverError, "Could not find acceptable solution" + with pytest.raises( + pybamm.SolverError, match="Could not find acceptable solution" ): esoh_solver.solve(inputs) # Solver fails to find a solution due to upper voltage limit @@ -108,7 +120,7 @@ def test_error(self): ) esoh_solver = pybamm.lithium_ion.ElectrodeSOHSolver(parameter_values, param) inputs = {"Q_n": Q_n, "Q_p": Q_p, "Q_Li": Q_Li} - with self.assertRaisesRegex(ValueError, "upper bound of the voltage"): + with pytest.raises(ValueError, match="upper bound of the voltage"): esoh_solver.solve(inputs) # Solver fails to find a solution due to lower voltage limit parameter_values.update( @@ -121,7 +133,7 @@ def test_error(self): ) esoh_solver = pybamm.lithium_ion.ElectrodeSOHSolver(parameter_values, param) inputs = {"Q_n": Q_n, "Q_p": Q_p, "Q_Li": Q_Li} - with self.assertRaisesRegex(ValueError, "lower bound of the voltage"): + with pytest.raises(ValueError, match="lower bound of the voltage"): esoh_solver.solve(inputs) # errors for cell capacity based solver @@ -136,24 +148,18 @@ def test_error(self): esoh_solver = pybamm.lithium_ion.ElectrodeSOHSolver( parameter_values, param, known_value="cell capacity" ) - with self.assertRaisesRegex(ValueError, "solve_for must be "): + with pytest.raises(ValueError, match="solve_for must be "): esoh_solver._get_electrode_soh_sims_split() inputs = {"Q_n": Q_n, "Q_p": Q_p, "Q": 2 * Q_p} - with self.assertRaisesRegex( - ValueError, "larger than the maximum possible capacity" + with pytest.raises( + ValueError, match="larger than the maximum possible capacity" ): esoh_solver.solve(inputs) -class TestElectrodeSOHMSMR(unittest.TestCase): - def test_known_solution(self): - options = { - "open-circuit potential": "MSMR", - "particle": "MSMR", - "number of MSMR reactions": ("6", "4"), - "intercalation kinetics": "MSMR", - } +class TestElectrodeSOHMSMR: + def test_known_solution(self, options): param = pybamm.LithiumIonParameters(options=options) parameter_values = pybamm.ParameterValues("MSMR_Example") @@ -172,27 +178,21 @@ def test_known_solution(self): # Solve the model and check outputs sol = esoh_solver.solve(inputs) - self.assertAlmostEqual(sol["Up(y_100) - Un(x_100)"], Vmax, places=5) - self.assertAlmostEqual(sol["Up(y_0) - Un(x_0)"], Vmin, places=5) - self.assertAlmostEqual(sol["Q_Li"], Q_Li, places=5) + assert sol["Up(y_100) - Un(x_100)"] == pytest.approx(Vmax, abs=1e-05) + assert sol["Up(y_0) - Un(x_0)"] == pytest.approx(Vmin, abs=1e-05) + assert sol["Q_Li"] == pytest.approx(Q_Li, abs=1e-05) # Solve with split esoh and check outputs ics = esoh_solver._set_up_solve(inputs) sol_split = esoh_solver._solve_split(inputs, ics) for key in sol: if key != "Maximum theoretical energy [W.h]": - self.assertAlmostEqual(sol[key], sol_split[key].data[0], places=5) + assert sol[key] == pytest.approx(sol_split[key].data[0], abs=1e-05) # Check feasibility checks can be performed successfully esoh_solver._check_esoh_feasible(inputs) - def test_known_solution_cell_capacity(self): - options = { - "open-circuit potential": "MSMR", - "particle": "MSMR", - "number of MSMR reactions": ("6", "4"), - "intercalation kinetics": "MSMR", - } + def test_known_solution_cell_capacity(self, options): param = pybamm.LithiumIonParameters(options) parameter_values = pybamm.ParameterValues("MSMR_Example") @@ -211,28 +211,22 @@ def test_known_solution_cell_capacity(self): # Solve the model and check outputs sol = esoh_solver.solve(inputs) - self.assertAlmostEqual(sol["Up(y_100) - Un(x_100)"], Vmax, places=5) - self.assertAlmostEqual(sol["Up(y_0) - Un(x_0)"], Vmin, places=5) - self.assertAlmostEqual(sol["Q"], Q, places=5) + assert sol["Up(y_100) - Un(x_100)"] == pytest.approx(Vmax, abs=1e-05) + assert sol["Up(y_0) - Un(x_0)"] == pytest.approx(Vmin, abs=1e-05) + assert sol["Q"] == pytest.approx(Q, abs=1e-05) - def test_error(self): - options = { - "open-circuit potential": "MSMR", - "particle": "MSMR", - "number of MSMR reactions": ("6", "4"), - "intercalation kinetics": "MSMR", - } + def test_error(self, options): param = pybamm.LithiumIonParameters(options) parameter_values = pybamm.ParameterValues("MSMR_Example") esoh_solver = pybamm.lithium_ion.ElectrodeSOHSolver( parameter_values, param, known_value="cell capacity", options=options ) - with self.assertRaisesRegex(ValueError, "solve_for must be "): + with pytest.raises(ValueError, match="solve_for must be "): esoh_solver._get_electrode_soh_sims_split() -class TestElectrodeSOHHalfCell(unittest.TestCase): +class TestElectrodeSOHHalfCell: def test_known_solution(self): model = pybamm.lithium_ion.ElectrodeSOHHalfCell() param = pybamm.LithiumIonParameters({"working electrode": "positive"}) @@ -243,12 +237,12 @@ def test_known_solution(self): V_max = 4.2 # Solve the model and check outputs sol = sim.solve([0], inputs={"Q_w": Q_w}) - self.assertAlmostEqual(sol["Uw(x_100)"].data[0], V_max, places=5) - self.assertAlmostEqual(sol["Uw(x_0)"].data[0], V_min, places=5) + assert sol["Uw(x_100)"].data[0] == pytest.approx(V_max, abs=1e-05) + assert sol["Uw(x_0)"].data[0] == pytest.approx(V_min, abs=1e-05) -class TestCalculateTheoreticalEnergy(unittest.TestCase): - def test_efficiency(self): +class TestCalculateTheoreticalEnergy: + def test_efficiency(self, options): model = pybamm.lithium_ion.DFN(options={"calculate discharge energy": "true"}) parameter_values = pybamm.ParameterValues("Chen2020") sim = pybamm.Simulation(model, parameter_values=parameter_values) @@ -261,12 +255,12 @@ def test_efficiency(self): ) # Real energy should be less than discharge energy, # and both should be greater than 0 - self.assertLess(discharge_energy, theoretical_energy) - self.assertLess(0, discharge_energy) - self.assertLess(0, theoretical_energy) + assert discharge_energy < theoretical_energy + assert 0 < discharge_energy + assert 0 < theoretical_energy -class TestGetInitialSOC(unittest.TestCase): +class TestGetInitialSOC: def test_initial_soc(self): param = pybamm.LithiumIonParameters() parameter_values = pybamm.ParameterValues("Mohtat2020") @@ -276,26 +270,26 @@ def test_initial_soc(self): 1, parameter_values, param ) V = parameter_values.evaluate(param.p.prim.U(y100, T) - param.n.prim.U(x100, T)) - self.assertAlmostEqual(V, 4.2) + assert V == pytest.approx(4.2) x0, y0 = pybamm.lithium_ion.get_initial_stoichiometries( 0, parameter_values, param ) V = parameter_values.evaluate(param.p.prim.U(y0, T) - param.n.prim.U(x0, T)) - self.assertAlmostEqual(V, 2.8) + assert V == pytest.approx(2.8) x, y = pybamm.lithium_ion.get_initial_stoichiometries( 0.4, parameter_values, param ) - self.assertEqual(x, x0 + 0.4 * (x100 - x0)) - self.assertEqual(y, y0 - 0.4 * (y0 - y100)) + assert x == x0 + 0.4 * (x100 - x0) + assert y == y0 - 0.4 * (y0 - y100) x, y = pybamm.lithium_ion.get_initial_stoichiometries( "4 V", parameter_values, param ) T = parameter_values.evaluate(param.T_ref) V = parameter_values.evaluate(param.p.prim.U(y, T) - param.n.prim.U(x, T)) - self.assertAlmostEqual(V, 4) + assert V == pytest.approx(4) def test_min_max_stoich(self): param = pybamm.LithiumIonParameters() @@ -306,9 +300,9 @@ def test_min_max_stoich(self): parameter_values, param ) V = parameter_values.evaluate(param.p.prim.U(y100, T) - param.n.prim.U(x100, T)) - self.assertAlmostEqual(V, 4.2) + assert V == pytest.approx(4.2) V = parameter_values.evaluate(param.p.prim.U(y0, T) - param.n.prim.U(x0, T)) - self.assertAlmostEqual(V, 2.8) + assert V == pytest.approx(2.8) x0, x100, y100, y0 = pybamm.lithium_ion.get_min_max_stoichiometries( parameter_values, @@ -316,9 +310,9 @@ def test_min_max_stoich(self): known_value="cell capacity", ) V = parameter_values.evaluate(param.p.prim.U(y100, T) - param.n.prim.U(x100, T)) - self.assertAlmostEqual(V, 4.2) + assert V == pytest.approx(4.2) V = parameter_values.evaluate(param.p.prim.U(y0, T) - param.n.prim.U(x0, T)) - self.assertAlmostEqual(V, 2.8) + assert V == pytest.approx(2.8) def test_initial_soc_cell_capacity(self): param = pybamm.LithiumIonParameters() @@ -329,7 +323,7 @@ def test_initial_soc_cell_capacity(self): 1, parameter_values, param, known_value="cell capacity" ) V = parameter_values.evaluate(param.p.prim.U(y100, T) - param.n.prim.U(x100, T)) - self.assertAlmostEqual(V, 4.2) + assert V == pytest.approx(4.2) def test_error(self): parameter_values = pybamm.ParameterValues("Chen2020") @@ -337,43 +331,41 @@ def test_error(self): {"working electrode": "positive"} ).default_parameter_values - with self.assertRaisesRegex( - ValueError, "Initial SOC should be between 0 and 1" - ): + with pytest.raises(ValueError, match="Initial SOC should be between 0 and 1"): pybamm.lithium_ion.get_initial_stoichiometries(2, parameter_values) - with self.assertRaisesRegex(ValueError, "outside the voltage limits"): + with pytest.raises(ValueError, match="outside the voltage limits"): pybamm.lithium_ion.get_initial_stoichiometries("1 V", parameter_values) - with self.assertRaisesRegex(ValueError, "must be a float"): + with pytest.raises(ValueError, match="must be a float"): pybamm.lithium_ion.get_initial_stoichiometries("5 A", parameter_values) - with self.assertRaisesRegex(ValueError, "outside the voltage limits"): + with pytest.raises(ValueError, match="outside the voltage limits"): pybamm.lithium_ion.get_initial_stoichiometry_half_cell( "1 V", parameter_values_half_cell ) - with self.assertRaisesRegex(ValueError, "must be a float"): + with pytest.raises(ValueError, match="must be a float"): pybamm.lithium_ion.get_initial_stoichiometry_half_cell( "5 A", parameter_values_half_cell ) - with self.assertRaisesRegex( - ValueError, "Initial SOC should be between 0 and 1" - ): + with pytest.raises(ValueError, match="Initial SOC should be between 0 and 1"): pybamm.lithium_ion.get_initial_stoichiometry_half_cell( 2, parameter_values_half_cell ) - with self.assertRaisesRegex( - ValueError, "Known value must be cell capacity or cyclable lithium capacity" + with pytest.raises( + ValueError, + match="Known value must be cell capacity or cyclable lithium capacity", ): pybamm.lithium_ion.ElectrodeSOHSolver( parameter_values, known_value="something else" ) - with self.assertRaisesRegex( - ValueError, "Known value must be cell capacity or cyclable lithium capacity" + with pytest.raises( + ValueError, + match="Known value must be cell capacity or cyclable lithium capacity", ): param_MSMR = pybamm.lithium_ion.MSMR( {"number of MSMR reactions": "3"} @@ -382,24 +374,25 @@ def test_error(self): param=param_MSMR, known_value="something else" ) - with self.assertRaisesRegex( - ValueError, "Known value must be cell capacity or cyclable lithium capacity" + with pytest.raises( + ValueError, + match="Known value must be cell capacity or cyclable lithium capacity", ): pybamm.models.full_battery_models.lithium_ion.electrode_soh._ElectrodeSOH( known_value="something else" ) -class TestGetInitialOCP(unittest.TestCase): +class TestGetInitialOCP: def test_get_initial_ocp(self): param = pybamm.LithiumIonParameters() parameter_values = pybamm.ParameterValues("Mohtat2020") Un, Up = pybamm.lithium_ion.get_initial_ocps(1, parameter_values, param) - self.assertAlmostEqual(Up - Un, 4.2) + assert Up - Un == pytest.approx(4.2) Un, Up = pybamm.lithium_ion.get_initial_ocps(0, parameter_values, param) - self.assertAlmostEqual(Up - Un, 2.8) + assert Up - Un == pytest.approx(2.8) Un, Up = pybamm.lithium_ion.get_initial_ocps("4 V", parameter_values, param) - self.assertAlmostEqual(Up - Un, 4) + assert Up - Un == pytest.approx(4) def test_min_max_ocp(self): param = pybamm.LithiumIonParameters() @@ -408,61 +401,39 @@ def test_min_max_ocp(self): Un_0, Un_100, Up_100, Up_0 = pybamm.lithium_ion.get_min_max_ocps( parameter_values, param ) - self.assertAlmostEqual(Up_100 - Un_100, 4.2) - self.assertAlmostEqual(Up_0 - Un_0, 2.8) + assert Up_100 - Un_100 == pytest.approx(4.2) + assert Up_0 - Un_0 == pytest.approx(2.8) -class TestGetInitialOCPMSMR(unittest.TestCase): - def test_get_initial_ocp(self): - options = { - "open-circuit potential": "MSMR", - "particle": "MSMR", - "number of MSMR reactions": ("6", "4"), - "intercalation kinetics": "MSMR", - } +class TestGetInitialOCPMSMR: + def test_get_initial_ocp(self, options): param = pybamm.LithiumIonParameters(options) parameter_values = pybamm.ParameterValues("MSMR_Example") Un, Up = pybamm.lithium_ion.get_initial_ocps( 1, parameter_values, param, options=options ) - self.assertAlmostEqual(Up - Un, 4.2, places=5) + assert Up - Un == pytest.approx(4.2, abs=1e-05) Un, Up = pybamm.lithium_ion.get_initial_ocps( 0, parameter_values, param, options=options ) - self.assertAlmostEqual(Up - Un, 2.8, places=5) + assert Up - Un == pytest.approx(2.8, abs=1e-05) Un, Up = pybamm.lithium_ion.get_initial_ocps( "4 V", parameter_values, param, options=options ) - self.assertAlmostEqual(Up - Un, 4) + assert Up - Un == pytest.approx(4) - def test_min_max_ocp(self): - options = { - "open-circuit potential": "MSMR", - "particle": "MSMR", - "number of MSMR reactions": ("6", "4"), - "intercalation kinetics": "MSMR", - } + def test_min_max_ocp(self, options): param = pybamm.LithiumIonParameters(options) parameter_values = pybamm.ParameterValues("MSMR_Example") Un_0, Un_100, Up_100, Up_0 = pybamm.lithium_ion.get_min_max_ocps( parameter_values, param, options=options ) - self.assertAlmostEqual(Up_100 - Un_100, 4.2) - self.assertAlmostEqual(Up_0 - Un_0, 2.8) + assert Up_100 - Un_100 == pytest.approx(4.2) + assert Up_0 - Un_0 == pytest.approx(2.8) Un_0, Un_100, Up_100, Up_0 = pybamm.lithium_ion.get_min_max_ocps( parameter_values, param, known_value="cell capacity", options=options ) - self.assertAlmostEqual(Up_100 - Un_100, 4.2) - self.assertAlmostEqual(Up_0 - Un_0, 2.8) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert Up_100 - Un_100 == pytest.approx(4.2) + assert Up_0 - Un_0 == pytest.approx(2.8) diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_mpm.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_mpm.py index e5147f01e2..ad02212840 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_mpm.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_mpm.py @@ -2,11 +2,11 @@ # Tests for the lithium-ion MPM model # +import pytest import pybamm -import unittest -class TestMPM(unittest.TestCase): +class TestMPM: def test_well_posed(self): options = {"thermal": "isothermal"} model = pybamm.lithium_ion.MPM(options) @@ -20,9 +20,9 @@ def test_well_posed(self): def test_default_parameter_values(self): # check default parameters are added correctly model = pybamm.lithium_ion.MPM() - self.assertEqual( - model.default_parameter_values["Negative minimum particle radius [m]"], - 0.0, + assert ( + model.default_parameter_values["Negative minimum particle radius [m]"] + == 0.0 ) def test_lumped_thermal_model_1D(self): @@ -32,7 +32,7 @@ def test_lumped_thermal_model_1D(self): def test_x_full_thermal_not_implemented(self): options = {"thermal": "x-full"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_thermal_1plus1D(self): @@ -51,7 +51,7 @@ def test_particle_uniform(self): def test_particle_quadratic(self): options = {"particle": "quadratic profile"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_differential_surface_form(self): @@ -66,31 +66,31 @@ def test_current_sigmoid(self): def test_necessary_options(self): options = {"particle size": "single"} - with self.assertRaises(pybamm.OptionError): + with pytest.raises(pybamm.OptionError): pybamm.lithium_ion.MPM(options) options = {"surface form": "false"} - with self.assertRaises(pybamm.OptionError): + with pytest.raises(pybamm.OptionError): pybamm.lithium_ion.MPM(options) def test_nonspherical_particle_not_implemented(self): options = {"particle shape": "user"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_loss_active_material_stress_negative_not_implemented(self): options = {"loss of active material": ("stress-driven", "none")} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_loss_active_material_stress_positive_not_implemented(self): options = {"loss of active material": ("none", "stress-driven")} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_loss_active_material_stress_both_not_implemented(self): options = {"loss of active material": "stress-driven"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_reversible_plating_with_porosity_not_implemented(self): @@ -98,12 +98,12 @@ def test_reversible_plating_with_porosity_not_implemented(self): "lithium plating": "reversible", "lithium plating porosity change": "true", } - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_stress_induced_diffusion_not_implemented(self): options = {"stress-induced diffusion": "true"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_msmr(self): @@ -124,7 +124,7 @@ def test_wycisk_ocp(self): model.check_well_posedness() -class TestMPMExternalCircuits(unittest.TestCase): +class TestMPMExternalCircuits: def test_well_posed_voltage(self): options = {"operating mode": "voltage"} model = pybamm.lithium_ion.MPM(options) @@ -146,25 +146,25 @@ def external_circuit_function(variables): model.check_well_posedness() -class TestMPMWithSEI(unittest.TestCase): +class TestMPMWithSEI: def test_reaction_limited_not_implemented(self): options = {"SEI": "reaction limited"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_solvent_diffusion_limited_not_implemented(self): options = {"SEI": "solvent-diffusion limited"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_electron_migration_limited_not_implemented(self): options = {"SEI": "electron-migration limited"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_interstitial_diffusion_limited_not_implemented(self): options = {"SEI": "interstitial-diffusion limited"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_ec_reaction_limited_not_implemented(self): @@ -172,49 +172,39 @@ def test_ec_reaction_limited_not_implemented(self): "SEI": "ec reaction limited", "SEI porosity change": "true", } - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) -class TestMPMWithMechanics(unittest.TestCase): +class TestMPMWithMechanics: def test_well_posed_negative_cracking_not_implemented(self): options = {"particle mechanics": ("swelling and cracking", "none")} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_well_posed_positive_cracking_not_implemented(self): options = {"particle mechanics": ("none", "swelling and cracking")} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_well_posed_both_cracking_not_implemented(self): options = {"particle mechanics": "swelling and cracking"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_well_posed_both_swelling_only_not_implemented(self): options = {"particle mechanics": "swelling only"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) -class TestMPMWithPlating(unittest.TestCase): +class TestMPMWithPlating: def test_well_posed_reversible_plating_not_implemented(self): options = {"lithium plating": "reversible"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_well_posed_irreversible_plating_not_implemented(self): options = {"lithium plating": "irreversible"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_parameters/test_bpx.py b/tests/unit/test_parameters/test_bpx.py index 3cc7cfebf1..aeb249cbc0 100644 --- a/tests/unit/test_parameters/test_bpx.py +++ b/tests/unit/test_parameters/test_bpx.py @@ -1,5 +1,4 @@ import tempfile -import unittest import json import pybamm import copy @@ -7,7 +6,8 @@ import pytest -class TestBPX(unittest.TestCase): +class TestBPX: + @pytest.fixture(autouse=True) def setUp(self): self.base = { "Header": { @@ -197,13 +197,13 @@ def check_constant_output(func): stos = [0, 1] T = 298.15 p_vals = [func(sto, T) for sto in stos] - self.assertEqual(p_vals[0], p_vals[1]) + assert p_vals[0] == p_vals[1] for electrode in ["Negative", "Positive"]: D = param[f"{electrode} particle diffusivity [m2.s-1]"] dUdT = param[f"{electrode} electrode OCP entropic change [V.K-1]"] check_constant_output(D) - self.assertEqual(dUdT, 1) + assert dUdT == 1 kappa = param["Electrolyte conductivity [S.m-1]"] De = param["Electrolyte diffusivity [m2.s-1]"] @@ -250,21 +250,21 @@ def test_table_data(self): # correct child c = pybamm.Variable("c") kappa = param["Electrolyte conductivity [S.m-1]"](c, 298.15) - self.assertIsInstance(kappa, pybamm.Interpolant) - self.assertEqual(kappa.children[0], c) + assert isinstance(kappa, pybamm.Interpolant) + assert kappa.children[0] == c # Check other parameters give interpolants D = param["Electrolyte diffusivity [m2.s-1]"](c, 298.15) - self.assertIsInstance(D, pybamm.Interpolant) + assert isinstance(D, pybamm.Interpolant) for electrode in ["Negative", "Positive"]: D = param[f"{electrode} particle diffusivity [m2.s-1]"](c, 298.15) - self.assertIsInstance(D, pybamm.Interpolant) + assert isinstance(D, pybamm.Interpolant) OCP = param[f"{electrode} electrode OCP [V]"](c) - self.assertIsInstance(OCP, pybamm.Interpolant) + assert isinstance(OCP, pybamm.Interpolant) dUdT = param[f"{electrode} electrode OCP entropic change [V.K-1]"](c) - self.assertIsInstance(dUdT, pybamm.Interpolant) + assert isinstance(dUdT, pybamm.Interpolant) def test_bpx_soc_error(self): - with self.assertRaisesRegex(ValueError, "Target SOC"): + with pytest.raises(ValueError, match="Target SOC"): pybamm.ParameterValues.create_from_bpx("blah.json", target_soc=10) def test_bpx_arrhenius(self): @@ -303,7 +303,7 @@ def arrhenius_assertion(pv, param_key, Ea_key): calc_ratio = pybamm.exp(Ea / pybamm.constants.R * (1 / T_ref - 1 / T)).value - self.assertAlmostEqual(eval_ratio, calc_ratio) + assert eval_ratio == pytest.approx(calc_ratio) param_keys = [ "Electrolyte conductivity [S.m-1]", @@ -452,7 +452,7 @@ def test_bpx_blended_error(self): json.dump(bpx_obj, tmp) tmp.flush() - with self.assertRaisesRegex(NotImplementedError, "PyBaMM does not support"): + with pytest.raises(NotImplementedError, match="PyBaMM does not support"): pybamm.ParameterValues.create_from_bpx(tmp.name) def test_bpx_user_defined(self): @@ -476,21 +476,11 @@ def test_bpx_user_defined(self): param = pybamm.ParameterValues.create_from_bpx(tmp.name) - self.assertEqual(param["User-defined scalar parameter"], 1.0) + assert param["User-defined scalar parameter"] == 1.0 var = pybamm.Variable("var") - self.assertIsInstance( + assert isinstance( param["User-defined parameter data"](var), pybamm.Interpolant ) - self.assertIsInstance( + assert isinstance( param["User-defined parameter data function"](var), pybamm.Power ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_parameters/test_current_functions.py b/tests/unit/test_parameters/test_current_functions.py index b00cba0b89..6e4914092b 100644 --- a/tests/unit/test_parameters/test_current_functions.py +++ b/tests/unit/test_parameters/test_current_functions.py @@ -4,21 +4,20 @@ import pybamm import numbers -import unittest import numpy as np import pandas as pd import pytest from tests import no_internet_connection -class TestCurrentFunctions(unittest.TestCase): +class TestCurrentFunctions: def test_constant_current(self): # test simplify param = pybamm.electrical_parameters current = param.current_with_time parameter_values = pybamm.ParameterValues({"Current function [A]": 2}) processed_current = parameter_values.process_symbol(current) - self.assertIsInstance(processed_current, pybamm.Scalar) + assert isinstance(processed_current, pybamm.Scalar) @pytest.mark.skipif( no_internet_connection(), @@ -99,13 +98,3 @@ def test_output_type(self): def test_all(self): self.test_output_type() - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_parameters/test_ecm_parameters.py b/tests/unit/test_parameters/test_ecm_parameters.py index 543b4f4e5b..39ee80afee 100644 --- a/tests/unit/test_parameters/test_ecm_parameters.py +++ b/tests/unit/test_parameters/test_ecm_parameters.py @@ -3,7 +3,6 @@ # import pybamm -import unittest values = { @@ -33,7 +32,7 @@ parameter_values = pybamm.ParameterValues(values) -class TestEcmParameters(unittest.TestCase): +class TestEcmParameters: def test_init_parameters(self): param = pybamm.EcmParameters() @@ -54,13 +53,13 @@ def test_init_parameters(self): for symbol, key in simpled_mapped_parameters: value = parameter_values.evaluate(symbol) expected_value = values[key] - self.assertEqual(value, expected_value) + assert value == expected_value value = parameter_values.evaluate(param.initial_T_cell) - self.assertEqual(value, values["Initial temperature [K]"] - 273.15) + assert value == values["Initial temperature [K]"] - 273.15 value = parameter_values.evaluate(param.initial_T_jig) - self.assertEqual(value, values["Initial temperature [K]"] - 273.15) + assert value == values["Initial temperature [K]"] - 273.15 compatibility_parameters = [ (param.n_electrodes_parallel, 1), @@ -70,7 +69,7 @@ def test_init_parameters(self): for symbol, expected_value in compatibility_parameters: value = parameter_values.evaluate(symbol) - self.assertEqual(value, expected_value) + assert value == expected_value def test_function_parameters(self): param = pybamm.EcmParameters() @@ -89,17 +88,7 @@ def test_function_parameters(self): for symbol, key in mapped_functions: value = parameter_values.evaluate(symbol) expected_value = values[key] - self.assertEqual(value, expected_value) + assert value == expected_value value = parameter_values.evaluate(param.T_amb(sym)) - self.assertEqual(value, values["Ambient temperature [K]"] - 273.15) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert value == values["Ambient temperature [K]"] - 273.15 diff --git a/tests/unit/test_parameters/test_lead_acid_parameters.py b/tests/unit/test_parameters/test_lead_acid_parameters.py index 3fc62fde93..1d02d91ff0 100644 --- a/tests/unit/test_parameters/test_lead_acid_parameters.py +++ b/tests/unit/test_parameters/test_lead_acid_parameters.py @@ -1,19 +1,19 @@ # # Test for the standard lead acid parameters # +import pytest import os import pybamm from tests import get_discretisation_for_testing from tempfile import TemporaryDirectory -import unittest -class TestStandardParametersLeadAcid(unittest.TestCase): +class TestStandardParametersLeadAcid: def test_scipy_constants(self): constants = pybamm.constants - self.assertAlmostEqual(constants.R.evaluate(), 8.314, places=3) - self.assertAlmostEqual(constants.F.evaluate(), 96485, places=0) + assert constants.R.evaluate() == pytest.approx(8.314, abs=0.001) + assert constants.F.evaluate() == pytest.approx(96485, abs=1) def test_print_parameters(self): with TemporaryDirectory() as dir_name: @@ -30,17 +30,19 @@ def test_parameters_defaults_lead_acid(self): # Volume change positive in negative electrode and negative in positive # electrode - self.assertLess(param_eval["n.DeltaVsurf"], 0) - self.assertGreater(param_eval["p.DeltaVsurf"], 0) + assert param_eval["n.DeltaVsurf"] < 0 + assert param_eval["p.DeltaVsurf"] > 0 def test_concatenated_parameters(self): # create param = pybamm.LeadAcidParameters() eps_param = param.epsilon_init - self.assertIsInstance(eps_param, pybamm.Concatenation) - self.assertEqual( - eps_param.domain, ["negative electrode", "separator", "positive electrode"] - ) + assert isinstance(eps_param, pybamm.Concatenation) + assert eps_param.domain == [ + "negative electrode", + "separator", + "positive electrode", + ] # process parameters and discretise parameter_values = pybamm.ParameterValues("Sulzer2019") @@ -49,7 +51,7 @@ def test_concatenated_parameters(self): # test output submeshes = disc.mesh[("negative electrode", "separator", "positive electrode")] - self.assertEqual(processed_eps.shape, (submeshes.npts, 1)) + assert processed_eps.shape == (submeshes.npts, 1) def test_current_functions(self): # create current functions @@ -70,7 +72,7 @@ def test_current_functions(self): } ) current_density_eval = parameter_values.process_symbol(current_density) - self.assertAlmostEqual(current_density_eval.evaluate(t=3), 2 / (8 * 0.1 * 0.1)) + assert current_density_eval.evaluate(t=3) == pytest.approx(2 / (8 * 0.1 * 0.1)) def test_thermal_parameters(self): values = pybamm.lead_acid.BaseModel().default_parameter_values @@ -78,18 +80,18 @@ def test_thermal_parameters(self): T = 300 # dummy temperature as the values are constant # Density - self.assertEqual(values.evaluate(param.n.rho_c_p_cc(T)), 11300 * 130) - self.assertEqual(values.evaluate(param.n.rho_c_p(T)), 11300 * 130) - self.assertEqual(values.evaluate(param.s.rho_c_p(T)), 1680 * 700) - self.assertEqual(values.evaluate(param.p.rho_c_p(T)), 9375 * 256) - self.assertEqual(values.evaluate(param.p.rho_c_p_cc(T)), 9375 * 256) + assert values.evaluate(param.n.rho_c_p_cc(T)) == 11300 * 130 + assert values.evaluate(param.n.rho_c_p(T)) == 11300 * 130 + assert values.evaluate(param.s.rho_c_p(T)) == 1680 * 700 + assert values.evaluate(param.p.rho_c_p(T)) == 9375 * 256 + assert values.evaluate(param.p.rho_c_p_cc(T)) == 9375 * 256 # Thermal conductivity - self.assertEqual(values.evaluate(param.n.lambda_cc(T)), 35) - self.assertEqual(values.evaluate(param.n.lambda_(T)), 35) - self.assertEqual(values.evaluate(param.s.lambda_(T)), 0.04) - self.assertEqual(values.evaluate(param.p.lambda_(T)), 35) - self.assertEqual(values.evaluate(param.p.lambda_cc(T)), 35) + assert values.evaluate(param.n.lambda_cc(T)) == 35 + assert values.evaluate(param.n.lambda_(T)) == 35 + assert values.evaluate(param.s.lambda_(T)) == 0.04 + assert values.evaluate(param.p.lambda_(T)) == 35 + assert values.evaluate(param.p.lambda_cc(T)) == 35 def test_functions_lead_acid(self): # Load parameters to be tested @@ -112,17 +114,7 @@ def test_functions_lead_acid(self): param_eval = parameter_values.print_parameters(parameters) # Known monotonicity for functions - self.assertGreater(param_eval["chi_1"], param_eval["chi_0.5"]) - self.assertLess(param_eval["U_n_1"], param_eval["U_n_0.5"]) - self.assertGreater(param_eval["U_p_1"], param_eval["U_p_0.5"]) - self.assertGreater(param_eval["j0_Ox_1"], param_eval["j0_Ox_0.5"]) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert param_eval["chi_1"] > param_eval["chi_0.5"] + assert param_eval["U_n_1"] < param_eval["U_n_0.5"] + assert param_eval["U_p_1"] > param_eval["U_p_0.5"] + assert param_eval["j0_Ox_1"] > param_eval["j0_Ox_0.5"] diff --git a/tests/unit/test_parameters/test_lithium_ion_parameters.py b/tests/unit/test_parameters/test_lithium_ion_parameters.py index 66c4ea398e..7ac573d785 100644 --- a/tests/unit/test_parameters/test_lithium_ion_parameters.py +++ b/tests/unit/test_parameters/test_lithium_ion_parameters.py @@ -5,11 +5,10 @@ import pybamm from tempfile import TemporaryDirectory -import unittest import numpy as np -class TestLithiumIonParameterValues(unittest.TestCase): +class TestLithiumIonParameterValues: def test_print_parameters(self): with TemporaryDirectory() as dir_name: parameters = pybamm.LithiumIonParameters() @@ -138,13 +137,3 @@ def test_parameter_functions(self): c_e_test = 1000 values.evaluate(param.D_e(c_e_test, T_test)) values.evaluate(param.kappa_e(c_e_test, T_test)) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() From ba2aa674244a247bc47b6ae67b5f6bf43e55f335 Mon Sep 17 00:00:00 2001 From: Pradyot Ranjan <99216956+prady0t@users.noreply.github.com> Date: Fri, 13 Sep 2024 18:46:55 +0530 Subject: [PATCH 18/23] Migrating unittest to pytest (Part 7) (#4431) * Migrating unittest to pytest (Part 7) Signed-off-by: Pradyot Ranjan <99216956+pradyotRanjan@users.noreply.github.com> * style: pre-commit fixes * Removing style failures Signed-off-by: Pradyot Ranjan <99216956+pradyotRanjan@users.noreply.github.com> * Update tests/unit/test_parameters/test_parameter_values.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Update tests/unit/test_parameters/test_process_parameter_data.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Update tests/unit/test_parameters/test_process_parameter_data.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Update tests/unit/test_parameters/test_process_parameter_data.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Update tests/unit/test_plotting/test_quick_plot.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Update tests/unit/test_spatial_methods/test_spectral_volume.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Update tests/unit/test_spatial_methods/test_spectral_volume.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Update tests/unit/test_spatial_methods/test_spectral_volume.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Update tests/unit/test_spatial_methods/test_spectral_volume.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Update tests/unit/test_plotting/test_quick_plot.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Update tests/unit/test_solvers/test_casadi_algebraic_solver.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Update tests/unit/test_solvers/test_casadi_algebraic_solver.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Update tests/unit/test_spatial_methods/test_spectral_volume.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Update tests/unit/test_spatial_methods/test_spectral_volume.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Update tests/unit/test_spatial_methods/test_spectral_volume.py Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> * Removing DepricatioWarning failure Signed-off-by: Pradyot Ranjan <99216956+pradyotRanjan@users.noreply.github.com> --------- Signed-off-by: Pradyot Ranjan <99216956+pradyotRanjan@users.noreply.github.com> Co-authored-by: Pradyot Ranjan <99216956+pradyotRanjan@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> --- .../test_parameters/test_parameter_values.py | 366 ++++++++---------- .../test_process_parameter_data.py | 56 ++- tests/unit/test_plotting/test_quick_plot.py | 113 +++--- .../test_serialisation/test_serialisation.py | 153 ++++---- tests/unit/test_simulation.py | 163 ++++---- .../test_solvers/test_algebraic_solver.py | 39 +- tests/unit/test_solvers/test_base_solver.py | 105 +++-- .../test_casadi_algebraic_solver.py | 34 +- tests/unit/test_solvers/test_casadi_solver.py | 56 ++- tests/unit/test_solvers/test_idaklu_solver.py | 60 ++- .../unit/test_solvers/test_jax_bdf_solver.py | 20 +- tests/unit/test_solvers/test_jax_solver.py | 38 +- .../test_solvers/test_processed_variable.py | 64 +-- .../test_processed_variable_computed.py | 22 +- .../test_base_spatial_method.py | 72 ++-- .../test_finite_volume/test_extrapolation.py | 27 +- .../test_finite_volume/test_finite_volume.py | 88 ++--- .../test_ghost_nodes_and_neumann.py | 60 +-- .../test_grad_div_shapes.py | 13 +- .../test_finite_volume/test_integration.py | 46 +-- .../test_scikit_finite_element.py | 38 +- .../test_spectral_volume.py | 24 +- 22 files changed, 693 insertions(+), 964 deletions(-) diff --git a/tests/unit/test_parameters/test_parameter_values.py b/tests/unit/test_parameters/test_parameter_values.py index eaeb4a5a42..4086abea6d 100644 --- a/tests/unit/test_parameters/test_parameter_values.py +++ b/tests/unit/test_parameters/test_parameter_values.py @@ -3,8 +3,8 @@ # +import pytest import os -import unittest import numpy as np import pandas as pd @@ -19,88 +19,82 @@ import casadi -class TestParameterValues(unittest.TestCase): +class TestParameterValues: def test_init(self): # from dict param = pybamm.ParameterValues({"a": 1}) - self.assertEqual(param["a"], 1) - self.assertIn("a", param.keys()) - self.assertIn(1, param.values()) - self.assertIn(("a", 1), param.items()) + assert param["a"] == 1 + assert "a" in param.keys() + assert 1 in param.values() + assert ("a", 1) in param.items() # from dict with strings param = pybamm.ParameterValues({"a": "1"}) - self.assertEqual(param["a"], 1) + assert param["a"] == 1 # from dict "chemistry" key gets removed param = pybamm.ParameterValues({"a": 1, "chemistry": "lithium-ion"}) - self.assertNotIn("chemistry", param.keys()) + assert "chemistry" not in param.keys() # chemistry kwarg removed - with self.assertRaisesRegex( - ValueError, "'chemistry' keyword argument has been deprecated" + with pytest.raises( + ValueError, match="'chemistry' keyword argument has been deprecated" ): pybamm.ParameterValues(None, chemistry="lithium-ion") # junk param values rejected - with self.assertRaisesRegex(ValueError, "'Junk' is not a valid parameter set."): + with pytest.raises(ValueError, match="'Junk' is not a valid parameter set."): pybamm.ParameterValues("Junk") def test_repr(self): param = pybamm.ParameterValues({"a": 1}) - self.assertEqual( - repr(param), - "{'Boltzmann constant [J.K-1]': 1.380649e-23,\n" + assert ( + repr(param) == "{'Boltzmann constant [J.K-1]': 1.380649e-23,\n" " 'Electron charge [C]': 1.602176634e-19,\n" " 'Faraday constant [C.mol-1]': 96485.33212,\n" " 'Ideal gas constant [J.K-1.mol-1]': 8.314462618,\n" - " 'a': 1}", - ) - self.assertEqual( - param._ipython_key_completions_(), - [ - "Ideal gas constant [J.K-1.mol-1]", - "Faraday constant [C.mol-1]", - "Boltzmann constant [J.K-1]", - "Electron charge [C]", - "a", - ], + " 'a': 1}" ) + assert param._ipython_key_completions_() == [ + "Ideal gas constant [J.K-1.mol-1]", + "Faraday constant [C.mol-1]", + "Boltzmann constant [J.K-1]", + "Electron charge [C]", + "a", + ] def test_eq(self): - self.assertEqual( - pybamm.ParameterValues({"a": 1}), pybamm.ParameterValues({"a": 1}) - ) + assert pybamm.ParameterValues({"a": 1}) == pybamm.ParameterValues({"a": 1}) def test_update(self): # equate values param = pybamm.ParameterValues({"a": 1}) - self.assertEqual(param["a"], 1) + assert param["a"] == 1 # no conflict param.update({"a": 2}) - self.assertEqual(param["a"], 2) + assert param["a"] == 2 param.update({"a": 2}, check_conflict=True) - self.assertEqual(param["a"], 2) + assert param["a"] == 2 # with conflict param.update({"a": 3}) # via __setitem__ param["a"] = 2 - self.assertEqual(param["a"], 2) - with self.assertRaisesRegex( - ValueError, "parameter 'a' already defined with value '2'" + assert param["a"] == 2 + with pytest.raises( + ValueError, match="parameter 'a' already defined with value '2'" ): param.update({"a": 4}, check_conflict=True) # with parameter not existing yet - with self.assertRaisesRegex(KeyError, "Cannot update parameter"): + with pytest.raises(KeyError, match="Cannot update parameter"): param.update({"b": 1}) # update with a ParameterValues object new_param = pybamm.ParameterValues(param) - self.assertEqual(new_param["a"], 2) + assert new_param["a"] == 2 # test deleting a parameter del param["a"] - self.assertNotIn("a", param.keys()) + assert "a" not in param.keys() def test_set_initial_stoichiometries(self): param = pybamm.ParameterValues("Chen2020") @@ -113,12 +107,12 @@ def test_set_initial_stoichiometries(self): x = param["Initial concentration in negative electrode [mol.m-3]"] x_0 = param_0["Initial concentration in negative electrode [mol.m-3]"] x_100 = param_100["Initial concentration in negative electrode [mol.m-3]"] - self.assertAlmostEqual(x, x_0 + 0.4 * (x_100 - x_0)) + assert x == pytest.approx(x_0 + 0.4 * (x_100 - x_0)) y = param["Initial concentration in positive electrode [mol.m-3]"] y_0 = param_0["Initial concentration in positive electrode [mol.m-3]"] y_100 = param_100["Initial concentration in positive electrode [mol.m-3]"] - self.assertAlmostEqual(y, y_0 - 0.4 * (y_0 - y_100)) + assert y == pytest.approx(y_0 - 0.4 * (y_0 - y_100)) def test_set_initial_stoichiometry_half_cell(self): param = pybamm.lithium_ion.DFN( @@ -137,7 +131,7 @@ def test_set_initial_stoichiometry_half_cell(self): y = param["Initial concentration in positive electrode [mol.m-3]"] y_0 = param_0["Initial concentration in positive electrode [mol.m-3]"] y_100 = param_100["Initial concentration in positive electrode [mol.m-3]"] - self.assertAlmostEqual(y, y_0 - 0.4 * (y_0 - y_100)) + assert y == pytest.approx(y_0 - 0.4 * (y_0 - y_100)) # inplace for 100% coverage param_t = pybamm.lithium_ion.DFN( @@ -161,11 +155,11 @@ def test_set_initial_stoichiometry_half_cell(self): 1, inplace=True, options={"working electrode": "positive"} ) y_100 = param_100["Initial concentration in positive electrode [mol.m-3]"] - self.assertAlmostEqual(y, y_0 - 0.4 * (y_0 - y_100)) + assert y == pytest.approx(y_0 - 0.4 * (y_0 - y_100)) # test error param = pybamm.ParameterValues("Chen2020") - with self.assertRaisesRegex(OptionError, "working electrode"): + with pytest.raises(OptionError, match="working electrode"): param.set_initial_stoichiometry_half_cell( 0.1, options={"working electrode": "negative"} ) @@ -183,20 +177,20 @@ def test_set_initial_ocps(self): Un_0 = param_0["Initial voltage in negative electrode [V]"] Up_0 = param_0["Initial voltage in positive electrode [V]"] - self.assertAlmostEqual(Up_0 - Un_0, 2.8) + assert Up_0 - Un_0 == pytest.approx(2.8) Un_100 = param_100["Initial voltage in negative electrode [V]"] Up_100 = param_100["Initial voltage in positive electrode [V]"] - self.assertAlmostEqual(Up_100 - Un_100, 4.2) + assert Up_100 - Un_100 == pytest.approx(4.2) def test_check_parameter_values(self): - with self.assertRaisesRegex(ValueError, "propotional term"): + with pytest.raises(ValueError, match="propotional term"): pybamm.ParameterValues( {"Negative electrode LAM constant propotional term": 1} ) # The + character in "1 + dlnf/dlnc" is appended with a backslash (\+), # since + has other meanings in regex - with self.assertRaisesRegex(ValueError, "Thermodynamic factor"): + with pytest.raises(ValueError, match="Thermodynamic factor"): pybamm.ParameterValues({"1 + dlnf/dlnc": 1}) def test_process_symbol(self): @@ -204,86 +198,86 @@ def test_process_symbol(self): # process parameter a = pybamm.Parameter("a") processed_a = parameter_values.process_symbol(a) - self.assertIsInstance(processed_a, pybamm.Scalar) - self.assertEqual(processed_a.value, 4) + assert isinstance(processed_a, pybamm.Scalar) + assert processed_a.value == 4 # process binary operation var = pybamm.Variable("var") add = a + var processed_add = parameter_values.process_symbol(add) - self.assertIsInstance(processed_add, pybamm.Addition) - self.assertIsInstance(processed_add.children[0], pybamm.Scalar) - self.assertIsInstance(processed_add.children[1], pybamm.Variable) - self.assertEqual(processed_add.children[0].value, 4) + assert isinstance(processed_add, pybamm.Addition) + assert isinstance(processed_add.children[0], pybamm.Scalar) + assert isinstance(processed_add.children[1], pybamm.Variable) + assert processed_add.children[0].value == 4 b = pybamm.Parameter("b") add = a + b processed_add = parameter_values.process_symbol(add) - self.assertIsInstance(processed_add, pybamm.Scalar) - self.assertEqual(processed_add.value, 6) + assert isinstance(processed_add, pybamm.Scalar) + assert processed_add.value == 6 scal = pybamm.Scalar(34) mul = a * scal processed_mul = parameter_values.process_symbol(mul) - self.assertIsInstance(processed_mul, pybamm.Scalar) - self.assertEqual(processed_mul.value, 136) + assert isinstance(processed_mul, pybamm.Scalar) + assert processed_mul.value == 136 # process integral aa = pybamm.PrimaryBroadcast(pybamm.Parameter("a"), "negative electrode") x = pybamm.SpatialVariable("x", domain=["negative electrode"]) integ = pybamm.Integral(aa, x) processed_integ = parameter_values.process_symbol(integ) - self.assertIsInstance(processed_integ, pybamm.Integral) - self.assertIsInstance(processed_integ.children[0], pybamm.PrimaryBroadcast) - self.assertEqual(processed_integ.children[0].child.value, 4) - self.assertEqual(processed_integ.integration_variable[0], x) + assert isinstance(processed_integ, pybamm.Integral) + assert isinstance(processed_integ.children[0], pybamm.PrimaryBroadcast) + assert processed_integ.children[0].child.value == 4 + assert processed_integ.integration_variable[0] == x # process unary operation v = pybamm.Variable("v", domain="test") grad = pybamm.Gradient(v) processed_grad = parameter_values.process_symbol(grad) - self.assertIsInstance(processed_grad, pybamm.Gradient) - self.assertIsInstance(processed_grad.children[0], pybamm.Variable) + assert isinstance(processed_grad, pybamm.Gradient) + assert isinstance(processed_grad.children[0], pybamm.Variable) # process delta function aa = pybamm.Parameter("a") delta_aa = pybamm.DeltaFunction(aa, "left", "some domain") processed_delta_aa = parameter_values.process_symbol(delta_aa) - self.assertIsInstance(processed_delta_aa, pybamm.DeltaFunction) - self.assertEqual(processed_delta_aa.side, "left") + assert isinstance(processed_delta_aa, pybamm.DeltaFunction) + assert processed_delta_aa.side == "left" processed_a = processed_delta_aa.children[0] - self.assertIsInstance(processed_a, pybamm.Scalar) - self.assertEqual(processed_a.value, 4) + assert isinstance(processed_a, pybamm.Scalar) + assert processed_a.value == 4 # process boundary operator (test for BoundaryValue) aa = pybamm.Parameter("a") x = pybamm.SpatialVariable("x", domain=["negative electrode"]) boundary_op = pybamm.BoundaryValue(aa * x, "left") processed_boundary_op = parameter_values.process_symbol(boundary_op) - self.assertIsInstance(processed_boundary_op, pybamm.BoundaryOperator) + assert isinstance(processed_boundary_op, pybamm.BoundaryOperator) processed_a = processed_boundary_op.children[0].children[0] processed_x = processed_boundary_op.children[0].children[1] - self.assertIsInstance(processed_a, pybamm.Scalar) - self.assertEqual(processed_a.value, 4) - self.assertEqual(processed_x, x) + assert isinstance(processed_a, pybamm.Scalar) + assert processed_a.value == 4 + assert processed_x == x # process EvaluateAt evaluate_at = pybamm.EvaluateAt(x, aa) processed_evaluate_at = parameter_values.process_symbol(evaluate_at) - self.assertIsInstance(processed_evaluate_at, pybamm.EvaluateAt) - self.assertEqual(processed_evaluate_at.children[0], x) - self.assertEqual(processed_evaluate_at.position, 4) - with self.assertRaisesRegex(ValueError, "'position' in 'EvaluateAt'"): + assert isinstance(processed_evaluate_at, pybamm.EvaluateAt) + assert processed_evaluate_at.children[0] == x + assert processed_evaluate_at.position == 4 + with pytest.raises(ValueError, match="'position' in 'EvaluateAt'"): parameter_values.process_symbol(pybamm.EvaluateAt(x, x)) # process broadcast whole_cell = ["negative electrode", "separator", "positive electrode"] broad = pybamm.PrimaryBroadcast(a, whole_cell) processed_broad = parameter_values.process_symbol(broad) - self.assertIsInstance(processed_broad, pybamm.Broadcast) - self.assertEqual(processed_broad.domain, whole_cell) - self.assertIsInstance(processed_broad.children[0], pybamm.Scalar) - self.assertEqual(processed_broad.children[0].evaluate(), 4) + assert isinstance(processed_broad, pybamm.Broadcast) + assert processed_broad.domain == whole_cell + assert isinstance(processed_broad.children[0], pybamm.Scalar) + assert processed_broad.children[0].evaluate() == 4 # process concatenation conc = pybamm.concatenation( @@ -291,8 +285,8 @@ def test_process_symbol(self): pybamm.Vector(2 * np.ones(15), domain="test 2"), ) processed_conc = parameter_values.process_symbol(conc) - self.assertIsInstance(processed_conc.children[0], pybamm.Vector) - self.assertIsInstance(processed_conc.children[1], pybamm.Vector) + assert isinstance(processed_conc.children[0], pybamm.Vector) + assert isinstance(processed_conc.children[1], pybamm.Vector) np.testing.assert_array_equal(processed_conc.children[0].entries, 1) np.testing.assert_array_equal(processed_conc.children[1].entries, 2) @@ -304,52 +298,52 @@ def test_process_symbol(self): processed_dom_con = parameter_values.process_symbol(dom_con) a_proc = processed_dom_con.children[0].children[0] b_proc = processed_dom_con.children[1].children[0] - self.assertIsInstance(a_proc, pybamm.Scalar) - self.assertIsInstance(b_proc, pybamm.Scalar) - self.assertEqual(a_proc.value, 4) - self.assertEqual(b_proc.value, 2) + assert isinstance(a_proc, pybamm.Scalar) + assert isinstance(b_proc, pybamm.Scalar) + assert a_proc.value == 4 + assert b_proc.value == 2 # process variable c = pybamm.Variable("c") processed_c = parameter_values.process_symbol(c) - self.assertIsInstance(processed_c, pybamm.Variable) - self.assertEqual(processed_c.name, "c") + assert isinstance(processed_c, pybamm.Variable) + assert processed_c.name == "c" # process scalar d = pybamm.Scalar(14) processed_d = parameter_values.process_symbol(d) - self.assertIsInstance(processed_d, pybamm.Scalar) - self.assertEqual(processed_d.value, 14) + assert isinstance(processed_d, pybamm.Scalar) + assert processed_d.value == 14 # process array types e = pybamm.Vector(np.ones(4)) processed_e = parameter_values.process_symbol(e) - self.assertIsInstance(processed_e, pybamm.Vector) + assert isinstance(processed_e, pybamm.Vector) np.testing.assert_array_equal(processed_e.evaluate(), np.ones((4, 1))) f = pybamm.Matrix(np.ones((5, 6))) processed_f = parameter_values.process_symbol(f) - self.assertIsInstance(processed_f, pybamm.Matrix) + assert isinstance(processed_f, pybamm.Matrix) np.testing.assert_array_equal(processed_f.evaluate(), np.ones((5, 6))) # process statevector g = pybamm.StateVector(slice(0, 10)) processed_g = parameter_values.process_symbol(g) - self.assertIsInstance(processed_g, pybamm.StateVector) + assert isinstance(processed_g, pybamm.StateVector) np.testing.assert_array_equal( processed_g.evaluate(y=np.ones(10)), np.ones((10, 1)) ) # not found - with self.assertRaises(KeyError): + with pytest.raises(KeyError): x = pybamm.Parameter("x") parameter_values.process_symbol(x) parameter_values = pybamm.ParameterValues({"x": np.nan}) - with self.assertRaisesRegex(ValueError, "Parameter 'x' not found"): + with pytest.raises(ValueError, match="Parameter 'x' not found"): x = pybamm.Parameter("x") parameter_values.process_symbol(x) - with self.assertRaisesRegex(ValueError, "possibly a function"): + with pytest.raises(ValueError, match="possibly a function"): x = pybamm.FunctionParameter("x", {}) parameter_values.process_symbol(x) @@ -361,11 +355,11 @@ def test_process_parameter_in_parameter(self): # process 2a parameter a = pybamm.Parameter("2a") processed_a = parameter_values.process_symbol(a) - self.assertEqual(processed_a.evaluate(), 4) + assert processed_a.evaluate() == 4 # case where parameter can't be processed b = pybamm.Parameter("b") - with self.assertRaisesRegex(TypeError, "Cannot process parameter"): + with pytest.raises(TypeError, match="Cannot process parameter"): parameter_values.process_symbol(b) def test_process_input_parameter(self): @@ -375,19 +369,19 @@ def test_process_input_parameter(self): # process input parameter a = pybamm.Parameter("a") processed_a = parameter_values.process_symbol(a) - self.assertIsInstance(processed_a, pybamm.InputParameter) - self.assertEqual(processed_a.evaluate(inputs={"a": 5}), 5) + assert isinstance(processed_a, pybamm.InputParameter) + assert processed_a.evaluate(inputs={"a": 5}) == 5 # process binary operation b = pybamm.Parameter("b") add = a + b processed_add = parameter_values.process_symbol(add) - self.assertEqual(processed_add, 3 + pybamm.InputParameter("a")) + assert processed_add == 3 + pybamm.InputParameter("a") # process complex input parameter c = pybamm.Parameter("c times 2") processed_c = parameter_values.process_symbol(c) - self.assertEqual(processed_c.evaluate(inputs={"c": 5}), 10) + assert processed_c.evaluate(inputs={"c": 5}) == 10 def test_process_function_parameter(self): def test_function(var): @@ -408,7 +402,7 @@ def test_function(var): # process function func = pybamm.FunctionParameter("func", {"a": a}) processed_func = parameter_values.process_symbol(func) - self.assertEqual(processed_func.evaluate(inputs={"a": 3}), 369) + assert processed_func.evaluate(inputs={"a": 3}) == 369 # process constant function # this should work even if the parameter in the function is not provided @@ -416,35 +410,35 @@ def test_function(var): "const", {"a": pybamm.Parameter("not provided")} ) processed_const = parameter_values.process_symbol(const) - self.assertIsInstance(processed_const, pybamm.Scalar) - self.assertEqual(processed_const.evaluate(), 254) + assert isinstance(processed_const, pybamm.Scalar) + assert processed_const.evaluate() == 254 # process case where parameter provided is a pybamm symbol # (e.g. a multiplication) mult = pybamm.FunctionParameter("mult", {"a": a}) processed_mult = parameter_values.process_symbol(mult) - self.assertEqual(processed_mult.evaluate(inputs={"a": 14, "b": 63}), 63 * 5) + assert processed_mult.evaluate(inputs={"a": 14, "b": 63}) == 63 * 5 # process differentiated function parameter diff_func = func.diff(a) processed_diff_func = parameter_values.process_symbol(diff_func) - self.assertEqual(processed_diff_func.evaluate(inputs={"a": 3}), 123) + assert processed_diff_func.evaluate(inputs={"a": 3}) == 123 # make sure diff works, despite simplifications, when the child is constant a_const = pybamm.Scalar(3) func_const = pybamm.FunctionParameter("func", {"a": a_const}) diff_func_const = func_const.diff(a_const) processed_diff_func_const = parameter_values.process_symbol(diff_func_const) - self.assertEqual(processed_diff_func_const.evaluate(), 123) + assert processed_diff_func_const.evaluate() == 123 # function parameter that returns a python float func = pybamm.FunctionParameter("float_func", {"a": a}) processed_func = parameter_values.process_symbol(func) - self.assertEqual(processed_func.evaluate(), 42) + assert processed_func.evaluate() == 42 # weird type raises error func = pybamm.FunctionParameter("bad type", {"a": a}) - with self.assertRaisesRegex(TypeError, "Parameter provided for"): + with pytest.raises(TypeError, match="Parameter provided for"): parameter_values.process_symbol(func) # function itself as input (different to the variable being an input) @@ -452,7 +446,7 @@ def test_function(var): a = pybamm.Scalar(3) func = pybamm.FunctionParameter("func", {"a": a}) processed_func = parameter_values.process_symbol(func) - self.assertEqual(processed_func.evaluate(inputs={"func": 13}), 13) + assert processed_func.evaluate(inputs={"func": 13}) == 13 # make sure function keeps the domain of the original function @@ -473,11 +467,11 @@ def my_func(x): ) func3 = parameter_values.process_symbol(func) - self.assertEqual(func1.domains, func2.domains) - self.assertEqual(func1.domains, func3.domains) + assert func1.domains == func2.domains + assert func1.domains == func3.domains # [function] is deprecated - with self.assertRaisesRegex(ValueError, "[function]"): + with pytest.raises(ValueError, match="[function]"): pybamm.ParameterValues({"func": "[function]something"}) def test_process_inline_function_parameters(self): @@ -490,12 +484,12 @@ def D(c): func = pybamm.FunctionParameter("Diffusivity", {"a": a}) processed_func = parameter_values.process_symbol(func) - self.assertEqual(processed_func.evaluate(), 9) + assert processed_func.evaluate() == 9 # process differentiated function parameter diff_func = func.diff(a) processed_diff_func = parameter_values.process_symbol(diff_func) - self.assertEqual(processed_diff_func.evaluate(), 6) + assert processed_diff_func.evaluate() == 6 def test_multi_var_function_with_parameters(self): def D(a, b): @@ -508,8 +502,8 @@ def D(a, b): processed_func = parameter_values.process_symbol(func) # Function of scalars gets automatically simplified - self.assertIsInstance(processed_func, pybamm.Scalar) - self.assertEqual(processed_func.evaluate(), 3) + assert isinstance(processed_func, pybamm.Scalar) + assert processed_func.evaluate() == 3 def test_multi_var_function_parameter(self): def D(a, b): @@ -522,7 +516,7 @@ def D(a, b): func = pybamm.FunctionParameter("Diffusivity", {"a": a, "b": b}) processed_func = parameter_values.process_symbol(func) - self.assertEqual(processed_func.evaluate(), 3) + assert processed_func.evaluate() == 3 def test_process_interpolant(self): x = np.linspace(0, 10)[:, np.newaxis] @@ -533,18 +527,18 @@ def test_process_interpolant(self): func = pybamm.FunctionParameter("Times two", {"a": a}) processed_func = parameter_values.process_symbol(func) - self.assertIsInstance(processed_func, pybamm.Interpolant) - self.assertEqual(processed_func.evaluate(inputs={"a": 3.01}), 6.02) + assert isinstance(processed_func, pybamm.Interpolant) + assert processed_func.evaluate(inputs={"a": 3.01}) == 6.02 # interpolant defined up front interp = pybamm.Interpolant(data[:, 0], data[:, 1], a, interpolator="cubic") processed_interp = parameter_values.process_symbol(interp) - self.assertEqual(processed_interp.evaluate(inputs={"a": 3.01}), 6.02) + assert processed_interp.evaluate(inputs={"a": 3.01}) == 6.02 # process differentiated function parameter diff_interp = interp.diff(a) processed_diff_interp = parameter_values.process_symbol(diff_interp) - self.assertEqual(processed_diff_interp.evaluate(inputs={"a": 3.01}), 2) + assert processed_diff_interp.evaluate(inputs={"a": 3.01}) == 2 def test_process_interpolant_2d(self): x_ = [np.linspace(0, 10), np.linspace(0, 20)] @@ -566,9 +560,9 @@ def test_process_interpolant_2d(self): func = pybamm.FunctionParameter("Times two", {"a": a, "b": b}) processed_func = parameter_values.process_symbol(func) - self.assertIsInstance(processed_func, pybamm.Interpolant) - self.assertAlmostEqual( - processed_func.evaluate(inputs={"a": 3.01, "b": 4.4}), 14.82 + assert isinstance(processed_func, pybamm.Interpolant) + assert processed_func.evaluate(inputs={"a": 3.01, "b": 4.4}) == pytest.approx( + 14.82 ) # process differentiated function parameter @@ -579,9 +573,7 @@ def test_process_interpolant_2d(self): # interpolant defined up front interp2 = pybamm.Interpolant(data[0], data[1], children=(a, b)) processed_interp2 = parameter_values.process_symbol(interp2) - self.assertEqual( - processed_interp2.evaluate(inputs={"a": 3.01, "b": 4.4}), 14.82 - ) + assert processed_interp2.evaluate(inputs={"a": 3.01, "b": 4.4}) == 14.82 y3 = (3 * x).sum(axis=1) @@ -598,7 +590,7 @@ def test_process_interpolant_2d(self): func = pybamm.FunctionParameter("Times three", {"a": a, "b": b}) processed_func = parameter_values.process_symbol(func) - self.assertIsInstance(processed_func, pybamm.Interpolant) + assert isinstance(processed_func, pybamm.Interpolant) # self.assertEqual(processed_func.evaluate().flatten()[0], 22.23) np.testing.assert_almost_equal( processed_func.evaluate(inputs={"a": 3.01, "b": 4.4}).flatten()[0], @@ -765,7 +757,7 @@ def test_process_integral_broadcast(self): param = pybamm.ParameterValues({"func": 2}) func_proc = param.process_symbol(func) - self.assertEqual(func_proc, pybamm.Scalar(2, name="func")) + assert func_proc == pybamm.Scalar(2, name="func") # test with auxiliary domains @@ -780,9 +772,8 @@ def test_process_integral_broadcast(self): param = pybamm.ParameterValues({"func": 2}) func_proc = param.process_symbol(func) - self.assertEqual( - func_proc, - pybamm.PrimaryBroadcast(pybamm.Scalar(2, name="func"), "current collector"), + assert func_proc == pybamm.PrimaryBroadcast( + pybamm.Scalar(2, name="func"), "current collector" ) # secondary and tertiary @@ -799,11 +790,8 @@ def test_process_integral_broadcast(self): param = pybamm.ParameterValues({"func": 2}) func_proc = param.process_symbol(func) - self.assertEqual( - func_proc, - pybamm.FullBroadcast( - pybamm.Scalar(2, name="func"), "negative particle", "current collector" - ), + assert func_proc == pybamm.FullBroadcast( + pybamm.Scalar(2, name="func"), "negative particle", "current collector" ) # secondary, tertiary and quaternary @@ -821,16 +809,13 @@ def test_process_integral_broadcast(self): param = pybamm.ParameterValues({"func": 2}) func_proc = param.process_symbol(func) - self.assertEqual( - func_proc, - pybamm.FullBroadcast( - pybamm.Scalar(2, name="func"), - "negative particle", - { - "secondary": "negative particle size", - "tertiary": "current collector", - }, - ), + assert func_proc == pybamm.FullBroadcast( + pybamm.Scalar(2, name="func"), + "negative particle", + { + "secondary": "negative particle size", + "tertiary": "current collector", + }, ) # special case for integral of concatenations of broadcasts @@ -854,7 +839,7 @@ def test_process_integral_broadcast(self): ) func_proc = param.process_symbol(func) - self.assertEqual(func_proc, pybamm.Scalar(3)) + assert func_proc == pybamm.Scalar(3) # with auxiliary domains var_n = pybamm.Variable( @@ -889,9 +874,8 @@ def test_process_integral_broadcast(self): ) func_proc = param.process_symbol(func) - self.assertEqual( - func_proc, - pybamm.PrimaryBroadcast(pybamm.Scalar(3), "current collector"), + assert func_proc == pybamm.PrimaryBroadcast( + pybamm.Scalar(3), "current collector" ) def test_process_size_average(self): @@ -913,16 +897,16 @@ def dist(R): ) var_av_proc = param.process_symbol(var_av) - self.assertIsInstance(var_av_proc, pybamm.SizeAverage) + assert isinstance(var_av_proc, pybamm.SizeAverage) R = pybamm.SpatialVariable("R", "negative particle size") - self.assertEqual(var_av_proc.f_a_dist, R**2) + assert var_av_proc.f_a_dist == R**2 def test_process_not_constant(self): param = pybamm.ParameterValues({"a": 4}) a = pybamm.NotConstant(pybamm.Parameter("a")) - self.assertIsInstance(param.process_symbol(a), pybamm.NotConstant) - self.assertEqual(param.process_symbol(a).evaluate(), 4) + assert isinstance(param.process_symbol(a), pybamm.NotConstant) + assert param.process_symbol(a).evaluate() == 4 def test_process_complex_expression(self): var1 = pybamm.Variable("var1") @@ -933,12 +917,12 @@ def test_process_complex_expression(self): param = pybamm.ParameterValues({"par1": 2, "par2": 4}) exp_param = param.process_symbol(expression) - self.assertEqual(exp_param, 3.0 * (2.0**var2) / ((-4.0 + var1) + var2)) + assert exp_param == 3.0 * (2.0**var2) / ((-4.0 + var1) + var2) def test_process_geometry(self): var = pybamm.Variable("var") geometry = {"negative electrode": {"x": {"min": 0, "max": var}}} - with self.assertRaisesRegex(ValueError, "Geometry parameters must be Scalars"): + with pytest.raises(ValueError, match="Geometry parameters must be Scalars"): pybamm.ParameterValues({}).process_geometry(geometry) def test_process_model(self): @@ -965,39 +949,37 @@ def test_process_model(self): parameter_values = pybamm.ParameterValues({"a": 1, "b": 2, "c": 3, "d": 42}) parameter_values.process_model(model) # rhs - self.assertIsInstance(model.rhs[var1], pybamm.Gradient) + assert isinstance(model.rhs[var1], pybamm.Gradient) # algebraic - self.assertIsInstance(model.algebraic[var2], pybamm.Multiplication) - self.assertIsInstance(model.algebraic[var2].children[0], pybamm.Scalar) - self.assertIsInstance(model.algebraic[var2].children[1], pybamm.Variable) - self.assertEqual(model.algebraic[var2].children[0].value, 3) + assert isinstance(model.algebraic[var2], pybamm.Multiplication) + assert isinstance(model.algebraic[var2].children[0], pybamm.Scalar) + assert isinstance(model.algebraic[var2].children[1], pybamm.Variable) + assert model.algebraic[var2].children[0].value == 3 # initial conditions - self.assertIsInstance(model.initial_conditions[var1], pybamm.Scalar) - self.assertEqual(model.initial_conditions[var1].value, 2) + assert isinstance(model.initial_conditions[var1], pybamm.Scalar) + assert model.initial_conditions[var1].value == 2 # boundary conditions bc_key = next(iter(model.boundary_conditions.keys())) - self.assertIsInstance(bc_key, pybamm.Variable) + assert isinstance(bc_key, pybamm.Variable) bc_value = next(iter(model.boundary_conditions.values())) - self.assertIsInstance(bc_value["left"][0], pybamm.Scalar) - self.assertEqual(bc_value["left"][0].value, 3) - self.assertIsInstance(bc_value["right"][0], pybamm.Scalar) - self.assertEqual(bc_value["right"][0].value, 42) + assert isinstance(bc_value["left"][0], pybamm.Scalar) + assert bc_value["left"][0].value == 3 + assert isinstance(bc_value["right"][0], pybamm.Scalar) + assert bc_value["right"][0].value == 42 # variables - self.assertEqual(model.variables["var1"], var1) - self.assertIsInstance(model.variables["grad_var1"], pybamm.Gradient) - self.assertIsInstance(model.variables["grad_var1"].children[0], pybamm.Variable) - self.assertEqual( - model.variables["d_var1"], (pybamm.Scalar(42, name="d") * var1) - ) - self.assertIsInstance(model.variables["d_var1"].children[0], pybamm.Scalar) - self.assertIsInstance(model.variables["d_var1"].children[1], pybamm.Variable) + assert model.variables["var1"] == var1 + assert isinstance(model.variables["grad_var1"], pybamm.Gradient) + assert isinstance(model.variables["grad_var1"].children[0], pybamm.Variable) + assert model.variables["d_var1"] == (pybamm.Scalar(42, name="d") * var1) + assert isinstance(model.variables["d_var1"].children[0], pybamm.Scalar) + assert isinstance(model.variables["d_var1"].children[1], pybamm.Variable) # bad boundary conditions model = pybamm.BaseModel() model.algebraic = {var1: var1} x = pybamm.Parameter("x") model.boundary_conditions = {var1: {"left": (x, "Dirichlet")}} - with self.assertRaises(KeyError): + with pytest.raises(KeyError): parameter_values.process_model(model) def test_inplace(self): @@ -1006,16 +988,16 @@ def test_inplace(self): new_model = param.process_model(model, inplace=False) V = model.variables["Voltage [V]"] - self.assertTrue(V.has_symbol_of_classes(pybamm.Parameter)) + assert V.has_symbol_of_classes(pybamm.Parameter) V = new_model.variables["Voltage [V]"] - self.assertFalse(V.has_symbol_of_classes(pybamm.Parameter)) + assert not V.has_symbol_of_classes(pybamm.Parameter) def test_process_empty_model(self): model = pybamm.BaseModel() parameter_values = pybamm.ParameterValues({"a": 1, "b": 2, "c": 3, "d": 42}) - with self.assertRaisesRegex( - pybamm.ModelError, "Cannot process parameters for empty model" + with pytest.raises( + pybamm.ModelError, match="Cannot process parameters for empty model" ): parameter_values.process_model(model) @@ -1024,15 +1006,15 @@ def test_evaluate(self): a = pybamm.Parameter("a") b = pybamm.Parameter("b") c = pybamm.Parameter("c") - self.assertEqual(parameter_values.evaluate(a), 1) - self.assertEqual(parameter_values.evaluate(a + (b * c)), 7) + assert parameter_values.evaluate(a) == 1 + assert parameter_values.evaluate(a + (b * c)) == 7 d = pybamm.Parameter("a") + pybamm.Parameter("b") * pybamm.Array([4, 5]) np.testing.assert_array_equal( parameter_values.evaluate(d), np.array([9, 11])[:, np.newaxis] ) y = pybamm.StateVector(slice(0, 1)) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): parameter_values.evaluate(y) def test_exchange_current_density_plating(self): @@ -1042,18 +1024,8 @@ def test_exchange_current_density_plating(self): param = pybamm.Parameter( "Exchange-current density for lithium metal electrode [A.m-2]" ) - with self.assertRaisesRegex( + with pytest.raises( KeyError, - "referring to the reaction at the surface of a lithium metal electrode", + match="referring to the reaction at the surface of a lithium metal electrode", ): parameter_values.evaluate(param) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_parameters/test_process_parameter_data.py b/tests/unit/test_parameters/test_process_parameter_data.py index 3230f374f2..9352894c5c 100644 --- a/tests/unit/test_parameters/test_process_parameter_data.py +++ b/tests/unit/test_parameters/test_process_parameter_data.py @@ -7,62 +7,52 @@ import numpy as np import pybamm -import unittest +import pytest -class TestProcessParameterData(unittest.TestCase): +class TestProcessParameterData: def test_process_1D_data(self): name = "lico2_ocv_example" path = os.path.abspath(os.path.dirname(__file__)) processed = pybamm.parameters.process_1D_data(name, path) - self.assertEqual(processed[0], name) - self.assertIsInstance(processed[1], tuple) - self.assertIsInstance(processed[1][0][0], np.ndarray) - self.assertIsInstance(processed[1][1], np.ndarray) + assert processed[0] == name + assert isinstance(processed[1], tuple) + assert isinstance(processed[1][0][0], np.ndarray) + assert isinstance(processed[1][1], np.ndarray) def test_process_2D_data(self): name = "lico2_diffusivity_Dualfoil1998_2D" path = os.path.abspath(os.path.dirname(__file__)) processed = pybamm.parameters.process_2D_data(name, path) - self.assertEqual(processed[0], name) - self.assertIsInstance(processed[1], tuple) - self.assertIsInstance(processed[1][0][0], np.ndarray) - self.assertIsInstance(processed[1][0][1], np.ndarray) - self.assertIsInstance(processed[1][1], np.ndarray) + assert processed[0] == name + assert isinstance(processed[1], tuple) + assert isinstance(processed[1][0][0], np.ndarray) + assert isinstance(processed[1][0][1], np.ndarray) + assert isinstance(processed[1][1], np.ndarray) def test_process_2D_data_csv(self): name = "data_for_testing_2D" path = os.path.abspath(os.path.dirname(__file__)) processed = pybamm.parameters.process_2D_data_csv(name, path) - self.assertEqual(processed[0], name) - self.assertIsInstance(processed[1], tuple) - self.assertIsInstance(processed[1][0][0], np.ndarray) - self.assertIsInstance(processed[1][0][1], np.ndarray) - self.assertIsInstance(processed[1][1], np.ndarray) + assert processed[0] == name + assert isinstance(processed[1], tuple) + assert isinstance(processed[1][0][0], np.ndarray) + assert isinstance(processed[1][0][1], np.ndarray) + assert isinstance(processed[1][1], np.ndarray) def test_process_3D_data_csv(self): name = "data_for_testing_3D" path = os.path.abspath(os.path.dirname(__file__)) processed = pybamm.parameters.process_3D_data_csv(name, path) - self.assertEqual(processed[0], name) - self.assertIsInstance(processed[1], tuple) - self.assertIsInstance(processed[1][0][0], np.ndarray) - self.assertIsInstance(processed[1][0][1], np.ndarray) - self.assertIsInstance(processed[1][0][2], np.ndarray) - self.assertIsInstance(processed[1][1], np.ndarray) + assert processed[0] == name + assert isinstance(processed[1], tuple) + assert isinstance(processed[1][0][0], np.ndarray) + assert isinstance(processed[1][0][1], np.ndarray) + assert isinstance(processed[1][0][2], np.ndarray) + assert isinstance(processed[1][1], np.ndarray) def test_error(self): - with self.assertRaisesRegex(FileNotFoundError, "Could not find file"): + with pytest.raises(FileNotFoundError, match="Could not find file"): pybamm.parameters.process_1D_data("not_a_real_file", "not_a_real_path") - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_plotting/test_quick_plot.py b/tests/unit/test_plotting/test_quick_plot.py index eb6c0607e3..d5d994117d 100644 --- a/tests/unit/test_plotting/test_quick_plot.py +++ b/tests/unit/test_plotting/test_quick_plot.py @@ -1,12 +1,12 @@ import os import pybamm -import unittest +import pytest import numpy as np from tempfile import TemporaryDirectory -class TestQuickPlot(unittest.TestCase): +class TestQuickPlot: def test_simple_ode_model(self): model = pybamm.lithium_ion.BaseModel(name="Simple ODE Model") @@ -77,11 +77,11 @@ def test_simple_ode_model(self): # update the axis new_axis = [0, 0.5, 0, 1] quick_plot.axis_limits.update({("a",): new_axis}) - self.assertEqual(quick_plot.axis_limits[("a",)], new_axis) + assert quick_plot.axis_limits[("a",)] == new_axis # and now reset them quick_plot.reset_axis() - self.assertNotEqual(quick_plot.axis_limits[("a",)], new_axis) + assert quick_plot.axis_limits[("a",)] != new_axis # check dynamic plot loads quick_plot.dynamic_plot(show_plot=False) @@ -90,7 +90,7 @@ def test_simple_ode_model(self): # Test with different output variables quick_plot = pybamm.QuickPlot(solution, ["b broadcasted"]) - self.assertEqual(len(quick_plot.axis_limits), 1) + assert len(quick_plot.axis_limits) == 1 quick_plot.plot(0) quick_plot = pybamm.QuickPlot( @@ -103,18 +103,18 @@ def test_simple_ode_model(self): "c broadcasted positive electrode", ], ) - self.assertEqual(len(quick_plot.axis_limits), 5) + assert len(quick_plot.axis_limits) == 5 quick_plot.plot(0) # update the axis new_axis = [0, 0.5, 0, 1] var_key = ("c broadcasted",) quick_plot.axis_limits.update({var_key: new_axis}) - self.assertEqual(quick_plot.axis_limits[var_key], new_axis) + assert quick_plot.axis_limits[var_key] == new_axis # and now reset them quick_plot.reset_axis() - self.assertNotEqual(quick_plot.axis_limits[var_key], new_axis) + assert quick_plot.axis_limits[var_key] != new_axis # check dynamic plot loads quick_plot.dynamic_plot(show_plot=False) @@ -135,19 +135,19 @@ def test_simple_ode_model(self): labels=["sol 1", "sol 2"], n_rows=2, ) - self.assertEqual(quick_plot.colors, ["r", "g", "b"]) - self.assertEqual(quick_plot.linestyles, ["-", "--"]) - self.assertEqual(quick_plot.figsize, (1, 2)) - self.assertEqual(quick_plot.labels, ["sol 1", "sol 2"]) - self.assertEqual(quick_plot.n_rows, 2) - self.assertEqual(quick_plot.n_cols, 1) + assert quick_plot.colors == ["r", "g", "b"] + assert quick_plot.linestyles == ["-", "--"] + assert quick_plot.figsize == (1, 2) + assert quick_plot.labels == ["sol 1", "sol 2"] + assert quick_plot.n_rows == 2 + assert quick_plot.n_cols == 1 # Test different time units quick_plot = pybamm.QuickPlot(solution, ["a"]) - self.assertEqual(quick_plot.time_scaling_factor, 1) + assert quick_plot.time_scaling_factor == 1 quick_plot = pybamm.QuickPlot(solution, ["a"], time_unit="seconds") quick_plot.plot(0) - self.assertEqual(quick_plot.time_scaling_factor, 1) + assert quick_plot.time_scaling_factor == 1 np.testing.assert_array_almost_equal( quick_plot.plots[("a",)][0][0].get_xdata(), t_eval ) @@ -156,7 +156,7 @@ def test_simple_ode_model(self): ) quick_plot = pybamm.QuickPlot(solution, ["a"], time_unit="minutes") quick_plot.plot(0) - self.assertEqual(quick_plot.time_scaling_factor, 60) + assert quick_plot.time_scaling_factor == 60 np.testing.assert_array_almost_equal( quick_plot.plots[("a",)][0][0].get_xdata(), t_eval / 60 ) @@ -165,30 +165,30 @@ def test_simple_ode_model(self): ) quick_plot = pybamm.QuickPlot(solution, ["a"], time_unit="hours") quick_plot.plot(0) - self.assertEqual(quick_plot.time_scaling_factor, 3600) + assert quick_plot.time_scaling_factor == 3600 np.testing.assert_array_almost_equal( quick_plot.plots[("a",)][0][0].get_xdata(), t_eval / 3600 ) np.testing.assert_array_almost_equal( quick_plot.plots[("a",)][0][0].get_ydata(), 0.2 * t_eval ) - with self.assertRaisesRegex(ValueError, "time unit"): + with pytest.raises(ValueError, match="time unit"): pybamm.QuickPlot(solution, ["a"], time_unit="bad unit") # long solution defaults to hours instead of seconds solution_long = solver.solve(model, np.linspace(0, 1e5)) quick_plot = pybamm.QuickPlot(solution_long, ["a"]) - self.assertEqual(quick_plot.time_scaling_factor, 3600) + assert quick_plot.time_scaling_factor == 3600 # Test different spatial units quick_plot = pybamm.QuickPlot(solution, ["a"]) - self.assertEqual(quick_plot.spatial_unit, r"$\mu$m") + assert quick_plot.spatial_unit == r"$\mu$m" quick_plot = pybamm.QuickPlot(solution, ["a"], spatial_unit="m") - self.assertEqual(quick_plot.spatial_unit, "m") + assert quick_plot.spatial_unit == "m" quick_plot = pybamm.QuickPlot(solution, ["a"], spatial_unit="mm") - self.assertEqual(quick_plot.spatial_unit, "mm") + assert quick_plot.spatial_unit == "mm" quick_plot = pybamm.QuickPlot(solution, ["a"], spatial_unit="um") - self.assertEqual(quick_plot.spatial_unit, r"$\mu$m") - with self.assertRaisesRegex(ValueError, "spatial unit"): + assert quick_plot.spatial_unit == r"$\mu$m" + with pytest.raises(ValueError, match="spatial unit"): pybamm.QuickPlot(solution, ["a"], spatial_unit="bad unit") # Test 2D variables @@ -197,24 +197,25 @@ def test_simple_ode_model(self): quick_plot.dynamic_plot(show_plot=False) quick_plot.slider_update(0.01) - with self.assertRaisesRegex(NotImplementedError, "Cannot plot 2D variables"): + with pytest.raises(NotImplementedError, match="Cannot plot 2D variables"): pybamm.QuickPlot([solution, solution], ["2D variable"]) # Test different variable limits quick_plot = pybamm.QuickPlot( solution, ["a", ["c broadcasted", "c broadcasted"]], variable_limits="tight" ) - self.assertEqual(quick_plot.axis_limits[("a",)][2:], [None, None]) - self.assertEqual( - quick_plot.axis_limits[("c broadcasted", "c broadcasted")][2:], [None, None] - ) + assert quick_plot.axis_limits[("a",)][2:] == [None, None] + assert quick_plot.axis_limits[("c broadcasted", "c broadcasted")][2:] == [ + None, + None, + ] quick_plot.plot(0) quick_plot.slider_update(1) quick_plot = pybamm.QuickPlot( solution, ["2D variable"], variable_limits="tight" ) - self.assertEqual(quick_plot.variable_limits[("2D variable",)], (None, None)) + assert quick_plot.variable_limits[("2D variable",)] == (None, None) quick_plot.plot(0) quick_plot.slider_update(1) @@ -223,41 +224,37 @@ def test_simple_ode_model(self): ["a", ["c broadcasted", "c broadcasted"]], variable_limits={"a": [1, 2], ("c broadcasted", "c broadcasted"): [3, 4]}, ) - self.assertEqual(quick_plot.axis_limits[("a",)][2:], [1, 2]) - self.assertEqual( - quick_plot.axis_limits[("c broadcasted", "c broadcasted")][2:], [3, 4] - ) + assert quick_plot.axis_limits[("a",)][2:] == [1, 2] + assert quick_plot.axis_limits[("c broadcasted", "c broadcasted")][2:] == [3, 4] quick_plot.plot(0) quick_plot.slider_update(1) quick_plot = pybamm.QuickPlot( solution, ["a", "b broadcasted"], variable_limits={"a": "tight"} ) - self.assertEqual(quick_plot.axis_limits[("a",)][2:], [None, None]) - self.assertNotEqual( - quick_plot.axis_limits[("b broadcasted",)][2:], [None, None] - ) + assert quick_plot.axis_limits[("a",)][2:] == [None, None] + assert quick_plot.axis_limits[("b broadcasted",)][2:] != [None, None] quick_plot.plot(0) quick_plot.slider_update(1) - with self.assertRaisesRegex( - TypeError, "variable_limits must be 'fixed', 'tight', or a dict" + with pytest.raises( + TypeError, match="variable_limits must be 'fixed', 'tight', or a dict" ): pybamm.QuickPlot( solution, ["a", "b broadcasted"], variable_limits="bad variable limits" ) # Test errors - with self.assertRaisesRegex(ValueError, "Mismatching variable domains"): + with pytest.raises(ValueError, match="Mismatching variable domains"): pybamm.QuickPlot(solution, [["a", "b broadcasted"]]) - with self.assertRaisesRegex(ValueError, "labels"): + with pytest.raises(ValueError, match="labels"): pybamm.QuickPlot( [solution, solution], ["a"], labels=["sol 1", "sol 2", "sol 3"] ) # No variable can be NaN - with self.assertRaisesRegex( - ValueError, "All-NaN variable 'NaN variable' provided" + with pytest.raises( + ValueError, match="All-NaN variable 'NaN variable' provided" ): pybamm.QuickPlot(solution, ["NaN variable"]) @@ -269,7 +266,7 @@ def test_plot_with_different_models(self): model.rhs = {a: pybamm.Scalar(0)} model.initial_conditions = {a: pybamm.Scalar(0)} solution = pybamm.CasadiSolver("fast").solve(model, [0, 1]) - with self.assertRaisesRegex(ValueError, "No default output variables"): + with pytest.raises(ValueError, match="No default output variables"): pybamm.QuickPlot(solution) def test_spm_simulation(self): @@ -462,17 +459,17 @@ def test_plot_2plus1D_spm(self): ][1] np.testing.assert_array_almost_equal(qp_data.T, phi_n[:, :, -1]) - with self.assertRaisesRegex(NotImplementedError, "Shape not recognized for"): + with pytest.raises(NotImplementedError, match="Shape not recognized for"): pybamm.QuickPlot(solution, ["Negative particle concentration [mol.m-3]"]) pybamm.close_plots() def test_invalid_input_type_failure(self): - with self.assertRaisesRegex(TypeError, "Solutions must be"): + with pytest.raises(TypeError, match="Solutions must be"): pybamm.QuickPlot(1) def test_empty_list_failure(self): - with self.assertRaisesRegex(TypeError, "QuickPlot requires at least 1"): + with pytest.raises(TypeError, match="QuickPlot requires at least 1"): pybamm.QuickPlot([]) def test_model_with_inputs(self): @@ -509,20 +506,10 @@ def test_model_with_inputs(self): pybamm.close_plots() -class TestQuickPlotAxes(unittest.TestCase): +class TestQuickPlotAxes: def test_quick_plot_axes(self): axes = pybamm.QuickPlotAxes() axes.add(("test 1", "test 2"), 1) - self.assertEqual(axes[0], 1) - self.assertEqual(axes.by_variable("test 1"), 1) - self.assertEqual(axes.by_variable("test 2"), 1) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert axes[0] == 1 + assert axes.by_variable("test 1") == 1 + assert axes.by_variable("test 2") == 1 diff --git a/tests/unit/test_serialisation/test_serialisation.py b/tests/unit/test_serialisation/test_serialisation.py index a1286cad26..adf53b7b46 100644 --- a/tests/unit/test_serialisation/test_serialisation.py +++ b/tests/unit/test_serialisation/test_serialisation.py @@ -4,8 +4,7 @@ import json import os -import unittest -import unittest.mock as mock +import pytest from datetime import datetime import numpy as np import pybamm @@ -14,14 +13,14 @@ from pybamm.expression_tree.operations.serialise import Serialise -def scalar_var_dict(): +def scalar_var_dict(mocker): """variable, json pair for a pybamm.Scalar instance""" a = pybamm.Scalar(5) a_dict = { - "py/id": mock.ANY, + "py/id": mocker.ANY, "py/object": "pybamm.expression_tree.scalar.Scalar", "name": "5.0", - "id": mock.ANY, + "id": mocker.ANY, "value": 5.0, "children": [], } @@ -29,7 +28,7 @@ def scalar_var_dict(): return a, a_dict -def mesh_var_dict(): +def mesh_var_dict(mocker): """mesh, json pair for a pybamm.Mesh instance""" r = pybamm.SpatialVariable( @@ -48,13 +47,13 @@ def mesh_var_dict(): mesh_json = { "py/object": "pybamm.meshes.meshes.Mesh", - "py/id": mock.ANY, + "py/id": mocker.ANY, "submesh_pts": {"negative particle": {"r": 20}}, "base_domains": ["negative particle"], "sub_meshes": { "negative particle": { "py/object": "pybamm.meshes.one_dimensional_submeshes.Uniform1DSubMesh", - "py/id": mock.ANY, + "py/id": mocker.ANY, "edges": [ 0.0, 0.05, @@ -86,7 +85,7 @@ def mesh_var_dict(): return mesh, mesh_json -class TestSerialiseModels(unittest.TestCase): +class TestSerialiseModels: def test_user_defined_model_recreaction(self): # Start with a base model model = pybamm.BaseModel() @@ -146,26 +145,26 @@ def test_user_defined_model_recreaction(self): os.remove("heat_equation.json") -class TestSerialise(unittest.TestCase): +class TestSerialise: # test the symbol encoder - def test_symbol_encoder_symbol(self): + def test_symbol_encoder_symbol(self, mocker): """test basic symbol encoder with & without children""" # without children - a, a_dict = scalar_var_dict() + a, a_dict = scalar_var_dict(mocker) a_ser_json = Serialise._SymbolEncoder().default(a) - self.assertEqual(a_ser_json, a_dict) + assert a_ser_json == a_dict # with children add = pybamm.Addition(2, 4) add_json = { - "py/id": mock.ANY, + "py/id": mocker.ANY, "py/object": "pybamm.expression_tree.binary_operators.Addition", "name": "+", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": [], "secondary": [], @@ -174,18 +173,18 @@ def test_symbol_encoder_symbol(self): }, "children": [ { - "py/id": mock.ANY, + "py/id": mocker.ANY, "py/object": "pybamm.expression_tree.scalar.Scalar", "name": "2.0", - "id": mock.ANY, + "id": mocker.ANY, "value": 2.0, "children": [], }, { - "py/id": mock.ANY, + "py/id": mocker.ANY, "py/object": "pybamm.expression_tree.scalar.Scalar", "name": "4.0", - "id": mock.ANY, + "id": mocker.ANY, "value": 4.0, "children": [], }, @@ -194,32 +193,32 @@ def test_symbol_encoder_symbol(self): add_ser_json = Serialise._SymbolEncoder().default(add) - self.assertEqual(add_ser_json, add_json) + assert add_ser_json == add_json - def test_symbol_encoder_explicitTimeIntegral(self): + def test_symbol_encoder_explicit_time_integral(self, mocker): """test symbol encoder with initial conditions""" expr = pybamm.ExplicitTimeIntegral(pybamm.Scalar(5), pybamm.Scalar(1)) expr_json = { "py/object": "pybamm.expression_tree.unary_operators.ExplicitTimeIntegral", - "py/id": mock.ANY, + "py/id": mocker.ANY, "name": "explicit time integral", - "id": mock.ANY, + "id": mocker.ANY, "children": [ { "py/object": "pybamm.expression_tree.scalar.Scalar", - "py/id": mock.ANY, + "py/id": mocker.ANY, "name": "5.0", - "id": mock.ANY, + "id": mocker.ANY, "value": 5.0, "children": [], } ], "initial_condition": { "py/object": "pybamm.expression_tree.scalar.Scalar", - "py/id": mock.ANY, + "py/id": mocker.ANY, "name": "1.0", - "id": mock.ANY, + "id": mocker.ANY, "value": 1.0, "children": [], }, @@ -227,9 +226,9 @@ def test_symbol_encoder_explicitTimeIntegral(self): expr_ser_json = Serialise._SymbolEncoder().default(expr) - self.assertEqual(expr_json, expr_ser_json) + assert expr_json == expr_ser_json - def test_symbol_encoder_event(self): + def test_symbol_encoder_event(self, mocker): """test symbol encoder with event""" expression = pybamm.Scalar(1) @@ -237,32 +236,32 @@ def test_symbol_encoder_event(self): event_json = { "py/object": "pybamm.models.event.Event", - "py/id": mock.ANY, + "py/id": mocker.ANY, "name": "my event", "event_type": ["EventType.TERMINATION", 0], "expression": { "py/object": "pybamm.expression_tree.scalar.Scalar", - "py/id": mock.ANY, + "py/id": mocker.ANY, "name": "1.0", - "id": mock.ANY, + "id": mocker.ANY, "value": 1.0, "children": [], }, } event_ser_json = Serialise._SymbolEncoder().default(event) - self.assertEqual(event_ser_json, event_json) + assert event_ser_json == event_json # test the mesh encoder - def test_mesh_encoder(self): - mesh, mesh_json = mesh_var_dict() + def test_mesh_encoder(self, mocker): + mesh, mesh_json = mesh_var_dict(mocker) # serialise mesh mesh_ser_json = Serialise._MeshEncoder().default(mesh) - self.assertEqual(mesh_ser_json, mesh_json) + assert mesh_ser_json == mesh_json - def test_deconstruct_pybamm_dicts(self): + def test_deconstruct_pybamm_dicts(self, mocker): """tests serialisation of dictionaries with pybamm classes as keys""" x = pybamm.SpatialVariable("x", "negative electrode") @@ -273,9 +272,9 @@ def test_deconstruct_pybamm_dicts(self): "rod": { "symbol_x": { "py/object": "pybamm.expression_tree.independent_variable.SpatialVariable", - "py/id": mock.ANY, + "py/id": mocker.ANY, "name": "x", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": ["negative electrode"], "secondary": [], @@ -288,40 +287,40 @@ def test_deconstruct_pybamm_dicts(self): } } - self.assertEqual(Serialise()._deconstruct_pybamm_dicts(test_dict), ser_dict) + assert Serialise()._deconstruct_pybamm_dicts(test_dict) == ser_dict - def test_get_pybamm_class(self): + def test_get_pybamm_class(self, mocker): # symbol - _, scalar_dict = scalar_var_dict() + _, scalar_dict = scalar_var_dict(mocker) scalar_class = Serialise()._get_pybamm_class(scalar_dict) - self.assertIsInstance(scalar_class, pybamm.Scalar) + assert isinstance(scalar_class, pybamm.Scalar) # mesh - _, mesh_dict = mesh_var_dict() + _, mesh_dict = mesh_var_dict(mocker) mesh_class = Serialise()._get_pybamm_class(mesh_dict) - self.assertIsInstance(mesh_class, pybamm.Mesh) + assert isinstance(mesh_class, pybamm.Mesh) - with self.assertRaises(AttributeError): + with pytest.raises(AttributeError): unrecognised_symbol = { - "py/id": mock.ANY, + "py/id": mocker.ANY, "py/object": "pybamm.expression_tree.scalar.Scale", "name": "5.0", - "id": mock.ANY, + "id": mocker.ANY, "value": 5.0, "children": [], } Serialise()._get_pybamm_class(unrecognised_symbol) - def test_reconstruct_symbol(self): - scalar, scalar_dict = scalar_var_dict() + def test_reconstruct_symbol(self, mocker): + scalar, scalar_dict = scalar_var_dict(mocker) new_scalar = Serialise()._reconstruct_symbol(scalar_dict) - self.assertEqual(new_scalar, scalar) + assert new_scalar == scalar def test_reconstruct_expression_tree(self): y = pybamm.StateVector(slice(0, 1)) @@ -395,10 +394,10 @@ def test_reconstruct_expression_tree(self): new_equation = Serialise()._reconstruct_expression_tree(equation_json) - self.assertEqual(new_equation, equation) + assert new_equation == equation - def test_reconstruct_mesh(self): - mesh, mesh_dict = mesh_var_dict() + def test_reconstruct_mesh(self, mocker): + mesh, mesh_dict = mesh_var_dict(mocker) new_mesh = Serialise()._reconstruct_mesh(mesh_dict) @@ -410,12 +409,12 @@ def test_reconstruct_mesh(self): ) # reconstructed meshes are only used for plotting, geometry not reconstructed. - with self.assertRaisesRegex( - AttributeError, "'Mesh' object has no attribute '_geometry'" + with pytest.raises( + AttributeError, match="'Mesh' object has no attribute '_geometry'" ): - self.assertEqual(new_mesh.geometry, mesh.geometry) + assert new_mesh.geometry == mesh.geometry - def test_reconstruct_pybamm_dict(self): + def test_reconstruct_pybamm_dict(self, mocker): x = pybamm.SpatialVariable("x", "negative electrode") test_dict = {"rod": {x: {"min": 0.0, "max": 2.0}}} @@ -424,9 +423,9 @@ def test_reconstruct_pybamm_dict(self): "rod": { "symbol_x": { "py/object": "pybamm.expression_tree.independent_variable.SpatialVariable", - "py/id": mock.ANY, + "py/id": mocker.ANY, "name": "x", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": ["negative electrode"], "secondary": [], @@ -441,13 +440,13 @@ def test_reconstruct_pybamm_dict(self): new_dict = Serialise()._reconstruct_pybamm_dict(ser_dict) - self.assertEqual(new_dict, test_dict) + assert new_dict == test_dict # test recreation if not passed a dict test_list = ["left", "right"] new_list = Serialise()._reconstruct_pybamm_dict(test_list) - self.assertEqual(test_list, new_list) + assert test_list == new_list def test_convert_options(self): options_dict = { @@ -462,7 +461,7 @@ def test_convert_options(self): "open-circuit potential": (("single", "current sigmoid"), "single"), } - self.assertEqual(Serialise()._convert_options(options_dict), options_result) + assert Serialise()._convert_options(options_dict) == options_result def test_save_load_model(self): model = pybamm.lithium_ion.SPM(name="test_spm") @@ -473,9 +472,9 @@ def test_save_load_model(self): mesh = pybamm.Mesh(geometry, model.default_submesh_types, model.default_var_pts) # test error if not discretised - with self.assertRaisesRegex( + with pytest.raises( NotImplementedError, - "PyBaMM can only serialise a discretised, ready-to-solve model", + match="PyBaMM can only serialise a discretised, ready-to-solve model", ): Serialise().save_model(model, filename="test_model") @@ -484,12 +483,12 @@ def test_save_load_model(self): # default save Serialise().save_model(model, filename="test_model") - self.assertTrue(os.path.exists("test_model.json")) + assert os.path.exists("test_model.json") # default save where filename isn't provided Serialise().save_model(model) filename = "test_spm_" + datetime.now().strftime("%Y_%m_%d-%p%I_%M") + ".json" - self.assertTrue(os.path.exists(filename)) + assert os.path.exists(filename) os.remove(filename) # default load @@ -500,9 +499,9 @@ def test_save_load_model(self): new_solution = new_solver.solve(new_model, [0, 3600]) # check an error is raised when plotting the solution - with self.assertRaisesRegex( + with pytest.raises( AttributeError, - "No variables to plot", + match="No variables to plot", ): new_solution.plot() @@ -519,7 +518,7 @@ def test_save_load_model(self): with open("test_model.json", "w") as f: json.dump(model_data, f) - with self.assertRaises(TypeError): + with pytest.raises(TypeError): Serialise().load_model("test_model.json") os.remove("test_model.json") @@ -534,9 +533,9 @@ def test_save_experiment_model_error(self): sim = pybamm.Simulation(model, experiment=experiment) sim.solve() - with self.assertRaisesRegex( + with pytest.raises( NotImplementedError, - "Serialising models coupled to experiments is not yet supported.", + match="Serialising models coupled to experiments is not yet supported.", ): sim.save_model("spm_experiment", mesh=False, variables=False) @@ -591,13 +590,3 @@ def test_serialised_model_plotting(self): # check dynamic plot loads new_solution.plot(show_plot=False) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_simulation.py b/tests/unit/test_simulation.py index 744ea2457c..fc9fec9745 100644 --- a/tests/unit/test_simulation.py +++ b/tests/unit/test_simulation.py @@ -3,8 +3,6 @@ import pandas as pd import os -import sys -import unittest import uuid import pytest from tempfile import TemporaryDirectory @@ -12,7 +10,7 @@ from tests import no_internet_connection -class TestSimulation(unittest.TestCase): +class TestSimulation: def test_simple_model(self): model = pybamm.BaseModel() v = pybamm.Variable("v") @@ -27,49 +25,49 @@ def test_basic_ops(self): sim = pybamm.Simulation(model) # check that the model is unprocessed - self.assertEqual(sim._mesh, None) - self.assertEqual(sim._disc, None) + assert sim._mesh is None + assert sim._disc is None V = sim.model.variables["Voltage [V]"] - self.assertTrue(V.has_symbol_of_classes(pybamm.Parameter)) - self.assertFalse(V.has_symbol_of_classes(pybamm.Matrix)) + assert V.has_symbol_of_classes(pybamm.Parameter) + assert not V.has_symbol_of_classes(pybamm.Matrix) sim.set_parameters() - self.assertEqual(sim._mesh, None) - self.assertEqual(sim._disc, None) + assert sim._mesh is None + assert sim._disc is None V = sim.model_with_set_params.variables["Voltage [V]"] - self.assertFalse(V.has_symbol_of_classes(pybamm.Parameter)) - self.assertFalse(V.has_symbol_of_classes(pybamm.Matrix)) + assert not V.has_symbol_of_classes(pybamm.Parameter) + assert not V.has_symbol_of_classes(pybamm.Matrix) # Make sure model is unchanged - self.assertNotEqual(sim.model, model) + assert sim.model != model V = model.variables["Voltage [V]"] - self.assertTrue(V.has_symbol_of_classes(pybamm.Parameter)) - self.assertFalse(V.has_symbol_of_classes(pybamm.Matrix)) + assert V.has_symbol_of_classes(pybamm.Parameter) + assert not V.has_symbol_of_classes(pybamm.Matrix) - self.assertEqual(sim.submesh_types, model.default_submesh_types) - self.assertEqual(sim.var_pts, model.default_var_pts) - self.assertIsNone(sim.mesh) + assert sim.submesh_types == model.default_submesh_types + assert sim.var_pts == model.default_var_pts + assert sim.mesh is None for key in sim.spatial_methods.keys(): - self.assertEqual( - sim.spatial_methods[key].__class__, - model.default_spatial_methods[key].__class__, + assert ( + sim.spatial_methods[key].__class__ + == model.default_spatial_methods[key].__class__ ) sim.build() - self.assertFalse(sim._mesh is None) - self.assertFalse(sim._disc is None) + assert sim._mesh is not None + assert sim._disc is not None V = sim.built_model.variables["Voltage [V]"] - self.assertFalse(V.has_symbol_of_classes(pybamm.Parameter)) - self.assertTrue(V.has_symbol_of_classes(pybamm.Matrix)) + assert not V.has_symbol_of_classes(pybamm.Parameter) + assert V.has_symbol_of_classes(pybamm.Matrix) def test_solve(self): sim = pybamm.Simulation(pybamm.lithium_ion.SPM()) sim.solve([0, 600]) - self.assertFalse(sim._solution is None) + assert sim._solution is not None for val in list(sim.built_model.rhs.values()): - self.assertFalse(val.has_symbol_of_classes(pybamm.Parameter)) + assert not val.has_symbol_of_classes(pybamm.Parameter) # skip test for scalar variables (e.g. discharge capacity) if val.size > 1: - self.assertTrue(val.has_symbol_of_classes(pybamm.Matrix)) + assert val.has_symbol_of_classes(pybamm.Matrix) # test solve without check sim = pybamm.Simulation( @@ -77,15 +75,15 @@ def test_solve(self): ) sol = sim.solve(t_eval=[0, 600]) for val in list(sim.built_model.rhs.values()): - self.assertFalse(val.has_symbol_of_classes(pybamm.Parameter)) + assert not val.has_symbol_of_classes(pybamm.Parameter) # skip test for scalar variables (e.g. discharge capacity) if val.size > 1: - self.assertTrue(val.has_symbol_of_classes(pybamm.Matrix)) + assert val.has_symbol_of_classes(pybamm.Matrix) # Test options that are only available when simulating an experiment - with self.assertRaisesRegex(ValueError, "save_at_cycles"): + with pytest.raises(ValueError, match="save_at_cycles"): sim.solve(save_at_cycles=2) - with self.assertRaisesRegex(ValueError, "starting_solution"): + with pytest.raises(ValueError, match="starting_solution"): sim.solve(starting_solution=sol) def test_solve_remove_independent_variables_from_rhs(self): @@ -157,8 +155,8 @@ def test_set_crate(self): model = pybamm.lithium_ion.SPM() current_1C = model.default_parameter_values["Current function [A]"] sim = pybamm.Simulation(model, C_rate=2) - self.assertEqual(sim.parameter_values["Current function [A]"], 2 * current_1C) - self.assertEqual(sim.C_rate, 2) + assert sim.parameter_values["Current function [A]"] == 2 * current_1C + assert sim.C_rate == 2 def test_step(self): dt = 0.001 @@ -166,24 +164,24 @@ def test_step(self): sim = pybamm.Simulation(model) sim.step(dt) # 1 step stores first two points - self.assertEqual(sim.solution.y.full()[0, :].size, 2) + assert sim.solution.y.full()[0, :].size == 2 np.testing.assert_array_almost_equal(sim.solution.t, np.array([0, dt])) saved_sol = sim.solution sim.step(dt) # automatically append the next step - self.assertEqual(sim.solution.y.full()[0, :].size, 4) + assert sim.solution.y.full()[0, :].size == 4 np.testing.assert_array_almost_equal( sim.solution.t, np.array([0, dt, dt + 1e-9, 2 * dt]) ) sim.step(dt, save=False) # now only store the two end step points - self.assertEqual(sim.solution.y.full()[0, :].size, 2) + assert sim.solution.y.full()[0, :].size == 2 np.testing.assert_array_almost_equal( sim.solution.t, np.array([2 * dt + 1e-9, 3 * dt]) ) # Start from saved solution sim.step(dt, starting_solution=saved_sol) - self.assertEqual(sim.solution.y.full()[0, :].size, 4) + assert sim.solution.y.full()[0, :].size == 4 np.testing.assert_array_almost_equal( sim.solution.t, np.array([0, dt, dt + 1e-9, 2 * dt]) ) @@ -197,15 +195,15 @@ def test_solve_with_initial_soc(self): param = model.default_parameter_values sim = pybamm.Simulation(model, parameter_values=param) sim.solve(t_eval=[0, 600], initial_soc=1) - self.assertEqual(sim._built_initial_soc, 1) + assert sim._built_initial_soc == 1 sim.solve(t_eval=[0, 600], initial_soc=0.5) - self.assertEqual(sim._built_initial_soc, 0.5) + assert sim._built_initial_soc == 0.5 exp = pybamm.Experiment( [pybamm.step.string("Discharge at 1C until 3.6V", period="1 minute")] ) sim = pybamm.Simulation(model, parameter_values=param, experiment=exp) sim.solve(initial_soc=0.8) - self.assertEqual(sim._built_initial_soc, 0.8) + assert sim._built_initial_soc == 0.8 # test with drive cycle data_loader = pybamm.DataLoader() @@ -220,12 +218,12 @@ def test_solve_with_initial_soc(self): param["Current function [A]"] = current_interpolant sim = pybamm.Simulation(model, parameter_values=param) sim.solve(initial_soc=0.8) - self.assertEqual(sim._built_initial_soc, 0.8) + assert sim._built_initial_soc == 0.8 # Test that build works with initial_soc sim = pybamm.Simulation(model, parameter_values=param) sim.build(initial_soc=0.5) - self.assertEqual(sim._built_initial_soc, 0.5) + assert sim._built_initial_soc == 0.5 # Test that initial soc works with a relevant input parameter model = pybamm.lithium_ion.DFN() @@ -236,7 +234,7 @@ def test_solve_with_initial_soc(self): ) sim = pybamm.Simulation(model, parameter_values=param) sim.solve(t_eval=[0, 1], initial_soc=0.8, inputs={"eps_p": og_eps_p}) - self.assertEqual(sim._built_initial_soc, 0.8) + assert sim._built_initial_soc == 0.8 # test having an input parameter in the ocv function model = pybamm.lithium_ion.SPM() @@ -264,14 +262,14 @@ def ocv_with_parameter(sto): model = pybamm.lithium_ion.DFN(options) sim = pybamm.Simulation(model) sim.solve([0, 1], initial_soc=0.9) - self.assertEqual(sim._built_initial_soc, 0.9) + assert sim._built_initial_soc == 0.9 # Test whether initial_soc works with half cell (build) options = {"working electrode": "positive"} model = pybamm.lithium_ion.DFN(options) sim = pybamm.Simulation(model) sim.build(initial_soc=0.9) - self.assertEqual(sim._built_initial_soc, 0.9) + assert sim._built_initial_soc == 0.9 # Test whether initial_soc works with half cell when it is a voltage model = pybamm.lithium_ion.SPM({"working electrode": "positive"}) @@ -284,14 +282,14 @@ def ocv_with_parameter(sto): sim = pybamm.Simulation(model, parameter_values=parameter_values) sol = sim.solve([0, 1], initial_soc=f"{ucv} V") voltage = sol["Terminal voltage [V]"].entries - self.assertAlmostEqual(voltage[0], ucv, places=5) + assert voltage[0] == pytest.approx(ucv, abs=1e-05) # test with MSMR model = pybamm.lithium_ion.MSMR({"number of MSMR reactions": ("6", "4")}) param = pybamm.ParameterValues("MSMR_Example") sim = pybamm.Simulation(model, parameter_values=param) sim.build(initial_soc=0.5) - self.assertEqual(sim._built_initial_soc, 0.5) + assert sim._built_initial_soc == 0.5 def test_solve_with_initial_soc_with_input_param_in_ocv(self): # test having an input parameter in the ocv function @@ -314,7 +312,7 @@ def ocv_with_parameter(sto): model, parameter_values=parameter_values, experiment=experiment ) sim.solve([0, 3600], inputs={"a": 1}, initial_soc=0.8) - self.assertEqual(sim._built_initial_soc, 0.8) + assert sim._built_initial_soc == 0.8 def test_esoh_with_input_param(self): # Test that initial soc works with a relevant input parameter @@ -326,7 +324,7 @@ def test_esoh_with_input_param(self): ) sim = pybamm.Simulation(model, parameter_values=param) sim.solve(t_eval=[0, 1], initial_soc=0.8, inputs={"eps_p": original_eps_p}) - self.assertEqual(sim._built_initial_soc, 0.8) + assert sim._built_initial_soc == 0.8 def test_solve_with_inputs(self): model = pybamm.lithium_ion.SPM() @@ -347,17 +345,17 @@ def test_step_with_inputs(self): sim.step( dt, inputs={"Current function [A]": 1} ) # 1 step stores first two points - self.assertEqual(sim.solution.t.size, 2) - self.assertEqual(sim.solution.y.full()[0, :].size, 2) - self.assertEqual(sim.solution.t[0], 0) - self.assertEqual(sim.solution.t[1], dt) + assert sim.solution.t.size == 2 + assert sim.solution.y.full()[0, :].size == 2 + assert sim.solution.t[0] == 0 + assert sim.solution.t[1] == dt np.testing.assert_array_equal( sim.solution.all_inputs[0]["Current function [A]"], 1 ) sim.step( dt, inputs={"Current function [A]": 2} ) # automatically append the next step - self.assertEqual(sim.solution.y.full()[0, :].size, 4) + assert sim.solution.y.full()[0, :].size == 4 np.testing.assert_array_almost_equal( sim.solution.t, np.array([0, dt, dt + 1e-9, 2 * dt]) ) @@ -399,13 +397,13 @@ def oscillating(t): def f(t, x=x): return x + t - with self.assertRaises(ValueError): + with pytest.raises(ValueError): operating_mode(f) def g(t, y): return t - with self.assertRaises(TypeError): + with pytest.raises(TypeError): operating_mode(g) def test_save_load(self): @@ -418,13 +416,13 @@ def test_save_load(self): sim.save(test_name) sim_load = pybamm.load_sim(test_name) - self.assertEqual(sim.model.name, sim_load.model.name) + assert sim.model.name == sim_load.model.name # save after solving sim.solve([0, 600]) sim.save(test_name) sim_load = pybamm.load_sim(test_name) - self.assertEqual(sim.model.name, sim_load.model.name) + assert sim.model.name == sim_load.model.name # with python formats model.convert_to_format = None @@ -434,8 +432,9 @@ def test_save_load(self): model.convert_to_format = "python" sim = pybamm.Simulation(model) sim.solve([0, 600]) - with self.assertRaisesRegex( - NotImplementedError, "Cannot save simulation if model format is python" + with pytest.raises( + NotImplementedError, + match="Cannot save simulation if model format is python", ): sim.save(test_name) @@ -454,11 +453,11 @@ def test_load_param(self): os.remove(filename) raise excep - self.assertEqual( - "graphite_LGM50_electrolyte_exchange_current_density_Chen2020", - pkl_obj.parameter_values[ + assert ( + "graphite_LGM50_electrolyte_exchange_current_density_Chen2020" + == pkl_obj.parameter_values[ "Negative electrode exchange-current density [A.m-2]" - ].__name__, + ].__name__ ) os.remove(filename) @@ -474,7 +473,7 @@ def test_save_load_dae(self): sim.solve([0, 600]) sim.save(test_name) sim_load = pybamm.load_sim(test_name) - self.assertEqual(sim.model.name, sim_load.model.name) + assert sim.model.name == sim_load.model.name # with python format model.convert_to_format = None @@ -492,7 +491,7 @@ def test_save_load_dae(self): sim.solve([0, 600]) sim.save(test_name) sim_load = pybamm.load_sim(test_name) - self.assertEqual(sim.model.name, sim_load.model.name) + assert sim.model.name == sim_load.model.name def test_save_load_model(self): model = pybamm.lead_acid.LOQS({"surface form": "algebraic"}) @@ -500,7 +499,7 @@ def test_save_load_model(self): sim = pybamm.Simulation(model) # test exception if not discretised - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): sim.save_model("sim_save") # save after solving @@ -510,7 +509,7 @@ def test_save_load_model(self): # load model saved_model = pybamm.load_model("sim_save.json") - self.assertEqual(model.options, saved_model.options) + assert model.options == saved_model.options os.remove("sim_save.json") @@ -518,7 +517,7 @@ def test_plot(self): sim = pybamm.Simulation(pybamm.lithium_ion.SPM()) # test exception if not solved - with self.assertRaises(ValueError): + with pytest.raises(ValueError): sim.plot() # now solve and plot @@ -529,8 +528,8 @@ def test_plot(self): def test_create_gif(self): with TemporaryDirectory() as dir_name: sim = pybamm.Simulation(pybamm.lithium_ion.SPM()) - with self.assertRaisesRegex( - ValueError, "The simulation has not been solved yet." + with pytest.raises( + ValueError, match="The simulation has not been solved yet." ): sim.create_gif() sim.solve(t_eval=[0, 10]) @@ -577,13 +576,13 @@ def test_drive_cycle_interpolant(self): # check warning raised if the largest gap in t_eval is bigger than the # smallest gap in the data - with self.assertWarns(pybamm.SolverWarning): + with pytest.warns(pybamm.SolverWarning): sim.solve(t_eval=np.linspace(0, 10, 3)) # check warning raised if t_eval doesnt contain time_data , but has a finer # resolution (can still solve, but good for users to know they dont have # the solution returned at the data points) - with self.assertWarns(pybamm.SolverWarning): + with pytest.warns(pybamm.SolverWarning): sim.solve(t_eval=np.linspace(0, time_data[-1], 800)) def test_discontinuous_current(self): @@ -604,20 +603,20 @@ def car_current(t): ) sim.solve([0, 3600]) current = sim.solution["Current [A]"] - self.assertEqual(current(0), 1) - self.assertEqual(current(1500), -0.5) - self.assertEqual(current(3000), 0.5) + assert current(0) == 1 + assert current(1500) == -0.5 + assert current(3000) == 0.5 def test_t_eval(self): model = pybamm.lithium_ion.SPM() sim = pybamm.Simulation(model) # test no t_eval - with self.assertRaisesRegex(pybamm.SolverError, "'t_eval' must be provided"): + with pytest.raises(pybamm.SolverError, match="'t_eval' must be provided"): sim.solve() # test t_eval list of length != 2 - with self.assertRaisesRegex(pybamm.SolverError, "'t_eval' can be provided"): + with pytest.raises(pybamm.SolverError, match="'t_eval' can be provided"): sim.solve(t_eval=[0, 1, 2]) # tets list gets turned into np.linspace(t0, tf, 100) @@ -633,11 +632,3 @@ def test_battery_model_with_input_height(self): inputs = {"Electrode height [m]": 0.2} sim = pybamm.Simulation(model=model, parameter_values=parameter_values) sim.solve(t_eval=t_eval, inputs=inputs) - - -if __name__ == "__main__": - print("Add -v for more debug output") - - if "-v" in sys.argv: - debug = True - unittest.main() diff --git a/tests/unit/test_solvers/test_algebraic_solver.py b/tests/unit/test_solvers/test_algebraic_solver.py index 6e8b3a3d80..89ca7d750c 100644 --- a/tests/unit/test_solvers/test_algebraic_solver.py +++ b/tests/unit/test_solvers/test_algebraic_solver.py @@ -3,24 +3,24 @@ # import pybamm -import unittest +import pytest import numpy as np from tests import get_discretisation_for_testing -class TestAlgebraicSolver(unittest.TestCase): +class TestAlgebraicSolver: def test_algebraic_solver_init(self): solver = pybamm.AlgebraicSolver( method="hybr", tol=1e-4, extra_options={"maxfev": 100} ) - self.assertEqual(solver.method, "hybr") - self.assertEqual(solver.extra_options, {"maxfev": 100}) - self.assertEqual(solver.tol, 1e-4) + assert solver.method == "hybr" + assert solver.extra_options == {"maxfev": 100} + assert solver.tol == 1e-4 solver.method = "krylov" - self.assertEqual(solver.method, "krylov") + assert solver.method == "krylov" solver.tol = 1e-5 - self.assertEqual(solver.tol, 1e-5) + assert solver.tol == 1e-5 def test_wrong_solver(self): # Create model @@ -31,9 +31,9 @@ def test_wrong_solver(self): # test errors solver = pybamm.AlgebraicSolver() - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Cannot use algebraic solver to solve model with time derivatives", + match="Cannot use algebraic solver to solve model with time derivatives", ): solver.solve(model) @@ -61,7 +61,7 @@ def algebraic_eval(self, t, y, inputs): # Relax options and see worse results solver = pybamm.AlgebraicSolver(extra_options={"ftol": 1}) solution = solver._integrate(model, np.array([0])) - self.assertNotEqual(solution.y, -2) + assert solution.y != -2 def test_root_find_fail(self): class Model(pybamm.BaseModel): @@ -81,15 +81,16 @@ def algebraic_eval(self, t, y, inputs): model = Model() solver = pybamm.AlgebraicSolver(method="hybr") - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Could not find acceptable solution: The iteration is not making", + match="Could not find acceptable solution: The iteration is not making", ): solver._integrate(model, np.array([0])) solver = pybamm.AlgebraicSolver() - with self.assertRaisesRegex( - pybamm.SolverError, "Could not find acceptable solution: solver terminated" + with pytest.raises( + pybamm.SolverError, + match="Could not find acceptable solution: solver terminated", ): solver._integrate(model, np.array([0])) @@ -303,13 +304,3 @@ def test_solve_with_input(self): solver = pybamm.AlgebraicSolver() solution = solver.solve(model, np.linspace(0, 1, 10), inputs={"value": 7}) np.testing.assert_array_equal(solution.y, -7) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_solvers/test_base_solver.py b/tests/unit/test_solvers/test_base_solver.py index 9a1e87acec..6753513e72 100644 --- a/tests/unit/test_solvers/test_base_solver.py +++ b/tests/unit/test_solvers/test_base_solver.py @@ -2,39 +2,38 @@ # Tests for the Base Solver class # +import pytest import casadi import pybamm import numpy as np from scipy.sparse import csr_matrix -import unittest - -class TestBaseSolver(unittest.TestCase): +class TestBaseSolver: def test_base_solver_init(self): solver = pybamm.BaseSolver(rtol=1e-2, atol=1e-4) - self.assertEqual(solver.rtol, 1e-2) - self.assertEqual(solver.atol, 1e-4) + assert solver.rtol == 1e-2 + assert solver.atol == 1e-4 solver.rtol = 1e-5 - self.assertEqual(solver.rtol, 1e-5) + assert solver.rtol == 1e-5 solver.rtol = 1e-7 - self.assertEqual(solver.rtol, 1e-7) + assert solver.rtol == 1e-7 def test_root_method_init(self): solver = pybamm.BaseSolver(root_method="casadi") - self.assertIsInstance(solver.root_method, pybamm.CasadiAlgebraicSolver) + assert isinstance(solver.root_method, pybamm.CasadiAlgebraicSolver) solver = pybamm.BaseSolver(root_method="lm") - self.assertIsInstance(solver.root_method, pybamm.AlgebraicSolver) - self.assertEqual(solver.root_method.method, "lm") + assert isinstance(solver.root_method, pybamm.AlgebraicSolver) + assert solver.root_method.method == "lm" root_solver = pybamm.AlgebraicSolver() solver = pybamm.BaseSolver(root_method=root_solver) - self.assertEqual(solver.root_method, root_solver) + assert solver.root_method == root_solver - with self.assertRaisesRegex( - pybamm.SolverError, "Root method must be an algebraic solver" + with pytest.raises( + pybamm.SolverError, match="Root method must be an algebraic solver" ): pybamm.BaseSolver(root_method=pybamm.ScipySolver()) @@ -42,9 +41,9 @@ def test_step_or_solve_empty_model(self): model = pybamm.BaseModel() solver = pybamm.BaseSolver() error = "Cannot simulate an empty model" - with self.assertRaisesRegex(pybamm.ModelError, error): + with pytest.raises(pybamm.ModelError, match=error): solver.step(None, model, None) - with self.assertRaisesRegex(pybamm.ModelError, error): + with pytest.raises(pybamm.ModelError, match=error): solver.solve(model, None) def test_t_eval_none(self): @@ -56,7 +55,7 @@ def test_t_eval_none(self): disc.process_model(model) solver = pybamm.BaseSolver() - with self.assertRaisesRegex(ValueError, "t_eval cannot be None"): + with pytest.raises(ValueError, match="t_eval cannot be None"): solver.solve(model, None) def test_nonmonotonic_teval(self): @@ -64,29 +63,29 @@ def test_nonmonotonic_teval(self): model = pybamm.BaseModel() a = pybamm.Scalar(0) model.rhs = {a: a} - with self.assertRaisesRegex( - pybamm.SolverError, "t_eval must increase monotonically" + with pytest.raises( + pybamm.SolverError, match="t_eval must increase monotonically" ): solver.solve(model, np.array([1, 2, 3, 2])) # Check stepping with step size too small dt = -1e-9 - with self.assertRaisesRegex(pybamm.SolverError, "Step time must be >0"): + with pytest.raises(pybamm.SolverError, match="Step time must be >0"): solver.step(None, model, dt) # Checking if array t_eval lies within range dt = 2 t_eval = np.array([0, 1]) - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Elements inside array t_eval must lie in the closed interval 0 to dt", + match="Elements inside array t_eval must lie in the closed interval 0 to dt", ): solver.step(None, model, dt, t_eval=t_eval) t_eval = np.array([1, dt]) - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Elements inside array t_eval must lie in the closed interval 0 to dt", + match="Elements inside array t_eval must lie in the closed interval 0 to dt", ): solver.step(None, model, dt, t_eval=t_eval) @@ -96,8 +95,8 @@ def test_solution_time_length_fail(self): model.variables = {"v": v} solver = pybamm.DummySolver() t_eval = np.array([0]) - with self.assertRaisesRegex( - pybamm.SolverError, "Solution time vector has length 1" + with pytest.raises( + pybamm.SolverError, match="Solution time vector has length 1" ): solver.solve(model, t_eval) @@ -107,9 +106,7 @@ def test_block_symbolic_inputs(self): a = pybamm.Variable("a") p = pybamm.InputParameter("p") model.rhs = {a: a * p} - with self.assertRaisesRegex( - pybamm.SolverError, "No value provided for input 'p'" - ): + with pytest.raises(pybamm.SolverError, match="No value provided for input 'p'"): solver.solve(model, np.array([1, 2, 3])) def test_ode_solver_fail_with_dae(self): @@ -118,7 +115,7 @@ def test_ode_solver_fail_with_dae(self): model.algebraic = {a: a} model.concatenated_initial_conditions = pybamm.Scalar(0) solver = pybamm.ScipySolver() - with self.assertRaisesRegex(pybamm.SolverError, "Cannot use ODE solver"): + with pytest.raises(pybamm.SolverError, match="Cannot use ODE solver"): solver.set_up(model) def test_find_consistent_initialization(self): @@ -231,20 +228,22 @@ def algebraic_eval(self, t, y, inputs): solver = pybamm.BaseSolver(root_method="hybr") - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Could not find acceptable solution: The iteration is not making", + match="Could not find acceptable solution: The iteration is not making", ): solver.calculate_consistent_state(Model()) solver = pybamm.BaseSolver(root_method="lm") - with self.assertRaisesRegex( - pybamm.SolverError, "Could not find acceptable solution: solver terminated" + with pytest.raises( + pybamm.SolverError, + match="Could not find acceptable solution: solver terminated", ): solver.calculate_consistent_state(Model()) # with casadi solver = pybamm.BaseSolver(root_method="casadi") - with self.assertRaisesRegex( - pybamm.SolverError, "Could not find acceptable solution: Error in Function" + with pytest.raises( + pybamm.SolverError, + match="Could not find acceptable solution: Error in Function", ): solver.calculate_consistent_state(Model()) @@ -256,9 +255,9 @@ def test_discretise_model(self): model.initial_conditions = {v: 1} solver = pybamm.BaseSolver() - self.assertFalse(model.is_discretised) + assert not model.is_discretised solver.set_up(model, {}) - self.assertTrue(model.is_discretised) + assert model.is_discretised # 1D model cannot be automatically discretised model = pybamm.BaseModel() @@ -266,8 +265,8 @@ def test_discretise_model(self): model.rhs = {v: -1} model.initial_conditions = {v: 1} - with self.assertRaisesRegex( - pybamm.DiscretisationError, "Cannot automatically discretise model" + with pytest.raises( + pybamm.DiscretisationError, match="Cannot automatically discretise model" ): solver.set_up(model, {}) @@ -285,7 +284,7 @@ def test_convert_to_casadi_format(self): solver = pybamm.BaseSolver(root_method="casadi") pybamm.set_logging_level("ERROR") solver.set_up(model, {}) - self.assertEqual(model.convert_to_format, "casadi") + assert model.convert_to_format == "casadi" pybamm.set_logging_level("WARNING") def test_inputs_step(self): @@ -301,7 +300,7 @@ def test_inputs_step(self): sol = solver.step( old_solution=None, model=model, dt=1.0, inputs={input_key: interp} ) - self.assertFalse(input_key in sol.all_inputs[0]) + assert input_key not in sol.all_inputs[0] def test_extrapolation_warnings(self): # Make sure the extrapolation warnings work @@ -326,10 +325,10 @@ def test_extrapolation_warnings(self): solver = pybamm.ScipySolver() solver.set_up(model) - with self.assertWarns(pybamm.SolverWarning): + with pytest.warns(pybamm.SolverWarning): solver.step(old_solution=None, model=model, dt=1.0) - with self.assertWarns(pybamm.SolverWarning): + with pytest.warns(pybamm.SolverWarning): solver.solve(model, t_eval=[0, 1]) def test_multiple_models_error(self): @@ -344,7 +343,7 @@ def test_multiple_models_error(self): solver = pybamm.ScipySolver() solver.solve(model, t_eval=[0, 1]) - with self.assertRaisesRegex(RuntimeError, "already been initialised"): + with pytest.raises(RuntimeError, match="already been initialised"): solver.solve(model2, t_eval=[0, 1]) def test_multiprocess_context(self): @@ -353,12 +352,16 @@ def test_multiprocess_context(self): assert solver.get_platform_context("Linux") == "fork" assert solver.get_platform_context("Darwin") == "fork" - @unittest.skipIf(not pybamm.has_idaklu(), "idaklu solver is not installed") + @pytest.mark.skipif( + not pybamm.has_idaklu(), reason="idaklu solver is not installed" + ) def test_sensitivities(self): def exact_diff_a(y, a, b): return np.array([[y[0] ** 2 + 2 * a], [y[0]]]) - @unittest.skipIf(not pybamm.has_jax(), "jax or jaxlib is not installed") + @pytest.mark.skipif( + not pybamm.has_jax(), reason="jax or jaxlib is not installed" + ) def exact_diff_b(y, a, b): return np.array([[y[0]], [0]]) @@ -397,13 +400,3 @@ def exact_diff_b(y, a, b): np.testing.assert_allclose( sens_b, exact_diff_b(y, inputs["a"], inputs["b"]) ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_solvers/test_casadi_algebraic_solver.py b/tests/unit/test_solvers/test_casadi_algebraic_solver.py index b85f4292b9..b2dc92d25b 100644 --- a/tests/unit/test_solvers/test_casadi_algebraic_solver.py +++ b/tests/unit/test_solvers/test_casadi_algebraic_solver.py @@ -1,18 +1,18 @@ import casadi import pybamm -import unittest +import pytest import numpy as np from scipy.optimize import least_squares import tests -class TestCasadiAlgebraicSolver(unittest.TestCase): +class TestCasadiAlgebraicSolver: def test_algebraic_solver_init(self): solver = pybamm.CasadiAlgebraicSolver(tol=1e-4) - self.assertEqual(solver.tol, 1e-4) + assert solver.tol == 1e-4 solver.tol = 1e-5 - self.assertEqual(solver.tol, 1e-5) + assert solver.tol == 1e-5 def test_simple_root_find(self): # Simple system: a single algebraic equation @@ -65,13 +65,15 @@ def algebraic_eval(self, t, y, inputs): model = Model() solver = pybamm.CasadiAlgebraicSolver() - with self.assertRaisesRegex( - pybamm.SolverError, "Could not find acceptable solution: Error in Function" + with pytest.raises( + pybamm.SolverError, + match="Could not find acceptable solution: Error in Function", ): solver._integrate(model, np.array([0]), {}) solver = pybamm.CasadiAlgebraicSolver(extra_options={"error_on_fail": False}) - with self.assertRaisesRegex( - pybamm.SolverError, "Could not find acceptable solution: solver terminated" + with pytest.raises( + pybamm.SolverError, + match="Could not find acceptable solution: solver terminated", ): solver._integrate(model, np.array([0]), {}) @@ -91,9 +93,9 @@ def algebraic_eval(self, t, y, inputs): return y**0.5 model = NaNModel() - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Could not find acceptable solution: solver returned NaNs", + match="Could not find acceptable solution: solver returned NaNs", ): solver._integrate(model, np.array([0]), {}) @@ -170,7 +172,7 @@ def test_solve_with_input(self): np.testing.assert_array_equal(solution.y, -7) -class TestCasadiAlgebraicSolverSensitivity(unittest.TestCase): +class TestCasadiAlgebraicSolverSensitivity: def test_solve_with_symbolic_input(self): # Simple system: a single algebraic equation var = pybamm.Variable("var") @@ -344,13 +346,3 @@ def objective(x): # without Jacobian lsq_sol = least_squares(objective, [2, 2], method="lm") np.testing.assert_array_almost_equal(lsq_sol.x, [3, 3], decimal=3) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_solvers/test_casadi_solver.py b/tests/unit/test_solvers/test_casadi_solver.py index 76f5a6c9dc..3e1023c7d4 100644 --- a/tests/unit/test_solvers/test_casadi_solver.py +++ b/tests/unit/test_solvers/test_casadi_solver.py @@ -1,13 +1,13 @@ +import pytest import pybamm -import unittest import numpy as np from tests import get_mesh_for_testing, get_discretisation_for_testing from scipy.sparse import eye -class TestCasadiSolver(unittest.TestCase): +class TestCasadiSolver: def test_bad_mode(self): - with self.assertRaisesRegex(ValueError, "invalid mode"): + with pytest.raises(ValueError, match="invalid mode"): pybamm.CasadiSolver(mode="bad mode") def test_model_solver(self): @@ -102,7 +102,7 @@ def test_without_grid(self): # Safe mode, without grid (enforce events that won't be triggered) solver = pybamm.CasadiSolver(mode="safe without grid", rtol=1e-8, atol=1e-8) - with self.assertRaisesRegex(pybamm.SolverError, "Maximum number of decreased"): + with pytest.raises(pybamm.SolverError, match="Maximum number of decreased"): solver.solve(model, [0, 10]) def test_model_solver_python(self): @@ -143,9 +143,9 @@ def test_model_solver_failure(self): # Solution fails early but manages to take some steps so we return it anyway # Check that the final solution does indeed stop before t=20 t_eval = np.linspace(0, 20, 100) - with self.assertWarns(pybamm.SolverWarning): + with pytest.warns(pybamm.SolverWarning): solution = solver.solve(model_disc, t_eval) - self.assertLess(solution.t[-1], 20) + assert solution.t[-1] < 20 # Solve with failure at t=0 solver = pybamm.CasadiSolver( dt_max=1e-3, return_solution_if_failed_early=True, max_step_decrease_count=2 @@ -155,7 +155,7 @@ def test_model_solver_failure(self): t_eval = np.linspace(0, 20, 100) # This one should fail immediately and throw a `SolverError` # since no progress can be made from the first timestep - with self.assertRaisesRegex(pybamm.SolverError, "Maximum number of decreased"): + with pytest.raises(pybamm.SolverError, match="Maximum number of decreased"): solver.solve(model, t_eval) def test_model_solver_events(self): @@ -390,7 +390,7 @@ def test_model_solver_with_inputs(self): solver = pybamm.CasadiSolver(rtol=1e-8, atol=1e-8) t_eval = np.linspace(0, 10, 100) solution = solver.solve(model, t_eval, inputs={"rate": 0.1}) - self.assertLess(len(solution.t), len(t_eval)) + assert len(solution.t) < len(t_eval) np.testing.assert_allclose( solution.y.full()[0], np.exp(-0.1 * solution.t), rtol=1e-04 ) @@ -399,12 +399,12 @@ def test_model_solver_with_inputs(self): solver = pybamm.CasadiSolver(mode="safe without grid", rtol=1e-8, atol=1e-8) t_eval = np.linspace(0, 10, 100) solution = solver.solve(model, t_eval, inputs={"rate": 0.1}) - self.assertLess(len(solution.t), len(t_eval)) + assert len(solution.t) < len(t_eval) np.testing.assert_allclose( solution.y.full()[0], np.exp(-0.1 * solution.t), rtol=1e-04 ) solution = solver.solve(model, t_eval, inputs={"rate": 1.1}) - self.assertLess(len(solution.t), len(t_eval)) + assert len(solution.t) < len(t_eval) np.testing.assert_allclose( solution.y.full()[0], np.exp(-1.1 * solution.t), rtol=1e-04 ) @@ -482,8 +482,8 @@ def test_dae_solver_algebraic_model(self): solver = pybamm.CasadiSolver() t_eval = np.linspace(0, 1) - with self.assertRaisesRegex( - pybamm.SolverError, "Cannot use CasadiSolver to solve algebraic model" + with pytest.raises( + pybamm.SolverError, match="Cannot use CasadiSolver to solve algebraic model" ): solver.solve(model, t_eval) @@ -507,7 +507,7 @@ def func(var): solver = pybamm.CasadiSolver() t_eval = [0, 5] - with self.assertRaisesRegex(pybamm.SolverError, "interpolation bounds"): + with pytest.raises(pybamm.SolverError, match="interpolation bounds"): solver.solve(model, t_eval) def test_casadi_safe_no_termination(self): @@ -532,7 +532,7 @@ def test_casadi_safe_no_termination(self): solver = pybamm.CasadiSolver(mode="safe") solver.set_up(model) - with self.assertRaisesRegex(pybamm.SolverError, "interpolation bounds"): + with pytest.raises(pybamm.SolverError, match="interpolation bounds"): solver.solve(model, t_eval=[0, 1]) def test_modulo_non_smooth_events(self): @@ -581,7 +581,7 @@ def test_modulo_non_smooth_events(self): ) -class TestCasadiSolverODEsWithForwardSensitivityEquations(unittest.TestCase): +class TestCasadiSolverODEsWithForwardSensitivityEquations: def test_solve_sensitivity_scalar_var_scalar_input(self): # Create model model = pybamm.BaseModel() @@ -931,7 +931,7 @@ def test_solve_sensitivity_subset(self): solution.sensitivities["q"], (0.1 * solution.t)[:, np.newaxis], ) - self.assertTrue("r" not in solution.sensitivities) + assert "r" not in solution.sensitivities np.testing.assert_allclose( solution.sensitivities["all"], np.hstack( @@ -949,8 +949,8 @@ def test_solve_sensitivity_subset(self): calculate_sensitivities=["r"], ) np.testing.assert_allclose(solution.y[0], -1 + 0.2 * solution.t) - self.assertTrue("p" not in solution.sensitivities) - self.assertTrue("q" not in solution.sensitivities) + assert "p" not in solution.sensitivities + assert "q" not in solution.sensitivities np.testing.assert_allclose(solution.sensitivities["r"], 1) np.testing.assert_allclose( solution.sensitivities["all"], @@ -962,7 +962,7 @@ def test_solve_sensitivity_subset(self): ) -class TestCasadiSolverDAEsWithForwardSensitivityEquations(unittest.TestCase): +class TestCasadiSolverDAEsWithForwardSensitivityEquations: def test_solve_sensitivity_scalar_var_scalar_input(self): # Create model model = pybamm.BaseModel() @@ -1066,8 +1066,8 @@ def test_solve_sensitivity_subset(self): solution.sensitivities["q"][::2], (0.1 * solution.t)[:, np.newaxis], ) - self.assertTrue("r" not in solution.sensitivities) - self.assertTrue("s" not in solution.sensitivities) + assert "r" not in solution.sensitivities + assert "s" not in solution.sensitivities np.testing.assert_allclose( solution.sensitivities["all"], np.hstack( @@ -1096,18 +1096,8 @@ def test_solver_interpolation_warning(self): # Check for warning with t_interp t_eval = np.linspace(0, 1, 10) t_interp = t_eval - with self.assertWarns( + with pytest.warns( pybamm.SolverWarning, - msg=f"Explicit interpolation times not implemented for {solver.name}", + match=f"Explicit interpolation times not implemented for {solver.name}", ): solver.solve(model, t_eval, t_interp=t_interp) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_solvers/test_idaklu_solver.py b/tests/unit/test_solvers/test_idaklu_solver.py index 3f9dcf0508..32e289b3e0 100644 --- a/tests/unit/test_solvers/test_idaklu_solver.py +++ b/tests/unit/test_solvers/test_idaklu_solver.py @@ -4,7 +4,6 @@ from contextlib import redirect_stdout import io -import unittest import pytest import numpy as np @@ -13,8 +12,8 @@ @pytest.mark.cibw -@unittest.skipIf(not pybamm.has_idaklu(), "idaklu solver is not installed") -class TestIDAKLUSolver(unittest.TestCase): +@pytest.mark.skipif(not pybamm.has_idaklu(), reason="idaklu solver is not installed") +class TestIDAKLUSolver: def test_ida_roberts_klu(self): # this test implements a python version of the ida Roberts # example provided in sundials @@ -105,7 +104,7 @@ def test_model_events(self): ) # Check invalid atol type raises an error - with self.assertRaises(pybamm.SolverError): + with pytest.raises(pybamm.SolverError): solver._check_atol_type({"key": "value"}, []) # enforce events that won't be triggered @@ -136,7 +135,7 @@ def test_model_events(self): options={"jax_evaluator": "iree"} if form == "iree" else {}, ) solution = solver.solve(model_disc, t_eval, t_interp=t_interp) - self.assertLess(len(solution.t), len(t_interp)) + assert len(solution.t) < len(t_interp) np.testing.assert_array_almost_equal( solution.y[0], np.exp(0.1 * solution.t), @@ -344,7 +343,7 @@ def test_ida_roberts_klu_sensitivities(self): ) # should be no sensitivities calculated - with self.assertRaises(KeyError): + with pytest.raises(KeyError): print(sol.sensitivities["a"]) # now solve with sensitivities (this should cause set_up to be run again) @@ -566,7 +565,7 @@ def test_failures(self): solver = pybamm.IDAKLUSolver() t_eval = [0, 3] - with self.assertRaisesRegex(pybamm.SolverError, "KLU requires the Jacobian"): + with pytest.raises(pybamm.SolverError, match="KLU requires the Jacobian"): solver.solve(model, t_eval) model = pybamm.BaseModel() @@ -581,8 +580,8 @@ def test_failures(self): # will give solver error t_eval = [0, -3] - with self.assertRaisesRegex( - pybamm.SolverError, "t_eval must increase monotonically" + with pytest.raises( + pybamm.SolverError, match="t_eval must increase monotonically" ): solver.solve(model, t_eval) @@ -598,7 +597,7 @@ def test_failures(self): solver = pybamm.IDAKLUSolver() t_eval = [0, 3] - with self.assertRaisesRegex(pybamm.SolverError, "FAILURE IDA"): + with pytest.raises(pybamm.SolverError, match="FAILURE IDA"): solver.solve(model, t_eval) def test_dae_solver_algebraic_model(self): @@ -677,14 +676,14 @@ def test_setup_options(self): with redirect_stdout(f): solver.solve(model, t_eval, t_interp=t_interp) s = f.getvalue() - self.assertIn("Solver Stats", s) + assert "Solver Stats" in s solver = pybamm.IDAKLUSolver(options={"print_stats": False}) f = io.StringIO() with redirect_stdout(f): solver.solve(model, t_eval, t_interp=t_interp) s = f.getvalue() - self.assertEqual(len(s), 0) + assert len(s) == 0 # test everything else for jacobian in ["none", "dense", "sparse", "matrix-free", "garbage"]: @@ -731,7 +730,7 @@ def test_setup_options(self): soln = solver.solve(model, t_eval, t_interp=t_interp) np.testing.assert_array_almost_equal(soln.y, soln_base.y, 4) else: - with self.assertRaises(ValueError): + with pytest.raises(ValueError): soln = solver.solve(model, t_eval, t_interp=t_interp) def test_solver_options(self): @@ -796,7 +795,7 @@ def test_solver_options(self): options = {option: options_fail[option]} solver = pybamm.IDAKLUSolver(options=options) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): solver.solve(model, t_eval) def test_with_output_variables(self): @@ -899,7 +898,7 @@ def construct_model(): # Check that the missing variables are not available in the solution for varname in inaccessible_vars: - with self.assertRaises(KeyError): + with pytest.raises(KeyError): sol[varname].data # Mock a 1D current collector and initialise (none in the model) @@ -1005,13 +1004,13 @@ def test_with_output_variables_and_sensitivities(self): def test_bad_jax_evaluator(self): model = pybamm.lithium_ion.DFN() model.convert_to_format = "jax" - with self.assertRaises(pybamm.SolverError): + with pytest.raises(pybamm.SolverError): pybamm.IDAKLUSolver(options={"jax_evaluator": "bad_evaluator"}) def test_bad_jax_evaluator_output_variables(self): model = pybamm.lithium_ion.DFN() model.convert_to_format = "jax" - with self.assertRaises(pybamm.SolverError): + with pytest.raises(pybamm.SolverError): pybamm.IDAKLUSolver( options={"jax_evaluator": "bad_evaluator"}, output_variables=["Terminal voltage [V]"], @@ -1027,7 +1026,7 @@ def test_with_output_variables_and_event_termination(self): solver=pybamm.IDAKLUSolver(output_variables=["Terminal voltage [V]"]), ) sol = sim.solve(np.linspace(0, 3600, 2)) - self.assertEqual(sol.termination, "event: Minimum voltage [V]") + assert sol.termination == "event: Minimum voltage [V]" # create an event that doesn't require the state vector eps_p = model.variables["Positive electrode porosity"] @@ -1045,7 +1044,7 @@ def test_with_output_variables_and_event_termination(self): solver=pybamm.IDAKLUSolver(output_variables=["Terminal voltage [V]"]), ) sol3 = sim3.solve(np.linspace(0, 3600, 2)) - self.assertEqual(sol3.termination, "event: Minimum voltage [V]") + assert sol3.termination == "event: Minimum voltage [V]" def test_simulation_period(self): model = pybamm.lithium_ion.DFN() @@ -1107,29 +1106,18 @@ def test_python_idaklu_deprecation_errors(self): ) if form == "python": - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Unsupported option for convert_to_format=python", + match="Unsupported option for convert_to_format=python", ): - with self.assertWarnsRegex( + with pytest.raises( DeprecationWarning, - "The python-idaklu solver has been deprecated.", + match="The python-idaklu solver has been deprecated.", ): _ = solver.solve(model, t_eval) elif form == "jax": - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Unsupported evaluation engine for convert_to_format=jax", + match="Unsupported evaluation engine for convert_to_format=jax", ): _ = solver.solve(model, t_eval) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - - unittest.main() diff --git a/tests/unit/test_solvers/test_jax_bdf_solver.py b/tests/unit/test_solvers/test_jax_bdf_solver.py index e0064ae463..98eaed8e6a 100644 --- a/tests/unit/test_solvers/test_jax_bdf_solver.py +++ b/tests/unit/test_solvers/test_jax_bdf_solver.py @@ -1,16 +1,15 @@ +import pytest import pybamm -import unittest from tests import get_mesh_for_testing -import sys import numpy as np if pybamm.has_jax(): import jax -@unittest.skipIf(not pybamm.has_jax(), "jax or jaxlib is not installed") -class TestJaxBDFSolver(unittest.TestCase): +@pytest.mark.skipif(not pybamm.has_jax(), reason="jax or jaxlib is not installed") +class TestJaxBDFSolver: def test_solver_(self): # Trailing _ manipulates the random seed # Create model model = pybamm.BaseModel() @@ -113,7 +112,7 @@ def solve_bdf(rate): grad_solve_bdf = jax.jit(jax.grad(solve_bdf)) grad_bdf = grad_solve_bdf(rate) - self.assertAlmostEqual(grad_bdf, grad_num, places=3) + assert grad_bdf == pytest.approx(grad_num, abs=0.001) def test_mass_matrix_with_sensitivities(self): # Solve @@ -146,7 +145,7 @@ def solve_bdf(rate): grad_solve_bdf = jax.jit(jax.grad(solve_bdf)) grad_bdf = grad_solve_bdf(rate) - self.assertAlmostEqual(grad_bdf, grad_num, places=3) + assert grad_bdf == pytest.approx(grad_num, abs=0.001) def test_solver_with_inputs(self): # Create model @@ -176,12 +175,3 @@ def fun(y, t, inputs): ) np.testing.assert_allclose(y[:, 0].reshape(-1), np.exp(-0.1 * t_eval)) - - -if __name__ == "__main__": - print("Add -v for more debug output") - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_solvers/test_jax_solver.py b/tests/unit/test_solvers/test_jax_solver.py index b1c293c2f2..f7e5b8d3b6 100644 --- a/tests/unit/test_solvers/test_jax_solver.py +++ b/tests/unit/test_solvers/test_jax_solver.py @@ -1,16 +1,15 @@ +import pytest import pybamm -import unittest from tests import get_mesh_for_testing -import sys import numpy as np if pybamm.has_jax(): import jax -@unittest.skipIf(not pybamm.has_jax(), "jax or jaxlib is not installed") -class TestJaxSolver(unittest.TestCase): +@pytest.mark.skipif(not pybamm.has_jax(), reason="jax or jaxlib is not installed") +class TestJaxSolver: def test_model_solver(self): # Create model model = pybamm.BaseModel() @@ -38,10 +37,8 @@ def test_model_solver(self): ) # Test time - self.assertEqual( - solution.total_time, solution.solve_time + solution.set_up_time - ) - self.assertEqual(solution.termination, "final time") + assert solution.total_time == solution.solve_time + solution.set_up_time + assert solution.termination == "final time" second_solution = solver.solve(model, t_eval) @@ -76,10 +73,8 @@ def test_semi_explicit_model(self): np.testing.assert_allclose(solution.y[-1], 2 * soln, rtol=1e-7, atol=1e-7) # Test time - self.assertEqual( - solution.total_time, solution.solve_time + solution.set_up_time - ) - self.assertEqual(solution.termination, "final time") + assert solution.total_time == solution.solve_time + solution.set_up_time + assert solution.termination == "final time" second_solution = solver.solve(model, t_eval) np.testing.assert_array_equal(second_solution.y, solution.y) @@ -124,7 +119,7 @@ def solve_model(rate, solve=solve): grad_solve = jax.jit(jax.grad(solve_model)) grad = grad_solve(rate) - self.assertAlmostEqual(grad, grad_num, places=1) + assert grad == pytest.approx(grad_num, abs=0.1) def test_solver_only_works_with_jax(self): model = pybamm.BaseModel() @@ -144,7 +139,7 @@ def test_solver_only_works_with_jax(self): model.convert_to_format = convert_to_format solver = pybamm.JaxSolver() - with self.assertRaisesRegex(RuntimeError, "must be converted to JAX"): + with pytest.raises(RuntimeError, match="must be converted to JAX"): solver.solve(model, t_eval) def test_solver_doesnt_support_events(self): @@ -171,7 +166,7 @@ def test_solver_doesnt_support_events(self): # Solve solver = pybamm.JaxSolver() t_eval = np.linspace(0, 10, 100) - with self.assertRaisesRegex(RuntimeError, "Terminate events not supported"): + with pytest.raises(RuntimeError, match="Terminate events not supported"): solver.solve(model, t_eval) def test_model_solver_with_inputs(self): @@ -223,14 +218,14 @@ def test_get_solve(self): disc.process_model(model) # test that another method string gives error - with self.assertRaises(ValueError): + with pytest.raises(ValueError): solver = pybamm.JaxSolver(method="not_real") # Solve solver = pybamm.JaxSolver(rtol=1e-8, atol=1e-8) t_eval = np.linspace(0, 5, 80) - with self.assertRaisesRegex(RuntimeError, "Model is not set up for solving"): + with pytest.raises(RuntimeError, match="Model is not set up for solving"): solver.get_solve(model, t_eval) solver.solve(model, t_eval, inputs={"rate": 0.1}) @@ -242,12 +237,3 @@ def test_get_solve(self): y = solver({"rate": 0.2}) np.testing.assert_allclose(y[0], np.exp(-0.2 * t_eval), rtol=1e-6, atol=1e-6) - - -if __name__ == "__main__": - print("Add -v for more debug output") - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_solvers/test_processed_variable.py b/tests/unit/test_solvers/test_processed_variable.py index b6ae669878..6cd456347d 100644 --- a/tests/unit/test_solvers/test_processed_variable.py +++ b/tests/unit/test_solvers/test_processed_variable.py @@ -7,7 +7,7 @@ import tests import numpy as np -import unittest +import pytest def to_casadi(var_pybamm, y, inputs=None): @@ -61,7 +61,7 @@ def process_and_check_2D_variable( return y_sol, first_sol, second_sol, t_sol -class TestProcessedVariable(unittest.TestCase): +class TestProcessedVariable: def test_processed_variable_0D(self): # without space t = pybamm.t @@ -112,7 +112,7 @@ def test_processed_variable_0D_no_sensitivity(self): ) # test no inputs (i.e. no sensitivity) - self.assertDictEqual(processed_var.sensitivities, {}) + assert processed_var.sensitivities == {} # with parameter t = pybamm.t @@ -132,7 +132,7 @@ def test_processed_variable_0D_no_sensitivity(self): ) # test no sensitivity raises error - with self.assertRaisesRegex(ValueError, "Cannot compute sensitivities"): + with pytest.raises(ValueError, match="Cannot compute sensitivities"): print(processed_var.sensitivities) def test_processed_variable_1D(self): @@ -562,10 +562,10 @@ def test_processed_var_1D_interpolation(self): processed_eqn(t_sol, x_sol), t_sol * y_sol + x_sol[:, np.newaxis] ) # 1 vector, 1 scalar - self.assertEqual(processed_eqn(0.5, x_sol[10:30]).shape, (20,)) - self.assertEqual(processed_eqn(t_sol[4:9], x_sol[-1]).shape, (5,)) + assert processed_eqn(0.5, x_sol[10:30]).shape == (20,) + assert processed_eqn(t_sol[4:9], x_sol[-1]).shape == (5,) # 2 scalars - self.assertEqual(processed_eqn(0.5, x_sol[-1]).shape, ()) + assert processed_eqn(0.5, x_sol[-1]).shape == () # test x x_disc = disc.process_symbol(x) @@ -686,7 +686,7 @@ def test_processed_var_wrong_spatial_variable_names(self): "domain B": {b: {"min": 0, "max": 1}}, } ) - with self.assertRaisesRegex(NotImplementedError, "Spatial variable name"): + with pytest.raises(NotImplementedError, match="Spatial variable name"): pybamm.ProcessedVariable( [var_sol], [var_casadi], @@ -892,7 +892,7 @@ def test_processed_var_2D_secondary_broadcast(self): processed_var(t_sol, x_sol, r_sol).shape, (10, 35, 50) ) - def test_processed_var_2D_scikit_interpolation(self): + def test_processed_var_2_d_scikit_interpolation(self): var = pybamm.Variable("var", domain=["current collector"]) disc = tests.get_2p1d_discretisation_for_testing() @@ -1061,7 +1061,7 @@ def test_3D_raises_error(self): u_sol = np.ones(var_sol.shape[0] * 3)[:, np.newaxis] var_casadi = to_casadi(var_sol, u_sol) - with self.assertRaisesRegex(NotImplementedError, "Shape not recognized"): + with pytest.raises(NotImplementedError, match="Shape not recognized"): pybamm.ProcessedVariable( [var_sol], [var_casadi], @@ -1088,51 +1088,23 @@ def test_process_spatial_variable_names(self): ) # Test empty list returns None - self.assertIsNone(processed_var._process_spatial_variable_names([])) + assert processed_var._process_spatial_variable_names([]) is None # Test tabs is ignored - self.assertEqual( - processed_var._process_spatial_variable_names(["tabs", "var"]), - "var", - ) + assert processed_var._process_spatial_variable_names(["tabs", "var"]) == "var" # Test strings stay strings - self.assertEqual( - processed_var._process_spatial_variable_names(["y"]), - "y", - ) + assert processed_var._process_spatial_variable_names(["y"]) == "y" # Test spatial variables are converted to strings x = pybamm.SpatialVariable("x", domain=["domain"]) - self.assertEqual( - processed_var._process_spatial_variable_names([x]), - "x", - ) + assert processed_var._process_spatial_variable_names([x]) == "x" # Test renaming for PyBaMM convention - self.assertEqual( - processed_var._process_spatial_variable_names(["x_a", "x_b"]), - "x", - ) - self.assertEqual( - processed_var._process_spatial_variable_names(["r_a", "r_b"]), - "r", - ) - self.assertEqual( - processed_var._process_spatial_variable_names(["R_a", "R_b"]), - "R", - ) + assert processed_var._process_spatial_variable_names(["x_a", "x_b"]) == "x" + assert processed_var._process_spatial_variable_names(["r_a", "r_b"]) == "r" + assert processed_var._process_spatial_variable_names(["R_a", "R_b"]) == "R" # Test error raised if spatial variable name not recognised - with self.assertRaisesRegex(NotImplementedError, "Spatial variable name"): + with pytest.raises(NotImplementedError, match="Spatial variable name"): processed_var._process_spatial_variable_names(["var1", "var2"]) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_solvers/test_processed_variable_computed.py b/tests/unit/test_solvers/test_processed_variable_computed.py index 407d422e4c..59a062b199 100644 --- a/tests/unit/test_solvers/test_processed_variable_computed.py +++ b/tests/unit/test_solvers/test_processed_variable_computed.py @@ -6,12 +6,12 @@ # values itself since it does not have access to the full state vector # +import pytest import casadi import pybamm import tests import numpy as np -import unittest def to_casadi(var_pybamm, y, inputs=None): @@ -68,7 +68,7 @@ def process_and_check_2D_variable( return y_sol, first_sol, second_sol, t_sol -class TestProcessedVariableComputed(unittest.TestCase): +class TestProcessedVariableComputed: def test_processed_variable_0D(self): # without space y = pybamm.StateVector(slice(0, 1)) @@ -115,7 +115,7 @@ def test_processed_variable_0D_no_sensitivity(self): ) # test no inputs (i.e. no sensitivity) - self.assertDictEqual(processed_var.sensitivities, {}) + assert processed_var.sensitivities == {} # with parameter t = pybamm.t @@ -136,7 +136,7 @@ def test_processed_variable_0D_no_sensitivity(self): ) # test no sensitivity raises error - self.assertIsNone(processed_var.sensitivities) + assert processed_var.sensitivities is None def test_processed_variable_1D(self): var = pybamm.Variable("var", domain=["negative electrode", "separator"]) @@ -386,7 +386,7 @@ def test_processed_variable_2D_space_only(self): np.testing.assert_array_equal(processed_var.unroll(), y_sol.reshape(10, 40, 1)) # Check unroll function (3D) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): processed_var.dimensions = 3 processed_var.unroll() @@ -428,7 +428,7 @@ def test_3D_raises_error(self): u_sol = np.ones(var_sol.shape[0] * 3)[:, np.newaxis] var_casadi = to_casadi(var_sol, u_sol) - with self.assertRaisesRegex(NotImplementedError, "Shape not recognized"): + with pytest.raises(NotImplementedError, match="Shape not recognized"): pybamm.ProcessedVariableComputed( [var_sol], [var_casadi], @@ -436,13 +436,3 @@ def test_3D_raises_error(self): pybamm.Solution(t_sol, u_sol, pybamm.BaseModel(), {}), warn=False, ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_base_spatial_method.py b/tests/unit/test_spatial_methods/test_base_spatial_method.py index 647616c924..190fbd8f94 100644 --- a/tests/unit/test_spatial_methods/test_base_spatial_method.py +++ b/tests/unit/test_spatial_methods/test_base_spatial_method.py @@ -2,9 +2,9 @@ # Test for the base Spatial Method class # +import pytest import numpy as np import pybamm -import unittest from tests import ( get_mesh_for_testing, get_1p1d_mesh_for_testing, @@ -12,31 +12,31 @@ ) -class TestSpatialMethod(unittest.TestCase): +class TestSpatialMethod: def test_basics(self): mesh = get_mesh_for_testing() spatial_method = pybamm.SpatialMethod() spatial_method.build(mesh) - self.assertEqual(spatial_method.mesh, mesh) - with self.assertRaises(NotImplementedError): + assert spatial_method.mesh == mesh + with pytest.raises(NotImplementedError): spatial_method.gradient(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.divergence(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.laplacian(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.gradient_squared(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.integral(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.indefinite_integral(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.boundary_integral(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.delta_function(None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.internal_neumann_condition(None, None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.evaluate_at(None, None, None) def test_get_auxiliary_domain_repeats(self): @@ -47,20 +47,18 @@ def test_get_auxiliary_domain_repeats(self): # No auxiliary domains repeats = spatial_method._get_auxiliary_domain_repeats({}) - self.assertEqual(repeats, 1) + assert repeats == 1 # Just secondary domain repeats = spatial_method._get_auxiliary_domain_repeats( {"secondary": ["negative electrode"]} ) - self.assertEqual(repeats, mesh["negative electrode"].npts) + assert repeats == mesh["negative electrode"].npts repeats = spatial_method._get_auxiliary_domain_repeats( {"secondary": ["negative electrode", "separator"]} ) - self.assertEqual( - repeats, mesh["negative electrode"].npts + mesh["separator"].npts - ) + assert repeats == mesh["negative electrode"].npts + mesh["separator"].npts # With tertiary domain repeats = spatial_method._get_auxiliary_domain_repeats( @@ -69,17 +67,17 @@ def test_get_auxiliary_domain_repeats(self): "tertiary": ["current collector"], } ) - self.assertEqual( - repeats, - (mesh["negative electrode"].npts + mesh["separator"].npts) - * mesh["current collector"].npts, + assert ( + repeats + == (mesh["negative electrode"].npts + mesh["separator"].npts) + * mesh["current collector"].npts ) # Just tertiary domain repeats = spatial_method._get_auxiliary_domain_repeats( {"tertiary": ["current collector"]}, ) - self.assertEqual(repeats, mesh["current collector"].npts) + assert repeats == mesh["current collector"].npts # With quaternary domain repeats = spatial_method._get_auxiliary_domain_repeats( @@ -89,11 +87,11 @@ def test_get_auxiliary_domain_repeats(self): "quaternary": ["current collector"], } ) - self.assertEqual( - repeats, - mesh["negative particle size"].npts + assert ( + repeats + == mesh["negative particle size"].npts * (mesh["negative electrode"].npts + mesh["separator"].npts) - * mesh["current collector"].npts, + * mesh["current collector"].npts ) def test_discretise_spatial_variable(self): @@ -108,7 +106,7 @@ def test_discretise_spatial_variable(self): r = pybamm.SpatialVariable("r", ["negative particle"]) for var in [x1, x2, r]: var_disc = spatial_method.spatial_variable(var) - self.assertIsInstance(var_disc, pybamm.Vector) + assert isinstance(var_disc, pybamm.Vector) np.testing.assert_array_equal( var_disc.evaluate()[:, 0], mesh[var.domain].nodes ) @@ -119,7 +117,7 @@ def test_discretise_spatial_variable(self): r_edge = pybamm.SpatialVariableEdge("r", ["negative particle"]) for var in [x1_edge, x2_edge, r_edge]: var_disc = spatial_method.spatial_variable(var) - self.assertIsInstance(var_disc, pybamm.Vector) + assert isinstance(var_disc, pybamm.Vector) np.testing.assert_array_equal( var_disc.evaluate()[:, 0], mesh[var.domain].edges ) @@ -130,12 +128,12 @@ def test_boundary_value_checks(self): mesh = get_mesh_for_testing() spatial_method = pybamm.SpatialMethod() spatial_method.build(mesh) - with self.assertRaisesRegex(TypeError, "Cannot process BoundaryGradient"): + with pytest.raises(TypeError, match="Cannot process BoundaryGradient"): spatial_method.boundary_value_or_flux(symbol, child) # test also symbol "right" symbol = pybamm.BoundaryGradient(child, "right") - with self.assertRaisesRegex(TypeError, "Cannot process BoundaryGradient"): + with pytest.raises(TypeError, match="Cannot process BoundaryGradient"): spatial_method.boundary_value_or_flux(symbol, child) mesh = get_1p1d_mesh_for_testing() @@ -147,15 +145,5 @@ def test_boundary_value_checks(self): auxiliary_domains={"secondary": "current collector"}, ) symbol = pybamm.BoundaryGradient(child, "left") - with self.assertRaisesRegex(NotImplementedError, "Cannot process 2D symbol"): + with pytest.raises(NotImplementedError, match="Cannot process 2D symbol"): spatial_method.boundary_value_or_flux(symbol, child) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_finite_volume/test_extrapolation.py b/tests/unit/test_spatial_methods/test_finite_volume/test_extrapolation.py index c51e2d9a13..8479097031 100644 --- a/tests/unit/test_spatial_methods/test_finite_volume/test_extrapolation.py +++ b/tests/unit/test_spatial_methods/test_finite_volume/test_extrapolation.py @@ -9,7 +9,6 @@ get_1p1d_mesh_for_testing, ) import numpy as np -import unittest def errors(pts, function, method_options, bcs=None): @@ -57,7 +56,7 @@ def get_errors(function, method_options, pts, bcs=None): return l_errors, r_errors -class TestExtrapolation(unittest.TestCase): +class TestExtrapolation: def test_convergence_without_bcs(self): # all tests are performed on x in [0, 1] linear = {"extrapolation": {"order": "linear"}} @@ -262,8 +261,8 @@ def test_linear_extrapolate_left_right(self): # check constant extrapolates to constant constant_y = np.ones_like(macro_submesh.nodes[:, np.newaxis]) - self.assertEqual(extrap_left_disc.evaluate(None, constant_y), 2) - self.assertEqual(extrap_right_disc.evaluate(None, constant_y), 3) + assert extrap_left_disc.evaluate(None, constant_y) == 2 + assert extrap_right_disc.evaluate(None, constant_y) == 3 # check linear variable extrapolates correctly linear_y = macro_submesh.nodes @@ -297,7 +296,7 @@ def test_linear_extrapolate_left_right(self): # check constant extrapolates to constant constant_y = np.ones_like(micro_submesh.nodes[:, np.newaxis]) - self.assertEqual(surf_eqn_disc.evaluate(None, constant_y), 1.0) + assert surf_eqn_disc.evaluate(None, constant_y) == 1.0 # check linear variable extrapolates correctly linear_y = micro_submesh.nodes @@ -359,7 +358,7 @@ def test_quadratic_extrapolate_left_right(self): np.testing.assert_array_almost_equal( extrap_flux_left_disc.evaluate(None, constant_y), 0 ) - self.assertEqual(extrap_flux_right_disc.evaluate(None, constant_y), 0) + assert extrap_flux_right_disc.evaluate(None, constant_y) == 0 # check linear variable extrapolates correctly np.testing.assert_array_almost_equal( @@ -448,7 +447,7 @@ def test_extrapolate_2d_models(self): extrap_right = pybamm.BoundaryValue(var, "right") disc.set_variable_slices([var]) extrap_right_disc = disc.process_symbol(extrap_right) - self.assertEqual(extrap_right_disc.domain, ["negative electrode"]) + assert extrap_right_disc.domain == ["negative electrode"] # evaluate y_macro = mesh["negative electrode"].nodes y_micro = mesh["negative particle"].nodes @@ -462,7 +461,7 @@ def test_extrapolate_2d_models(self): extrap_right = pybamm.BoundaryValue(var, "right") disc.set_variable_slices([var]) extrap_right_disc = disc.process_symbol(extrap_right) - self.assertEqual(extrap_right_disc.domain, []) + assert extrap_right_disc.domain == [] # 2d macroscale mesh = get_1p1d_mesh_for_testing() @@ -471,7 +470,7 @@ def test_extrapolate_2d_models(self): extrap_right = pybamm.BoundaryValue(var, "right") disc.set_variable_slices([var]) extrap_right_disc = disc.process_symbol(extrap_right) - self.assertEqual(extrap_right_disc.domain, []) + assert extrap_right_disc.domain == [] # test extrapolate to "negative tab" gives same as "left" and # "positive tab" gives same "right" (see get_mesh_for_testing) @@ -497,13 +496,3 @@ def test_extrapolate_2d_models(self): extrap_pos_disc.evaluate(None, constant_y), extrap_right_disc.evaluate(None, constant_y), ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_finite_volume/test_finite_volume.py b/tests/unit/test_spatial_methods/test_finite_volume/test_finite_volume.py index de31b770ff..204e831855 100644 --- a/tests/unit/test_spatial_methods/test_finite_volume/test_finite_volume.py +++ b/tests/unit/test_spatial_methods/test_finite_volume/test_finite_volume.py @@ -10,10 +10,10 @@ ) import numpy as np from scipy.sparse import kron, eye -import unittest +import pytest -class TestFiniteVolume(unittest.TestCase): +class TestFiniteVolume: def test_node_to_edge_to_node(self): # Create discretisation mesh = get_mesh_for_testing() @@ -46,14 +46,14 @@ def test_node_to_edge_to_node(self): ) # bad shift key - with self.assertRaisesRegex(ValueError, "shift key"): + with pytest.raises(ValueError, match="shift key"): fin_vol.shift(c, "bad shift key", "arithmetic") - with self.assertRaisesRegex(ValueError, "shift key"): + with pytest.raises(ValueError, match="shift key"): fin_vol.shift(c, "bad shift key", "harmonic") # bad method - with self.assertRaisesRegex(ValueError, "method"): + with pytest.raises(ValueError, match="method"): fin_vol.shift(c, "shift key", "bad method") def test_concatenation(self): @@ -71,7 +71,7 @@ def test_concatenation(self): edges = [ pybamm.Vector(np.ones(mesh[dom].npts + 2), domain=dom) for dom in whole_cell ] - with self.assertRaisesRegex(pybamm.ShapeError, "child must have size n_nodes"): + with pytest.raises(pybamm.ShapeError, match="child must have size n_nodes"): fin_vol.concatenation(edges) def test_discretise_diffusivity_times_spatial_operator(self): @@ -154,14 +154,14 @@ def test_discretise_spatial_variable(self): # macroscale x1 = pybamm.SpatialVariable("x", ["negative electrode"]) x1_disc = disc.process_symbol(x1) - self.assertIsInstance(x1_disc, pybamm.Vector) + assert isinstance(x1_disc, pybamm.Vector) np.testing.assert_array_equal( x1_disc.evaluate(), disc.mesh["negative electrode"].nodes[:, np.newaxis] ) # macroscale with concatenation x2 = pybamm.SpatialVariable("x", ["negative electrode", "separator"]) x2_disc = disc.process_symbol(x2) - self.assertIsInstance(x2_disc, pybamm.Vector) + assert isinstance(x2_disc, pybamm.Vector) np.testing.assert_array_equal( x2_disc.evaluate(), disc.mesh[("negative electrode", "separator")].nodes[:, np.newaxis], @@ -169,7 +169,7 @@ def test_discretise_spatial_variable(self): # microscale r = 3 * pybamm.SpatialVariable("r", ["negative particle"]) r_disc = disc.process_symbol(r) - self.assertIsInstance(r_disc, pybamm.Vector) + assert isinstance(r_disc, pybamm.Vector) np.testing.assert_array_equal( r_disc.evaluate(), 3 * disc.mesh["negative particle"].nodes[:, np.newaxis] ) @@ -326,8 +326,8 @@ def test_boundary_value_domain(self): c_s_p_surf = pybamm.surf(c_s_p) c_s_n_surf_disc = disc.process_symbol(c_s_n_surf) c_s_p_surf_disc = disc.process_symbol(c_s_p_surf) - self.assertEqual(c_s_n_surf_disc.domain, ["negative electrode"]) - self.assertEqual(c_s_p_surf_disc.domain, ["positive electrode"]) + assert c_s_n_surf_disc.domain == ["negative electrode"] + assert c_s_p_surf_disc.domain == ["positive electrode"] def test_delta_function(self): mesh = get_mesh_for_testing() @@ -344,17 +344,17 @@ def test_delta_function(self): # Basic shape and type tests y = np.ones_like(mesh["negative electrode"].nodes[:, np.newaxis]) # Left - self.assertEqual(delta_fn_left_disc.domains, delta_fn_left.domains) - self.assertIsInstance(delta_fn_left_disc, pybamm.Multiplication) - self.assertIsInstance(delta_fn_left_disc.left, pybamm.Matrix) + assert delta_fn_left_disc.domains == delta_fn_left.domains + assert isinstance(delta_fn_left_disc, pybamm.Multiplication) + assert isinstance(delta_fn_left_disc.left, pybamm.Matrix) np.testing.assert_array_equal(delta_fn_left_disc.left.evaluate()[:, 1:], 0) - self.assertEqual(delta_fn_left_disc.shape, y.shape) + assert delta_fn_left_disc.shape == y.shape # Right - self.assertEqual(delta_fn_right_disc.domains, delta_fn_right.domains) - self.assertIsInstance(delta_fn_right_disc, pybamm.Multiplication) - self.assertIsInstance(delta_fn_right_disc.left, pybamm.Matrix) + assert delta_fn_right_disc.domains == delta_fn_right.domains + assert isinstance(delta_fn_right_disc, pybamm.Multiplication) + assert isinstance(delta_fn_right_disc.left, pybamm.Matrix) np.testing.assert_array_equal(delta_fn_right_disc.left.evaluate()[:, :-1], 0) - self.assertEqual(delta_fn_right_disc.shape, y.shape) + assert delta_fn_right_disc.shape == y.shape # Value tests # Delta function should integrate to the same thing as variable @@ -378,7 +378,7 @@ def test_heaviside(self): # process_binary_operators should work with heaviside disc_heav = disc.process_symbol(heav * var) nodes = mesh["negative electrode"].nodes - self.assertEqual(disc_heav.size, nodes.size) + assert disc_heav.size == nodes.size np.testing.assert_array_equal(disc_heav.evaluate(y=2 * np.ones_like(nodes)), 2) np.testing.assert_array_equal(disc_heav.evaluate(y=-2 * np.ones_like(nodes)), 0) @@ -404,8 +404,8 @@ def test_upwind_downwind(self): nodes = mesh["negative electrode"].nodes n = mesh["negative electrode"].npts - self.assertEqual(disc_upwind.size, nodes.size + 1) - self.assertEqual(disc_downwind.size, nodes.size + 1) + assert disc_upwind.size == nodes.size + 1 + assert disc_downwind.size == nodes.size + 1 y_test = 2 * np.ones_like(nodes) np.testing.assert_array_equal( @@ -420,7 +420,7 @@ def test_upwind_downwind(self): # Remove boundary conditions and check error is raised disc.bcs = {} disc._discretised_symbols = {} - with self.assertRaisesRegex(pybamm.ModelError, "Boundary conditions"): + with pytest.raises(pybamm.ModelError, match="Boundary conditions"): disc.process_symbol(upwind) # Set wrong boundary conditions and check error is raised @@ -430,9 +430,9 @@ def test_upwind_downwind(self): "right": (pybamm.Scalar(3), "Neumann"), } } - with self.assertRaisesRegex(pybamm.ModelError, "Dirichlet boundary conditions"): + with pytest.raises(pybamm.ModelError, match="Dirichlet boundary conditions"): disc.process_symbol(upwind) - with self.assertRaisesRegex(pybamm.ModelError, "Dirichlet boundary conditions"): + with pytest.raises(pybamm.ModelError, match="Dirichlet boundary conditions"): disc.process_symbol(downwind) def test_grad_div_with_bcs_on_tab(self): @@ -525,10 +525,10 @@ def test_neg_pos_bcs(self): # check after disc that negative tab goes to left and positive tab goes # to right disc.process_symbol(grad_eqn) - self.assertEqual(disc.bcs[var]["left"][0], pybamm.Scalar(1)) - self.assertEqual(disc.bcs[var]["left"][1], "Dirichlet") - self.assertEqual(disc.bcs[var]["right"][0], pybamm.Scalar(0)) - self.assertEqual(disc.bcs[var]["right"][1], "Neumann") + assert disc.bcs[var]["left"][0] == pybamm.Scalar(1) + assert disc.bcs[var]["left"][1] == "Dirichlet" + assert disc.bcs[var]["right"][0] == pybamm.Scalar(0) + assert disc.bcs[var]["right"][1] == "Neumann" def test_full_broadcast_domains(self): model = pybamm.BaseModel() @@ -568,12 +568,12 @@ def test_evaluate_at(self): evaluate_at = pybamm.EvaluateAt(var, position) evaluate_at_disc = disc.process_symbol(evaluate_at) - self.assertIsInstance(evaluate_at_disc, pybamm.MatrixMultiplication) - self.assertIsInstance(evaluate_at_disc.left, pybamm.Matrix) - self.assertIsInstance(evaluate_at_disc.right, pybamm.StateVector) + assert isinstance(evaluate_at_disc, pybamm.MatrixMultiplication) + assert isinstance(evaluate_at_disc.left, pybamm.Matrix) + assert isinstance(evaluate_at_disc.right, pybamm.StateVector) y = np.arange(n)[:, np.newaxis] - self.assertEqual(evaluate_at_disc.evaluate(y=y), y[idx]) + assert evaluate_at_disc.evaluate(y=y) == y[idx] def test_inner(self): # standard @@ -598,9 +598,9 @@ def test_inner(self): disc.bcs = boundary_conditions inner_disc = disc.process_symbol(inner) - self.assertIsInstance(inner_disc, pybamm.Inner) - self.assertIsInstance(inner_disc.left, pybamm.MatrixMultiplication) - self.assertIsInstance(inner_disc.right, pybamm.MatrixMultiplication) + assert isinstance(inner_disc, pybamm.Inner) + assert isinstance(inner_disc.left, pybamm.MatrixMultiplication) + assert isinstance(inner_disc.right, pybamm.MatrixMultiplication) n = mesh["negative particle"].npts y = np.ones(n)[:, np.newaxis] @@ -613,19 +613,9 @@ def test_inner(self): inner_disc = disc.process_symbol(inner) - self.assertIsInstance(inner_disc, pybamm.Inner) - self.assertIsInstance(inner_disc.left, pybamm.MatrixMultiplication) - self.assertIsInstance(inner_disc.right, pybamm.MatrixMultiplication) + assert isinstance(inner_disc, pybamm.Inner) + assert isinstance(inner_disc.left, pybamm.MatrixMultiplication) + assert isinstance(inner_disc.right, pybamm.MatrixMultiplication) m = mesh["negative electrode"].npts np.testing.assert_array_equal(inner_disc.evaluate(y=y), np.zeros((n * m, 1))) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_finite_volume/test_ghost_nodes_and_neumann.py b/tests/unit/test_spatial_methods/test_finite_volume/test_ghost_nodes_and_neumann.py index ba82f2fb09..0044d20c0a 100644 --- a/tests/unit/test_spatial_methods/test_finite_volume/test_ghost_nodes_and_neumann.py +++ b/tests/unit/test_spatial_methods/test_finite_volume/test_ghost_nodes_and_neumann.py @@ -5,10 +5,10 @@ import pybamm from tests import get_mesh_for_testing, get_p2d_mesh_for_testing import numpy as np -import unittest +import pytest -class TestGhostNodes(unittest.TestCase): +class TestGhostNodes: def test_add_ghost_nodes(self): # Set up @@ -36,25 +36,25 @@ def test_add_ghost_nodes(self): np.testing.assert_array_equal( sym_ghost.evaluate(y=y_test)[1:-1], discretised_symbol.evaluate(y=y_test) ) - self.assertEqual( - (sym_ghost.evaluate(y=y_test)[0] + sym_ghost.evaluate(y=y_test)[1]) / 2, 0 - ) - self.assertEqual( - (sym_ghost.evaluate(y=y_test)[-2] + sym_ghost.evaluate(y=y_test)[-1]) / 2, 3 - ) + assert ( + sym_ghost.evaluate(y=y_test)[0] + sym_ghost.evaluate(y=y_test)[1] + ) / 2 == 0 + assert ( + sym_ghost.evaluate(y=y_test)[-2] + sym_ghost.evaluate(y=y_test)[-1] + ) / 2 == 3 # test errors bcs = {"left": (pybamm.Scalar(0), "x"), "right": (pybamm.Scalar(3), "Neumann")} - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.add_ghost_nodes(var, discretised_symbol, bcs) - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.add_neumann_values(var, discretised_symbol, bcs, var.domain) bcs = {"left": (pybamm.Scalar(0), "Neumann"), "right": (pybamm.Scalar(3), "x")} - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.add_ghost_nodes(var, discretised_symbol, bcs) - with self.assertRaisesRegex(ValueError, "No boundary conditions"): + with pytest.raises(ValueError, match="No boundary conditions"): sp_meth.add_ghost_nodes(var, discretised_symbol, {}) - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.add_neumann_values(var, discretised_symbol, bcs, var.domain) def test_add_ghost_nodes_concatenation(self): @@ -92,22 +92,14 @@ def test_add_ghost_nodes_concatenation(self): symbol_plus_ghost_both.evaluate(None, y_test)[1:-1], discretised_symbol.evaluate(None, y_test), ) - self.assertEqual( - ( - symbol_plus_ghost_both.evaluate(None, y_test)[0] - + symbol_plus_ghost_both.evaluate(None, y_test)[1] - ) - / 2, - 0, - ) - self.assertEqual( - ( - symbol_plus_ghost_both.evaluate(None, y_test)[-2] - + symbol_plus_ghost_both.evaluate(None, y_test)[-1] - ) - / 2, - 3, - ) + assert ( + symbol_plus_ghost_both.evaluate(None, y_test)[0] + + symbol_plus_ghost_both.evaluate(None, y_test)[1] + ) / 2 == 0 + assert ( + symbol_plus_ghost_both.evaluate(None, y_test)[-2] + + symbol_plus_ghost_both.evaluate(None, y_test)[-1] + ) / 2 == 3 def test_p2d_add_ghost_nodes(self): # create discretisation @@ -187,13 +179,3 @@ def test_p2d_add_ghost_nodes(self): np.testing.assert_array_equal( (c_s_p_ghost_eval[:, -2] + c_s_p_ghost_eval[:, -1]) / 2, 3 ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_finite_volume/test_grad_div_shapes.py b/tests/unit/test_spatial_methods/test_finite_volume/test_grad_div_shapes.py index a1dd402f56..9e7f993e2a 100644 --- a/tests/unit/test_spatial_methods/test_finite_volume/test_grad_div_shapes.py +++ b/tests/unit/test_spatial_methods/test_finite_volume/test_grad_div_shapes.py @@ -10,10 +10,9 @@ get_cylindrical_mesh_for_testing, ) import numpy as np -import unittest -class TestFiniteVolumeGradDiv(unittest.TestCase): +class TestFiniteVolumeGradDiv: def test_grad_div_shapes_Dirichlet_bcs(self): """ Test grad and div with Dirichlet boundary conditions in Cartesian coordinates @@ -637,13 +636,3 @@ def test_grad_1plus1d(self): np.testing.assert_array_almost_equal( grad_eqn_disc.evaluate(None, linear_y), expected ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_finite_volume/test_integration.py b/tests/unit/test_spatial_methods/test_finite_volume/test_integration.py index e9730a8eb7..bf6f44059a 100644 --- a/tests/unit/test_spatial_methods/test_finite_volume/test_integration.py +++ b/tests/unit/test_spatial_methods/test_finite_volume/test_integration.py @@ -2,6 +2,7 @@ # Tests for integration using Finite Volume method # +import pytest import pybamm from tests import ( get_mesh_for_testing, @@ -9,10 +10,9 @@ get_cylindrical_mesh_for_testing, ) import numpy as np -import unittest -class TestFiniteVolumeIntegration(unittest.TestCase): +class TestFiniteVolumeIntegration: def test_definite_integral(self): # create discretisation mesh = get_mesh_for_testing(xpts=200, rpts=200) @@ -37,7 +37,7 @@ def test_definite_integral(self): submesh = mesh[("negative electrode", "separator")] constant_y = np.ones_like(submesh.nodes[:, np.newaxis]) - self.assertEqual(integral_eqn_disc.evaluate(None, constant_y), ln + ls) + assert integral_eqn_disc.evaluate(None, constant_y) == ln + ls linear_y = submesh.nodes np.testing.assert_array_almost_equal( integral_eqn_disc.evaluate(None, linear_y), (ln + ls) ** 2 / 2 @@ -56,10 +56,10 @@ def test_definite_integral(self): submesh = mesh[("separator", "positive electrode")] constant_y = np.ones_like(submesh.nodes[:, np.newaxis]) - self.assertEqual(integral_eqn_disc.evaluate(None, constant_y), ls + lp) + assert integral_eqn_disc.evaluate(None, constant_y) == ls + lp linear_y = submesh.nodes - self.assertAlmostEqual( - integral_eqn_disc.evaluate(None, linear_y)[0][0], (1 - (ln) ** 2) / 2 + assert integral_eqn_disc.evaluate(None, linear_y)[0][0] == pytest.approx( + (1 - (ln) ** 2) / 2 ) cos_y = np.cos(submesh.nodes[:, np.newaxis]) np.testing.assert_array_almost_equal( @@ -122,9 +122,9 @@ def test_definite_integral(self): # test failure for secondary dimension column form finite_volume = pybamm.FiniteVolume() finite_volume.build(mesh) - with self.assertRaisesRegex( + with pytest.raises( NotImplementedError, - "Integral in secondary vector only implemented in 'row' form", + match="Integral in secondary vector only implemented in 'row' form", ): finite_volume.definite_integral_matrix(var, "column", "secondary") @@ -293,14 +293,14 @@ def test_definite_integral_vector(self): # row (default) vec = pybamm.DefiniteIntegralVector(var) vec_disc = disc.process_symbol(vec) - self.assertEqual(vec_disc.shape[0], 1) - self.assertEqual(vec_disc.shape[1], mesh["negative electrode"].npts) + assert vec_disc.shape[0] == 1 + assert vec_disc.shape[1] == mesh["negative electrode"].npts # column vec = pybamm.DefiniteIntegralVector(var, vector_type="column") vec_disc = disc.process_symbol(vec) - self.assertEqual(vec_disc.shape[0], mesh["negative electrode"].npts) - self.assertEqual(vec_disc.shape[1], 1) + assert vec_disc.shape[0] == mesh["negative electrode"].npts + assert vec_disc.shape[1] == 1 def test_indefinite_integral(self): # create discretisation @@ -340,7 +340,7 @@ def test_indefinite_integral(self): phi_approx = int_grad_phi_disc.evaluate(None, phi_exact) phi_approx += 1 # add constant of integration np.testing.assert_array_almost_equal(phi_exact, phi_approx) - self.assertEqual(left_boundary_value_disc.evaluate(y=phi_exact), 0) + assert left_boundary_value_disc.evaluate(y=phi_exact) == 0 # linear case phi_exact = submesh.nodes[:, np.newaxis] phi_approx = int_grad_phi_disc.evaluate(None, phi_exact) @@ -380,7 +380,7 @@ def test_indefinite_integral(self): phi_approx = int_grad_phi_disc.evaluate(None, phi_exact) phi_approx += 1 # add constant of integration np.testing.assert_array_almost_equal(phi_exact, phi_approx) - self.assertEqual(left_boundary_value_disc.evaluate(y=phi_exact), 0) + assert left_boundary_value_disc.evaluate(y=phi_exact) == 0 # linear case phi_exact = submesh.nodes[:, np.newaxis] - submesh.edges[0] @@ -441,7 +441,7 @@ def test_indefinite_integral(self): c_approx = c_integral_disc.evaluate(None, c_exact) c_approx += 1 # add constant of integration np.testing.assert_array_almost_equal(c_exact, c_approx) - self.assertEqual(left_boundary_value_disc.evaluate(y=c_exact), 0) + assert left_boundary_value_disc.evaluate(y=c_exact) == 0 # linear case c_exact = submesh.nodes[:, np.newaxis] @@ -489,7 +489,7 @@ def test_backward_indefinite_integral(self): phi_approx = int_grad_phi_disc.evaluate(None, phi_exact) phi_approx += 1 # add constant of integration np.testing.assert_array_almost_equal(phi_exact, phi_approx) - self.assertEqual(right_boundary_value_disc.evaluate(y=phi_exact), 0) + assert right_boundary_value_disc.evaluate(y=phi_exact) == 0 # linear case phi_exact = submesh.nodes - submesh.edges[-1] @@ -583,9 +583,9 @@ def test_indefinite_integral_on_nodes(self): int_c = pybamm.IndefiniteIntegral(c, r) disc.set_variable_slices([c]) - with self.assertRaisesRegex( + with pytest.raises( NotImplementedError, - "Indefinite integral on a spherical polar domain is not implemented", + match="Indefinite integral on a spherical polar domain is not implemented", ): disc.process_symbol(int_c) @@ -655,13 +655,3 @@ def test_forward_plus_backward_integral(self): full_int_phi_disc.evaluate(y=phi_exact).flatten(), int_plus_back_int_phi_disc.evaluate(y=phi_exact).flatten(), ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_scikit_finite_element.py b/tests/unit/test_spatial_methods/test_scikit_finite_element.py index 18c941517b..42b282e08a 100644 --- a/tests/unit/test_spatial_methods/test_scikit_finite_element.py +++ b/tests/unit/test_spatial_methods/test_scikit_finite_element.py @@ -2,21 +2,21 @@ # Test for the operator class # +import pytest import pybamm from tests import get_2p1d_mesh_for_testing, get_unit_2p1D_mesh_for_testing import numpy as np -import unittest -class TestScikitFiniteElement(unittest.TestCase): +class TestScikitFiniteElement: def test_not_implemented(self): mesh = get_2p1d_mesh_for_testing(include_particles=False) spatial_method = pybamm.ScikitFiniteElement() spatial_method.build(mesh) - self.assertEqual(spatial_method.mesh, mesh) - with self.assertRaises(NotImplementedError): + assert spatial_method.mesh == mesh + with pytest.raises(NotImplementedError): spatial_method.divergence(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.indefinite_integral(None, None, None) def test_discretise_equations(self): @@ -100,7 +100,7 @@ def test_discretise_equations(self): "positive tab": (pybamm.Scalar(1), "Other BC"), } } - with self.assertRaises(ValueError): + with pytest.raises(ValueError): eqn_disc = disc.process_symbol(eqn) disc.bcs = { var: { @@ -108,19 +108,19 @@ def test_discretise_equations(self): "positive tab": (pybamm.Scalar(1), "Neumann"), } } - with self.assertRaises(ValueError): + with pytest.raises(ValueError): eqn_disc = disc.process_symbol(eqn) # raise ModelError if no BCs provided new_var = pybamm.Variable("new_var", domain="current collector") disc.set_variable_slices([new_var]) eqn = pybamm.laplacian(new_var) - with self.assertRaises(pybamm.ModelError): + with pytest.raises(pybamm.ModelError): eqn_disc = disc.process_symbol(eqn) # check GeometryError if using scikit-fem not in y or z x = pybamm.SpatialVariable("x", ["current collector"]) - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): disc.process_symbol(x) def test_gradient(self): @@ -389,14 +389,14 @@ def test_definite_integral_vector(self): # row (default) vec = pybamm.DefiniteIntegralVector(var) vec_disc = disc.process_symbol(vec) - self.assertEqual(vec_disc.shape[0], 1) - self.assertEqual(vec_disc.shape[1], mesh["current collector"].npts) + assert vec_disc.shape[0] == 1 + assert vec_disc.shape[1] == mesh["current collector"].npts # column vec = pybamm.DefiniteIntegralVector(var, vector_type="column") vec_disc = disc.process_symbol(vec) - self.assertEqual(vec_disc.shape[0], mesh["current collector"].npts) - self.assertEqual(vec_disc.shape[1], 1) + assert vec_disc.shape[0] == mesh["current collector"].npts + assert vec_disc.shape[1] == 1 def test_neg_pos(self): mesh = get_2p1d_mesh_for_testing(include_particles=False) @@ -423,7 +423,7 @@ def test_neg_pos(self): # test BoundaryGradient not implemented extrap_neg = pybamm.BoundaryGradient(var, "negative tab") - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): disc.process_symbol(extrap_neg) def test_boundary_integral(self): @@ -562,13 +562,3 @@ def test_disc_spatial_var(self): # spatial vars should discretise to the flattend meshgrid np.testing.assert_array_equal(y_disc.evaluate(), y_actual) np.testing.assert_array_equal(z_disc.evaluate(), z_actual) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_spectral_volume.py b/tests/unit/test_spatial_methods/test_spectral_volume.py index f6a631e84c..1fd97c2ebd 100644 --- a/tests/unit/test_spatial_methods/test_spectral_volume.py +++ b/tests/unit/test_spatial_methods/test_spectral_volume.py @@ -2,9 +2,9 @@ # Test for the operator class # +import pytest import pybamm import numpy as np -import unittest def get_mesh_for_testing( @@ -87,10 +87,10 @@ def get_1p1d_mesh_for_testing( ) -class TestSpectralVolume(unittest.TestCase): +class TestSpectralVolume: def test_exceptions(self): sp_meth = pybamm.SpectralVolume() - with self.assertRaises(ValueError): + with pytest.raises(ValueError): sp_meth.chebyshev_differentiation_matrices(3, 3) mesh = get_mesh_for_testing() @@ -104,14 +104,14 @@ def test_exceptions(self): sp_meth.build(mesh) bcs = {"left": (pybamm.Scalar(0), "x"), "right": (pybamm.Scalar(3), "Neumann")} - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.replace_dirichlet_values(var, discretised_symbol, bcs) - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.replace_neumann_values(var, discretised_symbol, bcs) bcs = {"left": (pybamm.Scalar(0), "Neumann"), "right": (pybamm.Scalar(3), "x")} - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.replace_dirichlet_values(var, discretised_symbol, bcs) - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.replace_neumann_values(var, discretised_symbol, bcs) def test_grad_div_shapes_Dirichlet_bcs(self): @@ -628,13 +628,3 @@ def test_grad_1plus1d(self): np.testing.assert_array_almost_equal( grad_eqn_disc.evaluate(None, linear_y), expected ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() From 35bcb78e6b4f9671b802569a1e93b8d0ce980753 Mon Sep 17 00:00:00 2001 From: Martin Robinson Date: Fri, 13 Sep 2024 16:44:30 +0100 Subject: [PATCH 19/23] feat: support sensitivities for pybamm.Simulation and pybamm.Experiment (#4415) * main changes relate to updating the `BaseSolver.step` function to support this * `BaseSolver.step` now can use the input Solution to initialise the sensitivities for the new step --------- Co-authored-by: Eric G. Kratz --- CHANGELOG.md | 3 + src/pybamm/simulation.py | 50 +++- src/pybamm/solvers/base_solver.py | 158 ++++++++++-- .../c_solvers/idaklu/IDAKLUSolverOpenMP.inl | 6 +- src/pybamm/solvers/casadi_algebraic_solver.py | 2 +- src/pybamm/solvers/casadi_solver.py | 8 +- src/pybamm/solvers/idaklu_solver.py | 2 +- src/pybamm/solvers/processed_variable.py | 70 +++--- src/pybamm/solvers/scipy_solver.py | 2 +- src/pybamm/solvers/solution.py | 227 +++++++++++++----- .../test_simulation_with_experiment.py | 77 ++++++ tests/unit/test_simulation.py | 43 ++++ tests/unit/test_solvers/test_casadi_solver.py | 2 +- tests/unit/test_solvers/test_solution.py | 36 ++- 14 files changed, 553 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 701603584c..80cda39db7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # [Unreleased](https://github.com/pybamm-team/PyBaMM/) +## Features +- Added sensitivity calculation support for `pybamm.Simulation` and `pybamm.Experiment` ([#4415](https://github.com/pybamm-team/PyBaMM/pull/4415)) + ## Optimizations - Removed the `start_step_offset` setting and disabled minimum `dt` warnings for drive cycles with the (`IDAKLUSolver`). ([#4416](https://github.com/pybamm-team/PyBaMM/pull/4416)) diff --git a/src/pybamm/simulation.py b/src/pybamm/simulation.py index 0aa85d1c20..da0ac08316 100644 --- a/src/pybamm/simulation.py +++ b/src/pybamm/simulation.py @@ -174,7 +174,7 @@ def _set_random_seed(self): % (2**32) ) - def set_up_and_parameterise_experiment(self): + def set_up_and_parameterise_experiment(self, solve_kwargs=None): """ Create and parameterise the models for each step in the experiment. @@ -182,6 +182,46 @@ def set_up_and_parameterise_experiment(self): reduces simulation time since the model formulation is efficient. """ parameter_values = self._parameter_values.copy() + + # some parameters are used to control the experiment, and should not be + # input parameters + restrict_list = {"Initial temperature [K]", "Ambient temperature [K]"} + for step in self.experiment.steps: + if issubclass(step.__class__, pybamm.experiment.step.BaseStepImplicit): + restrict_list.update(step.get_parameter_values([]).keys()) + elif issubclass(step.__class__, pybamm.experiment.step.BaseStepExplicit): + restrict_list.update(["Current function [A]"]) + for key in restrict_list: + if key in parameter_values.keys() and isinstance( + parameter_values[key], pybamm.InputParameter + ): + raise pybamm.ModelError( + f"Cannot use '{key}' as an input parameter in this experiment. " + f"This experiment is controlled via the following parameters: {restrict_list}. " + f"None of these parameters are able to be input parameters." + ) + + if ( + solve_kwargs is not None + and "calculate_sensitivities" in solve_kwargs + and solve_kwargs["calculate_sensitivities"] + ): + for step in self.experiment.steps: + if any( + [ + isinstance( + term, + pybamm.experiment.step.step_termination.BaseTermination, + ) + for term in step.termination + ] + ): + pybamm.logger.warning( + f"Step '{step}' has a termination condition based on an event. Sensitivity calculation will be inaccurate " + "if the time of each step event changes rapidly with respect to the parameters. " + ) + break + # Set the initial temperature to be the temperature of the first step # We can set this globally for all steps since any subsequent steps will either # start at the temperature at the end of the previous step (if non-isothermal @@ -303,7 +343,7 @@ def build(self, initial_soc=None, inputs=None): # rebuilt model so clear solver setup self._solver._model_set_up = {} - def build_for_experiment(self, initial_soc=None, inputs=None): + def build_for_experiment(self, initial_soc=None, inputs=None, solve_kwargs=None): """ Similar to :meth:`Simulation.build`, but for the case of simulating an experiment, where there may be several models and solvers to build. @@ -314,7 +354,7 @@ def build_for_experiment(self, initial_soc=None, inputs=None): if self.steps_to_built_models: return else: - self.set_up_and_parameterise_experiment() + self.set_up_and_parameterise_experiment(solve_kwargs) # Can process geometry with default parameter values (only electrical # parameters change between parameter values) @@ -497,7 +537,9 @@ def solve( elif self.operating_mode == "with experiment": callbacks.on_experiment_start(logs) - self.build_for_experiment(initial_soc=initial_soc, inputs=inputs) + self.build_for_experiment( + initial_soc=initial_soc, inputs=inputs, solve_kwargs=kwargs + ) if t_eval is not None: pybamm.logger.warning( "Ignoring t_eval as solution times are specified by the experiment" diff --git a/src/pybamm/solvers/base_solver.py b/src/pybamm/solvers/base_solver.py index 9c0d94f1a9..1df9aef35f 100644 --- a/src/pybamm/solvers/base_solver.py +++ b/src/pybamm/solvers/base_solver.py @@ -670,6 +670,33 @@ def calculate_consistent_state(self, model, time=0, inputs=None): y0 = root_sol.all_ys[0] return y0 + def _solve_process_calculate_sensitivities_arg( + inputs, model, calculate_sensitivities + ): + # get a list-only version of calculate_sensitivities + if isinstance(calculate_sensitivities, bool): + if calculate_sensitivities: + calculate_sensitivities_list = [p for p in inputs.keys()] + else: + calculate_sensitivities_list = [] + else: + calculate_sensitivities_list = calculate_sensitivities + + calculate_sensitivities_list.sort() + if not hasattr(model, "calculate_sensitivities"): + model.calculate_sensitivities = [] + + # Check that calculate_sensitivites have not been updated + sensitivities_have_changed = ( + calculate_sensitivities_list != model.calculate_sensitivities + ) + + # save sensitivity parameters so we can identify them later on + # (FYI: this is used in the Solution class) + model.calculate_sensitivities = calculate_sensitivities_list + + return calculate_sensitivities_list, sensitivities_have_changed + def solve( self, model, @@ -700,7 +727,11 @@ def solve( calculate_sensitivities : list of str or bool, optional Whether the solver calculates sensitivities of all input parameters. Defaults to False. If only a subset of sensitivities are required, can also pass a - list of input parameter names + list of input parameter names. **Limitations**: sensitivities are not calculated up to numerical tolerances + so are not guarenteed to be within the tolerances set by the solver, please raise an issue if you + require this functionality. Also, when using this feature with `pybamm.Experiment`, the sensitivities + do not take into account the movement of step-transitions wrt input parameters, so do not use this feature + if the timings of your experimental protocol change rapidly with respect to your input parameters. t_interp : None, list or ndarray, optional The times (in seconds) at which to interpolate the solution. Defaults to None. Only valid for solvers that support intra-solve interpolation (`IDAKLUSolver`). @@ -722,15 +753,6 @@ def solve( """ pybamm.logger.info(f"Start solving {model.name} with {self.name}") - # get a list-only version of calculate_sensitivities - if isinstance(calculate_sensitivities, bool): - if calculate_sensitivities: - calculate_sensitivities_list = [p for p in inputs.keys()] - else: - calculate_sensitivities_list = [] - else: - calculate_sensitivities_list = calculate_sensitivities - # Make sure model isn't empty self._check_empty_model(model) @@ -772,6 +794,12 @@ def solve( self._set_up_model_inputs(model, inputs) for inputs in inputs_list ] + calculate_sensitivities_list, sensitivities_have_changed = ( + BaseSolver._solve_process_calculate_sensitivities_arg( + model_inputs_list[0], model, calculate_sensitivities + ) + ) + # (Re-)calculate consistent initialization # Assuming initial conditions do not depend on input parameters # when len(inputs_list) > 1, only `model_inputs_list[0]` @@ -792,13 +820,8 @@ def solve( "for initial conditions." ) - # Check that calculate_sensitivites have not been updated - calculate_sensitivities_list.sort() - if hasattr(model, "calculate_sensitivities"): - model.calculate_sensitivities.sort() - else: - model.calculate_sensitivities = [] - if calculate_sensitivities_list != model.calculate_sensitivities: + # if any setup configuration has changed, we need to re-set up + if sensitivities_have_changed: self._model_set_up.pop(model, None) # CasadiSolver caches its integrators using model, so delete this too if isinstance(self, pybamm.CasadiSolver): @@ -1066,6 +1089,58 @@ def _check_events_with_initialization(t_eval, model, inputs_dict): f"Events {event_names} are non-positive at initial conditions" ) + def _set_sens_initial_conditions_from( + self, solution: pybamm.Solution, model: pybamm.BaseModel + ) -> tuple: + """ + A restricted version of BaseModel.set_initial_conditions_from that only extracts the + sensitivities from a solution object, and only for a model that has been descretised. + This is used when setting the initial conditions for a sensitivity model. + + Parameters + ---------- + solution : :class:`pybamm.Solution` + The solution to use to initialize the model + + model: :class:`pybamm.BaseModel` + The model whose sensitivities to set + + Returns + ------- + + initial_conditions : tuple of ndarray + The initial conditions for the sensitivities, each element of the tuple + corresponds to an input parameter + """ + + ninputs = len(model.calculate_sensitivities) + initial_conditions = tuple([] for _ in range(ninputs)) + solution = solution.last_state + for var in model.initial_conditions: + final_state = solution[var.name] + final_state = final_state.sensitivities + final_state_eval = tuple( + final_state[key] for key in model.calculate_sensitivities + ) + + scale, reference = var.scale.value, var.reference.value + for i in range(ninputs): + scaled_final_state_eval = (final_state_eval[i] - reference) / scale + initial_conditions[i].append(scaled_final_state_eval) + + # Also update the concatenated initial conditions if the model is already + # discretised + # Unpack slices for sorting + y_slices = {var: slce for var, slce in model.y_slices.items()} + slices = [y_slices[symbol][0] for symbol in model.initial_conditions.keys()] + + # sort equations according to slices + concatenated_initial_conditions = [ + casadi.vertcat(*[eq for _, eq in sorted(zip(slices, init))]) + for init in initial_conditions + ] + return concatenated_initial_conditions + def process_t_interp(self, t_interp): # set a variable for this no_interp = (not self.supports_interp) and ( @@ -1092,6 +1167,7 @@ def step( npts=None, inputs=None, save=True, + calculate_sensitivities=False, t_interp=None, ): """ @@ -1117,6 +1193,14 @@ def step( Any input parameters to pass to the model when solving save : bool, optional Save solution with all previous timesteps. Defaults to True. + calculate_sensitivities : list of str or bool, optional + Whether the solver calculates sensitivities of all input parameters. Defaults to False. + If only a subset of sensitivities are required, can also pass a + list of input parameter names. **Limitations**: sensitivities are not calculated up to numerical tolerances + so are not guarenteed to be within the tolerances set by the solver, please raise an issue if you + require this functionality. Also, when using this feature with `pybamm.Experiment`, the sensitivities + do not take into account the movement of step-transitions wrt input parameters, so do not use this feature + if the timings of your experimental protocol change rapidly with respect to your input parameters. t_interp : None, list or ndarray, optional The times (in seconds) at which to interpolate the solution. Defaults to None. Only valid for solvers that support intra-solve interpolation (`IDAKLUSolver`). @@ -1188,8 +1272,15 @@ def step( # Set up inputs model_inputs = self._set_up_model_inputs(model, inputs) + # process calculate_sensitivities argument + calculate_sensitivities_list, sensitivities_have_changed = ( + BaseSolver._solve_process_calculate_sensitivities_arg( + model_inputs, model, calculate_sensitivities + ) + ) + first_step_this_model = model not in self._model_set_up - if first_step_this_model: + if first_step_this_model or sensitivities_have_changed: if len(self._model_set_up) > 0: existing_model = next(iter(self._model_set_up)) raise RuntimeError( @@ -1208,18 +1299,45 @@ def step( ): pybamm.logger.verbose(f"Start stepping {model.name} with {self.name}") + using_sensitivities = len(model.calculate_sensitivities) > 0 + if isinstance(old_solution, pybamm.EmptySolution): if not first_step_this_model: # reset y0 to original initial conditions self.set_up(model, model_inputs, ics_only=True) elif old_solution.all_models[-1] == model: - # initialize with old solution - model.y0 = old_solution.all_ys[-1][:, -1] + last_state = old_solution.last_state + model.y0 = last_state.all_ys[0] + if using_sensitivities and isinstance(last_state._all_sensitivities, dict): + full_sens = last_state._all_sensitivities["all"][0] + model.y0S = tuple(full_sens[:, i] for i in range(full_sens.shape[1])) + else: _, concatenated_initial_conditions = model.set_initial_conditions_from( old_solution, return_type="ics" ) model.y0 = concatenated_initial_conditions.evaluate(0, inputs=model_inputs) + if using_sensitivities: + model.y0S = self._set_sens_initial_conditions_from(old_solution, model) + + # hopefully we'll get rid of explicit sensitivities soon so we can remove this + explicit_sensitivities = model.len_rhs_sens > 0 or model.len_alg_sens > 0 + if ( + explicit_sensitivities + and using_sensitivities + and not isinstance(old_solution, pybamm.EmptySolution) + and not old_solution.all_models[-1] == model + ): + y0_list = [] + if model.len_rhs > 0: + y0_list.append(model.y0[: model.len_rhs]) + for s in model.y0S: + y0_list.append(s[: model.len_rhs]) + if model.len_alg > 0: + y0_list.append(model.y0[model.len_rhs :]) + for s in model.y0S: + y0_list.append(s[model.len_rhs :]) + model.y0 = casadi.vertcat(*y0_list) set_up_time = timer.time() diff --git a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.inl b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.inl index 7ed4dcfad8..fd8eb38257 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.inl +++ b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.inl @@ -445,7 +445,7 @@ Solution IDAKLUSolverOpenMP::solve( } if (sensitivity) { - CheckErrors(IDAGetSens(ida_mem, &t_val, yyS)); + CheckErrors(IDAGetSensDky(ida_mem, t_val, 0, yyS)); } // Store Consistent initialization @@ -478,7 +478,7 @@ Solution IDAKLUSolverOpenMP::solve( bool hit_adaptive = save_adaptive_steps && retval == IDA_SUCCESS; if (sensitivity) { - CheckErrors(IDAGetSens(ida_mem, &t_val, yyS)); + CheckErrors(IDAGetSensDky(ida_mem, t_val, 0, yyS)); } if (hit_tinterp) { @@ -499,7 +499,7 @@ Solution IDAKLUSolverOpenMP::solve( // Reset the states and sensitivities at t = t_val CheckErrors(IDAGetDky(ida_mem, t_val, 0, yy)); if (sensitivity) { - CheckErrors(IDAGetSens(ida_mem, &t_val, yyS)); + CheckErrors(IDAGetSensDky(ida_mem, t_val, 0, yyS)); } } diff --git a/src/pybamm/solvers/casadi_algebraic_solver.py b/src/pybamm/solvers/casadi_algebraic_solver.py index cf44912952..2dd6f2d341 100644 --- a/src/pybamm/solvers/casadi_algebraic_solver.py +++ b/src/pybamm/solvers/casadi_algebraic_solver.py @@ -170,7 +170,7 @@ def _integrate(self, model, t_eval, inputs_dict=None, t_interp=None): model, inputs_dict, termination="final time", - sensitivities=explicit_sensitivities, + all_sensitivities=explicit_sensitivities, ) sol.integration_time = integration_time return sol diff --git a/src/pybamm/solvers/casadi_solver.py b/src/pybamm/solvers/casadi_solver.py index b4ac9d1561..89e20631dd 100644 --- a/src/pybamm/solvers/casadi_solver.py +++ b/src/pybamm/solvers/casadi_solver.py @@ -193,7 +193,7 @@ def _integrate(self, model, t_eval, inputs_dict=None, t_interp=None): y0, model, inputs_dict, - sensitivities=False, + all_sensitivities=False, ) solution.solve_time = 0 solution.integration_time = 0 @@ -478,7 +478,7 @@ def integer_bisect(): np.array([t_event]), y_event[:, np.newaxis], "event", - sensitivities=bool(model.calculate_sensitivities), + all_sensitivities=False, ) solution.integration_time = ( coarse_solution.integration_time + dense_step_sol.integration_time @@ -696,7 +696,7 @@ def _run_integrator( y_sol, model, inputs_dict, - sensitivities=extract_sensitivities_in_solution, + all_sensitivities=extract_sensitivities_in_solution, check_solution=False, ) sol.integration_time = integration_time @@ -736,7 +736,7 @@ def _run_integrator( y_sol, model, inputs_dict, - sensitivities=extract_sensitivities_in_solution, + all_sensitivities=extract_sensitivities_in_solution, check_solution=False, ) sol.integration_time = integration_time diff --git a/src/pybamm/solvers/idaklu_solver.py b/src/pybamm/solvers/idaklu_solver.py index 41e0c8855f..08f86b3264 100644 --- a/src/pybamm/solvers/idaklu_solver.py +++ b/src/pybamm/solvers/idaklu_solver.py @@ -818,7 +818,7 @@ def _integrate(self, model, t_eval, inputs_dict=None, t_interp=None): np.array([sol.t[-1]]), np.transpose(y_event)[:, np.newaxis], termination, - sensitivities=yS_out, + all_sensitivities=yS_out, ) newsol.integration_time = integration_time if not self.output_variables: diff --git a/src/pybamm/solvers/processed_variable.py b/src/pybamm/solvers/processed_variable.py index 8c1190c2f4..2464466348 100644 --- a/src/pybamm/solvers/processed_variable.py +++ b/src/pybamm/solvers/processed_variable.py @@ -66,7 +66,7 @@ def __init__( # Sensitivity starts off uninitialized, only set when called self._sensitivities = None - self.solution_sensitivities = solution.sensitivities + self.all_solution_sensitivities = solution._all_sensitivities # Store time self.t_pts = solution.t @@ -404,7 +404,7 @@ def sensitivities(self): return {} # Otherwise initialise and return sensitivities if self._sensitivities is None: - if self.solution_sensitivities != {}: + if self.all_solution_sensitivities: self.initialise_sensitivity_explicit_forward() else: raise ValueError( @@ -417,48 +417,54 @@ def sensitivities(self): def initialise_sensitivity_explicit_forward(self): "Set up the sensitivity dictionary" - inputs_stacked = self.all_inputs_casadi[0] - - # Set up symbolic variables - t_casadi = casadi.MX.sym("t") - y_casadi = casadi.MX.sym("y", self.all_ys[0].shape[0]) - p_casadi = { - name: casadi.MX.sym(name, value.shape[0]) - for name, value in self.all_inputs[0].items() - } - - p_casadi_stacked = casadi.vertcat(*[p for p in p_casadi.values()]) - # Convert variable to casadi format for differentiating - var_casadi = self.base_variables[0].to_casadi( - t_casadi, y_casadi, inputs=p_casadi - ) - dvar_dy = casadi.jacobian(var_casadi, y_casadi) - dvar_dp = casadi.jacobian(var_casadi, p_casadi_stacked) - - # Convert to functions and evaluate index-by-index - dvar_dy_func = casadi.Function( - "dvar_dy", [t_casadi, y_casadi, p_casadi_stacked], [dvar_dy] - ) - dvar_dp_func = casadi.Function( - "dvar_dp", [t_casadi, y_casadi, p_casadi_stacked], [dvar_dp] - ) - for index, (ts, ys) in enumerate(zip(self.all_ts, self.all_ys)): + all_S_var = [] + for ts, ys, inputs_stacked, inputs, base_variable, dy_dp in zip( + self.all_ts, + self.all_ys, + self.all_inputs_casadi, + self.all_inputs, + self.base_variables, + self.all_solution_sensitivities["all"], + ): + # Set up symbolic variables + t_casadi = casadi.MX.sym("t") + y_casadi = casadi.MX.sym("y", ys.shape[0]) + p_casadi = { + name: casadi.MX.sym(name, value.shape[0]) + for name, value in inputs.items() + } + + p_casadi_stacked = casadi.vertcat(*[p for p in p_casadi.values()]) + + # Convert variable to casadi format for differentiating + var_casadi = base_variable.to_casadi(t_casadi, y_casadi, inputs=p_casadi) + dvar_dy = casadi.jacobian(var_casadi, y_casadi) + dvar_dp = casadi.jacobian(var_casadi, p_casadi_stacked) + + # Convert to functions and evaluate index-by-index + dvar_dy_func = casadi.Function( + "dvar_dy", [t_casadi, y_casadi, p_casadi_stacked], [dvar_dy] + ) + dvar_dp_func = casadi.Function( + "dvar_dp", [t_casadi, y_casadi, p_casadi_stacked], [dvar_dp] + ) for idx, t in enumerate(ts): u = ys[:, idx] next_dvar_dy_eval = dvar_dy_func(t, u, inputs_stacked) next_dvar_dp_eval = dvar_dp_func(t, u, inputs_stacked) - if index == 0 and idx == 0: + if idx == 0: dvar_dy_eval = next_dvar_dy_eval dvar_dp_eval = next_dvar_dp_eval else: dvar_dy_eval = casadi.diagcat(dvar_dy_eval, next_dvar_dy_eval) dvar_dp_eval = casadi.vertcat(dvar_dp_eval, next_dvar_dp_eval) - # Compute sensitivity - dy_dp = self.solution_sensitivities["all"] - S_var = dvar_dy_eval @ dy_dp + dvar_dp_eval + # Compute sensitivity + S_var = dvar_dy_eval @ dy_dp + dvar_dp_eval + all_S_var.append(S_var) + S_var = casadi.vertcat(*all_S_var) sensitivities = {"all": S_var} # Add the individual sensitivity diff --git a/src/pybamm/solvers/scipy_solver.py b/src/pybamm/solvers/scipy_solver.py index 226b096887..daa8f706de 100644 --- a/src/pybamm/solvers/scipy_solver.py +++ b/src/pybamm/solvers/scipy_solver.py @@ -150,7 +150,7 @@ def event_fn(t, y): t_event, y_event, termination, - sensitivities=bool(model.calculate_sensitivities), + all_sensitivities=bool(model.calculate_sensitivities), ) sol.integration_time = integration_time return sol diff --git a/src/pybamm/solvers/solution.py b/src/pybamm/solvers/solution.py index c3c8451634..74d9ce7baf 100644 --- a/src/pybamm/solvers/solution.py +++ b/src/pybamm/solvers/solution.py @@ -2,6 +2,7 @@ # Solution class # import casadi +import copy import json import numbers import numpy as np @@ -57,11 +58,10 @@ class Solution: the event happens. termination : str String to indicate why the solution terminated - - sensitivities: bool or dict + all_sensitivities: bool or dict of lists True if sensitivities included as the solution of the explicit forwards equations. False if no sensitivities included/wanted. Dict if sensitivities are - provided as a dict of {parameter: sensitivities} pairs. + provided as a dict of {parameter: [sensitivities]} pairs. """ @@ -74,7 +74,7 @@ def __init__( t_event=None, y_event=None, termination="final time", - sensitivities=False, + all_sensitivities=False, check_solution=True, ): if not isinstance(all_ts, list): @@ -98,7 +98,18 @@ def __init__( else: self.all_inputs = all_inputs - self.sensitivities = sensitivities + if isinstance(all_sensitivities, bool): + self._all_sensitivities = all_sensitivities + elif isinstance(all_sensitivities, dict): + self._all_sensitivities = {} + for key, value in all_sensitivities.items(): + if isinstance(value, list): + self._all_sensitivities[key] = value + else: + self._all_sensitivities[key] = [value] + + else: + raise TypeError("sensitivities arg needs to be a bool or dict") # Check no ys are too large if check_solution: @@ -134,47 +145,31 @@ def __init__( # Solution now uses CasADi pybamm.citations.register("Andersson2019") - def extract_explicit_sensitivities(self): - # if we got here, we haven't set y yet - self.set_y() + def has_sensitivities(self) -> bool: + if isinstance(self._all_sensitivities, bool): + return self._all_sensitivities + elif isinstance(self._all_sensitivities, dict): + return len(self._all_sensitivities) > 0 - # extract sensitivities from full y solution - self._y, self._sensitivities = self._extract_explicit_sensitivities( - self.all_models[0], self.y, self.t, self.all_inputs[0] - ) + def extract_explicit_sensitivities(self): + self._all_sensitivities = {} - # make sure we remove all sensitivities from all_ys + # extract sensitivities from each sub-solution for index, (model, ys, ts, inputs) in enumerate( zip(self.all_models, self.all_ys, self.all_ts, self.all_inputs) ): - self._all_ys[index], _ = self._extract_explicit_sensitivities( + self._all_ys[index], sens_segment = self._extract_explicit_sensitivities( model, ys, ts, inputs ) + for key, value in sens_segment.items(): + if key in self._all_sensitivities: + self._all_sensitivities[key] = self._all_sensitivities[key] + [ + value + ] + else: + self._all_sensitivities[key] = [value] - def _extract_explicit_sensitivities(self, model, y, t_eval, inputs): - """ - given a model and a solution y, extracts the sensitivities - - Parameters - -------- - model : :class:`pybamm.BaseModel` - A model that has been already setup by this base solver - y: ndarray - The solution of the full explicit sensitivity equations - t_eval: ndarray - The evaluation times - inputs: dict - parameter inputs - - Returns - ------- - y: ndarray - The solution of the ode/dae in model - sensitivities: dict of (string: ndarray) - A dictionary of parameter names, and the corresponding solution of - the sensitivity equations - """ - + def _extract_sensitivity_matrix(self, model, y): n_states = model.len_rhs_and_alg n_rhs = model.len_rhs n_alg = model.len_alg @@ -185,7 +180,6 @@ def _extract_explicit_sensitivities(self, model, y, t_eval, inputs): n_p = model.len_alg_sens // model.len_alg len_rhs_and_sens = model.len_rhs + model.len_rhs_sens - n_t = len(t_eval) # y gets the part of the solution vector that correspond to the # actual ODE/DAE solution @@ -211,6 +205,8 @@ def _extract_explicit_sensitivities(self, model, y, t_eval, inputs): y_full = y.full() else: y_full = y + + n_t = y.shape[1] ode_sens = y_full[n_rhs:len_rhs_and_sens, :].reshape(n_p, n_rhs, n_t) alg_sens = y_full[len_rhs_and_sens + n_alg :, :].reshape(n_p, n_alg, n_t) # 2. Concatenate into a single 3D matrix with shape (n_p, n_states, n_t) @@ -221,6 +217,44 @@ def _extract_explicit_sensitivities(self, model, y, t_eval, inputs): n_t * n_states, n_p ) + # convert back to casadi (todo: this is not very efficient, should refactor + # to avoid this) + full_sens_matrix = casadi.DM(full_sens_matrix) + + y_dae = np.vstack( + [ + y[: model.len_rhs, :], + y[len_rhs_and_sens : len_rhs_and_sens + model.len_alg, :], + ] + ) + return y_dae, full_sens_matrix + + def _extract_explicit_sensitivities(self, model, y, t_eval, inputs): + """ + given a model and a solution y, extracts the sensitivities + + Parameters + -------- + model : :class:`pybamm.BaseModel` + A model that has been already setup by this base solver + y: ndarray + The solution of the full explicit sensitivity equations + t_eval: ndarray + The evaluation times + inputs: dict + parameter inputs + + Returns + ------- + y: ndarray + The solution of the ode/dae in model + sensitivities: dict of (string: ndarray) + A dictionary of parameter names, and the corresponding solution of + the sensitivity equations + """ + + y_dae, full_sens_matrix = self._extract_sensitivity_matrix(model, y) + # Save the full sensitivity matrix sensitivity = {"all": full_sens_matrix} @@ -234,12 +268,6 @@ def _extract_explicit_sensitivities(self, model, y, t_eval, inputs): sensitivity[name] = full_sens_matrix[:, start:end] start = end - y_dae = np.vstack( - [ - y[: model.len_rhs, :], - y[len_rhs_and_sens : len_rhs_and_sens + model.len_alg, :], - ] - ) return y_dae, sensitivity @property @@ -262,31 +290,56 @@ def y(self): try: return self._y except AttributeError: - self.set_y() - # if y is evaluated before sensitivities then need to extract them - if isinstance(self._sensitivities, bool) and self._sensitivities: + if isinstance(self._all_sensitivities, bool) and self._all_sensitivities: self.extract_explicit_sensitivities() + self.set_y() + return self._y @property def sensitivities(self): """Values of the sensitivities. Returns a dict of param_name: np_array""" - if isinstance(self._sensitivities, bool): - if self._sensitivities: - self.extract_explicit_sensitivities() - else: - self._sensitivities = {} + try: + return self._sensitivities + except AttributeError: + self.set_sensitivities() return self._sensitivities @sensitivities.setter def sensitivities(self, value): - """Updates the sensitivity""" + """Updates the sensitivity if False or True. Raises an error if sensitivities are a dict""" # sensitivities must be a dict or bool - if not isinstance(value, (bool, dict)): - raise TypeError("sensitivities arg needs to be a bool or dict") - self._sensitivities = value + if not isinstance(value, bool): + raise TypeError("sensitivities arg needs to be a bool") + + if isinstance(self._all_sensitivities, dict): + raise NotImplementedError( + "Setting sensitivities is not supported if sensitivities are " + "already provided as a dict of {parameter: sensitivities} pairs." + ) + + self._all_sensitivities = value + + def set_sensitivities(self): + if not self.has_sensitivities(): + self._sensitivities = {} + return + + # extract sensitivities if they are not already extracted + if isinstance(self._all_sensitivities, bool) and self._all_sensitivities: + self.extract_explicit_sensitivities() + + is_casadi = isinstance( + next(iter(self._all_sensitivities.values()))[0], (casadi.DM, casadi.MX) + ) + self._sensitivities = {} + for key, sens in self._all_sensitivities.items(): + if is_casadi: + self._sensitivities[key] = casadi.vertcat(*sens) + else: + self._sensitivities[key] = np.vstack(sens) def set_y(self): try: @@ -374,6 +427,13 @@ def first_state(self): than the full solution when only the first state is needed (e.g. to initialize a model with the solution) """ + if isinstance(self._all_sensitivities, bool): + sensitivities = self._all_sensitivities + elif isinstance(self._all_sensitivities, dict): + sensitivities = {} + n_states = self.all_models[0].len_rhs_and_alg + for key in self._all_sensitivities: + sensitivities[key] = self._all_sensitivities[key][0][-n_states:, :] new_sol = Solution( self.all_ts[0][:1], self.all_ys[0][:, :1], @@ -382,6 +442,7 @@ def first_state(self): None, None, "final time", + all_sensitivities=sensitivities, ) new_sol._all_inputs_casadi = self.all_inputs_casadi[:1] new_sol._sub_solutions = self.sub_solutions[:1] @@ -399,6 +460,13 @@ def last_state(self): than the full solution when only the final state is needed (e.g. to initialize a model with the solution) """ + if isinstance(self._all_sensitivities, bool): + sensitivities = self._all_sensitivities + elif isinstance(self._all_sensitivities, dict): + sensitivities = {} + n_states = self.all_models[-1].len_rhs_and_alg + for key in self._all_sensitivities: + sensitivities[key] = self._all_sensitivities[key][-1][-n_states:, :] new_sol = Solution( self.all_ts[-1][-1:], self.all_ys[-1][:, -1:], @@ -407,10 +475,10 @@ def last_state(self): self.t_event, self.y_event, self.termination, + all_sensitivities=sensitivities, ) new_sol._all_inputs_casadi = self.all_inputs_casadi[-1:] new_sol._sub_solutions = self.sub_solutions[-1:] - new_sol.solve_time = 0 new_sol.integration_time = 0 new_sol.set_up_time = 0 @@ -457,7 +525,7 @@ def set_summary_variables(self, all_summary_variables): def update(self, variables): """Add ProcessedVariables to the dictionary of variables in the solution""" # make sure that sensitivities are extracted if required - if isinstance(self._sensitivities, bool) and self._sensitivities: + if isinstance(self._all_sensitivities, bool) and self._all_sensitivities: self.extract_explicit_sensitivities() # Convert single entry to list @@ -758,6 +826,30 @@ def __add__(self, other): all_ts = self.all_ts + other.all_ts all_ys = self.all_ys + other.all_ys + # sensitivities can be: + # - bool if not using sensitivities or using explicit sensitivities which still + # need to be extracted + # - dict if sensitivities are provided as a dict of {parameter: sensitivities} + # both self and other should have the same type of sensitivities + # OR both can be either False or {} (i.e. no sensitivities) + if isinstance(self._all_sensitivities, bool) and isinstance( + other._all_sensitivities, bool + ): + all_sensitivities = self._all_sensitivities or other._all_sensitivities + elif isinstance(self._all_sensitivities, dict) and isinstance( + other._all_sensitivities, dict + ): + all_sensitivities = self._all_sensitivities + # we can assume that the keys are the same for both solutions + for key in other._all_sensitivities: + all_sensitivities[key] = ( + all_sensitivities[key] + other._all_sensitivities[key] + ) + elif not self._all_sensitivities and not other._all_sensitivities: + all_sensitivities = {} + else: + raise ValueError("Sensitivities must be of the same type") + new_sol = Solution( all_ts, all_ys, @@ -766,15 +858,19 @@ def __add__(self, other): other.t_event, other.y_event, other.termination, - bool(self.sensitivities), + all_sensitivities=all_sensitivities, ) new_sol.closest_event_idx = other.closest_event_idx new_sol._all_inputs_casadi = self.all_inputs_casadi + other.all_inputs_casadi - # Set solution time - new_sol.solve_time = self.solve_time + other.solve_time - new_sol.integration_time = self.integration_time + other.integration_time + # Add timers (if available) + for attr in ["solve_time", "integration_time", "set_up_time"]: + if ( + getattr(self, attr, None) is not None + and getattr(other, attr, None) is not None + ): + setattr(new_sol, attr, getattr(self, attr) + getattr(other, attr)) # Set sub_solutions new_sol._sub_solutions = self.sub_solutions + other.sub_solutions @@ -787,12 +883,14 @@ def __radd__(self, other): def copy(self): new_sol = self.__class__( self.all_ts, - self.all_ys, + # need to copy y in case it is modified by extract explicit sensitivities + [copy.copy(y) for y in self.all_ys], self.all_models, self.all_inputs, self.t_event, self.y_event, self.termination, + self._all_sensitivities, ) new_sol._all_inputs_casadi = self.all_inputs_casadi new_sol._sub_solutions = self.sub_solutions @@ -902,6 +1000,7 @@ def make_cycle_solution( sum_sols.t_event, sum_sols.y_event, sum_sols.termination, + sum_sols._all_sensitivities, ) cycle_solution._all_inputs_casadi = sum_sols.all_inputs_casadi cycle_solution._sub_solutions = sum_sols.sub_solutions diff --git a/tests/unit/test_experiments/test_simulation_with_experiment.py b/tests/unit/test_experiments/test_simulation_with_experiment.py index 3507d6e5c1..4f981ba04c 100644 --- a/tests/unit/test_experiments/test_simulation_with_experiment.py +++ b/tests/unit/test_experiments/test_simulation_with_experiment.py @@ -201,6 +201,83 @@ def test_run_experiment_cccv_solvers(self): ) self.assertEqual(solutions[1].termination, "final time") + @unittest.skipIf(not pybamm.has_idaklu(), "idaklu solver is not installed") + def test_solve_with_sensitivities_and_experiment(self): + experiment_2step = pybamm.Experiment( + [ + ( + "Discharge at C/20 for 1 hour", + "Charge at 1 A until 4.1 V", + "Hold at 4.1 V until C/2", + "Discharge at 2 W for 30 min", + "Discharge at 2 W for 30 min", # repeat to cover this case (changes initialisation) + ), + ] + * 2, + ) + + solutions = [] + for solver in [ + pybamm.CasadiSolver(), + pybamm.IDAKLUSolver(), + pybamm.ScipySolver(), + ]: + for calculate_sensitivities in [False, True]: + model = pybamm.lithium_ion.SPM() + param = model.default_parameter_values + input_param_name = "Negative electrode active material volume fraction" + input_param_value = param[input_param_name] + param.update({input_param_name: "[input]"}) + sim = pybamm.Simulation( + model, + experiment=experiment_2step, + solver=solver, + parameter_values=param, + ) + solution = sim.solve( + inputs={input_param_name: input_param_value}, + calculate_sensitivities=calculate_sensitivities, + ) + solutions.append(solution) + + # check solutions are the same, leave out the last solution point as it is slightly different + # for each solve due to numerical errors + # TODO: scipy solver does not work for this experiment, with or without sensitivities, + # so we skip this test for now + for i in range(1, len(solutions) - 2): + np.testing.assert_allclose( + solutions[0]["Voltage [V]"].data[:-1], + solutions[i]["Voltage [V]"](solutions[0].t[:-1]), + rtol=5e-2, + equal_nan=True, + ) + + # check sensitivities are roughly the same. Sundials isn't doing error control on the sensitivities + # by default, and the solution can be quite coarse for quickly changing sensitivities + sens_casadi = ( + solutions[1]["Voltage [V]"] + .sensitivities[input_param_name][:-2] + .full() + .flatten() + ) + sens_idaklu = np.interp( + solutions[1].t[:-2], + solutions[3].t, + solutions[3]["Voltage [V]"] + .sensitivities[input_param_name] + .full() + .flatten(), + ) + rtol = 1e-1 + atol = 1e-2 + error = np.sqrt( + np.sum( + ((sens_casadi - sens_idaklu) / (rtol * np.abs(sens_casadi) + atol)) ** 2 + ) + / len(sens_casadi) + ) + self.assertLess(error, 1.0) + def test_run_experiment_drive_cycle(self): drive_cycle = np.array([np.arange(10), np.arange(10)]).T experiment = pybamm.Experiment( diff --git a/tests/unit/test_simulation.py b/tests/unit/test_simulation.py index fc9fec9745..becd70cbe4 100644 --- a/tests/unit/test_simulation.py +++ b/tests/unit/test_simulation.py @@ -314,6 +314,17 @@ def ocv_with_parameter(sto): sim.solve([0, 3600], inputs={"a": 1}, initial_soc=0.8) assert sim._built_initial_soc == 0.8 + def test_restricted_input_params(self): + model = pybamm.lithium_ion.SPM() + parameter_values = model.default_parameter_values + parameter_values.update({"Initial temperature [K]": "[input]"}) + experiment = pybamm.Experiment(["Discharge at 1C until 2.5 V"]) + sim = pybamm.Simulation( + model, parameter_values=parameter_values, experiment=experiment + ) + with pytest.raises(pybamm.ModelError, match="Initial temperature"): + sim.solve([0, 3600]) + def test_esoh_with_input_param(self): # Test that initial soc works with a relevant input parameter model = pybamm.lithium_ion.DFN({"working electrode": "positive"}) @@ -336,6 +347,38 @@ def test_solve_with_inputs(self): sim.solution.all_inputs[0]["Current function [A]"], 1 ) + def test_solve_with_sensitivities(self): + model = pybamm.lithium_ion.SPM() + param = model.default_parameter_values + param.update({"Current function [A]": "[input]"}) + sim = pybamm.Simulation(model, parameter_values=param) + h = 1e-6 + sol1 = sim.solve( + t_eval=[0, 600], + inputs={"Current function [A]": 1}, + calculate_sensitivities=True, + ) + + # check that the sensitivities are stored + assert "Current function [A]" in sol1.sensitivities + + sol2 = sim.solve(t_eval=[0, 600], inputs={"Current function [A]": 1 + h}) + + # check that the sensitivities are not stored + assert "Current function [A]" not in sol2.sensitivities + + # check that the sensitivities are roughly correct + np.testing.assert_array_almost_equal( + sol1["Terminal voltage [V]"].entries + + h + * sol1["Terminal voltage [V]"] + .sensitivities["Current function [A]"] + .full() + .flatten(), + sol2["Terminal voltage [V]"].entries, + decimal=5, + ) + def test_step_with_inputs(self): dt = 0.001 model = pybamm.lithium_ion.SPM() diff --git a/tests/unit/test_solvers/test_casadi_solver.py b/tests/unit/test_solvers/test_casadi_solver.py index 3e1023c7d4..e6f631392a 100644 --- a/tests/unit/test_solvers/test_casadi_solver.py +++ b/tests/unit/test_solvers/test_casadi_solver.py @@ -1022,7 +1022,7 @@ def test_solve_sensitivity_algebraic(self): model, t_eval, inputs={"p": 0.1}, calculate_sensitivities=True ) np.testing.assert_array_equal(solution.t, t_eval) - np.testing.assert_allclose(solution.y[0], 0.1 * solution.t) + np.testing.assert_allclose(np.array(solution.y)[0], 0.1 * solution.t) np.testing.assert_allclose( solution.sensitivities["p"], solution.t.reshape(-1, 1), atol=1e-7 ) diff --git a/tests/unit/test_solvers/test_solution.py b/tests/unit/test_solvers/test_solution.py index 995898e8dd..5a584fabbf 100644 --- a/tests/unit/test_solvers/test_solution.py +++ b/tests/unit/test_solvers/test_solution.py @@ -52,6 +52,20 @@ def test_errors(self): pybamm.Solution(ts, bad_ys, model, {}) self.assertIn("exceeds the maximum", captured.records[0].getMessage()) + with self.assertRaisesRegex( + TypeError, "sensitivities arg needs to be a bool or dict" + ): + pybamm.Solution(ts, bad_ys, model, {}, all_sensitivities="bad") + + sol = pybamm.Solution(ts, bad_ys, model, {}, all_sensitivities={}) + with self.assertRaisesRegex(TypeError, "sensitivities arg needs to be a bool"): + sol.sensitivities = "bad" + with self.assertRaisesRegex( + NotImplementedError, + "Setting sensitivities is not supported if sensitivities are already provided as a dict", + ): + sol.sensitivities = True + def test_add_solutions(self): # Set up first solution t1 = np.linspace(0, 1) @@ -89,7 +103,7 @@ def test_add_solutions(self): # Add solution already contained in existing solution t3 = np.array([2]) - y3 = np.ones((20, 1)) + y3 = np.ones((1, 1)) sol3 = pybamm.Solution(t3, y3, pybamm.BaseModel(), {"a": 3}) self.assertEqual((sol_sum + sol3).all_ts, sol_sum.copy().all_ts) @@ -111,6 +125,23 @@ def test_add_solutions(self): ): 2 + sol3 + sol1 = pybamm.Solution( + t1, + y1, + pybamm.BaseModel(), + {}, + all_sensitivities={"test": [np.ones((1, 3))]}, + ) + sol2 = pybamm.Solution(t2, y2, pybamm.BaseModel(), {}, all_sensitivities=True) + with self.assertRaisesRegex( + ValueError, "Sensitivities must be of the same type" + ): + sol3 = sol1 + sol2 + sol1 = pybamm.Solution(t1, y3, pybamm.BaseModel(), {}, all_sensitivities=False) + sol2 = pybamm.Solution(t3, y3, pybamm.BaseModel(), {}, all_sensitivities={}) + sol3 = sol1 + sol2 + self.assertFalse(sol3._all_sensitivities) + def test_add_solutions_different_models(self): # Set up first solution t1 = np.linspace(0, 1) @@ -146,7 +177,8 @@ def test_copy(self): sol_copy = sol1.copy() self.assertEqual(sol_copy.all_ts, sol1.all_ts) - self.assertEqual(sol_copy.all_ys, sol1.all_ys) + for ys_copy, ys1 in zip(sol_copy.all_ys, sol1.all_ys): + np.testing.assert_array_equal(ys_copy, ys1) self.assertEqual(sol_copy.all_inputs, sol1.all_inputs) self.assertEqual(sol_copy.all_inputs_casadi, sol1.all_inputs_casadi) self.assertEqual(sol_copy.set_up_time, sol1.set_up_time) From 8e3eb31d71474b3c304d9be707ba0084329f0516 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 16:00:04 -0400 Subject: [PATCH 20/23] Build(deps): bump github/codeql-action in the actions group (#4444) Bumps the actions group with 1 update: [github/codeql-action](https://github.com/github/codeql-action). Updates `github/codeql-action` from 3.26.6 to 3.26.7 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/4dd16135b69a43b6c8efb853346f8437d92d3c93...8214744c546c1e5c8f03dde8fab3a7353211988d) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 784ccebc4f..efc8b23141 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -68,6 +68,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@4dd16135b69a43b6c8efb853346f8437d92d3c93 # v3.26.6 + uses: github/codeql-action/upload-sarif@8214744c546c1e5c8f03dde8fab3a7353211988d # v3.26.7 with: sarif_file: results.sarif From 05a0b240b7208adf989ea3afe05ca9065a118570 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 22:08:33 +0100 Subject: [PATCH 21/23] chore: update pre-commit hooks (#4445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.4 → v0.6.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.4...v0.6.5) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 985cd0291a..2135fa3c71 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.6.4" + rev: "v0.6.5" hooks: - id: ruff args: [--fix, --show-fixes] From e1118ecfaf18ea479d881a92fe051b7c73372d6c Mon Sep 17 00:00:00 2001 From: Marc Berliner <34451391+MarcBerliner@users.noreply.github.com> Date: Wed, 18 Sep 2024 12:35:47 -0400 Subject: [PATCH 22/23] Update CODEOWNERS (#4452) --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0f503f09a7..6984abf32c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -10,7 +10,7 @@ src/pybamm/meshes/ @martinjrobins @rtimms @valentinsulzer @rtimms src/pybamm/models/ @brosaplanella @DrSOKane @rtimms @valentinsulzer @TomTranter @rtimms src/pybamm/parameters/ @brosaplanella @DrSOKane @rtimms @valentinsulzer @TomTranter @rtimms @kratman src/pybamm/plotting/ @martinjrobins @rtimms @Saransh-cpp @valentinsulzer @rtimms @kratman @agriyakhetarpal -src/pybamm/solvers/ @martinjrobins @rtimms @valentinsulzer @TomTranter @rtimms +src/pybamm/solvers/ @martinjrobins @rtimms @valentinsulzer @TomTranter @rtimms @MarcBerliner src/pybamm/spatial_methods/ @martinjrobins @rtimms @valentinsulzer @rtimms src/pybamm/* @pybamm-team/maintainers # the files directly under /pybamm/, will not recurse From 48dbb68b56689fbe4e1dadd2838076fc1db68bc4 Mon Sep 17 00:00:00 2001 From: Martin Robinson Date: Wed, 18 Sep 2024 22:21:38 +0100 Subject: [PATCH 23/23] feat: add OpenMP parallelization to IDAKLU solver for lists of input parameters (#4449) * new solver option `num_solvers`, indicates how many solves run in parallel * existing `num_threads` gives total number of threads which are distributed among `num_solvers` --- CHANGELOG.md | 1 + CMakeLists.txt | 23 ++- src/pybamm/solvers/base_solver.py | 59 +++--- src/pybamm/solvers/c_solvers/idaklu.cpp | 15 +- .../solvers/c_solvers/idaklu/IDAKLUSolver.hpp | 20 +- .../c_solvers/idaklu/IDAKLUSolverGroup.cpp | 145 +++++++++++++++ .../c_solvers/idaklu/IDAKLUSolverGroup.hpp | 48 +++++ .../c_solvers/idaklu/IDAKLUSolverOpenMP.hpp | 17 +- .../c_solvers/idaklu/IDAKLUSolverOpenMP.inl | 174 ++++-------------- .../solvers/c_solvers/idaklu/Options.cpp | 17 ++ .../solvers/c_solvers/idaklu/Options.hpp | 1 + .../solvers/c_solvers/idaklu/Solution.hpp | 10 + .../solvers/c_solvers/idaklu/SolutionData.cpp | 67 +++++++ .../solvers/c_solvers/idaklu/SolutionData.hpp | 73 ++++++++ .../solvers/c_solvers/idaklu/common.cpp | 16 +- .../solvers/c_solvers/idaklu/common.hpp | 24 ++- .../c_solvers/idaklu/idaklu_solver.hpp | 122 ++++++++---- src/pybamm/solvers/idaklu_solver.py | 52 ++++-- src/pybamm/solvers/jax_solver.py | 8 +- tests/unit/test_solvers/test_idaklu_solver.py | 41 +++++ 20 files changed, 677 insertions(+), 256 deletions(-) create mode 100644 src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.cpp create mode 100644 src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.hpp create mode 100644 src/pybamm/solvers/c_solvers/idaklu/SolutionData.cpp create mode 100644 src/pybamm/solvers/c_solvers/idaklu/SolutionData.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 80cda39db7..b65d1342f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Features - Added sensitivity calculation support for `pybamm.Simulation` and `pybamm.Experiment` ([#4415](https://github.com/pybamm-team/PyBaMM/pull/4415)) +- Added OpenMP parallelization to IDAKLU solver for lists of input parameters ([#4449](https://github.com/pybamm-team/PyBaMM/pull/4449)) ## Optimizations - Removed the `start_step_offset` setting and disabled minimum `dt` warnings for drive cycles with the (`IDAKLUSolver`). ([#4416](https://github.com/pybamm-team/PyBaMM/pull/4416)) diff --git a/CMakeLists.txt b/CMakeLists.txt index ad56ac34ca..a7f68ce7a0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,7 +19,7 @@ endif() project(idaklu) -set(CMAKE_CXX_STANDARD 14) +set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_EXPORT_COMPILE_COMMANDS 1) @@ -82,6 +82,8 @@ pybind11_add_module(idaklu src/pybamm/solvers/c_solvers/idaklu/idaklu_solver.hpp src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolver.cpp src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolver.hpp + src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.cpp + src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.hpp src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.inl src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.hpp src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP_solvers.cpp @@ -94,6 +96,8 @@ pybind11_add_module(idaklu src/pybamm/solvers/c_solvers/idaklu/common.cpp src/pybamm/solvers/c_solvers/idaklu/Solution.cpp src/pybamm/solvers/c_solvers/idaklu/Solution.hpp + src/pybamm/solvers/c_solvers/idaklu/SolutionData.cpp + src/pybamm/solvers/c_solvers/idaklu/SolutionData.hpp src/pybamm/solvers/c_solvers/idaklu/Options.hpp src/pybamm/solvers/c_solvers/idaklu/Options.cpp # IDAKLU expressions / function evaluation [abstract] @@ -138,6 +142,23 @@ set_target_properties( INSTALL_RPATH_USE_LINK_PATH TRUE ) +# openmp +if (${CMAKE_SYSTEM_NAME} MATCHES "Darwin") + execute_process( + COMMAND "brew" "--prefix" + OUTPUT_VARIABLE HOMEBREW_PREFIX + OUTPUT_STRIP_TRAILING_WHITESPACE) + if (OpenMP_ROOT) + set(OpenMP_ROOT "${OpenMP_ROOT}:${HOMEBREW_PREFIX}/opt/libomp") + else() + set(OpenMP_ROOT "${HOMEBREW_PREFIX}/opt/libomp") + endif() +endif() +find_package(OpenMP) +if(OpenMP_CXX_FOUND) + target_link_libraries(idaklu PRIVATE OpenMP::OpenMP_CXX) +endif() + set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${PROJECT_SOURCE_DIR}) # Sundials find_package(SUNDIALS REQUIRED) diff --git a/src/pybamm/solvers/base_solver.py b/src/pybamm/solvers/base_solver.py index 1df9aef35f..dc1ac0cd72 100644 --- a/src/pybamm/solvers/base_solver.py +++ b/src/pybamm/solvers/base_solver.py @@ -86,6 +86,10 @@ def supports_interp(self): def root_method(self): return self._root_method + @property + def supports_parallel_solve(self): + return False + @root_method.setter def root_method(self, method): if method == "casadi": @@ -896,17 +900,8 @@ def solve( pybamm.logger.verbose( f"Calling solver for {t_eval[start_index]} < t < {t_eval[end_index - 1]}" ) - ninputs = len(model_inputs_list) - if ninputs == 1: - new_solution = self._integrate( - model, - t_eval[start_index:end_index], - model_inputs_list[0], - t_interp=t_interp, - ) - new_solutions = [new_solution] - elif model.convert_to_format == "jax": - # Jax can parallelize over the inputs efficiently + if self.supports_parallel_solve: + # Jax and IDAKLU solver can accept a list of inputs new_solutions = self._integrate( model, t_eval[start_index:end_index], @@ -914,18 +909,28 @@ def solve( t_interp, ) else: - with mp.get_context(self._mp_context).Pool(processes=nproc) as p: - new_solutions = p.starmap( - self._integrate, - zip( - [model] * ninputs, - [t_eval[start_index:end_index]] * ninputs, - model_inputs_list, - [t_interp] * ninputs, - ), + ninputs = len(model_inputs_list) + if ninputs == 1: + new_solution = self._integrate( + model, + t_eval[start_index:end_index], + model_inputs_list[0], + t_interp=t_interp, ) - p.close() - p.join() + new_solutions = [new_solution] + else: + with mp.get_context(self._mp_context).Pool(processes=nproc) as p: + new_solutions = p.starmap( + self._integrate, + zip( + [model] * ninputs, + [t_eval[start_index:end_index]] * ninputs, + model_inputs_list, + [t_interp] * ninputs, + ), + ) + p.close() + p.join() # Setting the solve time for each segment. # pybamm.Solution.__add__ assumes attribute solve_time. solve_time = timer.time() @@ -995,7 +1000,7 @@ def solve( ) # Return solution(s) - if ninputs == 1: + if len(solutions) == 1: return solutions[0] else: return solutions @@ -1350,7 +1355,13 @@ def step( # Step pybamm.logger.verbose(f"Stepping for {t_start_shifted:.0f} < t < {t_end:.0f}") timer.reset() - solution = self._integrate(model, t_eval, model_inputs, t_interp) + + # API for _integrate is different for JaxSolver and IDAKLUSolver + if self.supports_parallel_solve: + solutions = self._integrate(model, t_eval, [model_inputs], t_interp) + solution = solutions[0] + else: + solution = self._integrate(model, t_eval, model_inputs, t_interp) solution.solve_time = timer.time() # Check if extrapolation occurred diff --git a/src/pybamm/solvers/c_solvers/idaklu.cpp b/src/pybamm/solvers/c_solvers/idaklu.cpp index 3ef0194403..db7147feb2 100644 --- a/src/pybamm/solvers/c_solvers/idaklu.cpp +++ b/src/pybamm/solvers/c_solvers/idaklu.cpp @@ -9,6 +9,7 @@ #include #include "idaklu/idaklu_solver.hpp" +#include "idaklu/IDAKLUSolverGroup.hpp" #include "idaklu/IdakluJax.hpp" #include "idaklu/common.hpp" #include "idaklu/Expressions/Casadi/CasadiFunctions.hpp" @@ -26,15 +27,17 @@ casadi::Function generate_casadi_function(const std::string &data) namespace py = pybind11; PYBIND11_MAKE_OPAQUE(std::vector); +PYBIND11_MAKE_OPAQUE(std::vector); PYBIND11_MODULE(idaklu, m) { m.doc() = "sundials solvers"; // optional module docstring py::bind_vector>(m, "VectorNdArray"); + py::bind_vector>(m, "VectorSolution"); - py::class_(m, "IDAKLUSolver") - .def("solve", &IDAKLUSolver::solve, + py::class_(m, "IDAKLUSolverGroup") + .def("solve", &IDAKLUSolverGroup::solve, "perform a solve", py::arg("t_eval"), py::arg("t_interp"), @@ -43,8 +46,8 @@ PYBIND11_MODULE(idaklu, m) py::arg("inputs"), py::return_value_policy::take_ownership); - m.def("create_casadi_solver", &create_idaklu_solver, - "Create a casadi idaklu solver object", + m.def("create_casadi_solver_group", &create_idaklu_solver_group, + "Create a group of casadi idaklu solver objects", py::arg("number_of_states"), py::arg("number_of_parameters"), py::arg("rhs_alg"), @@ -70,8 +73,8 @@ PYBIND11_MODULE(idaklu, m) py::return_value_policy::take_ownership); #ifdef IREE_ENABLE - m.def("create_iree_solver", &create_idaklu_solver, - "Create a iree idaklu solver object", + m.def("create_iree_solver_group", &create_idaklu_solver_group, + "Create a group of iree idaklu solver objects", py::arg("number_of_states"), py::arg("number_of_parameters"), py::arg("rhs_alg"), diff --git a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolver.hpp b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolver.hpp index 29b451e6d3..379d64783a 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolver.hpp +++ b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolver.hpp @@ -2,7 +2,8 @@ #define PYBAMM_IDAKLU_CASADI_SOLVER_HPP #include "common.hpp" -#include "Solution.hpp" +#include "SolutionData.hpp" + /** * Abstract base class for solutions that can use different solvers and vector @@ -24,14 +25,17 @@ class IDAKLUSolver ~IDAKLUSolver() = default; /** - * @brief Abstract solver method that returns a Solution class + * @brief Abstract solver method that executes the solver */ - virtual Solution solve( - np_array t_eval_np, - np_array t_interp_np, - np_array y0_np, - np_array yp0_np, - np_array_dense inputs) = 0; + virtual SolutionData solve( + const std::vector &t_eval, + const std::vector &t_interp, + const realtype *y0, + const realtype *yp0, + const realtype *inputs, + bool save_adaptive_steps, + bool save_interp_steps + ) = 0; /** * Abstract method to initialize the solver, once vectors and solver classes diff --git a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.cpp b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.cpp new file mode 100644 index 0000000000..8a76d73cfe --- /dev/null +++ b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.cpp @@ -0,0 +1,145 @@ +#include "IDAKLUSolverGroup.hpp" +#include +#include + +std::vector IDAKLUSolverGroup::solve( + np_array t_eval_np, + np_array t_interp_np, + np_array y0_np, + np_array yp0_np, + np_array inputs) { + DEBUG("IDAKLUSolverGroup::solve"); + + // If t_interp is empty, save all adaptive steps + bool save_adaptive_steps = t_interp_np.size() == 0; + + const realtype* t_eval_begin = t_eval_np.data(); + const realtype* t_eval_end = t_eval_begin + t_eval_np.size(); + const realtype* t_interp_begin = t_interp_np.data(); + const realtype* t_interp_end = t_interp_begin + t_interp_np.size(); + + // Process the time inputs + // 1. Get the sorted and unique t_eval vector + auto const t_eval = makeSortedUnique(t_eval_begin, t_eval_end); + + // 2.1. Get the sorted and unique t_interp vector + auto const t_interp_unique_sorted = makeSortedUnique(t_interp_begin, t_interp_end); + + // 2.2 Remove the t_eval values from t_interp + auto const t_interp_setdiff = setDiff(t_interp_unique_sorted.begin(), t_interp_unique_sorted.end(), t_eval_begin, t_eval_end); + + // 2.3 Finally, get the sorted and unique t_interp vector with t_eval values removed + auto const t_interp = makeSortedUnique(t_interp_setdiff.begin(), t_interp_setdiff.end()); + + int const number_of_evals = t_eval.size(); + int const number_of_interps = t_interp.size(); + + // setDiff removes entries of t_interp that overlap with + // t_eval, so we need to check if we need to interpolate any unique points. + // This is not the same as save_adaptive_steps since some entries of t_interp + // may be removed by setDiff + bool save_interp_steps = number_of_interps > 0; + + // 3. Check if the timestepping entries are valid + if (number_of_evals < 2) { + throw std::invalid_argument( + "t_eval must have at least 2 entries" + ); + } else if (save_interp_steps) { + if (t_interp.front() < t_eval.front()) { + throw std::invalid_argument( + "t_interp values must be greater than the smallest t_eval value: " + + std::to_string(t_eval.front()) + ); + } else if (t_interp.back() > t_eval.back()) { + throw std::invalid_argument( + "t_interp values must be less than the greatest t_eval value: " + + std::to_string(t_eval.back()) + ); + } + } + + auto n_coeffs = number_of_states + number_of_parameters * number_of_states; + + // check y0 and yp0 and inputs have the correct dimensions + if (y0_np.ndim() != 2) + throw std::domain_error("y0 has wrong number of dimensions. Expected 2 but got " + std::to_string(y0_np.ndim())); + if (yp0_np.ndim() != 2) + throw std::domain_error("yp0 has wrong number of dimensions. Expected 2 but got " + std::to_string(yp0_np.ndim())); + if (inputs.ndim() != 2) + throw std::domain_error("inputs has wrong number of dimensions. Expected 2 but got " + std::to_string(inputs.ndim())); + + auto number_of_groups = y0_np.shape()[0]; + + // check y0 and yp0 and inputs have the correct shape + if (y0_np.shape()[1] != n_coeffs) + throw std::domain_error( + "y0 has wrong number of cols. Expected " + std::to_string(n_coeffs) + + " but got " + std::to_string(y0_np.shape()[1])); + + if (yp0_np.shape()[1] != n_coeffs) + throw std::domain_error( + "yp0 has wrong number of cols. Expected " + std::to_string(n_coeffs) + + " but got " + std::to_string(yp0_np.shape()[1])); + + if (yp0_np.shape()[0] != number_of_groups) + throw std::domain_error( + "yp0 has wrong number of rows. Expected " + std::to_string(number_of_groups) + + " but got " + std::to_string(yp0_np.shape()[0])); + + if (inputs.shape()[0] != number_of_groups) + throw std::domain_error( + "inputs has wrong number of rows. Expected " + std::to_string(number_of_groups) + + " but got " + std::to_string(inputs.shape()[0])); + + const std::size_t solves_per_thread = number_of_groups / m_solvers.size(); + const std::size_t remainder_solves = number_of_groups % m_solvers.size(); + + const realtype *y0 = y0_np.data(); + const realtype *yp0 = yp0_np.data(); + const realtype *inputs_data = inputs.data(); + + std::vector results(number_of_groups); + + std::optional exception; + + omp_set_num_threads(m_solvers.size()); + #pragma omp parallel for + for (int i = 0; i < m_solvers.size(); i++) { + try { + for (int j = 0; j < solves_per_thread; j++) { + const std::size_t index = i * solves_per_thread + j; + const realtype *y = y0 + index * y0_np.shape(1); + const realtype *yp = yp0 + index * yp0_np.shape(1); + const realtype *input = inputs_data + index * inputs.shape(1); + results[index] = m_solvers[i]->solve(t_eval, t_interp, y, yp, input, save_adaptive_steps, save_interp_steps); + } + } catch (std::exception &e) { + // If an exception is thrown, we need to catch it and rethrow it outside the parallel region + #pragma omp critical + { + exception = e; + } + } + } + + if (exception.has_value()) { + py::set_error(PyExc_ValueError, exception->what()); + throw py::error_already_set(); + } + + for (int i = 0; i < remainder_solves; i++) { + const std::size_t index = number_of_groups - remainder_solves + i; + const realtype *y = y0 + index * y0_np.shape(1); + const realtype *yp = yp0 + index * yp0_np.shape(1); + const realtype *input = inputs_data + index * inputs.shape(1); + results[index] = m_solvers[i]->solve(t_eval, t_interp, y, yp, input, save_adaptive_steps, save_interp_steps); + } + + // create solutions (needs to be serial as we're using the Python GIL) + std::vector solutions(number_of_groups); + for (int i = 0; i < number_of_groups; i++) { + solutions[i] = results[i].generate_solution(); + } + return solutions; +} diff --git a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.hpp b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.hpp new file mode 100644 index 0000000000..609b3b6fca --- /dev/null +++ b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.hpp @@ -0,0 +1,48 @@ +#ifndef PYBAMM_IDAKLU_SOLVER_GROUP_HPP +#define PYBAMM_IDAKLU_SOLVER_GROUP_HPP + +#include "IDAKLUSolver.hpp" +#include "common.hpp" + +/** + * @brief class for a group of solvers. + */ +class IDAKLUSolverGroup +{ +public: + + /** + * @brief Default constructor + */ + IDAKLUSolverGroup(std::vector> solvers, int number_of_states, int number_of_parameters): + m_solvers(std::move(solvers)), + number_of_states(number_of_states), + number_of_parameters(number_of_parameters) + {} + + // no copy constructor (unique_ptr cannot be copied) + IDAKLUSolverGroup(IDAKLUSolverGroup &) = delete; + + /** + * @brief Default destructor + */ + ~IDAKLUSolverGroup() = default; + + /** + * @brief solver method that returns a vector of Solutions + */ + std::vector solve( + np_array t_eval_np, + np_array t_interp_np, + np_array y0_np, + np_array yp0_np, + np_array inputs); + + + private: + std::vector> m_solvers; + int number_of_states; + int number_of_parameters; +}; + +#endif // PYBAMM_IDAKLU_SOLVER_GROUP_HPP diff --git a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.hpp b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.hpp index ca710fbff6..36c2872c3e 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.hpp +++ b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.hpp @@ -52,6 +52,7 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver int const number_of_states; // cppcheck-suppress unusedStructMember int const number_of_parameters; // cppcheck-suppress unusedStructMember int const number_of_events; // cppcheck-suppress unusedStructMember + int number_of_timesteps; int precon_type; // cppcheck-suppress unusedStructMember N_Vector yy, yp, avtol; // y, y', and absolute tolerance N_Vector *yyS; // cppcheck-suppress unusedStructMember @@ -106,12 +107,16 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver /** * @brief The main solve method that solves for each variable and time step */ - Solution solve( - np_array t_eval_np, - np_array t_interp_np, - np_array y0_np, - np_array yp0_np, - np_array_dense inputs) override; + SolutionData solve( + const std::vector &t_eval, + const std::vector &t_interp, + const realtype *y0, + const realtype *yp0, + const realtype *inputs, + bool save_adaptive_steps, + bool save_interp_steps + ) override; + /** * @brief Concrete implementation of initialization method diff --git a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.inl b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.inl index fd8eb38257..313c4ce12a 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.inl +++ b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.inl @@ -3,6 +3,7 @@ #include #include "common.hpp" +#include "SolutionData.hpp" template IDAKLUSolverOpenMP::IDAKLUSolverOpenMP( @@ -86,11 +87,20 @@ IDAKLUSolverOpenMP::IDAKLUSolverOpenMP( template void IDAKLUSolverOpenMP::AllocateVectors() { + DEBUG("IDAKLUSolverOpenMP::AllocateVectors (num_threads = " << setup_opts.num_threads << ")"); // Create vectors - yy = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); - yp = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); - avtol = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); - id = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); + if (setup_opts.num_threads == 1) { + yy = N_VNew_Serial(number_of_states, sunctx); + yp = N_VNew_Serial(number_of_states, sunctx); + avtol = N_VNew_Serial(number_of_states, sunctx); + id = N_VNew_Serial(number_of_states, sunctx); + } else { + DEBUG("IDAKLUSolverOpenMP::AllocateVectors OpenMP"); + yy = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); + yp = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); + avtol = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); + id = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); + } } template @@ -290,6 +300,7 @@ void IDAKLUSolverOpenMP::Initialize() { template IDAKLUSolverOpenMP::~IDAKLUSolverOpenMP() { + DEBUG("IDAKLUSolverOpenMP::~IDAKLUSolverOpenMP"); // Free memory if (sensitivity) { IDASensFree(ida_mem); @@ -313,63 +324,24 @@ IDAKLUSolverOpenMP::~IDAKLUSolverOpenMP() { } template -Solution IDAKLUSolverOpenMP::solve( - np_array t_eval_np, - np_array t_interp_np, - np_array y0_np, - np_array yp0_np, - np_array_dense inputs +SolutionData IDAKLUSolverOpenMP::solve( + const std::vector &t_eval, + const std::vector &t_interp, + const realtype *y0, + const realtype *yp0, + const realtype *inputs, + bool save_adaptive_steps, + bool save_interp_steps ) { DEBUG("IDAKLUSolver::solve"); + const int number_of_evals = t_eval.size(); + const int number_of_interps = t_interp.size(); - // If t_interp is empty, save all adaptive steps - bool save_adaptive_steps = t_interp_np.unchecked<1>().size() == 0; - - // Process the time inputs - // 1. Get the sorted and unique t_eval vector - auto const t_eval = makeSortedUnique(t_eval_np); - - // 2.1. Get the sorted and unique t_interp vector - auto const t_interp_unique_sorted = makeSortedUnique(t_interp_np); - - // 2.2 Remove the t_eval values from t_interp - auto const t_interp_setdiff = setDiff(t_interp_unique_sorted, t_eval); - - // 2.3 Finally, get the sorted and unique t_interp vector with t_eval values removed - auto const t_interp = makeSortedUnique(t_interp_setdiff); - - int const number_of_evals = t_eval.size(); - int const number_of_interps = t_interp.size(); - - // setDiff removes entries of t_interp that overlap with - // t_eval, so we need to check if we need to interpolate any unique points. - // This is not the same as save_adaptive_steps since some entries of t_interp - // may be removed by setDiff - bool save_interp_steps = number_of_interps > 0; - - // 3. Check if the timestepping entries are valid - if (number_of_evals < 2) { - throw std::invalid_argument( - "t_eval must have at least 2 entries" - ); - } else if (save_interp_steps) { - if (t_interp.front() < t_eval.front()) { - throw std::invalid_argument( - "t_interp values must be greater than the smallest t_eval value: " - + std::to_string(t_eval.front()) - ); - } else if (t_interp.back() > t_eval.back()) { - throw std::invalid_argument( - "t_interp values must be less than the greatest t_eval value: " - + std::to_string(t_eval.back()) - ); - } + if (t.size() < number_of_evals + number_of_interps) { + InitializeStorage(number_of_evals + number_of_interps); } - // Initialize length_of_return_vector, t, y, and yS - InitializeStorage(number_of_evals + number_of_interps); - int i_save = 0; realtype t0 = t_eval.front(); @@ -386,24 +358,11 @@ Solution IDAKLUSolverOpenMP::solve( t_interp_next = t_interp[0]; } - auto y0 = y0_np.unchecked<1>(); - auto yp0 = yp0_np.unchecked<1>(); auto n_coeffs = number_of_states + number_of_parameters * number_of_states; - if (y0.size() != n_coeffs) { - throw std::domain_error( - "y0 has wrong size. Expected " + std::to_string(n_coeffs) + - " but got " + std::to_string(y0.size())); - } else if (yp0.size() != n_coeffs) { - throw std::domain_error( - "yp0 has wrong size. Expected " + std::to_string(n_coeffs) + - " but got " + std::to_string(yp0.size())); - } - // set inputs - auto p_inputs = inputs.unchecked<2>(); for (int i = 0; i < functions->inputs.size(); i++) { - functions->inputs[i] = p_inputs(i, 0); + functions->inputs[i] = inputs[i]; } // Setup consistent initialization @@ -543,8 +502,8 @@ Solution IDAKLUSolverOpenMP::solve( PrintStats(); } - int const number_of_timesteps = i_save; - int count; + // store number of timesteps so we can generate the solution later + number_of_timesteps = i_save; // Copy the data to return as numpy arrays @@ -554,23 +513,9 @@ Solution IDAKLUSolverOpenMP::solve( t_return[i] = t[i]; } - py::capsule free_t_when_done( - t_return, - [](void *f) { - realtype *vect = reinterpret_cast(f); - delete[] vect; - } - ); - - np_array t_ret = np_array( - number_of_timesteps, - &t_return[0], - free_t_when_done - ); - // States, y realtype *y_return = new realtype[number_of_timesteps * length_of_return_vector]; - count = 0; + int count = 0; for (size_t i = 0; i < number_of_timesteps; i++) { for (size_t j = 0; j < length_of_return_vector; j++) { y_return[count] = y[i][j]; @@ -578,20 +523,6 @@ Solution IDAKLUSolverOpenMP::solve( } } - py::capsule free_y_when_done( - y_return, - [](void *f) { - realtype *vect = reinterpret_cast(f); - delete[] vect; - } - ); - - np_array y_ret = np_array( - number_of_timesteps * length_of_return_vector, - &y_return[0], - free_y_when_done - ); - // Sensitivity states, yS // Note: Ordering of vector is different if computing outputs vs returning // the complete state vector @@ -614,43 +545,7 @@ Solution IDAKLUSolverOpenMP::solve( } } - py::capsule free_yS_when_done( - yS_return, - [](void *f) { - realtype *vect = reinterpret_cast(f); - delete[] vect; - } - ); - - np_array yS_ret = np_array( - vector { - arg_sens0, - arg_sens1, - arg_sens2 - }, - &yS_return[0], - free_yS_when_done - ); - - // Final state slice, yterm - py::capsule free_yterm_when_done( - yterm_return, - [](void *f) { - realtype *vect = reinterpret_cast(f); - delete[] vect; - } - ); - - np_array y_term = np_array( - length_of_final_sv_slice, - &yterm_return[0], - free_yterm_when_done - ); - - // Store the solution - Solution sol(retval, t_ret, y_ret, yS_ret, y_term); - - return sol; + return SolutionData(retval, number_of_timesteps, length_of_return_vector, arg_sens0, arg_sens1, arg_sens2, length_of_final_sv_slice, t_return, y_return, yS_return, yterm_return); } template @@ -828,9 +723,8 @@ void IDAKLUSolverOpenMP::SetStepOutputSensitivities( template void IDAKLUSolverOpenMP::CheckErrors(int const & flag) { if (flag < 0) { - auto message = (std::string("IDA failed with flag ") + std::to_string(flag)).c_str(); - py::set_error(PyExc_ValueError, message); - throw py::error_already_set(); + auto message = std::string("IDA failed with flag ") + std::to_string(flag); + throw std::runtime_error(message.c_str()); } } diff --git a/src/pybamm/solvers/c_solvers/idaklu/Options.cpp b/src/pybamm/solvers/c_solvers/idaklu/Options.cpp index b6a33e016e..51544040ee 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/Options.cpp +++ b/src/pybamm/solvers/c_solvers/idaklu/Options.cpp @@ -1,6 +1,7 @@ #include "Options.hpp" #include #include +#include using namespace std::string_literals; @@ -11,9 +12,25 @@ SetupOptions::SetupOptions(py::dict &py_opts) precon_half_bandwidth(py_opts["precon_half_bandwidth"].cast()), precon_half_bandwidth_keep(py_opts["precon_half_bandwidth_keep"].cast()), num_threads(py_opts["num_threads"].cast()), + num_solvers(py_opts["num_solvers"].cast()), linear_solver(py_opts["linear_solver"].cast()), linsol_max_iterations(py_opts["linsol_max_iterations"].cast()) { + if (num_solvers > num_threads) + { + throw std::domain_error( + "Number of solvers must be less than or equal to the number of threads" + ); + } + + // input num_threads is the overall number of threads to use. num_solvers of these + // will be used to run solvers in parallel, leaving num_threads / num_solvers threads + // to be used by each solver. From here on num_threads is the number of threads to be used by each solver + num_threads = static_cast( + std::floor( + static_cast(num_threads) / static_cast(num_solvers) + ) + ); using_sparse_matrix = true; using_banded_matrix = false; diff --git a/src/pybamm/solvers/c_solvers/idaklu/Options.hpp b/src/pybamm/solvers/c_solvers/idaklu/Options.hpp index 66a175cfff..d0c0c1d766 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/Options.hpp +++ b/src/pybamm/solvers/c_solvers/idaklu/Options.hpp @@ -15,6 +15,7 @@ struct SetupOptions { int precon_half_bandwidth; int precon_half_bandwidth_keep; int num_threads; + int num_solvers; // IDALS linear solver interface std::string linear_solver; // klu, lapack, spbcg int linsol_max_iterations; diff --git a/src/pybamm/solvers/c_solvers/idaklu/Solution.hpp b/src/pybamm/solvers/c_solvers/idaklu/Solution.hpp index 72d48fa644..a43e6a7174 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/Solution.hpp +++ b/src/pybamm/solvers/c_solvers/idaklu/Solution.hpp @@ -9,6 +9,11 @@ class Solution { public: + /** + * @brief Default Constructor + */ + Solution() = default; + /** * @brief Constructor */ @@ -17,6 +22,11 @@ class Solution { } + /** + * @brief Default copy from another Solution + */ + Solution(const Solution &solution) = default; + int flag; np_array t; np_array y; diff --git a/src/pybamm/solvers/c_solvers/idaklu/SolutionData.cpp b/src/pybamm/solvers/c_solvers/idaklu/SolutionData.cpp new file mode 100644 index 0000000000..00c2ddbccc --- /dev/null +++ b/src/pybamm/solvers/c_solvers/idaklu/SolutionData.cpp @@ -0,0 +1,67 @@ +#include "SolutionData.hpp" + +Solution SolutionData::generate_solution() { + py::capsule free_t_when_done( + t_return, + [](void *f) { + realtype *vect = reinterpret_cast(f); + delete[] vect; + } + ); + + np_array t_ret = np_array( + number_of_timesteps, + &t_return[0], + free_t_when_done + ); + + py::capsule free_y_when_done( + y_return, + [](void *f) { + realtype *vect = reinterpret_cast(f); + delete[] vect; + } + ); + + np_array y_ret = np_array( + number_of_timesteps * length_of_return_vector, + &y_return[0], + free_y_when_done + ); + + py::capsule free_yS_when_done( + yS_return, + [](void *f) { + realtype *vect = reinterpret_cast(f); + delete[] vect; + } + ); + + np_array yS_ret = np_array( + std::vector { + arg_sens0, + arg_sens1, + arg_sens2 + }, + &yS_return[0], + free_yS_when_done + ); + + // Final state slice, yterm + py::capsule free_yterm_when_done( + yterm_return, + [](void *f) { + realtype *vect = reinterpret_cast(f); + delete[] vect; + } + ); + + np_array y_term = np_array( + length_of_final_sv_slice, + &yterm_return[0], + free_yterm_when_done + ); + + // Store the solution + return Solution(flag, t_ret, y_ret, yS_ret, y_term); +} diff --git a/src/pybamm/solvers/c_solvers/idaklu/SolutionData.hpp b/src/pybamm/solvers/c_solvers/idaklu/SolutionData.hpp new file mode 100644 index 0000000000..815e41daca --- /dev/null +++ b/src/pybamm/solvers/c_solvers/idaklu/SolutionData.hpp @@ -0,0 +1,73 @@ +#ifndef PYBAMM_IDAKLU_SOLUTION_DATA_HPP +#define PYBAMM_IDAKLU_SOLUTION_DATA_HPP + + +#include "common.hpp" +#include "Solution.hpp" + +/** + * @brief SolutionData class. Contains all the data needed to create a Solution + */ +class SolutionData +{ + public: + /** + * @brief Default constructor + */ + SolutionData() = default; + + /** + * @brief constructor using fields + */ + SolutionData( + int flag, + int number_of_timesteps, + int length_of_return_vector, + int arg_sens0, + int arg_sens1, + int arg_sens2, + int length_of_final_sv_slice, + realtype *t_return, + realtype *y_return, + realtype *yS_return, + realtype *yterm_return): + flag(flag), + number_of_timesteps(number_of_timesteps), + length_of_return_vector(length_of_return_vector), + arg_sens0(arg_sens0), + arg_sens1(arg_sens1), + arg_sens2(arg_sens2), + length_of_final_sv_slice(length_of_final_sv_slice), + t_return(t_return), + y_return(y_return), + yS_return(yS_return), + yterm_return(yterm_return) + {} + + + /** + * @brief Default copy from another SolutionData + */ + SolutionData(const SolutionData &solution_data) = default; + + /** + * @brief Create a solution object from this data + */ + Solution generate_solution(); + +private: + + int flag; + int number_of_timesteps; + int length_of_return_vector; + int arg_sens0; + int arg_sens1; + int arg_sens2; + int length_of_final_sv_slice; + realtype *t_return; + realtype *y_return; + realtype *yS_return; + realtype *yterm_return; +}; + +#endif // PYBAMM_IDAKLU_SOLUTION_DATA_HPP diff --git a/src/pybamm/solvers/c_solvers/idaklu/common.cpp b/src/pybamm/solvers/c_solvers/idaklu/common.cpp index bf38acc56a..161c14f340 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/common.cpp +++ b/src/pybamm/solvers/c_solvers/idaklu/common.cpp @@ -11,21 +11,9 @@ std::vector numpy2realtype(const np_array& input_np) { return output; } -std::vector setDiff(const std::vector& A, const std::vector& B) { - std::vector result; - if (!(A.empty())) { - std::set_difference(A.begin(), A.end(), B.begin(), B.end(), std::back_inserter(result)); - } - return result; -} -std::vector makeSortedUnique(const std::vector& input) { - std::unordered_set uniqueSet(input.begin(), input.end()); // Remove duplicates - std::vector uniqueVector(uniqueSet.begin(), uniqueSet.end()); // Convert to vector - std::sort(uniqueVector.begin(), uniqueVector.end()); // Sort the vector - return uniqueVector; -} std::vector makeSortedUnique(const np_array& input_np) { - return makeSortedUnique(numpy2realtype(input_np)); + const auto input_vec = numpy2realtype(input_np); + return makeSortedUnique(input_vec.begin(), input_vec.end()); } diff --git a/src/pybamm/solvers/c_solvers/idaklu/common.hpp b/src/pybamm/solvers/c_solvers/idaklu/common.hpp index 3289326541..58be90932e 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/common.hpp +++ b/src/pybamm/solvers/c_solvers/idaklu/common.hpp @@ -31,8 +31,8 @@ #include namespace py = pybind11; -using np_array = py::array_t; -using np_array_dense = py::array_t; +// note: we rely on c_style ordering for numpy arrays so don't change this! +using np_array = py::array_t; using np_array_int = py::array_t; /** @@ -83,12 +83,25 @@ std::vector numpy2realtype(const np_array& input_np); /** * @brief Utility function to compute the set difference of two vectors */ -std::vector setDiff(const std::vector& A, const std::vector& B); +template +std::vector setDiff(const T1 a_begin, const T1 a_end, const T2 b_begin, const T2 b_end) { + std::vector result; + if (std::distance(a_begin, a_end) > 0) { + std::set_difference(a_begin, a_end, b_begin, b_end, std::back_inserter(result)); + } + return result; +} /** * @brief Utility function to make a sorted and unique vector */ -std::vector makeSortedUnique(const std::vector& input); +template +std::vector makeSortedUnique(const T input_begin, const T input_end) { + std::unordered_set uniqueSet(input_begin, input_end); // Remove duplicates + std::vector uniqueVector(uniqueSet.begin(), uniqueSet.end()); // Convert to vector + std::sort(uniqueVector.begin(), uniqueVector.end()); // Sort the vector + return uniqueVector; +} std::vector makeSortedUnique(const np_array& input_np); @@ -126,8 +139,7 @@ std::vector makeSortedUnique(const np_array& input_np); } \ std::cout << "]" << std::endl; } -#define DEBUG_v(v, M) {\ - int N = 2; \ +#define DEBUG_v(v, N) {\ std::cout << #v << "[n=" << N << "] = ["; \ for (int i = 0; i < N; i++) { \ std::cout << v[i]; \ diff --git a/src/pybamm/solvers/c_solvers/idaklu/idaklu_solver.hpp b/src/pybamm/solvers/c_solvers/idaklu/idaklu_solver.hpp index ce1765aa82..dcc1e4f8cc 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/idaklu_solver.hpp +++ b/src/pybamm/solvers/c_solvers/idaklu/idaklu_solver.hpp @@ -2,6 +2,7 @@ #define PYBAMM_CREATE_IDAKLU_SOLVER_HPP #include "IDAKLUSolverOpenMP_solvers.hpp" +#include "IDAKLUSolverGroup.hpp" #include #include @@ -12,52 +13,21 @@ */ template IDAKLUSolver *create_idaklu_solver( - int number_of_states, + std::unique_ptr functions, int number_of_parameters, - const typename ExprSet::BaseFunctionType &rhs_alg, - const typename ExprSet::BaseFunctionType &jac_times_cjmass, const np_array_int &jac_times_cjmass_colptrs, const np_array_int &jac_times_cjmass_rowvals, const int jac_times_cjmass_nnz, const int jac_bandwidth_lower, const int jac_bandwidth_upper, - const typename ExprSet::BaseFunctionType &jac_action, - const typename ExprSet::BaseFunctionType &mass_action, - const typename ExprSet::BaseFunctionType &sens, - const typename ExprSet::BaseFunctionType &events, const int number_of_events, np_array rhs_alg_id, np_array atol_np, double rel_tol, int inputs_length, - const std::vector& var_fcns, - const std::vector& dvar_dy_fcns, - const std::vector& dvar_dp_fcns, - py::dict py_opts + SolverOptions solver_opts, + SetupOptions setup_opts ) { - auto setup_opts = SetupOptions(py_opts); - auto solver_opts = SolverOptions(py_opts); - auto functions = std::make_unique( - rhs_alg, - jac_times_cjmass, - jac_times_cjmass_nnz, - jac_bandwidth_lower, - jac_bandwidth_upper, - jac_times_cjmass_rowvals, - jac_times_cjmass_colptrs, - inputs_length, - jac_action, - mass_action, - sens, - events, - number_of_states, - number_of_events, - number_of_parameters, - var_fcns, - dvar_dy_fcns, - dvar_dp_fcns, - setup_opts - ); IDAKLUSolver *idakluSolver = nullptr; @@ -189,4 +159,88 @@ IDAKLUSolver *create_idaklu_solver( return idakluSolver; } +/** + * @brief Create a group of solvers using create_idaklu_solver + */ +template +IDAKLUSolverGroup *create_idaklu_solver_group( + int number_of_states, + int number_of_parameters, + const typename ExprSet::BaseFunctionType &rhs_alg, + const typename ExprSet::BaseFunctionType &jac_times_cjmass, + const np_array_int &jac_times_cjmass_colptrs, + const np_array_int &jac_times_cjmass_rowvals, + const int jac_times_cjmass_nnz, + const int jac_bandwidth_lower, + const int jac_bandwidth_upper, + const typename ExprSet::BaseFunctionType &jac_action, + const typename ExprSet::BaseFunctionType &mass_action, + const typename ExprSet::BaseFunctionType &sens, + const typename ExprSet::BaseFunctionType &events, + const int number_of_events, + np_array rhs_alg_id, + np_array atol_np, + double rel_tol, + int inputs_length, + const std::vector& var_fcns, + const std::vector& dvar_dy_fcns, + const std::vector& dvar_dp_fcns, + py::dict py_opts +) { + auto setup_opts = SetupOptions(py_opts); + auto solver_opts = SolverOptions(py_opts); + + + std::vector> solvers; + for (int i = 0; i < setup_opts.num_solvers; i++) { + // Note: we can't copy an ExprSet as it contains raw pointers to the functions + // So we create it in the loop + auto functions = std::make_unique( + rhs_alg, + jac_times_cjmass, + jac_times_cjmass_nnz, + jac_bandwidth_lower, + jac_bandwidth_upper, + jac_times_cjmass_rowvals, + jac_times_cjmass_colptrs, + inputs_length, + jac_action, + mass_action, + sens, + events, + number_of_states, + number_of_events, + number_of_parameters, + var_fcns, + dvar_dy_fcns, + dvar_dp_fcns, + setup_opts + ); + solvers.emplace_back( + std::unique_ptr( + create_idaklu_solver( + std::move(functions), + number_of_parameters, + jac_times_cjmass_colptrs, + jac_times_cjmass_rowvals, + jac_times_cjmass_nnz, + jac_bandwidth_lower, + jac_bandwidth_upper, + number_of_events, + rhs_alg_id, + atol_np, + rel_tol, + inputs_length, + solver_opts, + setup_opts + ) + ) + ); + } + + return new IDAKLUSolverGroup(std::move(solvers), number_of_states, number_of_parameters); +} + + + #endif // PYBAMM_CREATE_IDAKLU_SOLVER_HPP diff --git a/src/pybamm/solvers/idaklu_solver.py b/src/pybamm/solvers/idaklu_solver.py index 08f86b3264..ea3903b139 100644 --- a/src/pybamm/solvers/idaklu_solver.py +++ b/src/pybamm/solvers/idaklu_solver.py @@ -29,7 +29,8 @@ idaklu = importlib.util.module_from_spec(idaklu_spec) if idaklu_spec.loader: idaklu_spec.loader.exec_module(idaklu) - except ImportError: # pragma: no cover + except ImportError as e: # pragma: no cover + print(f"Error loading idaklu: {e}") idaklu_spec = None @@ -78,8 +79,10 @@ class IDAKLUSolver(pybamm.BaseSolver): options = { # Print statistics of the solver after every solve "print_stats": False, - # Number of threads available for OpenMP + # Number of threads available for OpenMP (must be greater than or equal to `num_solvers`) "num_threads": 1, + # Number of solvers to use in parallel (for solving multiple sets of input parameters in parallel) + "num_solvers": num_threads, # Evaluation engine to use for jax, can be 'jax'(native) or 'iree' "jax_evaluator": "jax", ## Linear solver interface @@ -182,6 +185,7 @@ def __init__( "precon_half_bandwidth": 5, "precon_half_bandwidth_keep": 5, "num_threads": 1, + "num_solvers": 1, "jax_evaluator": "jax", "linear_solver": "SUNLinSol_KLU", "linsol_max_iterations": 5, @@ -209,6 +213,8 @@ def __init__( if options is None: options = default_options else: + if "num_threads" in options and "num_solvers" not in options: + options["num_solvers"] = options["num_threads"] for key, value in default_options.items(): if key not in options: options[key] = value @@ -443,7 +449,7 @@ def inputs_to_dict(inputs): if model.convert_to_format == "casadi": # Serialize casadi functions - idaklu_solver_fcn = idaklu.create_casadi_solver + idaklu_solver_fcn = idaklu.create_casadi_solver_group rhs_algebraic = idaklu.generate_function(rhs_algebraic.serialize()) jac_times_cjmass = idaklu.generate_function(jac_times_cjmass.serialize()) jac_rhs_algebraic_action = idaklu.generate_function( @@ -457,7 +463,7 @@ def inputs_to_dict(inputs): and self._options["jax_evaluator"] == "iree" ): # Convert Jax functions to MLIR (also, demote to single precision) - idaklu_solver_fcn = idaklu.create_iree_solver + idaklu_solver_fcn = idaklu.create_iree_solver_group pybamm.demote_expressions_to_32bit = True if pybamm.demote_expressions_to_32bit: warnings.warn( @@ -726,7 +732,11 @@ def _check_mlir_conversion(self, name, mlir: str): def _demote_64_to_32(self, x: pybamm.EvaluatorJax): return pybamm.EvaluatorJax._demote_64_to_32(x) - def _integrate(self, model, t_eval, inputs_dict=None, t_interp=None): + @property + def supports_parallel_solve(self): + return True + + def _integrate(self, model, t_eval, inputs_list=None, t_interp=None): """ Solve a DAE model defined by residuals with initial conditions y0. @@ -736,22 +746,30 @@ def _integrate(self, model, t_eval, inputs_dict=None, t_interp=None): The model whose solution to calculate. t_eval : numeric type The times at which to stop the integration due to a discontinuity in time. - inputs_dict : dict, optional + inputs_list: list of dict, optional Any input parameters to pass to the model when solving. t_interp : None, list or ndarray, optional The times (in seconds) at which to interpolate the solution. Defaults to `None`, which returns the adaptive time-stepping times. """ - inputs_dict = inputs_dict or {} - # stack inputs - if inputs_dict: - arrays_to_stack = [np.array(x).reshape(-1, 1) for x in inputs_dict.values()] - inputs = np.vstack(arrays_to_stack) + inputs_list = inputs_list or [{}] + + # stack inputs so that they are a 2D array of shape (number_of_inputs, number_of_parameters) + if inputs_list and inputs_list[0]: + inputs = np.vstack( + [ + np.hstack([np.array(x).reshape(-1) for x in inputs_dict.values()]) + for inputs_dict in inputs_list + ] + ) else: inputs = np.array([[]]) - y0full = model.y0full - ydot0full = model.ydot0full + # stack y0full and ydot0full so they are a 2D array of shape (number_of_inputs, number_of_states + number_of_parameters * number_of_states) + # note that y0full and ydot0full are currently 1D arrays (i.e. independent of inputs), but in the future we will support + # different initial conditions for different inputs (see https://github.com/pybamm-team/PyBaMM/pull/4260). For now we just repeat the same initial conditions for each input + y0full = np.vstack([model.y0full] * len(inputs_list)) + ydot0full = np.vstack([model.ydot0full] * len(inputs_list)) atol = getattr(model, "atol", self.atol) atol = self._check_atol_type(atol, y0full.size) @@ -761,7 +779,7 @@ def _integrate(self, model, t_eval, inputs_dict=None, t_interp=None): model.convert_to_format == "jax" and self._options["jax_evaluator"] == "iree" ): - sol = self._setup["solver"].solve( + solns = self._setup["solver"].solve( t_eval, t_interp, y0full, @@ -773,6 +791,12 @@ def _integrate(self, model, t_eval, inputs_dict=None, t_interp=None): raise pybamm.SolverError("Unsupported IDAKLU solver configuration.") integration_time = timer.time() + return [ + self._post_process_solution(soln, model, integration_time, inputs_dict) + for soln, inputs_dict in zip(solns, inputs_list) + ] + + def _post_process_solution(self, sol, model, integration_time, inputs_dict): number_of_sensitivity_parameters = self._setup[ "number_of_sensitivity_parameters" ] diff --git a/src/pybamm/solvers/jax_solver.py b/src/pybamm/solvers/jax_solver.py index da5fd4983a..bfcdef1882 100644 --- a/src/pybamm/solvers/jax_solver.py +++ b/src/pybamm/solvers/jax_solver.py @@ -185,6 +185,10 @@ def solve_model_bdf(inputs): else: return jax.jit(solve_model_bdf) + @property + def supports_parallel_solve(self): + return True + def _integrate(self, model, t_eval, inputs=None, t_interp=None): """ Solve a model defined by dydt with initial conditions y0. @@ -200,7 +204,7 @@ def _integrate(self, model, t_eval, inputs=None, t_interp=None): Returns ------- - object + list of `pybamm.Solution` An object containing the times and values of the solution, as well as various diagnostic messages. @@ -301,6 +305,4 @@ async def solve_model_async(inputs_v): sol.integration_time = integration_time solutions.append(sol) - if len(solutions) == 1: - return solutions[0] return solutions diff --git a/tests/unit/test_solvers/test_idaklu_solver.py b/tests/unit/test_solvers/test_idaklu_solver.py index 32e289b3e0..213226bb4c 100644 --- a/tests/unit/test_solvers/test_idaklu_solver.py +++ b/tests/unit/test_solvers/test_idaklu_solver.py @@ -63,6 +63,47 @@ def test_ida_roberts_klu(self): true_solution = 0.1 * solution.t np.testing.assert_array_almost_equal(solution.y[0, :], true_solution) + def test_multiple_inputs(self): + model = pybamm.BaseModel() + var = pybamm.Variable("var") + rate = pybamm.InputParameter("rate") + model.rhs = {var: -rate * var} + model.initial_conditions = {var: 2} + disc = pybamm.Discretisation() + disc.process_model(model) + + for num_threads, num_solvers in [ + [1, None], + [2, None], + [8, None], + [8, 1], + [8, 2], + [8, 7], + ]: + options = {"num_threads": num_threads} + if num_solvers is not None: + options["num_solvers"] = num_solvers + solver = pybamm.IDAKLUSolver(rtol=1e-5, atol=1e-5, options=options) + t_interp = np.linspace(0, 1, 10) + t_eval = [t_interp[0], t_interp[-1]] + ninputs = 8 + inputs_list = [{"rate": 0.01 * (i + 1)} for i in range(ninputs)] + + solutions = solver.solve( + model, t_eval, inputs=inputs_list, t_interp=t_interp + ) + + # check solution + for inputs, solution in zip(inputs_list, solutions): + print("checking solution", inputs, solution.all_inputs) + np.testing.assert_array_equal(solution.t, t_interp) + np.testing.assert_allclose( + solution.y[0], + 2 * np.exp(-inputs["rate"] * solution.t), + atol=1e-4, + rtol=1e-4, + ) + def test_model_events(self): for form in ["casadi", "iree"]: if (form == "iree") and (not pybamm.has_jax() or not pybamm.has_iree()):