Source code for pytes.experiments.step

#
# Experiment steps and termination conditions
#
"""
Steps of a heat battery experiment, run with :func:`pytes.experiments.run_experiment`.

A model declares its controls (DESIGN D9); today they are the inlet temperature and the
mass flow rate. Each step declares the controls it drives. In an experiment the controls are
solver unknowns, and each step fixes them with one equation each (its control residuals):
either by holding them at given values, or by adjusting one of them to hold the power. This follows the "unified" experiment mode of
PyBaMM, so that the steps can move to PyBaMM's own experiment framework once it supports
models other than batteries.

The values a step holds can be constants, measured time series (a 2-column array of time
since the start of the step [s] and value) or functions of the time since the start of the
step [s]. Constant values are passed to the solver as inputs, so steps that only differ in
their constant values share the same compiled model branch.

The steps are named after what they hold, as PyBaMM's (``pybamm.step.Current``,
``Power``, ``Rest``): :class:`Inlet` holds the inlet conditions, :class:`Power` the power
and :class:`Rest` no flow. The power is positive when the battery delivers heat
(discharge), so a charge uses a negative power.
"""

import numbers

import numpy as np
import pybamm

from .. import deprecation

# The old names of the steps (ConstantInputs, ConstantPower) warn and give the new ones
__getattr__ = deprecation.module_getattr(__name__)

#: Input parameter holding the time [s] at which the current step started
START_TIME = pybamm.InputParameter("Step start time [s]")

#: Default step duration [s] when neither a duration nor a time series sets it
DEFAULT_DURATION = 24 * 3600

#: Default time between output points [s]
DEFAULT_PERIOD = 60

#: Input parameter holding the inlet temperature [K] at the end of the previous step, which
#: a :class:`Rest` holds by default
PREVIOUS_INLET_TEMPERATURE = pybamm.InputParameter(
    "Inlet temperature at the end of the previous step [K]"
)

INLET_TEMPERATURE = "Inlet temperature [K]"
MASS_FLOW_RATE = "Mass flow rate [kg.s-1]"


