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 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 GeneralContainer by defining them as either a ScalarParameter or ListParameter.

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 InputQuantities, which is a general container 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 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.

An example for a batter that one state, state of charge, is simple

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 HealthVariable.

Attributes which represents a health parameter must be ScalarParameter or 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.

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 CellModel.

A cell model contains two functions: update the transient state, and generate expected outputs.

Consider the example for the series resistor model below

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.