Extending Moirae ================ Welcome to our in progress page on how to add new features to Moirae. It will become more organized as the package becomes more mature. For now, it is disconnected sections written as we build capabilities. Adding a New Cell Model ----------------------- Add a new mathematical model for a storage system to Moirae through several steps. Primer on ``GeneralContainer`` ++++++++++++++++++++++++++++++ The inputs, outputs, and transient state of a system are defined using the :class:`~moirae.models.base.GeneralContainer`. The general container allows addressing batches of variables either by name (helpful when implement mathematical models) or as a array (helpful for estimation operations agnostic to the meaning of a variable). Any variable stored in a ``GeneralContainer`` is represented as a 2D numpy array. The first dimension is the batch dimension and the second indexes values within a vector parameter. Add new attributes to a subclass of :class:`~moirae.models.base.GeneralContainer` by defining them as either a :class:`~moirae.models.base.ScalarParameter` or :class:`~moirae.models.base.ListParameter`. .. code-block:: python from moirae.models.base import GeneralContainer, ListParameter, ScalarParameter class ExampleContainer(GeneralContainer): """A container that holds each type of variable""" a: ScalarParameter = 1. """A variable which is always one value""" b: ListParameter = (2., 3.) """A variable which is a vector of any length""" The ``GeneralContainer`` class is based on the ``BaseModel`` class from `pydantic `_, which will automatically create an ``__init__`` function for you. The ``ListParameter`` and ``ScalarParameter`` type decorations provide `validation `_ and `serialization `_ logic which helps ensure the values of each parameter are 2D numpy arrays. Set default values using native Python syntax (as above) or via the `Field class from pydantic `_. 1. Enumerate the Inputs and Outputs +++++++++++++++++++++++++++++++++++ The inputs of a system define how the system is being controlled. We assume, by convention, that the (dis)charge current is an input for all storage system. Define any others by subclassing :class:`~moirae.models.base.InputQuantities`, which is a `general container <#primer-on-generalcontainer>`_ with the attribute for current and the time of the inputs already defined. The outputs of a system are what is observable about the system state, which must include the terminal voltage. Define outputs appropriate for the new system by subclassing :class:`~moirae.models.base.OutputQuantities`. 2. Specify the Transient State ++++++++++++++++++++++++++++++ The transient state for a system are the independent variables on which the dynamics are defined. For example, the state of charge of a battery is a state variable. Define the transient state by creating a new `general container <#primer-on-generalcontainer>`_. An example for a batter that one state, state of charge, is simple .. code-block:: python class TransientState(GeneralContainer): """A container that holds each type of variable""" soc: ScalarParameter = 0. """How much the battery has been charged. 1 is fully charged, 0 is fully discharged""" 3. Define the Health Parameters +++++++++++++++++++++++++++++++ The state of health of a system are the parameters included in the dynamic model of a battery. The coefficients which capture open circuit voltage (OCV) changes with state of charge is a common state variable. Define the parameters for a new battery system by subclassing :class:`~moirae.models.base.HealthVariable`. Attributes which represents a health parameter must be :class:`~moirae.models.base.ScalarParameter` or :class:`~moirae.models.base.ListParameter` type, an other ``HealthVariable`` class, or tuples or dictionaries of ``HealthVariable`` classes. Consider the example battery with a series resistor and polynomial model for OCV below. .. code-block:: python from typing import Union import numpy as np from numpy.polynomial.polynomial import polyval from pydantic import Field from moirae.models.base import HealthVariable, ListParameter, ScalarParameter class OpenCircuitVoltage(HealthVariable): coeffs: ListParameter = [1, 0.5] """Parameters of a power-series polynomial""" def get_ocv(self, soc: Union[float, np.ndarray]) -> np.ndarray: """Compute the OCV as a function of SOC""" return polyval(soc, self.coeffs.T, tensor=False) class BatteryHealth(HealthVariable): ocv: OpenCircuitVoltage = Field(default_factory=OpenCircuitVoltage) r: ScalarParameter = 0.01 Note how the ``OpenCircuitVoltage`` is a Python class and, therefore, can provide methods which operate on its attributes. The coefficients of the polynomial are a vector of unlimited length, which we specify using the ``ListParameter`` type. The ``BatteryHealth`` class uses the ``OpenCircuitVoltage`` as one of its attributes and a scalar value for the resistance using ``ScalarParameter``. The default value for the OCV is set using the "default factory" feature of pydantic so that each instance of ``BatteryHealth`` receives a separate instance of ``OpenCircuitVoltage``. 4. Build a Cell Model +++++++++++++++++++++ The last step is to define the relationship between inputs, transient state, health parameters, and output via the :class:`~moirae.models.base.CellModel`. A cell model contains two functions: update the transient state, and generate expected outputs. Consider the example for the series resistor model below .. code-block:: python from moirae.models.base import CellModel class RintModel(CellModel): def update_transient_state( self, previous_inputs: InputQuantities, new_inputs: InputQuantities, transient_state: TransientState, asoh: BatteryHealth ) -> TransientState: new_output = transient_state.model_copy(deep=True) # Return a new copy dt = new_inputs.time - previous_inputs.time new_output.soc = transient_state.soc + new_inputs.current * dt / 3600. return new_output def calculate_terminal_voltage( self, new_inputs: InputQuantities, transient_state: TransientState, asoh: BatteryHealth) -> OutputQuantities: v = new_inputs.current * asoh.r + asoh.ocv.get_ocv(transient_state.soc) return OutputQuantities(terminal_voltage=v) A few points to note: - Values of health and transient state are accessible as attributes - The update function returns a *new* transient state object - The logic here uses `NumPy's broadcasting `_ to handle batches of inputs. Models that do not use NumPy may require inspecting the ``batch_size`` of the states. - It is acceptable to change the type annotations to match subclass. ``RintModel`` expects the ``asoh`` to be a ``BatteryHealth`` class rather than a generic ``HealthVariable``.