[docs] class Termination: """ A condition that ends a step, used in addition to its duration. Parameters ---------- name : str Name of the condition, used to report why a step ended. function : callable Function of the model variables (a dict of symbols) returning an expression that is positive while the step should continue, and crosses zero when it should end. """ def __init__(self, name, function): self.name = name self.function = function
[docs] def expression(self, variables): """ The condition as an expression. Parameters ---------- variables : dict Model variables. Returns ------- pybamm.Symbol Positive while the step should continue; the step ends when it crosses zero. """ return self.function(variables)
@property def key(self): """Identity of the condition, used to decide which steps share a model branch""" # The function object is part of the identity (the name alone is not enough) return f"{self.name}@{id(self.function)}" def __repr__(self): return f"Termination({self.name!r})"
class _ThresholdTermination(Termination): """ A variable crossing a threshold. Parameters ---------- variable : str Name of the model variable. threshold : float The threshold, in the units of the variable. below : bool True to end the step when the variable falls below the threshold, False when it rises above it. """ def __init__(self, variable, threshold, below): self.variable = variable self.threshold = threshold self.below = below sign = "<" if below else ">" super().__init__(f"{variable} {sign} {threshold}", None) def expression(self, variables): """Distance from the threshold, positive while the step should continue""" value = variables[self.variable] return value - self.threshold if self.below else self.threshold - value @property def key(self): """The name, which includes the variable, direction and threshold""" return self.name
[docs] def outlet_temperature_below(T): """End the step when the outlet temperature [K] falls below T""" return _ThresholdTermination("Outlet temperature [K]", T, below=True)
[docs] def outlet_temperature_above(T): """End the step when the outlet temperature [K] rises above T""" return _ThresholdTermination("Outlet temperature [K]", T, below=False)
[docs] def inlet_temperature_below(T): """End the step when the inlet temperature [K] falls below T""" return _ThresholdTermination("Inlet temperature [K]", T, below=True)
[docs] def inlet_temperature_above(T): """End the step when the inlet temperature [K] rises above T""" return _ThresholdTermination("Inlet temperature [K]", T, below=False)
[docs] def mass_flow_rate_above(mdot): """End the step when the mass flow rate [kg.s-1] rises above mdot""" return _ThresholdTermination(MASS_FLOW_RATE, mdot, below=False)
[docs] def average_liquid_fraction_below(phi): """End the step when the average liquid fraction falls below phi""" return _ThresholdTermination("Average liquid fraction", phi, below=True)
[docs] def average_liquid_fraction_above(phi): """End the step when the average liquid fraction rises above phi""" return _ThresholdTermination("Average liquid fraction", phi, below=False)
[docs] class Target: """ A value held by a step: a constant, a 2-column time series (time since the start of the step [s], value) or a function of the time since the start of the step [s]. A time series is interpolated linearly, and its last value is held if the step lasts longer than the series (unlike PyBaMM drive cycles, which are repeated). Parameters ---------- name : str Name of the quantity, e.g. "Inlet temperature [K]". value : float, numpy.ndarray or callable A constant, a 2-column array of time since the start of the step [s] (starting at 0) and value, or a function of the time since the start of the step [s] (a :class:`pybamm.Symbol`) returning a symbol. Raises ------ ValueError If a time series is not a 2-column array starting at time 0. TypeError If the value is none of the above. """ def __init__(self, name, value): self.name = name self.value = value self.is_constant = isinstance(value, numbers.Real) and not isinstance( value, bool ) if isinstance(value, np.ndarray): if value.ndim != 2 or value.shape[1] != 2: raise ValueError( f"{name}: a time series must be a 2-column array of time since the " "start of the step [s] and value" ) if value[0, 0] != 0: raise ValueError(f"{name}: a time series must start at time 0") elif not self.is_constant and not callable(value): raise TypeError( f"{name}: must be a number, a 2-column array or a function of time" ) @property def input_name(self): """Name of the input parameter holding a constant value""" return f"{self.name} target"
[docs] def symbol(self): """The value as a symbol in the model""" if self.is_constant: return pybamm.InputParameter(self.input_name) t = pybamm.t - START_TIME if callable(self.value): return self.value(t) # Clip the time to the series, which holds the last value after its end. This # also keeps the interpolant in range while other steps are active (their # branches are evaluated, though not used, at every time). t = pybamm.maximum(pybamm.minimum(t, self.value[-1, 0]), 0) return pybamm.Interpolant( self.value[:, 0], self.value[:, 1], t, name=f"{self.name} time series" )
@property def key(self): """Identity, used to decide which steps share a model branch""" return "input" if self.is_constant else f"series@{id(self.value)}" @property def initial_value(self): """Value at the start of the step, as a number""" if self.is_constant: return float(self.value) if callable(self.value): return float(self.value(pybamm.Scalar(0)).evaluate()) return float(self.value[0, 1]) @property def end_time(self): """Last time of a time series, or None""" if isinstance(self.value, np.ndarray): return float(self.value[-1, 0]) return None
[docs] class BaseStep: """ Base class for experiment steps. Subclasses declare the controls they drive (``controls``), pass their targets to this constructor and implement :meth:`control_residuals`. Parameters ---------- targets : list of :class:`Target` The values the step holds. duration : float, optional Maximum duration of the step [s]. Defaults to the length of the step's time series if it has one, otherwise one day. termination : :class:`Termination` or list of them, optional Conditions that end the step before its duration. period : float, optional Time between output points [s] (default 60 s). description : str, optional Description of the step, used when reporting results. """ #: Names of the controls the step drives controls = (INLET_TEMPERATURE, MASS_FLOW_RATE) def __init__( self, targets, duration=None, termination=None, period=None, description=None ): self.targets = {target.name: target for target in targets} if duration is None: end_times = [t.end_time for t in targets if t.end_time is not None] duration = max(end_times) if end_times else DEFAULT_DURATION self.duration = float(duration) if termination is None: termination = [] elif isinstance(termination, Termination): termination = [termination] self.termination = list(termination) + self.safety_terminations() self.period = float(period or DEFAULT_PERIOD) self.description = description or repr(self)
[docs] def safety_terminations(self): """Terminations every step of this type needs (none by default)""" return []
[docs] def control_residuals(self, variables, targets, power): """ Equations fixing the controls. Parameters ---------- variables : dict Model variables, including the controls and "Power [W]". targets : dict The step's target values as symbols, keyed by name. power : callable The model's power [W] with some controls replaced: ``power(controls)``, with ``controls`` a dict of symbols keyed by control name (see :meth:`pytes.BaseModel.power`). Returns ------- dict One residual per control in ``controls``, keyed by control name. """ raise NotImplementedError
[docs] def termination_expression(self, variables): """Combined termination: positive while the step should continue""" if not self.termination: return pybamm.Scalar(1) expressions = [term.expression(variables) for term in self.termination] expression = expressions[0] for other in expressions[1:]: expression = pybamm.minimum(expression, other) return expression
@property def branch_key(self): """ Steps with the same key share a model branch: the same type, the same kind of targets (constant inputs, or the same time series) and the same terminations. """ targets = ",".join(f"{n}={t.key}" for n, t in sorted(self.targets.items())) terms = ",".join(term.key for term in self.termination) return f"{type(self).__name__}({targets}; {terms})"
[docs] def input_values(self): """Values of the constant targets, as solver inputs""" return { t.input_name: float(t.value) for t in self.targets.values() if t.is_constant }
def __repr__(self): values = ", ".join( f"{n}={t.value if t.is_constant else 'series'}" for n, t in self.targets.items() ) return f"{type(self).__name__}({values}, duration={self.duration:g} s)"
[docs] class Inlet(BaseStep): """ Hold the inlet conditions: the inlet temperature [K] and the mass flow rate [kg.s-1]. Each can be a constant, a time series or a function of time (see :class:`Target`). Parameters ---------- inlet_temperature : float, numpy.ndarray or callable Inlet temperature [K]. mass_flow_rate : float, numpy.ndarray or callable Mass flow rate through the whole device [kg.s-1]. For no flow, use :class:`Rest`. **kwargs ``duration``, ``termination``, ``period`` and ``description``, see :class:`BaseStep`. """ def __init__(self, inlet_temperature, mass_flow_rate, **kwargs): super().__init__( [ Target(INLET_TEMPERATURE, inlet_temperature), Target(MASS_FLOW_RATE, mass_flow_rate), ], **kwargs, )
[docs] def control_residuals(self, variables, targets, power): """Both controls equal their targets""" return { name: variables[name] - targets[name] for name in (INLET_TEMPERATURE, MASS_FLOW_RATE) }
[docs] class Rest(BaseStep): """ Stop the flow: hold the mass flow rate at 0. The inlet temperature has no effect without flow, but the model still needs a value: by default the step holds it where the previous step left it (the initial temperature for a first step), so that the inlet temperature does not jump. Parameters ---------- inlet_temperature : float, numpy.ndarray or callable, optional Inlet temperature [K] to hold instead (see :class:`Target`). **kwargs ``duration``, ``termination``, ``period`` and ``description``, see :class:`BaseStep`. """ def __init__(self, inlet_temperature=None, **kwargs): targets = [Target(MASS_FLOW_RATE, 0)] if inlet_temperature is not None: targets.append(Target(INLET_TEMPERATURE, inlet_temperature)) super().__init__(targets, **kwargs) @property def holds_previous_inlet_temperature(self): """Whether the step holds the inlet temperature of the previous step""" return INLET_TEMPERATURE not in self.targets
[docs] def control_residuals(self, variables, targets, power): """No flow, and the inlet temperature at its target or its previous value""" T_in = targets.get(INLET_TEMPERATURE, PREVIOUS_INLET_TEMPERATURE) return { INLET_TEMPERATURE: variables[INLET_TEMPERATURE] - T_in, MASS_FLOW_RATE: variables[MASS_FLOW_RATE] - targets[MASS_FLOW_RATE], }
[docs] class Power(BaseStep): """ Hold the power [W] (positive for discharge) by adjusting the one control that is not given (DESIGN D10). Give exactly one of: - ``mass_flow_rate``: the inlet temperature is adjusted. Use :func:`inlet_temperature_below` and :func:`inlet_temperature_above` to limit it. - ``inlet_temperature``: the mass flow rate is adjusted. Use :func:`mass_flow_rate_above` to limit it: the flow needed grows without bound as the outlet temperature approaches the inlet temperature. The step also ends if the mass flow rate would become negative. Parameters ---------- power : float, numpy.ndarray or callable Power of the whole battery [W], positive for discharge and negative for charge. inlet_temperature : float, numpy.ndarray or callable, optional Inlet temperature [K], held. mass_flow_rate : float, numpy.ndarray or callable, optional Mass flow rate through the whole device [kg.s-1], held. Must not be 0: no power can be exchanged without flow. **kwargs ``duration``, ``termination``, ``period`` and ``description``, see :class:`BaseStep`. Attributes ---------- adjusted : str Name of the control the step adjusts. Raises ------ ValueError If both controls or neither are given. """ def __init__(self, power, *, inlet_temperature=None, mass_flow_rate=None, **kwargs): held = { name: value for name, value in ( (INLET_TEMPERATURE, inlet_temperature), (MASS_FLOW_RATE, mass_flow_rate), ) if value is not None } free = [name for name in self.controls if name not in held] if len(free) != 1: raise ValueError( "Power adjusts one control to hold the power: give exactly one " "of inlet_temperature and mass_flow_rate" ) self.adjusted = free[0] super().__init__( [Target("Power [W]", power)] + [Target(name, value) for name, value in held.items()], **kwargs, )
[docs] def safety_terminations(self): """End the step if the mass flow rate, when adjusted, would become negative""" if self.adjusted == MASS_FLOW_RATE: return [_ThresholdTermination(MASS_FLOW_RATE, 0, below=True)] return []
[docs] def control_residuals(self, variables, targets, power): """ The held control equals its target, and the power, written with the held control at its target, equals its target """ held = {name: targets[name] for name in self.controls if name != self.adjusted} residuals = {name: variables[name] - value for name, value in held.items()} # The power with the held control at its target (which it equals in this step), # so that the equation depends on the adjusted control whatever the held one # starts from (e.g. a mass flow rate of 0 after a rest) residuals[self.adjusted] = power(held) - targets["Power [W]"] return residuals