def solve( self, model, t_eval=None, external_variables=None, inputs=None, initial_conditions=None, nproc=None, ): """ Execute the solver setup and calculate the solution of the model at specified times. Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. Must have attributes rhs and initial_conditions t_eval : numeric type The times (in seconds) at which to compute the solution external_variables : dict A dictionary of external variables and their corresponding values at the current time inputs : dict or list, optional A dictionary or list of dictionaries describing any input parameters to pass to the model when solving initial_conditions : :class:`pybamm.Symbol`, optional Initial conditions to use when solving the model. If None (default), `model.concatenated_initial_conditions` is used. Otherwise, must be a symbol of size `len(model.rhs) + len(model.algebraic)`. nproc : int, optional Number of processes to use when solving for more than one set of input parameters. Defaults to value returned by "os.cpu_count()". Returns ------- :class:`pybamm.Solution` or list of :class:`pybamm.Solution` objects. If type of `inputs` is `list`, return a list of corresponding :class:`pybamm.Solution` objects. Raises ------ :class:`pybamm.ModelError` If an empty model is passed (`model.rhs = {}` and `model.algebraic={}` and `model.variables = {}`) """ pybamm.logger.info("Start solving {} with {}".format(model.name, self.name)) # Make sure model isn't empty if len(model.rhs) == 0 and len(model.algebraic) == 0: if not isinstance(self, pybamm.DummySolver): raise pybamm.ModelError( "Cannot solve empty model, use `pybamm.DummySolver` instead" ) # t_eval can only be None if the solver is an algebraic solver. In that case # set it to 0 if t_eval is None: if self.algebraic_solver is True: t_eval = np.array([0]) else: raise ValueError("t_eval cannot be None") # If t_eval is provided as [t0, tf] return the solution at 100 points elif isinstance(t_eval, list): if len(t_eval) == 1 and self.algebraic_solver is True: pass elif len(t_eval) != 2: raise pybamm.SolverError( "'t_eval' can be provided as an array of times at which to " "return the solution, or as a list [t0, tf] where t0 is the " "initial time and tf is the final time, but has been provided " "as a list of length {}.".format(len(t_eval)) ) else: t_eval = np.linspace(t_eval[0], t_eval[-1], 100) # Make sure t_eval is monotonic if (np.diff(t_eval) < 0).any(): raise pybamm.SolverError("t_eval must increase monotonically") # Set up external variables and inputs # # Argument "inputs" can be either a list of input dicts or # a single dict. The remaining of this function is only working # with variable "input_list", which is a list of dictionaries. # If "inputs" is a single dict, "inputs_list" is a list of only one dict. inputs_list = inputs if isinstance(inputs, list) else [inputs] ext_and_inputs_list = [ self._set_up_ext_and_inputs(model, external_variables, inputs) for inputs in inputs_list ] # Cannot use multiprocessing with model in "jax" format if (len(inputs_list) > 1) and model.convert_to_format == "jax": raise pybamm.SolverError( "Cannot solve list of inputs with multiprocessing " 'when model in format "jax".' ) # Set up (if not done already) timer = pybamm.Timer() if model not in self.models_set_up: # It is assumed that when len(inputs_list) > 1, model set # up (initial condition, time-scale and length-scale) does # not depend on input parameters. Thefore only `ext_and_inputs[0]` # is passed to `set_up`. # See https://github.com/pybamm-team/PyBaMM/pull/1261 self.set_up(model, ext_and_inputs_list[0], t_eval) self.models_set_up.update( {model: {"initial conditions": model.concatenated_initial_conditions}} ) else: ics_set_up = self.models_set_up[model]["initial conditions"] # Check that initial conditions have not been updated if ics_set_up.id != model.concatenated_initial_conditions.id: # If the new initial conditions are different, set up again # Doing the whole setup again might be slow, but no need to prematurely # optimize this self.set_up(model, ext_and_inputs_list[0], t_eval) self.models_set_up[model][ "initial conditions" ] = model.concatenated_initial_conditions set_up_time = timer.time() timer.reset() # (Re-)calculate consistent initial conditions # Assuming initial conditions do not depend on input parameters # when len(inputs_list) > 1, only `ext_and_inputs_list[0]` # is passed to `_set_initial_conditions`. # See https://github.com/pybamm-team/PyBaMM/pull/1261 if len(inputs_list) > 1: all_inputs_names = set( itertools.chain.from_iterable( [ext_and_inputs.keys() for ext_and_inputs in ext_and_inputs_list] ) ) initial_conditions_node_names = set( [it.name for it in model.concatenated_initial_conditions.pre_order()] ) if all_inputs_names.issubset(initial_conditions_node_names): raise pybamm.SolverError( "Input parameters cannot appear in expression " "for initial conditions." ) self._set_initial_conditions(model, ext_and_inputs_list[0], update_rhs=True) # Non-dimensionalise time t_eval_dimensionless = t_eval / model.timescale_eval # Calculate discontinuities discontinuities = [ # Assuming that discontinuities do not depend on # input parameters when len(input_list) > 1, only # `input_list[0]` is passed to `evaluate`. # See https://github.com/pybamm-team/PyBaMM/pull/1261 event.expression.evaluate(inputs=inputs_list[0]) for event in model.discontinuity_events_eval ] # make sure they are increasing in time discontinuities = sorted(discontinuities) # remove any identical discontinuities discontinuities = [ v for i, v in enumerate(discontinuities) if ( i == len(discontinuities) - 1 or discontinuities[i] < discontinuities[i + 1] ) and v > 0 ] # remove any discontinuities after end of t_eval discontinuities = [v for v in discontinuities if v < t_eval_dimensionless[-1]] if len(discontinuities) > 0: pybamm.logger.verbose( "Discontinuity events found at t = {}".format(discontinuities) ) if isinstance(inputs, list): raise pybamm.SolverError( "Cannot solve for a list of input parameters" " sets with discontinuities" ) else: pybamm.logger.verbose("No discontinuity events found") # insert time points around discontinuities in t_eval # keep track of sub sections to integrate by storing start and end indices start_indices = [0] end_indices = [] eps = sys.float_info.epsilon for dtime in discontinuities: dindex = np.searchsorted(t_eval_dimensionless, dtime, side="left") end_indices.append(dindex + 1) start_indices.append(dindex + 1) if dtime - eps < t_eval_dimensionless[dindex] < dtime + eps: t_eval_dimensionless[dindex] += eps t_eval_dimensionless = np.insert( t_eval_dimensionless, dindex, dtime - eps ) else: t_eval_dimensionless = np.insert( t_eval_dimensionless, dindex, [dtime - eps, dtime + eps] ) end_indices.append(len(t_eval_dimensionless)) # Integrate separately over each time segment and accumulate into the solution # object, restarting the solver at each discontinuity (and recalculating a # consistent state afterwards if a DAE) old_y0 = model.y0 solutions = None for start_index, end_index in zip(start_indices, end_indices): pybamm.logger.verbose( "Calling solver for {} < t < {}".format( t_eval_dimensionless[start_index] * model.timescale_eval, t_eval_dimensionless[end_index - 1] * model.timescale_eval, ) ) ninputs = len(ext_and_inputs_list) if ninputs == 1: new_solution = self._integrate( model, t_eval_dimensionless[start_index:end_index], ext_and_inputs_list[0], ) new_solutions = [new_solution] else: with mp.Pool(processes=nproc) as p: new_solutions = p.starmap( self._integrate, zip( [model] * ninputs, [t_eval_dimensionless[start_index:end_index]] * ninputs, ext_and_inputs_list, ), ) p.close() p.join() # Setting the solve time for each segment. # pybamm.Solution.__add__ assumes attribute solve_time. solve_time = timer.time() for sol in new_solutions: sol.solve_time = solve_time if start_index == start_indices[0]: solutions = [sol for sol in new_solutions] else: for i, new_solution in enumerate(new_solutions): solutions[i] = solutions[i] + new_solution if solutions[0].termination != "final time": break if end_index != len(t_eval_dimensionless): # setup for next integration subsection last_state = solutions[0].y[:, -1] # update y0 (for DAE solvers, this updates the initial guess for the # rootfinder) model.y0 = last_state if len(model.algebraic) > 0: model.y0 = self.calculate_consistent_state( model, t_eval_dimensionless[end_index], ext_and_inputs_list[0] ) solve_time = timer.time() for i, solution in enumerate(solutions): # Check if extrapolation occurred extrapolation = self.check_extrapolation(solution, model.events) if extrapolation: warnings.warn( "While solving {} extrapolation occurred for {}".format( model.name, extrapolation ), pybamm.SolverWarning, ) # Identify the event that caused termination and update the solution to # include the event time and state solutions[i], termination = self.get_termination_reason( solution, model.events ) # Assign times solutions[i].set_up_time = set_up_time # all solutions get the same solve time, but their integration time # will be different (see https://github.com/pybamm-team/PyBaMM/pull/1261) solutions[i].solve_time = solve_time # Restore old y0 model.y0 = old_y0 # Report times if len(solutions) == 1: pybamm.logger.info("Finish solving {} ({})".format(model.name, termination)) pybamm.logger.info( ( "Set-up time: {}, Solve time: {} (of which integration time: {}), " "Total time: {}" ).format( solutions[0].set_up_time, solutions[0].solve_time, solutions[0].integration_time, solutions[0].total_time, ) ) else: pybamm.logger.info("Finish solving {} for all inputs".format(model.name)) pybamm.logger.info( ("Set-up time: {}, Solve time: {}, Total time: {}").format( solutions[0].set_up_time, solutions[0].solve_time, solutions[0].total_time, ) ) # Raise error if solutions[0] only contains one timestep (except for algebraic # solvers, where we may only expect one time in the solution) if ( self.algebraic_solver is False and len(solution.all_ts) == 1 and len(solution.all_ts[0]) == 1 ): raise pybamm.SolverError( "Solution time vector has length 1. " "Check whether simulation terminated too early." ) # Return solution(s) if ninputs == 1: return solutions[0] else: return solutions
def set_up(self, model): """Unpack model, perform checks, simplify and calculate jacobian. Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. Must have attributes rhs and initial_conditions Raises ------ :class:`pybamm.SolverError` If the model contains any algebraic equations (in which case a DAE solver should be used instead) """ # Check for algebraic equations if len(model.algebraic) > 0: raise pybamm.SolverError( """Cannot use ODE solver to solve model with DAEs""") # create simplified rhs and event expressions concatenated_rhs = model.concatenated_rhs events = model.events if model.use_simplify: # set up simplification object, for re-use of dict simp = pybamm.Simplification() # create simplified rhs and event expressions pybamm.logger.info("Simplifying RHS") concatenated_rhs = simp.simplify(concatenated_rhs) pybamm.logger.info("Simplifying events") events = { name: simp.simplify(event) for name, event in events.items() } y0 = model.concatenated_initial_conditions[:, 0] if model.use_jacobian: # Create Jacobian from concatenated rhs y = pybamm.StateVector(slice(0, np.size(y0))) # set up Jacobian object, for re-use of dict jacobian = pybamm.Jacobian() pybamm.logger.info("Calculating jacobian") jac_rhs = jacobian.jac(concatenated_rhs, y) model.jacobian = jac_rhs model.jacobian_rhs = jac_rhs if model.use_simplify: pybamm.logger.info("Simplifying jacobian") jac_rhs = simp.simplify(jac_rhs) if model.use_to_python: pybamm.logger.info("Converting jacobian to python") jac_rhs = pybamm.EvaluatorPython(jac_rhs) else: jac_rhs = None if model.use_to_python: pybamm.logger.info("Converting RHS to python") concatenated_rhs = pybamm.EvaluatorPython(concatenated_rhs) pybamm.logger.info("Converting events to python") events = { name: pybamm.EvaluatorPython(event) for name, event in events.items() } # Create function to evaluate rhs def dydt(t, y): pybamm.logger.debug("Evaluating RHS for {} at t={}".format( model.name, t)) y = y[:, np.newaxis] dy = concatenated_rhs.evaluate(t, y, known_evals={})[0] return dy[:, 0] # Create event-dependent function to evaluate events def event_fun(event): def eval_event(t, y): return event.evaluate(t, y) return eval_event event_funs = [event_fun(event) for event in events.values()] # Create function to evaluate jacobian if jac_rhs is not None: def jacobian(t, y): return jac_rhs.evaluate(t, y, known_evals={})[0] else: jacobian = None # Add the solver attributes # Note: these are the (possibly) converted to python version rhs, algebraic # etc. The expression tree versions of these are attributes of the model self.y0 = y0 self.dydt = dydt self.events = events self.event_funs = event_funs self.jacobian = jacobian
def solve( self, t_eval=None, solver=None, external_variables=None, inputs=None, check_model=True, ): """ A method to solve the model. This method will automatically build and set the model parameters if not already done so. Parameters ---------- t_eval : numeric type, optional The times (in seconds) at which to compute the solution. Can be provided as an array of times at which to return the solution, or as a list `[t0, tf]` where `t0` is the initial time and `tf` is the final time. If provided as a list the solution is returned at 100 points within the interval `[t0, tf]`. If not using an experiment or running a drive cycle simulation (current provided as data) `t_eval` *must* be provided. If running an experiment the values in `t_eval` are ignored, and the solution times are specified by the experiment. If None and the parameter "Current function [A]" is read from data (i.e. drive cycle simulation) the model will be solved at the times provided in the data. solver : :class:`pybamm.BaseSolver` The solver to use to solve the model. external_variables : dict A dictionary of external variables and their corresponding values at the current time. The variables must correspond to the variables that would normally be found by solving the submodels that have been made external. inputs : dict, optional Any input parameters to pass to the model when solving check_model : bool, optional If True, model checks are performed after discretisation (see :meth:`pybamm.Discretisation.process_model`). Default is True. """ # Setup self.build(check_model=check_model) if solver is None: solver = self.solver if self.operating_mode in ["without experiment", "drive cycle"]: if self.operating_mode == "without experiment": if t_eval is None: raise pybamm.SolverError( "'t_eval' must be provided if not using an experiment or " "simulating a drive cycle. 't_eval' can be provided as an " "array of times at which to return the solution, or as a " "list [t0, tf] where t0 is the initial time and tf is the " "final time. " "For a constant current (dis)charge the suggested 't_eval' " "is [0, 3700/C] where C is the C-rate.") elif self.operating_mode == "drive cycle": # For drive cycles (current provided as data) we perform additional # tests on t_eval (if provided) to ensure the returned solution # captures the input. time_data = self._parameter_values[ "Current function [A]"].data[:, 0] # If no t_eval is provided, we use the times provided in the data. if t_eval is None: pybamm.logger.info( "Setting t_eval as specified by the data") t_eval = time_data # If t_eval is provided we first check if it contains all of the # times in the data to within 10-12. If it doesn't, we then check # that the largest gap in t_eval is smaller than the smallest gap in # the time data (to ensure the resolution of t_eval is fine enough). # We only raise a warning here as users may genuinely only want # the solution returned at some specified points. elif (set(np.round(time_data, 12)).issubset( set(np.round(t_eval, 12)))) is False: warnings.warn( """ t_eval does not contain all of the time points in the data set. Note: passing t_eval = None automatically sets t_eval to be the points in the data. """, pybamm.SolverWarning, ) dt_data_min = np.min(np.diff(time_data)) dt_eval_max = np.max(np.diff(t_eval)) if dt_eval_max > dt_data_min + sys.float_info.epsilon: warnings.warn( """ The largest timestep in t_eval ({}) is larger than the smallest timestep in the data ({}). The returned solution may not have the correct resolution to accurately capture the input. Try refining t_eval. Alternatively, passing t_eval = None automatically sets t_eval to be the points in the data. """.format(dt_eval_max, dt_data_min), pybamm.SolverWarning, ) self._solution = solver.solve( self.built_model, t_eval, external_variables=external_variables, inputs=inputs, ) self.t_eval = self._solution.t * self.model.timescale.evaluate() elif self.operating_mode == "with experiment": if t_eval is not None: pybamm.logger.warning( "Ignoring t_eval as solution times are specified by the experiment" ) # Re-initialize solution, e.g. for solving multiple times with different # inputs without having to build the simulation again self._solution = None # Step through all experimental conditions inputs = inputs or {} pybamm.logger.info("Start running experiment") timer = pybamm.Timer() for idx, (exp_inputs, dt) in enumerate( zip(self._experiment_inputs, self._experiment_times)): pybamm.logger.info( self.experiment.operating_conditions_strings[idx]) inputs.update(exp_inputs) # Make sure we take at least 2 timesteps npts = max(int(round(dt / exp_inputs["period"])) + 1, 2) self.step( dt, solver=solver, npts=npts, external_variables=external_variables, inputs=inputs, ) # Only allow events specified by experiment if not (self._solution.termination == "final time" or "[experiment]" in self._solution.termination): pybamm.logger.warning( "\n\n\tExperiment is infeasible: '{}' ".format( self._solution.termination) + "was triggered during '{}'. ".format( self.experiment.operating_conditions_strings[idx]) + "Try reducing current, shortening the time interval, " "or reducing the period.\n\n") break pybamm.logger.info("Finish experiment simulation, took {}".format( timer.format(timer.time()))) return self.solution
def set_up(self, model): """Unpack model, perform checks, simplify and calculate jacobian. Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. Must have attributes rhs and initial_conditions Returns ------- concatenated_algebraic : :class:`pybamm.Concatenation` Algebraic equations, which should evaluate to zero jac : :class:`pybamm.SparseStack` Jacobian matrix for the differential and algebraic equations Raises ------ :class:`pybamm.SolverError` If the model contains any time derivatives, i.e. rhs equations (in which case an ODE or DAE solver should be used instead) """ if len(model.rhs) > 0: raise pybamm.SolverError( """Cannot use algebraic solver to solve model with time derivatives""" ) # create simplified algebraic expressions concatenated_algebraic = model.concatenated_algebraic if model.use_simplify: # set up simplification object, for re-use of dict simp = pybamm.Simplification() pybamm.logger.info("Simplifying algebraic") concatenated_algebraic = simp.simplify(concatenated_algebraic) if model.use_jacobian: # Create Jacobian from concatenated algebraic y = pybamm.StateVector( slice(0, np.size(model.concatenated_initial_conditions))) # set up Jacobian object, for re-use of dict jacobian = pybamm.Jacobian() pybamm.logger.info("Calculating jacobian") jac = jacobian.jac(concatenated_algebraic, y) model.jacobian = jac model.jacobian_algebraic = jac if model.use_simplify: pybamm.logger.info("Simplifying jacobian") jac = simp.simplify(jac) if model.convert_to_format == "python": pybamm.logger.info("Converting jacobian to python") jac = pybamm.EvaluatorPython(jac) else: jac = None if model.convert_to_format == "python": pybamm.logger.info("Converting algebraic to python") concatenated_algebraic = pybamm.EvaluatorPython( concatenated_algebraic) return concatenated_algebraic, jac
def calculate_consistent_state(self, model, time=0, y0_guess=None, inputs=None): """ Calculate consistent state for the algebraic equations through root-finding Parameters ---------- model : :class:`pybamm.BaseModel` The model for which to calculate initial conditions. time : float The time at which to calculate the states y0_guess : :class:`np.array` Guess for the rootfinding inputs : dict, optional Any input parameters to pass to the model when solving Returns ------- y0_consistent : array-like, same shape as y0_guess Initial conditions that are consistent with the algebraic equations (roots of the algebraic equations) """ pybamm.logger.info("Start calculating consistent states") if y0_guess is None: y0_guess = model.concatenated_initial_conditions.flatten() # Split y0_guess into differential and algebraic len_rhs = model.rhs_eval(time, y0_guess).shape[0] y0_diff, y0_alg_guess = np.split(y0_guess, [len_rhs]) inputs = inputs or {} # Solve using casadi or scipy if self.root_method == "casadi": # Set up u_stacked = casadi.vertcat(*[x for x in inputs.values()]) u = casadi.MX.sym("u", u_stacked.shape[0]) y_alg = casadi.MX.sym("y_alg", y0_alg_guess.shape[0]) y = casadi.vertcat(y0_diff, y_alg) alg_root = model.casadi_algebraic(time, y, u) # Solve # set error_on_fail to False and just check the final output is small # enough roots = casadi.rootfinder( "roots", "newton", dict(x=y_alg, p=u, g=alg_root), {"abstol": self.root_tol}, ) try: y0_alg = roots(y0_alg_guess, u_stacked).full().flatten() success = True message = None # Check final output fun = model.casadi_algebraic(time, casadi.vertcat(y0_diff, y0_alg), u_stacked) except RuntimeError as err: success = False message = err.args[0] fun = None else: algebraic = model.algebraic_eval jac = model.jac_algebraic_eval def root_fun(y0_alg): "Evaluates algebraic using y0_diff (fixed) and y0_alg (changed by algo)" y0 = np.concatenate([y0_diff, y0_alg]) out = algebraic(time, y0) pybamm.logger.debug( "Evaluating algebraic equations at t={}, L2-norm is {}". format(time * model.timescale, np.linalg.norm(out))) return out if jac: if issparse(jac(0, y0_guess)): def jac_fn(y0_alg): """ Evaluates jacobian using y0_diff (fixed) and y0_alg (varying) """ y0 = np.concatenate([y0_diff, y0_alg]) return jac(0, y0)[:, len_rhs:].toarray() else: def jac_fn(y0_alg): """ Evaluates jacobian using y0_diff (fixed) and y0_alg (varying) """ y0 = np.concatenate([y0_diff, y0_alg]) return jac(0, y0)[:, len_rhs:] else: jac_fn = None # Find the values of y0_alg that are roots of the algebraic equations sol = optimize.root( root_fun, y0_alg_guess, jac=jac_fn, method=self.root_method, tol=self.root_tol, ) pybamm.citations.register("virtanen2020scipy") # Set outputs y0_alg = sol.x success = sol.success fun = sol.fun message = sol.message if success and np.all(fun < self.root_tol * len(y0_alg)): # Return full set of consistent initial conditions (y0_diff unchanged) y0_consistent = np.concatenate([y0_diff, y0_alg]) pybamm.logger.info( "Finish calculating consistent initial conditions") return y0_consistent elif not success: raise pybamm.SolverError( "Could not find consistent initial conditions: {}".format( message)) else: raise pybamm.SolverError(""" Could not find consistent initial conditions: solver terminated successfully, but maximum solution error ({}) above tolerance ({}) """.format(np.max(fun), self.root_tol * len(y0_alg)))
def _integrate(self, model, t_eval, inputs=None): """ Calculate the solution of the algebraic equations through root-finding Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. t_eval : :class:`numpy.array`, size (k,) The times at which to compute the solution inputs : dict, optional Any input parameters to pass to the model when solving """ inputs = inputs or {} if model.convert_to_format == "casadi": inputs = casadi.vertcat(*[x for x in inputs.values()]) y0 = model.y0 if isinstance(y0, casadi.DM): y0 = y0.full().flatten() # The casadi algebraic solver can read rhs equations, but leaves them unchanged # i.e. the part of the solution vector that corresponds to the differential # equations will be equal to the initial condition provided. This allows this # solver to be used for initialising the DAE solvers # Split y0 into differential and algebraic if model.rhs == {}: len_rhs = 0 else: len_rhs = model.rhs_eval(t_eval[0], y0, inputs).shape[0] y0_diff, y0_alg = np.split(y0, [len_rhs]) algebraic = model.algebraic_eval y_alg = np.empty((len(y0_alg), len(t_eval))) timer = pybamm.Timer() integration_time = 0 for idx, t in enumerate(t_eval): def root_fun(y_alg): "Evaluates algebraic using y" y = np.concatenate([y0_diff, y_alg]) out = algebraic(t, y, inputs) pybamm.logger.debug( "Evaluating algebraic equations at t={}, L2-norm is {}". format(t * model.timescale_eval, np.linalg.norm(out))) return out jac = model.jac_algebraic_eval if jac: if issparse(jac(t_eval[0], y0, inputs)): def jac_fn(y_alg): """ Evaluates jacobian using y0_diff (fixed) and y_alg (varying) """ y = np.concatenate([y0_diff, y_alg]) return jac(0, y, inputs)[:, len_rhs:].toarray() else: def jac_fn(y_alg): """ Evaluates jacobian using y0_diff (fixed) and y_alg (varying) """ y = np.concatenate([y0_diff, y_alg]) return jac(0, y, inputs)[:, len_rhs:] else: jac_fn = None # Evaluate algebraic with new t and previous y0, if it's already close # enough then keep it if np.all(abs(algebraic(t, y0, inputs)) < self.tol): pybamm.logger.debug("Keeping same solution at t={}".format(t)) y_alg[:, idx] = y0_alg # Otherwise calculate new y0 else: # Methods which use least-squares are specified as either "lsq", which # uses the default method, or with "lsq__methodname" if self.method.startswith("lsq"): if self.method == "lsq": method = "trf" else: method = self.method[5:] if jac_fn is None: jac_fn = "2-point" timer.reset() sol = optimize.least_squares( root_fun, y0_alg, method=method, ftol=self.tol, jac=jac_fn, bounds=model.bounds, **self.extra_options, ) integration_time += timer.time() # Methods which use minimize are specified as either "minimize", which # uses the default method, or with "minimize__methodname" elif self.method.startswith("minimize"): # Adapt the root function for minimize def root_norm(y): return np.sum(root_fun(y)**2) if jac_fn is None: jac_norm = None else: def jac_norm(y): return np.sum(2 * root_fun(y) * jac_fn(y), 0) if self.method == "minimize": method = None else: method = self.method[10:] extra_options = self.extra_options if np.any(model.bounds[0] != -np.inf) or np.any( model.bounds[1] != np.inf): bounds = [ (lb, ub) for lb, ub in zip(model.bounds[0], model.bounds[1]) ] extra_options["bounds"] = bounds timer.reset() sol = optimize.minimize( root_norm, y0_alg, method=method, tol=self.tol, jac=jac_norm, **extra_options, ) integration_time += timer.time() else: timer.reset() sol = optimize.root( root_fun, y0_alg, method=self.method, tol=self.tol, jac=jac_fn, options=self.extra_options, ) integration_time += timer.time() if sol.success and np.all(abs(sol.fun) < self.tol): # update initial guess for the next iteration y0_alg = sol.x # update solution array y_alg[:, idx] = y0_alg elif not sol.success: raise pybamm.SolverError( "Could not find acceptable solution: {}".format( sol.message)) else: raise pybamm.SolverError( "Could not find acceptable solution: solver terminated " "successfully, but maximum solution error " "({}) above tolerance ({})".format( np.max(abs(sol.fun)), self.tol)) # Concatenate differential part y_diff = np.r_[[y0_diff] * len(t_eval)].T y_sol = np.r_[y_diff, y_alg] # Return solution object (no events, so pass None to t_event, y_event) sol = pybamm.Solution(t_eval, y_sol, termination="success") sol.integration_time = integration_time return sol
def _integrate(self, model, t_eval, inputs=None): """ Solve a model defined by dydt with initial conditions y0. Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. t_eval : :class:`numpy.array`, size (k,) The times at which to compute the solution inputs : dict, optional Any input parameters to pass to the model when solving Returns ------- object An object containing the times and values of the solution, as well as various diagnostic messages. """ if model.convert_to_format == "casadi": inputs = casadi.vertcat(*[x for x in inputs.values()]) extra_options = {"rtol": self.rtol, "atol": self.atol} # check for user-supplied Jacobian implicit_methods = ["Radau", "BDF", "LSODA"] if np.any([self.method in implicit_methods]): if model.jacobian_eval: extra_options.update({ "jac": lambda t, y: model.jacobian_eval(t, y, inputs) }) # make events terminal so that the solver stops when they are reached if model.terminate_events_eval: def event_wrapper(event): def event_fn(t, y): return event(t, y, inputs) event_fn.terminal = True return event_fn events = [ event_wrapper(event) for event in model.terminate_events_eval ] extra_options.update({"events": events}) sol = it.solve_ivp(lambda t, y: model.rhs_eval(t, y, inputs), (t_eval[0], t_eval[-1]), model.y0, t_eval=t_eval, method=self.method, dense_output=True, **extra_options) if sol.success: # Set the reason for termination if sol.message == "A termination event occurred.": termination = "event" t_event = [] for time in sol.t_events: if time.size > 0: t_event = np.append(t_event, np.max(time)) t_event = np.array([np.max(t_event)]) y_event = sol.sol(t_event) elif sol.message.startswith( "The solver successfully reached the end"): termination = "final time" t_event = None y_event = np.array(None) return pybamm.Solution(sol.t, sol.y, t_event, y_event, termination) else: raise pybamm.SolverError(sol.message)
def _integrate(self, model, t_eval, inputs_dict=None): """ Calculate the solution of the algebraic equations through root-finding Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. t_eval : :class:`numpy.array`, size (k,) The times at which to compute the solution inputs_dict : dict, optional Any input parameters to pass to the model when solving. If any input parameters that are present in the model are missing from "inputs", then the solution will consist of `ProcessedSymbolicVariable` objects, which must be provided with inputs to obtain their value. """ # Record whether there are any symbolic inputs inputs_dict = inputs_dict or {} has_symbolic_inputs = any( isinstance(v, casadi.MX) for v in inputs_dict.values()) symbolic_inputs = casadi.vertcat( *[v for v in inputs_dict.values() if isinstance(v, casadi.MX)]) # Create casadi objects for the root-finder inputs = casadi.vertcat(*[v for v in inputs_dict.values()]) y0 = model.y0 # If y0 already satisfies the tolerance for all t then keep it if has_symbolic_inputs is False and all( np.all( abs(model.casadi_algebraic(t, y0, inputs).full()) < self.tol) for t in t_eval): pybamm.logger.debug("Keeping same solution at all times") return pybamm.Solution(t_eval, y0, model, inputs_dict, termination="success") # The casadi algebraic solver can read rhs equations, but leaves them unchanged # i.e. the part of the solution vector that corresponds to the differential # equations will be equal to the initial condition provided. This allows this # solver to be used for initialising the DAE solvers if model.rhs == {}: len_rhs = 0 y0_diff = casadi.DM() y0_alg = y0 else: len_rhs = model.concatenated_rhs.size y0_diff = y0[:len_rhs] y0_alg = y0[len_rhs:] y_alg = None # Set up t_sym = casadi.MX.sym("t") y_alg_sym = casadi.MX.sym("y_alg", y0_alg.shape[0]) y_sym = casadi.vertcat(y0_diff, y_alg_sym) t_and_inputs_sym = casadi.vertcat(t_sym, symbolic_inputs) alg = model.casadi_algebraic(t_sym, y_sym, inputs) # Check interpolant extrapolation if model.interpolant_extrapolation_events_eval: extrap_event = [ event(0, y0, inputs) for event in model.interpolant_extrapolation_events_eval ] if extrap_event: if (np.concatenate(extrap_event) < self.extrap_tol).any(): extrap_event_names = [] for event in model.events: if (event.event_type == pybamm.EventType.INTERPOLANT_EXTRAPOLATION and (event.expression.evaluate( 0, y0.full(), inputs=inputs) < self.extrap_tol)): extrap_event_names.append(event.name[12:]) raise pybamm.SolverError( "CasADi solver failed because the following interpolation " "bounds were exceeded at the initial conditions: {}. " "You may need to provide additional interpolation points " "outside these bounds.".format(extrap_event_names)) # Set constraints vector in the casadi format # Constrain the unknowns. 0 (default): no constraint on ui, 1: ui >= 0.0, # -1: ui <= 0.0, 2: ui > 0.0, -2: ui < 0.0. constraints = np.zeros_like(model.bounds[0], dtype=int) # If the lower bound is positive then the variable must always be positive constraints[model.bounds[0] >= 0] = 1 # If the upper bound is negative then the variable must always be negative constraints[model.bounds[1] <= 0] = -1 # Set up rootfinder roots = casadi.rootfinder( "roots", "newton", dict(x=y_alg_sym, p=t_and_inputs_sym, g=alg), { **self.extra_options, "abstol": self.tol, "constraints": list(constraints[len_rhs:]), }, ) timer = pybamm.Timer() integration_time = 0 for idx, t in enumerate(t_eval): # Evaluate algebraic with new t and previous y0, if it's already close # enough then keep it # We can't do this if there are symbolic inputs if has_symbolic_inputs is False and np.all( abs(model.casadi_algebraic(t, y0, inputs).full()) < self.tol): pybamm.logger.debug("Keeping same solution at t={}".format( t * model.timescale_eval)) if y_alg is None: y_alg = y0_alg else: y_alg = casadi.horzcat(y_alg, y0_alg) # Otherwise calculate new y_sol else: t_eval_inputs_sym = casadi.vertcat(t, symbolic_inputs) # Solve try: timer.reset() y_alg_sol = roots(y0_alg, t_eval_inputs_sym) integration_time += timer.time() success = True message = None # Check final output y_sol = casadi.vertcat(y0_diff, y_alg_sol) fun = model.casadi_algebraic(t, y_sol, inputs) except RuntimeError as err: success = False message = err.args[0] fun = None # If there are no symbolic inputs, check the function is below the tol # Skip this check if there are symbolic inputs if success and (has_symbolic_inputs is True or (not any(np.isnan(fun)) and np.all(casadi.fabs(fun) < self.tol))): # update initial guess for the next iteration y0_alg = y_alg_sol y0 = casadi.vertcat(y0_diff, y0_alg) # update solution array if y_alg is None: y_alg = y_alg_sol else: y_alg = casadi.horzcat(y_alg, y_alg_sol) elif not success: raise pybamm.SolverError( "Could not find acceptable solution: {}".format( message)) elif any(np.isnan(fun)): raise pybamm.SolverError( "Could not find acceptable solution: solver returned NaNs" ) else: raise pybamm.SolverError(""" Could not find acceptable solution: solver terminated successfully, but maximum solution error ({}) above tolerance ({}) """.format(casadi.mmax(casadi.fabs(fun)), self.tol)) # Concatenate differential part y_diff = casadi.horzcat(*[y0_diff] * len(t_eval)) y_sol = casadi.vertcat(y_diff, y_alg) # Return solution object (no events, so pass None to t_event, y_event) sol = pybamm.Solution([t_eval], y_sol, model, inputs_dict, termination="success") sol.integration_time = integration_time return sol
def __init__(self, base_variable, solution, known_evals=None): self.base_variable = base_variable self.t_sol = solution.t self.u_sol = solution.y self.mesh = base_variable.mesh self.inputs = solution.inputs self.domain = base_variable.domain self.auxiliary_domains = base_variable.auxiliary_domains self.known_evals = known_evals if self.known_evals: self.base_eval, self.known_evals[ solution.t[0]] = base_variable.evaluate( solution.t[0], solution.y[:, 0], inputs={ name: inp[0] for name, inp in solution.inputs.items() }, known_evals=self.known_evals[solution.t[0]], ) else: self.base_eval = base_variable.evaluate( solution.t[0], solution.y[:, 0], inputs={name: inp[0] for name, inp in solution.inputs.items()}, ) # handle 2D (in space) finite element variables differently if (self.mesh and "current collector" in self.domain and isinstance(self.mesh[0], pybamm.ScikitSubMesh2D)): if len(solution.t) == 1: # space only (steady solution) self.initialise_2D_fixed_t_scikit_fem() else: self.initialise_2D_scikit_fem() # check variable shape else: if len(solution.t) == 1: raise pybamm.SolverError( "Solution time vector must have length > 1. Check whether " "simulation terminated too early.") elif (isinstance(self.base_eval, numbers.Number) or len(self.base_eval.shape) == 0 or self.base_eval.shape[0] == 1): self.initialise_0D() else: n = self.mesh[0].npts base_shape = self.base_eval.shape[0] # Try some shapes that could make the variable a 1D variable if base_shape in [n, n + 1]: self.initialise_1D() else: # Try some shapes that could make the variable a 2D variable first_dim_nodes = self.mesh[0].nodes first_dim_edges = self.mesh[0].edges second_dim_pts = self.base_variable.secondary_mesh[0].nodes if self.base_eval.size // len(second_dim_pts) in [ len(first_dim_nodes), len(first_dim_edges), ]: self.initialise_2D() else: # Raise error for 3D variable raise NotImplementedError( "Shape not recognized for {} ".format( base_variable) + "(note processing of 3D variables is not yet implemented)" )
def calculate_consistent_initial_conditions(self, rhs, algebraic, y0_guess, jac=None): """ Calculate consistent initial conditions for the algebraic equations through root-finding Parameters ---------- rhs : method Function that takes in t and y and returns the value of the differential equations algebraic : method Function that takes in t and y and returns the value of the algebraic equations y0_guess : array-like Array of the user's guess for the initial conditions, used to initialise the root finding algorithm jac : method Function that takes in t and y and returns the value of the jacobian for the algebraic equations Returns ------- y0_consistent : array-like, same shape as y0_guess Initial conditions that are consistent with the algebraic equations (roots of the algebraic equations) """ pybamm.logger.info("Start calculating consistent initial conditions") # Split y0_guess into differential and algebraic len_rhs = rhs(0, y0_guess).shape[0] y0_diff, y0_alg_guess = np.split(y0_guess, [len_rhs]) def root_fun(y0_alg): "Evaluates algebraic using y0_diff (fixed) and y0_alg (changed by algo)" y0 = np.concatenate([y0_diff, y0_alg]) out = algebraic(0, y0) pybamm.logger.debug( "Evaluating algebraic equations at t=0, L2-norm is {}".format( np.linalg.norm(out))) return out if jac: if issparse(jac(0, y0_guess)): def jac_fn(y0_alg): """ Evaluates jacobian using y0_diff (fixed) and y0_alg (varying) """ y0 = np.concatenate([y0_diff, y0_alg]) return jac(0, y0)[:, len_rhs:].toarray() else: def jac_fn(y0_alg): """ Evaluates jacobian using y0_diff (fixed) and y0_alg (varying) """ y0 = np.concatenate([y0_diff, y0_alg]) return jac(0, y0)[:, len_rhs:] else: jac_fn = None # Find the values of y0_alg that are roots of the algebraic equations sol = optimize.root( root_fun, y0_alg_guess, jac=jac_fn, method=self.root_method, tol=self.root_tol, ) # Return full set of consistent initial conditions (y0_diff unchanged) y0_consistent = np.concatenate([y0_diff, sol.x]) if sol.success and np.all(sol.fun < self.root_tol * len(sol.x)): pybamm.logger.info( "Finish calculating consistent initial conditions") return y0_consistent elif not sol.success: raise pybamm.SolverError( "Could not find consistent initial conditions: {}".format( sol.message)) else: raise pybamm.SolverError(""" Could not find consistent initial conditions: solver terminated successfully, but maximum solution error ({}) above tolerance ({}) """.format(np.max(sol.fun), self.root_tol * len(sol.x)))
def _integrate(self, model, t_eval, inputs=None): """ Calculate the solution of the algebraic equations through root-finding Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. t_eval : :class:`numpy.array`, size (k,) The times at which to compute the solution inputs : dict, optional Any input parameters to pass to the model when solving """ inputs = inputs or {} if model.convert_to_format == "casadi": inputs = casadi.vertcat(*[x for x in inputs.values()]) algebraic = model.algebraic_eval y0 = model.y0 y = np.empty((len(y0), len(t_eval))) for idx, t in enumerate(t_eval): def root_fun(y): "Evaluates algebraic using y" out = algebraic(t, y, inputs) pybamm.logger.debug( "Evaluating algebraic equations at t={}, L2-norm is {}". format(t, np.linalg.norm(out))) return out if model.jacobian_eval is not None: def jac(y): return model.jacobian_eval(t, y, inputs) else: jac = None # Evaluate algebraic with new t and previous y0, if it's already close # enough then keep it if np.all(abs(algebraic(t, y0, inputs)) < self.tol): pybamm.logger.debug("Keeping same solution at t={}".format(t)) y[:, idx] = y0 # Otherwise calculate new y0 else: sol = optimize.root( root_fun, y0, method=self.method, tol=self.tol, jac=jac, ) if sol.success and np.all(abs(sol.fun) < self.tol): # update initial guess for the next iteration y0 = sol.x # update solution array y[:, idx] = y0 elif not sol.success: raise pybamm.SolverError( "Could not find acceptable solution: {}".format( sol.message)) else: raise pybamm.SolverError(""" Could not find acceptable solution: solver terminated successfully, but maximum solution error ({}) above tolerance ({}) """.format(np.max(sol.fun), self.tol)) # Return solution object (no events, so pass None to t_event, y_event) return pybamm.Solution(t_eval, y, termination="success")
def integrate(self, derivs, y0, t_eval, events=None, mass_matrix=None, jacobian=None): """ Solve a model defined by dydt with initial conditions y0. Parameters ---------- derivs : method A function that takes in t (size (1,)), y (size (n,)) and returns the time-derivative dydt (size (n,)) y0 : :class:`numpy.array`, size (n,) The initial conditions t_eval : :class:`numpy.array`, size (k,) The times at which to compute the solution events : method, optional A function that takes in t and y and returns conditions for the solver to stop mass_matrix : array_like, optional The (sparse) mass matrix for the chosen spatial method. jacobian : method, optional A function that takes in t and y and returns the Jacobian. If None, the solver will approximate the Jacobian. Returns ------- object An object containing the times and values of the solution, as well as various diagnostic messages. """ extra_options = {"rtol": self.rtol, "atol": self.atol} # check for user-supplied Jacobian implicit_methods = ["Radau", "BDF", "LSODA"] if np.any([self.method in implicit_methods]): if jacobian: extra_options.update({"jac": jacobian}) # make events terminal so that the solver stops when they are reached if events: for event in events: event.terminal = True extra_options.update({"events": events}) sol = it.solve_ivp(derivs, (t_eval[0], t_eval[-1]), y0, t_eval=t_eval, method=self.method, dense_output=True, **extra_options) if sol.success: # Set the reason for termination if sol.message == "A termination event occurred.": termination = "event" t_event = [] for time in sol.t_events: if time: t_event = np.append(t_event, np.max(time)) t_event = np.array([np.max(t_event)]) y_event = sol.sol(t_event) elif sol.message.startswith( "The solver successfully reached the end"): termination = "final time" t_event = None y_event = np.array(None) return pybamm.Solution(sol.t, sol.y, t_event, y_event, termination) else: raise pybamm.SolverError(sol.message)
def _integrate(self, model, t_eval, inputs=None): """ Calculate the solution of the algebraic equations through root-finding Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. t_eval : :class:`numpy.array`, size (k,) The times at which to compute the solution inputs : dict, optional Any input parameters to pass to the model when solving. If any input parameters that are present in the model are missing from "inputs", then the solution will consist of `ProcessedSymbolicVariable` objects, which must be provided with inputs to obtain their value. """ # Record whether there are any symbolic inputs inputs = inputs or {} has_symbolic_inputs = any(isinstance(v, casadi.MX) for v in inputs.values()) # Create casadi objects for the root-finder inputs = casadi.vertcat(*[x for x in inputs.values()]) y0 = model.y0 # The casadi algebraic solver can read rhs equations, but leaves them unchanged # i.e. the part of the solution vector that corresponds to the differential # equations will be equal to the initial condition provided. This allows this # solver to be used for initialising the DAE solvers if model.rhs == {}: len_rhs = 0 y0_diff = casadi.DM() y0_alg = y0 else: len_rhs = model.concatenated_rhs.size y0_diff = y0[:len_rhs] y0_alg = y0[len_rhs:] y_alg = None # Set up t_sym = casadi.MX.sym("t") y_alg_sym = casadi.MX.sym("y_alg", y0_alg.shape[0]) y_sym = casadi.vertcat(y0_diff, y_alg_sym) p_sym = casadi.MX.sym("p", inputs.shape[0]) t_p_sym = casadi.vertcat(t_sym, p_sym) alg = model.casadi_algebraic(t_sym, y_sym, p_sym) # Set constraints vector in the casadi format # Constrain the unknowns. 0 (default): no constraint on ui, 1: ui >= 0.0, # -1: ui <= 0.0, 2: ui > 0.0, -2: ui < 0.0. constraints = np.zeros_like(model.bounds[0], dtype=int) # If the lower bound is positive then the variable must always be positive constraints[model.bounds[0] >= 0] = 1 # If the upper bound is negative then the variable must always be negative constraints[model.bounds[1] <= 0] = -1 # Set up rootfinder roots = casadi.rootfinder( "roots", "newton", dict(x=y_alg_sym, p=t_p_sym, g=alg), { **self.extra_options, "abstol": self.tol, "constraints": list(constraints[len_rhs:]), }, ) for idx, t in enumerate(t_eval): # Evaluate algebraic with new t and previous y0, if it's already close # enough then keep it # We can't do this if there are symbolic inputs if has_symbolic_inputs is False and np.all( abs(model.casadi_algebraic(t, y0, inputs).full()) < self.tol ): pybamm.logger.debug( "Keeping same solution at t={}".format(t * model.timescale_eval) ) if y_alg is None: y_alg = y0_alg else: y_alg = casadi.horzcat(y_alg, y0_alg) # Otherwise calculate new y_sol else: t_inputs = casadi.vertcat(t, inputs) # Solve try: y_alg_sol = roots(y0_alg, t_inputs) success = True message = None # Check final output y_sol = casadi.vertcat(y0_diff, y_alg_sol) fun = model.casadi_algebraic(t, y_sol, inputs) except RuntimeError as err: success = False message = err.args[0] fun = None # If there are no symbolic inputs, check the function is below the tol # Skip this check if there are symbolic inputs if success and ( has_symbolic_inputs is True or np.all(casadi.fabs(fun) < self.tol) ): # update initial guess for the next iteration y0_alg = y_alg_sol # update solution array if y_alg is None: y_alg = y_alg_sol else: y_alg = casadi.horzcat(y_alg, y_alg_sol) elif not success: raise pybamm.SolverError( "Could not find acceptable solution: {}".format(message) ) else: raise pybamm.SolverError( """ Could not find acceptable solution: solver terminated successfully, but maximum solution error ({}) above tolerance ({}) """.format( casadi.mmax(casadi.fabs(fun)), self.tol ) ) # Concatenate differential part y_diff = casadi.horzcat(*[y0_diff] * len(t_eval)) y_sol = casadi.vertcat(y_diff, y_alg) # Return solution object (no events, so pass None to t_event, y_event) return pybamm.Solution(t_eval, y_sol, termination="success")
def _integrate(self, model, t_eval, inputs=None): """ Calculate the solution of the algebraic equations through root-finding Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. t_eval : :class:`numpy.array`, size (k,) The times at which to compute the solution inputs : dict, optional Any input parameters to pass to the model when solving """ inputs = inputs or {} if model.convert_to_format == "casadi": inputs = casadi.vertcat(*[x for x in inputs.values()]) y0 = model.y0 if isinstance(y0, casadi.DM): y0 = y0.full().flatten() # The casadi algebraic solver can read rhs equations, but leaves them unchanged # i.e. the part of the solution vector that corresponds to the differential # equations will be equal to the initial condition provided. This allows this # solver to be used for initialising the DAE solvers # Split y0 into differential and algebraic if model.rhs == {}: len_rhs = 0 else: len_rhs = model.rhs_eval(t_eval[0], y0, inputs).shape[0] y0_diff, y0_alg = np.split(y0, [len_rhs]) algebraic = model.algebraic_eval y_alg = np.empty((len(y0_alg), len(t_eval))) for idx, t in enumerate(t_eval): def root_fun(y_alg): "Evaluates algebraic using y" y = np.concatenate([y0_diff, y_alg]) out = algebraic(t, y, inputs) pybamm.logger.debug( "Evaluating algebraic equations at t={}, L2-norm is {}". format(t * model.timescale_eval, np.linalg.norm(out))) return out jac = model.jac_algebraic_eval if jac: if issparse(jac(t_eval[0], y0, inputs)): def jac_fn(y_alg): """ Evaluates jacobian using y0_diff (fixed) and y_alg (varying) """ y = np.concatenate([y0_diff, y_alg]) return jac(0, y, inputs)[:, len_rhs:].toarray() else: def jac_fn(y_alg): """ Evaluates jacobian using y0_diff (fixed) and y_alg (varying) """ y = np.concatenate([y0_diff, y_alg]) return jac(0, y, inputs)[:, len_rhs:] else: jac_fn = None # Evaluate algebraic with new t and previous y0, if it's already close # enough then keep it if np.all(abs(algebraic(t, y0, inputs)) < self.tol): pybamm.logger.debug("Keeping same solution at t={}".format(t)) y_alg[:, idx] = y0_alg # Otherwise calculate new y0 else: sol = optimize.root( root_fun, y0_alg, method=self.method, tol=self.tol, jac=jac_fn, options=self.extra_options, ) if sol.success and np.all(abs(sol.fun) < self.tol): # update initial guess for the next iteration y0_alg = sol.x # update solution array y_alg[:, idx] = y0_alg elif not sol.success: raise pybamm.SolverError( "Could not find acceptable solution: {}".format( sol.message)) else: raise pybamm.SolverError(""" Could not find acceptable solution: solver terminated successfully, but maximum solution error ({}) above tolerance ({}) """.format(np.max(sol.fun), self.tol)) # Concatenate differential part y_diff = np.r_[[y0_diff] * len(t_eval)].T y_sol = np.r_[y_diff, y_alg] # Return solution object (no events, so pass None to t_event, y_event) return pybamm.Solution(t_eval, y_sol, termination="success")
def _integrate(self, model, t_eval, inputs=None): """ Solve a DAE model defined by residuals with initial conditions y0. Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. t_eval : numeric type The times at which to compute the solution inputs : dict, optional Any external variables or input parameters to pass to the model when solving """ inputs = inputs or {} if self.mode == "fast": integrator = self.get_integrator(model, t_eval, inputs) solution = self._run_integrator(integrator, model, model.y0, inputs, t_eval) solution.termination = "final time" return solution elif not model.events: pybamm.logger.info("No events found, running fast mode") integrator = self.get_integrator(model, t_eval, inputs) solution = self._run_integrator(integrator, model, model.y0, inputs, t_eval) solution.termination = "final time" return solution elif self.mode == "safe": # Step-and-check t = t_eval[0] init_event_signs = np.sign( np.concatenate([ event(t, model.y0) for event in model.terminate_events_eval ])) pybamm.logger.info("Start solving {} with {}".format( model.name, self.name)) y0 = model.y0 # Initialize solution solution = pybamm.Solution(np.array([t]), y0[:, np.newaxis]) solution.solve_time = 0 for dt in np.diff(t_eval): # Step solved = False count = 0 while not solved: integrator = self.get_integrator(model, np.array([t, t + dt]), inputs) # Try to solve with the current step, if it fails then halve the # step size and try again. This will make solution.t slightly # different to t_eval, but shouldn't matter too much as it should # only happen near events. try: current_step_sol = self._run_integrator( integrator, model, y0, inputs, np.array([t, t + dt])) solved = True except pybamm.SolverError: dt /= 2 count += 1 if count >= self.max_step_decrease_count: raise pybamm.SolverError(""" Maximum number of decreased steps occurred at t={}. Try solving the model up to this time only """.format(t)) # Check most recent y new_event_signs = np.sign( np.concatenate([ event(t, current_step_sol.y[:, -1]) for event in model.terminate_events_eval ])) # Exit loop if the sign of an event changes if (new_event_signs != init_event_signs).any(): solution.termination = "event" solution.t_event = solution.t[-1] solution.y_event = solution.y[:, -1] break else: # assign temporary solve time current_step_sol.solve_time = np.nan # append solution from the current step to solution solution.append(current_step_sol) # update time t += dt # update y0 y0 = solution.y[:, -1] return solution
def set_up(self, model, inputs=None, t_eval=None): """Unpack model, perform checks, simplify and calculate jacobian. Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. Must have attributes rhs and initial_conditions inputs : dict, optional Any input parameters to pass to the model when solving t_eval : numeric type, optional The times (in seconds) at which to compute the solution """ # Check model.algebraic for ode solvers if self.ode_solver is True and len(model.algebraic) > 0: raise pybamm.SolverError( "Cannot use ODE solver '{}' to solve DAE model".format( self.name)) # Check model.rhs for algebraic solvers if self.algebraic_solver is True and len(model.rhs) > 0: raise pybamm.SolverError( """Cannot use algebraic solver to solve model with time derivatives""" ) # casadi solver won't allow solving algebraic model so we have to raise an # error here if isinstance(self, pybamm.CasadiSolver) and len(model.rhs) == 0: raise pybamm.SolverError( "Cannot use CasadiSolver to solve algebraic model, " "use CasadiAlgebraicSolver instead") # Discretise model if it isn't already discretised # This only works with purely 0D models, as otherwise the mesh and spatial # method should be specified by the user if model.is_discretised is False: try: disc = pybamm.Discretisation() disc.process_model(model) except pybamm.DiscretisationError as e: raise pybamm.DiscretisationError( "Cannot automatically discretise model, " "model should be discretised before solving ({})".format( e)) inputs = inputs or {} # Set model timescale model.timescale_eval = model.timescale.evaluate(inputs=inputs) # Set model lengthscales model.length_scales_eval = { domain: scale.evaluate(inputs=inputs) for domain, scale in model.length_scales.items() } if (isinstance(self, (pybamm.CasadiSolver, pybamm.CasadiAlgebraicSolver)) ) and model.convert_to_format != "casadi": pybamm.logger.warning( "Converting {} to CasADi for solving with CasADi solver". format(model.name)) model.convert_to_format = "casadi" if (isinstance(self.root_method, pybamm.CasadiAlgebraicSolver) and model.convert_to_format != "casadi"): pybamm.logger.warning( "Converting {} to CasADi for calculating ICs with CasADi". format(model.name)) model.convert_to_format = "casadi" if model.convert_to_format != "casadi": simp = pybamm.Simplification() # Create Jacobian from concatenated rhs and algebraic y = pybamm.StateVector( slice(0, model.concatenated_initial_conditions.size)) # set up Jacobian object, for re-use of dict jacobian = pybamm.Jacobian() else: # Convert model attributes to casadi t_casadi = casadi.MX.sym("t") y_diff = casadi.MX.sym("y_diff", model.concatenated_rhs.size) y_alg = casadi.MX.sym("y_alg", model.concatenated_algebraic.size) y_casadi = casadi.vertcat(y_diff, y_alg) p_casadi = {} for name, value in inputs.items(): if isinstance(value, numbers.Number): p_casadi[name] = casadi.MX.sym(name) else: p_casadi[name] = casadi.MX.sym(name, value.shape[0]) p_casadi_stacked = casadi.vertcat(*[p for p in p_casadi.values()]) def process(func, name, use_jacobian=None): def report(string): # don't log event conversion if "event" not in string: pybamm.logger.info(string) if use_jacobian is None: use_jacobian = model.use_jacobian if model.convert_to_format != "casadi": # Process with pybamm functions if model.use_simplify: report(f"Simplifying {name}") func = simp.simplify(func) if model.convert_to_format == "jax": report(f"Converting {name} to jax") jax_func = pybamm.EvaluatorJax(func) if use_jacobian: report(f"Calculating jacobian for {name}") jac = jacobian.jac(func, y) if model.use_simplify: report(f"Simplifying jacobian for {name}") jac = simp.simplify(jac) if model.convert_to_format == "python": report(f"Converting jacobian for {name} to python") jac = pybamm.EvaluatorPython(jac) elif model.convert_to_format == "jax": report(f"Converting jacobian for {name} to jax") jac = jax_func.get_jacobian() jac = jac.evaluate else: jac = None if model.convert_to_format == "python": report(f"Converting {name} to python") func = pybamm.EvaluatorPython(func) if model.convert_to_format == "jax": report(f"Converting {name} to jax") func = jax_func func = func.evaluate else: # Process with CasADi report(f"Converting {name} to CasADi") func = func.to_casadi(t_casadi, y_casadi, inputs=p_casadi) if use_jacobian: report(f"Calculating jacobian for {name} using CasADi") jac_casadi = casadi.jacobian(func, y_casadi) jac = casadi.Function( name, [t_casadi, y_casadi, p_casadi_stacked], [jac_casadi]) else: jac = None func = casadi.Function(name, [t_casadi, y_casadi, p_casadi_stacked], [func]) if name == "residuals": func_call = Residuals(func, name, model) else: func_call = SolverCallable(func, name, model) if jac is not None: jac_call = SolverCallable(jac, name + "_jac", model) else: jac_call = None return func, func_call, jac_call # Check for heaviside and modulo functions in rhs and algebraic and add # discontinuity events if these exist. # Note: only checks for the case of t < X, t <= X, X < t, or X <= t, but also # accounts for the fact that t might be dimensional # Only do this for DAE models as ODE models can deal with discontinuities fine if len(model.algebraic) > 0: for symbol in itertools.chain( model.concatenated_rhs.pre_order(), model.concatenated_algebraic.pre_order(), ): if isinstance(symbol, pybamm.Heaviside): found_t = False # Dimensionless if symbol.right.id == pybamm.t.id: expr = symbol.left found_t = True elif symbol.left.id == pybamm.t.id: expr = symbol.right found_t = True # Dimensional elif symbol.right.id == (pybamm.t * model.timescale).id: expr = symbol.left.new_copy( ) / symbol.right.right.new_copy() found_t = True elif symbol.left.id == (pybamm.t * model.timescale).id: expr = symbol.right.new_copy( ) / symbol.left.right.new_copy() found_t = True # Update the events if the heaviside function depended on t if found_t: model.events.append( pybamm.Event( str(symbol), expr.new_copy(), pybamm.EventType.DISCONTINUITY, )) elif isinstance(symbol, pybamm.Modulo): found_t = False # Dimensionless if symbol.left.id == pybamm.t.id: expr = symbol.right found_t = True # Dimensional elif symbol.left.id == (pybamm.t * model.timescale).id: expr = symbol.right.new_copy( ) / symbol.left.right.new_copy() found_t = True # Update the events if the modulo function depended on t if found_t: if t_eval is None: N_events = 200 else: N_events = t_eval[-1] // expr.value for i in np.arange(N_events): model.events.append( pybamm.Event( str(symbol), expr.new_copy() * pybamm.Scalar(i + 1), pybamm.EventType.DISCONTINUITY, )) # Process initial conditions initial_conditions = process( model.concatenated_initial_conditions, "initial_conditions", use_jacobian=False, )[0] init_eval = InitialConditions(initial_conditions, model) # Process rhs, algebraic and event expressions rhs, rhs_eval, jac_rhs = process(model.concatenated_rhs, "RHS") algebraic, algebraic_eval, jac_algebraic = process( model.concatenated_algebraic, "algebraic") terminate_events_eval = [ process(event.expression, "event", use_jacobian=False)[1] for event in model.events if event.event_type == pybamm.EventType.TERMINATION ] # discontinuity events are evaluated before the solver is called, so don't need # to process them discontinuity_events_eval = [ event for event in model.events if event.event_type == pybamm.EventType.DISCONTINUITY ] # Add the solver attributes model.init_eval = init_eval model.rhs_eval = rhs_eval model.algebraic_eval = algebraic_eval model.jac_algebraic_eval = jac_algebraic model.terminate_events_eval = terminate_events_eval model.discontinuity_events_eval = discontinuity_events_eval # Calculate initial conditions model.y0 = init_eval(inputs) # Save CasADi functions for the CasADi solver # Note: when we pass to casadi the ode part of the problem must be in explicit # form so we pre-multiply by the inverse of the mass matrix if isinstance( self.root_method, pybamm.CasadiAlgebraicSolver) or isinstance( self, (pybamm.CasadiSolver, pybamm.CasadiAlgebraicSolver)): # can use DAE solver to solve model with algebraic equations only if len(model.rhs) > 0: mass_matrix_inv = casadi.MX(model.mass_matrix_inv.entries) explicit_rhs = mass_matrix_inv @ rhs(t_casadi, y_casadi, p_casadi_stacked) model.casadi_rhs = casadi.Function( "rhs", [t_casadi, y_casadi, p_casadi_stacked], [explicit_rhs]) model.casadi_algebraic = algebraic if len(model.rhs) == 0: # No rhs equations: residuals is algebraic only model.residuals_eval = Residuals(algebraic, "residuals", model) model.jacobian_eval = jac_algebraic elif len(model.algebraic) == 0: # No algebraic equations: residuals is rhs only model.residuals_eval = Residuals(rhs, "residuals", model) model.jacobian_eval = jac_rhs # Calculate consistent initial conditions for the algebraic equations else: all_states = pybamm.NumpyConcatenation( model.concatenated_rhs, model.concatenated_algebraic) # Process again, uses caching so should be quick residuals_eval, jacobian_eval = process(all_states, "residuals")[1:] model.residuals_eval = residuals_eval model.jacobian_eval = jacobian_eval pybamm.logger.info("Finish solver set-up")
def _integrate(self, model, t_eval, inputs_dict=None): """ Solve a model defined by dydt with initial conditions y0. Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. t_eval : numeric type The times at which to compute the solution inputs_dict : dict, optional Any input parameters to pass to the model when solving """ if model.rhs_eval.form == "casadi": inputs = casadi.vertcat(*[x for x in inputs_dict.values()]) else: inputs = inputs_dict y0 = model.y0 if isinstance(y0, casadi.DM): y0 = y0.full().flatten() derivs = model.rhs_eval events = model.terminate_events_eval jacobian = model.jacobian_eval def eqsydot(t, y, return_ydot): return_ydot[:] = derivs(t, y, inputs) def rootfn(t, y, return_root): return_root[:] = [event(t, y, inputs) for event in events] if jacobian: jac_y0_t0 = jacobian(t_eval[0], y0, inputs) if sparse.issparse(jac_y0_t0): def jacfn(t, y, fy, J): J[:][:] = jacobian(t, y, inputs).toarray() def jac_times_vecfn(v, Jv, t, y, userdata): Jv[:] = userdata._jac_eval * v return 0 else: def jacfn(t, y, fy, J): J[:][:] = jacobian(t, y, inputs) def jac_times_vecfn(v, Jv, t, y, userdata): Jv[:] = np.matmul(userdata._jac_eval, v) return 0 def jac_times_setupfn(t, y, fy, userdata): userdata._jac_eval = jacobian(t, y, inputs) return 0 extra_options = { **self.extra_options, "old_api": False, "rtol": self.rtol, "atol": self.atol, } # Read linsolver (defaults to dense) linsolver = extra_options.get("linsolver", "dense") if jacobian: if linsolver in ("dense", "lapackdense"): extra_options.update({"jacfn": jacfn}) elif linsolver in ("spgmr", "spbcgs", "sptfqmr"): extra_options.update( { "jac_times_setupfn": jac_times_setupfn, "jac_times_vecfn": jac_times_vecfn, "user_data": self, } ) if events: extra_options.update({"rootfn": rootfn, "nr_rootfns": len(events)}) ode_solver = scikits_odes.ode(self.method, eqsydot, **extra_options) timer = pybamm.Timer() sol = ode_solver.solve(t_eval, y0) integration_time = timer.time() # return solution, we need to tranpose y to match scipy's ivp interface if sol.flag in [0, 2]: # 0 = solved for all t_eval if sol.flag == 0: termination = "final time" # 2 = found root(s) elif sol.flag == 2: termination = "event" if sol.roots.t is None: t_root = None else: t_root = sol.roots.t sol = pybamm.Solution( sol.values.t, np.transpose(sol.values.y), model, inputs_dict, t_root, np.transpose(sol.roots.y), termination, ) sol.integration_time = integration_time return sol else: raise pybamm.SolverError(sol.message)
def solve(self, model, t_eval=None, external_variables=None, inputs=None): """ Execute the solver setup and calculate the solution of the model at specified times. Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. Must have attributes rhs and initial_conditions t_eval : numeric type The times (in seconds) at which to compute the solution external_variables : dict A dictionary of external variables and their corresponding values at the current time inputs : dict, optional Any input parameters to pass to the model when solving Raises ------ :class:`pybamm.ModelError` If an empty model is passed (`model.rhs = {}` and `model.algebraic={}` and `model.variables = {}`) """ pybamm.logger.info("Start solving {} with {}".format( model.name, self.name)) # Make sure model isn't empty if len(model.rhs) == 0 and len(model.algebraic) == 0: if not isinstance(self, pybamm.DummySolver): raise pybamm.ModelError( "Cannot solve empty model, use `pybamm.DummySolver` instead" ) # t_eval can only be None if the solver is an algebraic solver. In that case # set it to 0 if t_eval is None: if self.algebraic_solver is True: t_eval = np.array([0]) else: raise ValueError("t_eval cannot be None") # If t_eval is provided as [t0, tf] return the solution at 100 points elif isinstance(t_eval, list): if len(t_eval) == 1 and self.algebraic_solver is True: pass elif len(t_eval) != 2: raise pybamm.SolverError( "'t_eval' can be provided as an array of times at which to " "return the solution, or as a list [t0, tf] where t0 is the " "initial time and tf is the final time, but has been provided " "as a list of length {}.".format(len(t_eval))) else: t_eval = np.linspace(t_eval[0], t_eval[-1], 100) # Make sure t_eval is monotonic if (np.diff(t_eval) < 0).any(): raise pybamm.SolverError("t_eval must increase monotonically") # Set up external variables and inputs ext_and_inputs = self._set_up_ext_and_inputs(model, external_variables, inputs) # Set up timer = pybamm.Timer() # Set up (if not done already) if model not in self.models_set_up: self.set_up(model, ext_and_inputs, t_eval) self.models_set_up.update({ model: { "initial conditions": model.concatenated_initial_conditions } }) else: ics_set_up = self.models_set_up[model]["initial conditions"] # Check that initial conditions have not been updated if ics_set_up.id != model.concatenated_initial_conditions.id: # If the new initial conditions are different, set up again # Doing the whole setup again might be slow, but no need to prematurely # optimize this self.set_up(model, ext_and_inputs, t_eval) self.models_set_up[model][ "initial conditions"] = model.concatenated_initial_conditions set_up_time = timer.time() timer.reset() # (Re-)calculate consistent initial conditions self._set_initial_conditions(model, ext_and_inputs, update_rhs=True) # Non-dimensionalise time t_eval_dimensionless = t_eval / model.timescale_eval # Calculate discontinuities discontinuities = [ event.expression.evaluate(inputs=inputs) for event in model.discontinuity_events_eval ] # make sure they are increasing in time discontinuities = sorted(discontinuities) # remove any identical discontinuities discontinuities = [ v for i, v in enumerate(discontinuities) if (i == len(discontinuities) - 1 or discontinuities[i] < discontinuities[i + 1]) and v > 0 ] # remove any discontinuities after end of t_eval discontinuities = [ v for v in discontinuities if v < t_eval_dimensionless[-1] ] if len(discontinuities) > 0: pybamm.logger.info( "Discontinuity events found at t = {}".format(discontinuities)) else: pybamm.logger.info("No discontinuity events found") # insert time points around discontinuities in t_eval # keep track of sub sections to integrate by storing start and end indices start_indices = [0] end_indices = [] eps = sys.float_info.epsilon for dtime in discontinuities: dindex = np.searchsorted(t_eval_dimensionless, dtime, side="left") end_indices.append(dindex + 1) start_indices.append(dindex + 1) if dtime - eps < t_eval_dimensionless[dindex] < dtime + eps: t_eval_dimensionless[dindex] += eps t_eval_dimensionless = np.insert(t_eval_dimensionless, dindex, dtime - eps) else: t_eval_dimensionless = np.insert(t_eval_dimensionless, dindex, [dtime - eps, dtime + eps]) end_indices.append(len(t_eval_dimensionless)) # integrate separately over each time segment and accumulate into the solution # object, restarting the solver at each discontinuity (and recalculating a # consistent state afterwards if a dae) old_y0 = model.y0 solution = None for start_index, end_index in zip(start_indices, end_indices): pybamm.logger.info("Calling solver for {} < t < {}".format( t_eval_dimensionless[start_index] * model.timescale_eval, t_eval_dimensionless[end_index - 1] * model.timescale_eval, )) new_solution = self._integrate( model, t_eval_dimensionless[start_index:end_index], ext_and_inputs) new_solution.solve_time = timer.time() if solution is None: solution = new_solution else: solution.append(new_solution, start_index=0) if solution.termination != "final time": break if end_index != len(t_eval_dimensionless): # setup for next integration subsection last_state = solution.y[:, -1] # update y0 (for DAE solvers, this updates the initial guess for the # rootfinder) model.y0 = last_state if len(model.algebraic) > 0: model.y0 = self.calculate_consistent_state( model, t_eval_dimensionless[end_index], ext_and_inputs) # Assign times solution.set_up_time = set_up_time solution.solve_time = timer.time() # restore old y0 model.y0 = old_y0 # Add model and inputs to solution solution.model = model solution.inputs = ext_and_inputs # Copy the timescale_eval and lengthscale_evals solution.timescale_eval = model.timescale_eval solution.length_scales_eval = model.length_scales_eval # Identify the event that caused termination termination = self.get_termination_reason(solution, model.events) pybamm.logger.info("Finish solving {} ({})".format( model.name, termination)) pybamm.logger.info( "Set-up time: {}, Solve time: {}, Total time: {}".format( timer.format(solution.set_up_time), timer.format(solution.solve_time), timer.format(solution.total_time), )) # Raise error if solution only contains one timestep (except for algebraic # solvers, where we may only expect one time in the solution) if self.algebraic_solver is False and len(solution.t) == 1: raise pybamm.SolverError( "Solution time vector has length 1. " "Check whether simulation terminated too early.") return solution
def solve( self, t_eval=None, solver=None, check_model=True, save_at_cycles=None, starting_solution=None, **kwargs, ): """ A method to solve the model. This method will automatically build and set the model parameters if not already done so. Parameters ---------- t_eval : numeric type, optional The times (in seconds) at which to compute the solution. Can be provided as an array of times at which to return the solution, or as a list `[t0, tf]` where `t0` is the initial time and `tf` is the final time. If provided as a list the solution is returned at 100 points within the interval `[t0, tf]`. If not using an experiment or running a drive cycle simulation (current provided as data) `t_eval` *must* be provided. If running an experiment the values in `t_eval` are ignored, and the solution times are specified by the experiment. If None and the parameter "Current function [A]" is read from data (i.e. drive cycle simulation) the model will be solved at the times provided in the data. solver : :class:`pybamm.BaseSolver`, optional The solver to use to solve the model. If None, Simulation.solver is used check_model : bool, optional If True, model checks are performed after discretisation (see :meth:`pybamm.Discretisation.process_model`). Default is True. save_at_cycles : int or list of ints, optional Which cycles to save the full sub-solutions for. If None, all cycles are saved. If int, every multiple of save_at_cycles is saved. If list, every cycle in the list is saved. starting_solution : :class:`pybamm.Solution` The solution to start stepping from. If None (default), then self._solution is used. Must be None if not using an experiment. **kwargs Additional key-word arguments passed to `solver.solve`. See :meth:`pybamm.BaseSolver.solve`. """ # Setup if solver is None: solver = self.solver if self.operating_mode in ["without experiment", "drive cycle"]: self.build(check_model=check_model) if save_at_cycles is not None: raise ValueError( "'save_at_cycles' option can only be used if simulating an " "Experiment ") if starting_solution is not None: raise ValueError( "starting_solution can only be provided if simulating an Experiment" ) if self.operating_mode == "without experiment": if t_eval is None: raise pybamm.SolverError( "'t_eval' must be provided if not using an experiment or " "simulating a drive cycle. 't_eval' can be provided as an " "array of times at which to return the solution, or as a " "list [t0, tf] where t0 is the initial time and tf is the " "final time. " "For a constant current (dis)charge the suggested 't_eval' " "is [0, 3700/C] where C is the C-rate. " "For example, run\n\n" "\tsim.solve([0, 3700])\n\n" "for a 1C discharge.") elif self.operating_mode == "drive cycle": # For drive cycles (current provided as data) we perform additional # tests on t_eval (if provided) to ensure the returned solution # captures the input. time_data = self._parameter_values["Current function [A]"].x[0] # If no t_eval is provided, we use the times provided in the data. if t_eval is None: pybamm.logger.info( "Setting t_eval as specified by the data") t_eval = time_data # If t_eval is provided we first check if it contains all of the # times in the data to within 10-12. If it doesn't, we then check # that the largest gap in t_eval is smaller than the smallest gap in # the time data (to ensure the resolution of t_eval is fine enough). # We only raise a warning here as users may genuinely only want # the solution returned at some specified points. elif (set(np.round(time_data, 12)).issubset( set(np.round(t_eval, 12)))) is False: warnings.warn( """ t_eval does not contain all of the time points in the data set. Note: passing t_eval = None automatically sets t_eval to be the points in the data. """, pybamm.SolverWarning, ) dt_data_min = np.min(np.diff(time_data)) dt_eval_max = np.max(np.diff(t_eval)) if dt_eval_max > dt_data_min + sys.float_info.epsilon: warnings.warn( """ The largest timestep in t_eval ({}) is larger than the smallest timestep in the data ({}). The returned solution may not have the correct resolution to accurately capture the input. Try refining t_eval. Alternatively, passing t_eval = None automatically sets t_eval to be the points in the data. """.format(dt_eval_max, dt_data_min), pybamm.SolverWarning, ) self._solution = solver.solve(self.built_model, t_eval, **kwargs) elif self.operating_mode == "with experiment": self.build_for_experiment(check_model=check_model) if t_eval is not None: pybamm.logger.warning( "Ignoring t_eval as solution times are specified by the experiment" ) # Re-initialize solution, e.g. for solving multiple times with different # inputs without having to build the simulation again self._solution = starting_solution # Step through all experimental conditions inputs = kwargs.get("inputs", {}) pybamm.logger.info("Start running experiment") timer = pybamm.Timer() if starting_solution is None: starting_solution_cycles = [] else: starting_solution_cycles = starting_solution.cycles.copy() cycle_offset = len(starting_solution_cycles) all_cycle_solutions = starting_solution_cycles current_solution = starting_solution idx = 0 num_cycles = len(self.experiment.cycle_lengths) feasible = True # simulation will stop if experiment is infeasible for cycle_num, cycle_length in enumerate( self.experiment.cycle_lengths, start=1): pybamm.logger.notice( f"Cycle {cycle_num+cycle_offset}/{num_cycles+cycle_offset} " f"({timer.time()} elapsed) " + "-" * 20) steps = [] cycle_solution = None for step_num in range(1, cycle_length + 1): exp_inputs = self._experiment_inputs[idx] dt = self._experiment_times[idx] op_conds_str = self.experiment.operating_conditions_strings[ idx] op_conds_elec = self.experiment.operating_conditions[ idx][:2] model = self.op_conds_to_built_models[op_conds_elec] # Use 1-indexing for printing cycle number as it is more # human-intuitive pybamm.logger.notice( f"Cycle {cycle_num+cycle_offset}/{num_cycles+cycle_offset}, " f"step {step_num}/{cycle_length}: {op_conds_str}") inputs.update(exp_inputs) kwargs["inputs"] = inputs # Make sure we take at least 2 timesteps npts = max(int(round(dt / exp_inputs["period"])) + 1, 2) step_solution = solver.step( current_solution, model, dt, npts=npts, save=False, **kwargs, ) steps.append(step_solution) current_solution = step_solution cycle_solution = cycle_solution + step_solution # Only allow events specified by experiment if not (cycle_solution is None or cycle_solution.termination == "final time" or "[experiment]" in cycle_solution.termination): feasible = False break # Increment index for next iteration idx += 1 # Break if the experiment is infeasible if feasible is False: pybamm.logger.warning( "\n\n\tExperiment is infeasible: '{}' ".format( cycle_solution.termination) + "was triggered during '{}'. ".format( self.experiment.operating_conditions_strings[idx]) + "The returned solution only contains the first " "{} cycles. ".format(cycle_num - 1 + cycle_offset) + "Try reducing the current, shortening the time interval, " "or reducing the period.\n\n") break # At the final step of the inner loop we save the cycle self._solution = self.solution + cycle_solution cycle_solution.steps = steps all_cycle_solutions.append(cycle_solution) if self.solution is not None: self.solution.cycles = all_cycle_solutions pybamm.logger.notice( "Finish experiment simulation, took {}".format(timer.time())) return self.solution
def step( self, old_solution, model, dt, npts=2, external_variables=None, inputs=None, save=True, ): """ Step the solution of the model forward by a given time increment. The first time this method is called it executes the necessary setup by calling `self.set_up(model)`. Parameters ---------- old_solution : :class:`pybamm.Solution` or None The previous solution to be added to. If `None`, a new solution is created. model : :class:`pybamm.BaseModel` The model whose solution to calculate. Must have attributes rhs and initial_conditions dt : numeric type The timestep (in seconds) over which to step the solution npts : int, optional The number of points at which the solution will be returned during the step dt. default is 2 (returns the solution at t0 and t0 + dt). external_variables : dict A dictionary of external variables and their corresponding values at the current time inputs : dict, optional Any input parameters to pass to the model when solving save : bool Turn on to store the solution of all previous timesteps Raises ------ :class:`pybamm.ModelError` If an empty model is passed (`model.rhs = {}` and `model.algebraic = {}` and `model.variables = {}`) """ if old_solution is not None and not ( old_solution.termination == "final time" or "[experiment]" in old_solution.termination): # Return same solution as an event has already been triggered # With hack to allow stepping past experiment current / voltage cut-off return old_solution # Make sure model isn't empty if len(model.rhs) == 0 and len(model.algebraic) == 0: if not isinstance(self, pybamm.DummySolver): raise pybamm.ModelError( "Cannot step empty model, use `pybamm.DummySolver` instead" ) # Set timer timer = pybamm.Timer() # Set up external variables and inputs external_variables = external_variables or {} inputs = inputs or {} ext_and_inputs = {**external_variables, **inputs} # Check that any inputs that may affect the scaling have not changed # Set model timescale temp_timescale_eval = model.timescale.evaluate(inputs=inputs) # Set model lengthscales temp_length_scales_eval = { domain: scale.evaluate(inputs=inputs) for domain, scale in model.length_scales.items() } if old_solution is not None: if temp_timescale_eval != old_solution.timescale_eval: raise pybamm.SolverError( "The model timescale is a function of an input parameter " "and the value has changed between steps!") for domain in temp_length_scales_eval.keys(): old_dom_eval = old_solution.length_scales_eval[domain] if temp_length_scales_eval[domain] != old_dom_eval: pybamm.logger.error( "The {} domain lengthscale is a function of an input " "parameter and the value has changed between " "steps!".format(domain)) # Run set up on first step if old_solution is None: pybamm.logger.info("Start stepping {} with {}".format( model.name, self.name)) self.set_up(model, ext_and_inputs) t = 0.0 else: # initialize with old solution t = old_solution.t[-1] model.y0 = old_solution.y[:, -1] set_up_time = timer.time() # (Re-)calculate consistent initial conditions self._set_initial_conditions(model, ext_and_inputs, update_rhs=False) # Non-dimensionalise dt dt_dimensionless = dt / model.timescale_eval # Step t_eval = np.linspace(t, t + dt_dimensionless, npts) pybamm.logger.info("Calling solver") timer.reset() solution = self._integrate(model, t_eval, ext_and_inputs) # Assign times solution.set_up_time = set_up_time solution.solve_time = timer.time() # Add model and inputs to solution solution.model = model solution.inputs = ext_and_inputs # Copy the timescale_eval and lengthscale_evals solution.timescale_eval = temp_timescale_eval solution.length_scales_eval = temp_length_scales_eval # Identify the event that caused termination termination = self.get_termination_reason(solution, model.events) pybamm.logger.debug("Finish stepping {} ({})".format( model.name, termination)) if set_up_time: pybamm.logger.debug( "Set-up time: {}, Step time: {}, Total time: {}".format( timer.format(solution.set_up_time), timer.format(solution.solve_time), timer.format(solution.total_time), )) else: pybamm.logger.debug("Step time: {}".format( timer.format(solution.solve_time))) if save is False or old_solution is None: return solution else: return old_solution + solution
def _integrate(self, model, t_eval, inputs=None): """ Calculate the solution of the algebraic equations through root-finding Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. t_eval : :class:`numpy.array`, size (k,) The times at which to compute the solution inputs : dict, optional Any input parameters to pass to the model when solving """ y0 = model.y0 y = np.empty((len(y0), len(t_eval))) # Set up inputs = casadi.vertcat(*[x for x in inputs.values()]) t_sym = casadi.MX.sym("t") y_sym = casadi.MX.sym("y_alg", y0.shape[0]) p_sym = casadi.MX.sym("p", inputs.shape[0]) t_p_sym = casadi.vertcat(t_sym, p_sym) alg = model.casadi_algebraic(t_sym, y_sym, p_sym) # Set up rootfinder roots = casadi.rootfinder( "roots", "newton", dict(x=y_sym, p=t_p_sym, g=alg), { **self.extra_options, "abstol": self.tol }, ) for idx, t in enumerate(t_eval): # Evaluate algebraic with new t and previous y0, if it's already close # enough then keep it if np.all(abs(model.algebraic_eval(t, y0, inputs)) < self.tol): pybamm.logger.debug("Keeping same solution at t={}".format( t * model.timescale_eval)) y[:, idx] = y0 # Otherwise calculate new y0 else: t_inputs = casadi.vertcat(t, inputs) # Solve try: y_sol = roots(y0, t_inputs).full().flatten() success = True message = None # Check final output fun = model.casadi_algebraic(t, y_sol, inputs) except RuntimeError as err: success = False message = err.args[0] fun = None if success and np.all(casadi.fabs(fun) < self.tol): # update initial guess for the next iteration y0 = y_sol # update solution array y[:, idx] = y_sol elif not success: raise pybamm.SolverError( "Could not find acceptable solution: {}".format( message)) else: raise pybamm.SolverError(""" Could not find acceptable solution: solver terminated successfully, but maximum solution error ({}) above tolerance ({}) """.format(casadi.mmax(fun), self.tol)) # Return solution object (no events, so pass None to t_event, y_event) return pybamm.Solution(t_eval, y, termination="success")
def _integrate(self, model, t_eval, inputs_dict=None): """ Solve a DAE model defined by residuals with initial conditions y0. Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. t_eval : numeric type The times at which to compute the solution inputs_dict : dict, optional Any external variables or input parameters to pass to the model when solving """ # Record whether there are any symbolic inputs inputs_dict = inputs_dict or {} has_symbolic_inputs = any( isinstance(v, casadi.MX) for v in inputs_dict.values()) # convert inputs to casadi format inputs = casadi.vertcat(*[x for x in inputs_dict.values()]) # Calculate initial event signs needed for some of the modes if (has_symbolic_inputs is False and self.mode != "fast" and model.terminate_events_eval): init_event_signs = np.sign( np.concatenate([ event(t_eval[0], model.y0, inputs) for event in model.terminate_events_eval ])) else: init_event_signs = np.sign([]) if has_symbolic_inputs: # Create integrator without grid to avoid having to create several times self.create_integrator(model, inputs) solution = self._run_integrator(model, model.y0, inputs_dict, inputs, t_eval, use_grid=False) solution.termination = "final time" return solution elif self.mode in ["fast", "fast with events"] or not model.events: if not model.events: pybamm.logger.info("No events found, running fast mode") if self.mode == "fast with events": # Create the integrator with an event switch that will set the rhs to # zero when voltage limits are crossed use_event_switch = True else: use_event_switch = False # Create an integrator with the grid (we just need to do this once) self.create_integrator(model, inputs, t_eval, use_event_switch=use_event_switch) solution = self._run_integrator(model, model.y0, inputs_dict, inputs, t_eval) # Check if the sign of an event changes, if so find an accurate # termination point and exit solution = self._solve_for_event(solution, init_event_signs) return solution elif self.mode in ["safe", "safe without grid"]: y0 = model.y0 # Step-and-check t = t_eval[0] t_f = t_eval[-1] pybamm.logger.debug("Start solving {} with {}".format( model.name, self.name)) if self.mode == "safe without grid": # in "safe without grid" mode, # create integrator once, without grid, # to avoid having to create several times self.create_integrator(model, inputs) # Initialize solution solution = pybamm.Solution(np.array([t]), y0, model, inputs_dict) solution.solve_time = 0 solution.integration_time = 0 use_grid = False else: solution = None use_grid = True # Try to integrate in global steps of size dt_max. Note: dt_max must # be at least as big as the the biggest step in t_eval (multiplied # by some tolerance, here 1.01) to avoid an empty integration window below if self.dt_max: # Non-dimensionalise provided dt_max dt_max = self.dt_max / model.timescale_eval else: dt_max = 0.01 dt_eval_max = np.max(np.diff(t_eval)) * 1.01 dt_max = np.max([dt_max, dt_eval_max]) while t < t_f: # Step solved = False count = 0 dt = dt_max while not solved: # Get window of time to integrate over (so that we return # all the points in t_eval, not just t and t+dt) t_window = np.concatenate( ([t], t_eval[(t_eval > t) & (t_eval < t + dt)])) # Sometimes near events the solver fails between two time # points in t_eval (i.e. no points t < t_i < t+dt for t_i # in t_eval), so we simply integrate from t to t+dt if len(t_window) == 1: t_window = np.array([t, t + dt]) if self.mode == "safe": # update integrator with the grid self.create_integrator(model, inputs, t_window) # Try to solve with the current global step, if it fails then # halve the step size and try again. try: current_step_sol = self._run_integrator( model, y0, inputs_dict, inputs, t_window, use_grid=use_grid) solved = True except pybamm.SolverError: dt /= 2 # also reduce maximum step size for future global steps dt_max = dt count += 1 if count >= self.max_step_decrease_count: raise pybamm.SolverError( "Maximum number of decreased steps occurred at t={}. Try " "solving the model up to this time only or reducing dt_max " "(currently, dt_max={})." "".format(t * model.timescale_eval, dt_max * model.timescale_eval)) # Check if the sign of an event changes, if so find an accurate # termination point and exit current_step_sol = self._solve_for_event( current_step_sol, init_event_signs) # assign temporary solve time current_step_sol.solve_time = np.nan # append solution from the current step to solution solution = solution + current_step_sol if current_step_sol.termination == "event": break else: # update time t = t_window[-1] # update y0 y0 = solution.all_ys[-1][:, -1] return solution
def set_up(self, model, inputs=None): """Unpack model, perform checks, simplify and calculate jacobian. Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. Must have attributes rhs and initial_conditions inputs : dict, optional Any input parameters to pass to the model when solving """ inputs = inputs or {} y0 = model.concatenated_initial_conditions.evaluate(0, None, inputs) # Set model timescale model.timescale_eval = model.timescale.evaluate(u=inputs) # Check model.algebraic for ode solvers if self.ode_solver is True and len(model.algebraic) > 0: raise pybamm.SolverError( "Cannot use ODE solver '{}' to solve DAE model".format( self.name)) if self.ode_solver is True: self.root_method = None if (isinstance(self, pybamm.CasadiSolver) or self.root_method == "casadi") and model.convert_to_format != "casadi": pybamm.logger.warning( f"Converting {model.name} to CasADi for solving with CasADi solver" ) model.convert_to_format = "casadi" if model.convert_to_format != "casadi": simp = pybamm.Simplification() # Create Jacobian from concatenated rhs and algebraic y = pybamm.StateVector(slice(0, np.size(y0))) # set up Jacobian object, for re-use of dict jacobian = pybamm.Jacobian() else: # Convert model attributes to casadi t_casadi = casadi.MX.sym("t") y_diff = casadi.MX.sym( "y_diff", len(model.concatenated_rhs.evaluate(0, y0, inputs))) y_alg = casadi.MX.sym( "y_alg", len(model.concatenated_algebraic.evaluate(0, y0, inputs))) y_casadi = casadi.vertcat(y_diff, y_alg) u_casadi = {} for name, value in inputs.items(): if isinstance(value, numbers.Number): u_casadi[name] = casadi.MX.sym(name) else: u_casadi[name] = casadi.MX.sym(name, value.shape[0]) u_casadi_stacked = casadi.vertcat(*[u for u in u_casadi.values()]) def process(func, name, use_jacobian=None): def report(string): # don't log event conversion if "event" not in string: pybamm.logger.info(string) if use_jacobian is None: use_jacobian = model.use_jacobian if model.convert_to_format != "casadi": # Process with pybamm functions if model.use_simplify: report(f"Simplifying {name}") func = simp.simplify(func) if use_jacobian: report(f"Calculating jacobian for {name}") jac = jacobian.jac(func, y) if model.use_simplify: report(f"Simplifying jacobian for {name}") jac = simp.simplify(jac) if model.convert_to_format == "python": report(f"Converting jacobian for {name} to python") jac = pybamm.EvaluatorPython(jac) jac = jac.evaluate else: jac = None if model.convert_to_format == "python": report(f"Converting {name} to python") func = pybamm.EvaluatorPython(func) func = func.evaluate else: # Process with CasADi report(f"Converting {name} to CasADi") func = func.to_casadi(t_casadi, y_casadi, u_casadi) if use_jacobian: report(f"Calculating jacobian for {name} using CasADi") jac_casadi = casadi.jacobian(func, y_casadi) jac = casadi.Function( name, [t_casadi, y_casadi, u_casadi_stacked], [jac_casadi]) else: jac = None func = casadi.Function(name, [t_casadi, y_casadi, u_casadi_stacked], [func]) if name == "residuals": func_call = Residuals(func, name, model) else: func_call = SolverCallable(func, name, model) func_call.set_inputs(inputs) if jac is not None: jac_call = SolverCallable(jac, name + "_jac", model) jac_call.set_inputs(inputs) else: jac_call = None return func, func_call, jac_call # Check for heaviside functions in rhs and algebraic and add discontinuity # events if these exist. # Note: only checks for the case of t < X, t <= X, X < t, or X <= t, but also # accounts for the fact that t might be dimensional # Only do this for DAE models as ODE models can deal with discontinuities fine if len(model.algebraic) > 0: for symbol in itertools.chain( model.concatenated_rhs.pre_order(), model.concatenated_algebraic.pre_order(), ): if isinstance(symbol, pybamm.Heaviside): # Dimensionless if symbol.right.id == pybamm.t.id: expr = symbol.left elif symbol.left.id == pybamm.t.id: expr = symbol.right # Dimensional elif symbol.right.id == (pybamm.t * model.timescale).id: expr = symbol.left.new_copy( ) / symbol.right.right.new_copy() elif symbol.left.id == (pybamm.t * model.timescale).id: expr = symbol.right.new_copy( ) / symbol.left.right.new_copy() model.events.append( pybamm.Event(str(symbol), expr.new_copy(), pybamm.EventType.DISCONTINUITY)) # Process rhs, algebraic and event expressions rhs, rhs_eval, jac_rhs = process(model.concatenated_rhs, "RHS") algebraic, algebraic_eval, jac_algebraic = process( model.concatenated_algebraic, "algebraic") terminate_events_eval = [ process(event.expression, "event", use_jacobian=False)[1] for event in model.events if event.event_type == pybamm.EventType.TERMINATION ] # discontinuity events are evaluated before the solver is called, so don't need # to process them discontinuity_events_eval = [ event for event in model.events if event.event_type == pybamm.EventType.DISCONTINUITY ] # Add the solver attributes model.rhs_eval = rhs_eval model.algebraic_eval = algebraic_eval model.jac_algebraic_eval = jac_algebraic model.terminate_events_eval = terminate_events_eval model.discontinuity_events_eval = discontinuity_events_eval # Save CasADi functions for the CasADi solver # Note: when we pass to casadi the ode part of the problem must be in explicit # form so we pre-multiply by the inverse of the mass matrix if self.root_method == "casadi" or isinstance(self, pybamm.CasadiSolver): mass_matrix_inv = casadi.MX(model.mass_matrix_inv.entries) explicit_rhs = mass_matrix_inv @ rhs(t_casadi, y_casadi, u_casadi_stacked) model.casadi_rhs = casadi.Function( "rhs", [t_casadi, y_casadi, u_casadi_stacked], [explicit_rhs]) model.casadi_algebraic = algebraic # Calculate consistent initial conditions for the algebraic equations if len(model.algebraic) > 0: all_states = pybamm.NumpyConcatenation( model.concatenated_rhs, model.concatenated_algebraic) # Process again, uses caching so should be quick residuals, residuals_eval, jacobian_eval = process( all_states, "residuals") model.residuals_eval = residuals_eval model.jacobian_eval = jacobian_eval y0_guess = y0.flatten() model.y0 = self.calculate_consistent_state(model, 0, y0_guess, inputs) else: # can use DAE solver to solve ODE model model.residuals_eval = Residuals(rhs, "residuals", model) model.jacobian_eval = jac_rhs model.y0 = y0.flatten() pybamm.logger.info("Finish solver set-up")
def _run_integrator(self, model, y0, inputs_dict, inputs, t_eval, use_grid=True): pybamm.logger.debug("Running CasADi integrator") if use_grid is True: t_eval_shifted = t_eval - t_eval[0] t_eval_shifted_rounded = np.round(t_eval_shifted, decimals=12).tobytes() integrator = self.integrators[model][t_eval_shifted_rounded] else: integrator = self.integrators[model]["no grid"] len_rhs = model.concatenated_rhs.size y0_diff = y0[:len_rhs] y0_alg = y0[len_rhs:] try: # Try solving if use_grid is True: t_min = t_eval[0] inputs_with_tmin = casadi.vertcat(inputs, t_min) # Call the integrator once, with the grid timer = pybamm.Timer() casadi_sol = integrator(x0=y0_diff, z0=y0_alg, p=inputs_with_tmin, **self.extra_options_call) integration_time = timer.time() y_sol = casadi.vertcat(casadi_sol["xf"], casadi_sol["zf"]) sol = pybamm.Solution(t_eval, y_sol, model, inputs_dict) sol.integration_time = integration_time return sol else: # Repeated calls to the integrator x = y0_diff z = y0_alg y_diff = x y_alg = z for i in range(len(t_eval) - 1): t_min = t_eval[i] t_max = t_eval[i + 1] inputs_with_tlims = casadi.vertcat(inputs, t_min, t_max) timer = pybamm.Timer() casadi_sol = integrator(x0=x, z0=z, p=inputs_with_tlims, **self.extra_options_call) integration_time = timer.time() x = casadi_sol["xf"] z = casadi_sol["zf"] y_diff = casadi.horzcat(y_diff, x) if not z.is_empty(): y_alg = casadi.horzcat(y_alg, z) if z.is_empty(): sol = pybamm.Solution(t_eval, y_diff, model, inputs_dict) else: y_sol = casadi.vertcat(y_diff, y_alg) sol = pybamm.Solution(t_eval, y_sol, model, inputs_dict) sol.integration_time = integration_time return sol except RuntimeError as e: # If it doesn't work raise error raise pybamm.SolverError(e.args[0])
def solve(self, model, t_eval, external_variables=None, inputs=None): """ Execute the solver setup and calculate the solution of the model at specified times. Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. Must have attributes rhs and initial_conditions t_eval : numeric type The times at which to compute the solution external_variables : dict A dictionary of external variables and their corresponding values at the current time inputs : dict, optional Any input parameters to pass to the model when solving Raises ------ :class:`pybamm.ModelError` If an empty model is passed (`model.rhs = {}` and `model.algebraic={}`) """ pybamm.logger.info("Start solving {} with {}".format( model.name, self.name)) # Make sure model isn't empty if len(model.rhs) == 0 and len(model.algebraic) == 0: raise pybamm.ModelError("Cannot solve empty model") # Make sure t_eval is monotonic if (np.diff(t_eval) < 0).any(): raise pybamm.SolverError("t_eval must increase monotonically") # Non-dimensionalise t_eval # Set up timer = pybamm.Timer() # Set up external variables and inputs external_variables = external_variables or {} inputs = inputs or {} ext_and_inputs = {**external_variables, **inputs} # Raise warning if t_eval looks like it was supposed to be dimensionless # already if t_eval[-1] < 0.5: raise pybamm.SolverError( """It looks like t_eval might be dimensionless. t_eval should now be provided in seconds""") # Set up (if not done already) if model not in self.models_set_up: self.set_up(model, ext_and_inputs) set_up_time = timer.time() self.models_set_up.add(model) else: set_up_time = 0 # Non-dimensionalise time t_eval_dimensionless = t_eval / model.timescale_eval # Solve # Set inputs and external self.set_inputs(model, ext_and_inputs) # Calculate discontinuities discontinuities = [ event.expression.evaluate(u=inputs) for event in model.discontinuity_events_eval ] # make sure they are increasing in time discontinuities = sorted(discontinuities) # remove any identical discontinuities discontinuities = [ v for i, v in enumerate(discontinuities) if (i == len(discontinuities) - 1 or discontinuities[i] < discontinuities[i + 1]) and v > 0 ] if len(discontinuities) > 0: pybamm.logger.info( "Discontinuity events found at t = {}".format(discontinuities)) else: pybamm.logger.info("No discontinuity events found") # insert time points around discontinuities in t_eval # keep track of sub sections to integrate by storing start and end indices start_indices = [0] end_indices = [] eps = sys.float_info.epsilon for dtime in discontinuities: dindex = np.searchsorted(t_eval_dimensionless, dtime, side="left") end_indices.append(dindex + 1) start_indices.append(dindex + 1) if dtime - eps < t_eval_dimensionless[dindex] < dtime + eps: t_eval_dimensionless[dindex] += eps t_eval_dimensionless = np.insert(t_eval_dimensionless, dindex, dtime - eps) else: t_eval_dimensionless = np.insert(t_eval_dimensionless, dindex, [dtime - eps, dtime + eps]) end_indices.append(len(t_eval_dimensionless)) # integrate separately over each time segment and accumulate into the solution # object, restarting the solver at each discontinuity (and recalculating a # consistent state afterwards if a dae) old_y0 = model.y0 solution = None for start_index, end_index in zip(start_indices, end_indices): pybamm.logger.info("Calling solver for {} < t < {}".format( t_eval_dimensionless[start_index] * model.timescale_eval, t_eval_dimensionless[end_index - 1] * model.timescale_eval, )) timer.reset() new_solution = self._integrate( model, t_eval_dimensionless[start_index:end_index], ext_and_inputs) new_solution.solve_time = timer.time() if solution is None: solution = new_solution else: solution.append(new_solution, start_index=0) if solution.termination != "final time": break if end_index != len(t_eval_dimensionless): # setup for next integration subsection last_state = solution.y[:, -1] if len(model.algebraic) > 0: model.y0 = self.calculate_consistent_state( model, t_eval_dimensionless[end_index], last_state, ext_and_inputs, ) else: model.y0 = last_state # restore old y0 model.y0 = old_y0 # Assign times solution.set_up_time = set_up_time # Add model and inputs to solution solution.model = model solution.inputs = ext_and_inputs # Identify the event that caused termination termination = self.get_termination_reason(solution, model.events) pybamm.logger.info("Finish solving {} ({})".format( model.name, termination)) pybamm.logger.info( "Set-up time: {}, Solve time: {}, Total time: {}".format( timer.format(solution.set_up_time), timer.format(solution.solve_time), timer.format(solution.total_time), )) return solution
def integrate(self, residuals, y0, t_eval, events, mass_matrix, jacobian): """ Solve a DAE model defined by residuals with initial conditions y0. Parameters ---------- residuals : method A function that takes in t, y and ydot and returns the residuals of the equations y0 : numeric type The initial conditions t_eval : numeric type The times at which to compute the solution events : method, A function that takes in t and y and returns conditions for the solver to stop mass_matrix : array_like, The (sparse) mass matrix for the chosen spatial method. jacobian : method, A function that takes in t and y and returns the Jacobian. If None, the solver will approximate the Jacobian. (see `SUNDIALS docs. <https://computation.llnl.gov/projects/sundials>`). """ if jacobian is None: pybamm.SolverError("KLU requires the Jacobian to be provided") if events is None: pybamm.SolverError("KLU requires events to be provided") rtol = self._rtol atol = self._atol if jacobian: jac_y0_t0 = jacobian(t_eval[0], y0) if sparse.issparse(jac_y0_t0): def jacfn(t, y, cj): j = jacobian(t, y) - cj * mass_matrix return j else: def jacfn(t, y, cj): jac_eval = jacobian(t, y) - cj * mass_matrix return sparse.csr_matrix(jac_eval) class SundialsJacobian: def __init__(self): self.J = None random = np.random.random(size=y0.size) J = jacfn(10, random, 20) self.nnz = J.nnz # hoping nnz remains constant... def jac_res(self, t, y, cj): # must be of form j_res = (dr/dy) - (cj) (dr/dy') # cj is just the input parameter # see p68 of the ida_guide.pdf for more details self.J = jacfn(t, y, cj) def get_jac_data(self): return self.J.data def get_jac_row_vals(self): return self.J.indices def get_jac_col_ptrs(self): return self.J.indptr # solver works with ydot0 set to zero ydot0 = np.zeros_like(y0) jac_class = SundialsJacobian() num_of_events = len(events) use_jac = 1 def rootfn(t, y): return_root = np.ones((num_of_events, )) return_root[:] = [event(t, y) for event in events] return return_root # get ids of rhs and algebraic variables rhs_ids = np.ones(self.rhs(0, y0).shape) alg_ids = np.zeros(self.algebraic(0, y0).shape) ids = np.concatenate((rhs_ids, alg_ids)) # solve sol = idaklu.solve( t_eval, y0, ydot0, self.residuals, jac_class.jac_res, jac_class.get_jac_data, jac_class.get_jac_row_vals, jac_class.get_jac_col_ptrs, jac_class.nnz, rootfn, num_of_events, use_jac, ids, rtol, atol, ) t = sol.t number_of_timesteps = t.size number_of_states = y0.size y_out = sol.y.reshape((number_of_timesteps, number_of_states)) # return solution, we need to tranpose y to match scipy's interface if sol.flag in [0, 2]: # 0 = solved for all t_eval if sol.flag == 0: termination = "final time" # 2 = found root(s) elif sol.flag == 2: termination = "event" return pybamm.Solution(sol.t, np.transpose(y_out), t[-1], np.transpose(y_out[-1]), termination) else: raise pybamm.SolverError(sol.message)
def _integrate(self, model, t_eval, inputs=None): """ Solve a DAE model defined by residuals with initial conditions y0. Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. t_eval : numeric type The times at which to compute the solution """ if model.rhs_eval.form == "casadi": # stack inputs inputs = casadi.vertcat(*[x for x in inputs.values()]) if model.jacobian_eval is None: raise pybamm.SolverError( "KLU requires the Jacobian to be provided") try: atol = model.atol except AttributeError: atol = self._atol y0 = model.y0 if isinstance(y0, casadi.DM): y0 = y0.full().flatten() rtol = self._rtol atol = self._check_atol_type(atol, y0.size) mass_matrix = model.mass_matrix.entries if model.jacobian_eval: jac_y0_t0 = model.jacobian_eval(t_eval[0], y0, inputs) if sparse.issparse(jac_y0_t0): def jacfn(t, y, cj): j = model.jacobian_eval(t, y, inputs) - cj * mass_matrix return j else: def jacfn(t, y, cj): jac_eval = model.jacobian_eval(t, y, inputs) - cj * mass_matrix return sparse.csr_matrix(jac_eval) class SundialsJacobian: def __init__(self): self.J = None random = np.random.random(size=y0.size) J = jacfn(10, random, 20) self.nnz = J.nnz # hoping nnz remains constant... def jac_res(self, t, y, cj): # must be of form j_res = (dr/dy) - (cj) (dr/dy') # cj is just the input parameter # see p68 of the ida_guide.pdf for more details self.J = jacfn(t, y, cj) def get_jac_data(self): return self.J.data def get_jac_row_vals(self): return self.J.indices def get_jac_col_ptrs(self): return self.J.indptr # solver works with ydot0 set to zero ydot0 = np.zeros_like(y0) jac_class = SundialsJacobian() num_of_events = len(model.terminate_events_eval) use_jac = 1 def rootfn(t, y): return_root = np.ones((num_of_events, )) return_root[:] = [ event(t, y, inputs) for event in model.terminate_events_eval ] return return_root # get ids of rhs and algebraic variables rhs_ids = np.ones(model.rhs_eval(0, y0, inputs).shape) alg_ids = np.zeros(len(y0) - len(rhs_ids)) ids = np.concatenate((rhs_ids, alg_ids)) # solve timer = pybamm.Timer() sol = idaklu.solve( t_eval, y0, ydot0, lambda t, y, ydot: model.residuals_eval(t, y, ydot, inputs), jac_class.jac_res, jac_class.get_jac_data, jac_class.get_jac_row_vals, jac_class.get_jac_col_ptrs, jac_class.nnz, rootfn, num_of_events, use_jac, ids, atol, rtol, ) integration_time = timer.time() t = sol.t number_of_timesteps = t.size number_of_states = y0.size y_out = sol.y.reshape((number_of_timesteps, number_of_states)) # return solution, we need to tranpose y to match scipy's interface if sol.flag in [0, 2]: # 0 = solved for all t_eval if sol.flag == 0: termination = "final time" # 2 = found root(s) elif sol.flag == 2: termination = "event" sol = pybamm.Solution( sol.t, np.transpose(y_out), t[-1], np.transpose(y_out[-1])[:, np.newaxis], termination, ) sol.integration_time = integration_time return sol else: raise pybamm.SolverError(sol.message)
def _integrate(self, model, t_eval, inputs_dict=None): """ Solve a model defined by dydt with initial conditions y0. Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. t_eval : numeric type The times at which to compute the solution inputs_dict : dict, optional Any input parameters to pass to the model when solving """ inputs_dict = inputs_dict or {} if model.convert_to_format == "casadi": inputs = casadi.vertcat(*[x for x in inputs_dict.values()]) else: inputs = inputs_dict y0 = model.y0 if isinstance(y0, casadi.DM): y0 = y0.full().flatten() residuals = model.residuals_eval events = model.terminate_events_eval jacobian = model.jacobian_eval mass_matrix = model.mass_matrix.entries def eqsres(t, y, ydot, return_residuals): return_residuals[:] = residuals(t, y, ydot, inputs) def rootfn(t, y, ydot, return_root): return_root[:] = [event(t, y, inputs) for event in events] extra_options = { **self.extra_options, "old_api": False, "rtol": self.rtol, "atol": self.atol, } if jacobian: jac_y0_t0 = jacobian(t_eval[0], y0, inputs) if sparse.issparse(jac_y0_t0): def jacfn(t, y, ydot, residuals, cj, J): jac_eval = jacobian(t, y, inputs) - cj * mass_matrix J[:][:] = jac_eval.toarray() else: def jacfn(t, y, ydot, residuals, cj, J): jac_eval = jacobian(t, y, inputs) - cj * mass_matrix J[:][:] = jac_eval extra_options.update({"jacfn": jacfn}) if events: extra_options.update({"rootfn": rootfn, "nr_rootfns": len(events)}) # solver works with ydot0 set to zero ydot0 = np.zeros_like(y0) # set up and solve dae_solver = scikits_odes.dae(self.method, eqsres, **extra_options) timer = pybamm.Timer() sol = dae_solver.solve(t_eval, y0, ydot0) integration_time = timer.time() # return solution, we need to tranpose y to match scipy's interface if sol.flag in [0, 2]: # 0 = solved for all t_eval if sol.flag == 0: termination = "final time" # 2 = found root(s) elif sol.flag == 2: termination = "event" if sol.roots.t is None: t_root = None else: t_root = sol.roots.t sol = pybamm.Solution( sol.values.t, np.transpose(sol.values.y), model, inputs_dict, t_root, np.transpose(sol.roots.y), termination, ) sol.integration_time = integration_time return sol else: raise pybamm.SolverError(sol.message)
def _integrate(self, model, t_eval, inputs=None): """ Solve a DAE model defined by residuals with initial conditions y0. Parameters ---------- model : :class:`pybamm.BaseModel` The model whose solution to calculate. t_eval : numeric type The times at which to compute the solution inputs : dict, optional Any external variables or input parameters to pass to the model when solving """ inputs = inputs or {} # convert inputs to casadi format inputs = casadi.vertcat(*[x for x in inputs.values()]) if self.mode == "fast": integrator = self.get_integrator(model, t_eval, inputs) solution = self._run_integrator(integrator, model, model.y0, inputs, t_eval) solution.termination = "final time" return solution elif not model.events: pybamm.logger.info("No events found, running fast mode") integrator = self.get_integrator(model, t_eval, inputs) solution = self._run_integrator(integrator, model, model.y0, inputs, t_eval) solution.termination = "final time" return solution elif self.mode == "safe": y0 = model.y0 if isinstance(y0, casadi.DM): y0 = y0.full().flatten() # Step-and-check t = t_eval[0] t_f = t_eval[-1] init_event_signs = np.sign( np.concatenate([ event(t, y0, inputs) for event in model.terminate_events_eval ])) pybamm.logger.info("Start solving {} with {}".format( model.name, self.name)) # Initialize solution solution = pybamm.Solution(np.array([t]), y0[:, np.newaxis]) solution.solve_time = 0 # Try to integrate in global steps of size dt_max. Note: dt_max must # be at least as big as the the biggest step in t_eval (multiplied # by some tolerance, here 1.01) to avoid an empty integration window below if self.dt_max: # Non-dimensionalise provided dt_max dt_max = self.dt_max / model.timescale_eval else: dt_max = 0.01 dt_eval_max = np.max(np.diff(t_eval)) * 1.01 dt_max = np.max([dt_max, dt_eval_max]) while t < t_f: # Step solved = False count = 0 dt = dt_max while not solved: # Get window of time to integrate over (so that we return # all the points in t_eval, not just t and t+dt) t_window = np.concatenate( ([t], t_eval[(t_eval > t) & (t_eval < t + dt)])) # Sometimes near events the solver fails between two time # points in t_eval (i.e. no points t < t_i < t+dt for t_i # in t_eval), so we simply integrate from t to t+dt if len(t_window) == 1: t_window = np.array([t, t + dt]) integrator = self.get_integrator(model, t_window, inputs) # Try to solve with the current global step, if it fails then # halve the step size and try again. try: current_step_sol = self._run_integrator( integrator, model, y0, inputs, t_window) solved = True except pybamm.SolverError: dt /= 2 # also reduce maximum step size for future global steps dt_max = dt count += 1 if count >= self.max_step_decrease_count: raise pybamm.SolverError(""" Maximum number of decreased steps occurred at t={}. Try solving the model up to this time only or reducing dt_max. """.format(t)) # Check most recent y to see if any events have been crossed new_event_signs = np.sign( np.concatenate([ event(t, current_step_sol.y[:, -1], inputs) for event in model.terminate_events_eval ])) # Exit loop if the sign of an event changes # Locate the event time using a root finding algorithm and # event state using interpolation. The solution is then truncated # so that only the times up to the event are returned if (new_event_signs != init_event_signs).any(): # get the index of the events that have been crossed event_ind = np.where( new_event_signs != init_event_signs)[0] active_events = [ model.terminate_events_eval[i] for i in event_ind ] # create interpolant to evaluate y in the current integration # window y_sol = interp1d(current_step_sol.t, current_step_sol.y) # loop over events to compute the time at which they were triggered t_events = [None] * len(active_events) for i, event in enumerate(active_events): def event_fun(t): return event(t, y_sol(t), inputs) if np.isnan(event_fun(current_step_sol.t[-1])[0]): # bracketed search fails if f(a) or f(b) is NaN, so we # need to find the times for which we can evaluate the event times = [ t for t in current_step_sol.t if event_fun(t)[0] == event_fun(t)[0] ] else: times = current_step_sol.t # skip if sign hasn't changed if np.sign(event_fun(times[0])) != np.sign( event_fun(times[-1])): t_events[i] = brentq(lambda t: event_fun(t), times[0], times[-1]) else: t_events[i] = np.nan # t_event is the earliest event triggered t_event = np.nanmin(t_events) y_event = y_sol(t_event) # return truncated solution t_truncated = current_step_sol.t[ current_step_sol.t < t_event] y_trunctaed = current_step_sol.y[:, 0:len(t_truncated)] truncated_step_sol = pybamm.Solution( t_truncated, y_trunctaed) # assign temporary solve time truncated_step_sol.solve_time = np.nan # append solution from the current step to solution solution.append(truncated_step_sol) solution.termination = "event" solution.t_event = t_event solution.y_event = y_event break else: # assign temporary solve time current_step_sol.solve_time = np.nan # append solution from the current step to solution solution.append(current_step_sol) # update time t = t_window[-1] # update y0 y0 = solution.y[:, -1] return solution elif self.mode == "old safe": y0 = model.y0 if isinstance(y0, casadi.DM): y0 = y0.full().flatten() # Step-and-check t = t_eval[0] init_event_signs = np.sign( np.concatenate([ event(t, y0, inputs) for event in model.terminate_events_eval ])) pybamm.logger.info("Start solving {} with {}".format( model.name, self.name)) # Initialize solution solution = pybamm.Solution(np.array([t]), y0[:, np.newaxis]) solution.solve_time = 0 for dt in np.diff(t_eval): # Step solved = False count = 0 while not solved: integrator = self.get_integrator(model, np.array([t, t + dt]), inputs) # Try to solve with the current step, if it fails then halve the # step size and try again. This will make solution.t slightly # different to t_eval, but shouldn't matter too much as it should # only happen near events. try: current_step_sol = self._run_integrator( integrator, model, y0, inputs, np.array([t, t + dt])) solved = True except pybamm.SolverError: dt /= 2 count += 1 if count >= self.max_step_decrease_count: raise pybamm.SolverError(""" Maximum number of decreased steps occurred at t={}. Try solving the model up to this time only. """.format(t)) # Check most recent y new_event_signs = np.sign( np.concatenate([ event(t, current_step_sol.y[:, -1], inputs) for event in model.terminate_events_eval ])) # Exit loop if the sign of an event changes if (new_event_signs != init_event_signs).any(): solution.termination = "event" solution.t_event = solution.t[-1] solution.y_event = solution.y[:, -1] break else: # assign temporary solve time current_step_sol.solve_time = np.nan # append solution from the current step to solution solution.append(current_step_sol) # update time t += dt # update y0 y0 = solution.y[:, -1] return solution
def get_termination_reason(self, solution, events): """ Identify the cause for termination. In particular, if the solver terminated due to an event, (try to) pinpoint which event was responsible. If an event occurs the event time and state are added to the solution object. Note that the current approach (evaluating all the events and then finding which one is smallest at the final timestep) is pretty crude, but is the easiest one that works for all the different solvers. Parameters ---------- solution : :class:`pybamm.Solution` The solution object events : dict Dictionary of events """ if solution.termination == "final time": return ( solution, "the solver successfully reached the end of the integration interval", ) elif solution.termination == "event": pybamm.logger.debug("Start post-processing events") # Get final event value final_event_values = {} for event in events: if event.event_type == pybamm.EventType.TERMINATION: final_event_values[event.name] = abs( event.expression.evaluate( solution.t_event, solution.y_event, inputs=solution.all_inputs[-1], ) ) termination_event = min(final_event_values, key=final_event_values.get) # Check that it's actually an event if abs(final_event_values[termination_event]) > 0.1: # pragma: no cover # Hard to test this raise pybamm.SolverError( "Could not determine which event was triggered " "(possibly due to NaNs)" ) # Add the event to the solution object solution.termination = "event: {}".format(termination_event) # Update t, y and inputs to include event time and state # Note: if the final entry of t is equal to the event time we skip # this (having duplicate entries causes an error later in ProcessedVariable) if solution.t_event != solution.all_ts[-1][-1]: event_sol = pybamm.Solution( solution.t_event, solution.y_event, solution.all_models[-1], solution.all_inputs[-1], solution.t_event, solution.y_event, solution.termination, ) event_sol.solve_time = 0 event_sol.integration_time = 0 solution = solution + event_sol pybamm.logger.debug("Finish post-processing events") return solution, solution.termination elif solution.termination == "success": return solution, solution.termination