Using the battery components from Dyad

BatteryComponents is a Dyad component library. A model instantiates its components in a .dyad file, connects their pins and signals, and runs an analysis. This page is that path from end to end; the component reference lists what each component offers.

Add the dependency to your package's Project.toml under [deps], and refer to the components as BatteryComponents.Name. No using declaration goes in the .dyad file itself.

One cell

EquivalentCircuitCell is a single equivalent-circuit cell. Its topology structural parameter selects the circuit. ChenRinconMora carries its own published open-circuit voltage law and state-of-charge-dependent elements, so it needs no parameters beyond the ones it defines:

component CellOnLoad
  battery = BatteryComponents.EquivalentCircuitCell(
    topology = BatteryComponents.ECMTopology.ChenRinconMora(),
    SOC_initial = 0.8)
  load = ElectricalComponents.Analog.Basic.Resistor(R = 2)
  ground = ElectricalComponents.Analog.Basic.Ground()
relations
  connect(battery.p, load.p)
  connect(load.n, battery.n, ground.g)
end

The other topologies take their open-circuit voltage as a signal, so that you choose the chemistry's OCV law. Supply it by assigning battery.ocv in volts, either a constant or the output of an OCV component:

component TheveninOnLoad
  battery = BatteryComponents.EquivalentCircuitCell(
    topology = BatteryComponents.ECMTopology.DualPolarization(),
    capacity = 100.0, SOC_initial = 0.55,
    R0 = 0.02402, R_dp = [0.00064, 0.00824], C_dp = [5630.0, 54277.0])
  load = ElectricalComponents.Analog.Basic.Resistor(R = 2)
  ground = ElectricalComponents.Analog.Basic.Ground()
relations
  connect(battery.p, load.p)
  connect(load.n, battery.n, ground.g)
  battery.ocv = 57.6
end

Capacity is in amp-hours and state of charge is a fraction; every other parameter is SI. Reported I is positive on discharge, while the pins follow the passive sign convention, so p.i = -I and a resistive load draws positive power as the state of charge falls.

Match the scale of the elements to the scale of the open-circuit voltage. The elements above are the He (2011) values, which characterise a 57.6 V nominal module rather than a single cell, so the OCV assigned to them is a module voltage.

A pack

ArrayBatteryPack is the pack, and a single cell is the 1 × 1 case of it. Its structural parameters choose what it models:

ParameterEnumSelects
familyBatteryModelFamilyequivalent circuit, or SPM, SPMe, DFN
topologyECMTopologythe circuit, when the family is an equivalent circuit
parameter_setBatteryParameterSeta published parameter set, or the model default
thermalBatteryThermalModelisothermal, lumped, or distributed
degradationECMDegradationfixed health, or externally prescribed health
sei, lamBatteryElectrodeSelectionthe electrodes on which SEI growth and loss of active material are active, for the electrochemical families
sei_parametersSEIParameterSetthe published SEI parameter set, when SEI growth is active
component PackOnLoad
  battery = BatteryComponents.ArrayBatteryPack(
    family = BatteryComponents.BatteryModelFamily.EquivalentCircuit(),
    topology = BatteryComponents.ECMTopology.Thevenin(),
    parameter_set = BatteryComponents.BatteryParameterSet.He2011(),
    series = 2, parallel = 3)
  load = ElectricalComponents.Analog.Basic.Resistor(R = 2)
  ground = ElectricalComponents.Analog.Basic.Ground()
relations
  connect(battery.p, load.p)
  connect(load.n, battery.n, ground.g)
end

series and parallel are structural, so the pack's shape is fixed when the model is compiled. The pins are scalar whatever the shape: a pack presents one pair of terminals, not one per cell. This pack reports about 126 V at rest, because He2011LiMn2O4 characterises a 57.6 V module and two of them are in series. Its identification is local to a state of charge between 0.5 and 0.6, which is why the default is 0.55 and why the examples here stay inside that window.

Per-cell parameters

Cells differ through array parameters, all shaped (series, parallel) except the series links:

ParameterShapeMeaning
SOC_initial(series, parallel)initial state of charge of each cell
T_initial(series, parallel)initial temperature of each cell
R_branch(series, parallel)interconnection resistance of each cell branch
R_link(series + 1)resistance of each series link
heat_capacity(series, parallel)lumped heat capacity, thermal equivalent circuits only
component UnequalPack
  structural parameter series::Integer = 2
  structural parameter parallel::Integer = 3
  parameter initial_soc::Real[series, parallel] = [[0.52, 0.55, 0.58], [0.53, 0.56, 0.59]]
  battery = BatteryComponents.ArrayBatteryPack(
    family = BatteryComponents.BatteryModelFamily.EquivalentCircuit(),
    topology = BatteryComponents.ECMTopology.Rint(),
    parameter_set = BatteryComponents.BatteryParameterSet.He2011(),
    series = series, parallel = parallel, SOC_initial = initial_soc)
  ground = ElectricalComponents.Analog.Basic.Ground()
relations
  connect(battery.n, ground.g)
end

Heat and health

A thermal pack exposes two ArrayHeatPorts, heatport_left and heatport_right, each carrying one temperature and one heat flow per series row rather than one port per cell. PackAmbient is a convective boundary of the matching shape:

  battery = BatteryComponents.ArrayBatteryPack(
    family = BatteryComponents.BatteryModelFamily.EquivalentCircuit(),
    topology = BatteryComponents.ECMTopology.Rint(),
    thermal = BatteryComponents.BatteryThermalModel.Lumped(),
    series = 2, parallel = 3)
  ambient = BatteryComponents.PackAmbient(series = 2, conductance = 5.0)
relations
  connect(battery.heatport_left, ambient.port)

That boundary cools every series row equally, and the cells within a row share one temperature, so no cell is hotter for sitting in the middle of the block. When that gradient is the point, build the pack from Julia with a PackThermalNetwork instead: it couples each cell to its geometric neighbours with a finite conductance and lets only the cells on the outside of the block reach the coolant, which a networked pack exposes as the heat port heatport_coolant. For an electrochemical model built with temperature = true it couples the faces of neighbouring cells, so the temperature difference across each cell is resolved together with the one across the pack. It is a Julia-level option, because it is configured with a struct rather than with the scalars a Dyad component declaration carries.

Both boundaries hold their far side at a fixed temperature. The third boundary, PackForcedAir, is an active one: a case wall, a stream of cooling air and a fan that a thermostat switches on when the hottest cell rises through a set temperature and off again when it falls through a deadband below it. It is the single-node air-cooling model of NREL's ADVISOR, with that model's convection correlation as the shipped defaults of ForcedAirCooling and every number a parameter. From Julia it takes the place of the fixed ambient or coolant:

chemistry = He2011LiMn2O4()
chemistry["overall"]["heat capacity"] = 200.0 # J/K, which the paper does not report
@named circuit = CyclingCircuit(; model = Thevenin(), chemistry, series = 4,
    parallel = 3, temperature = true, protocol = discharge(C / 5; time = 600),
    cooling = ForcedAirCooling(; area = 0.5, T_set = 308.15, hysteresis = 2.0))

The boundary is called cooler and reports h, air_flow, T_air, T_out, Q_case and Q_out. It is a Julia-level option for the same reason the network is.

Directional coolant flow

Use ForcedAirCooling(flow_axis = :series) for flow along the series rows, or :parallel for flow along the parallel columns. Flow enters at index 1. There is one case and air node per slice, with the total area and air_mass split evenly among nodes. The fan supplies the same mass flow through the entire chain.

chemistry = He2011LiMn2O4()
chemistry["overall"]["heat capacity"] = 200.0
@named circuit = CyclingCircuit(; model = Thevenin(), chemistry, series = 2, parallel = 4,
    temperature = true, protocol = discharge(1C; time = 60),
    thermal_network = PackThermalNetwork(; coolant_conductance_lateral = 1.0),
    cooling = ForcedAirCooling(; flow_axis = :parallel, area = 0.5, air_mass = 0.02))

A chain has (N, 1) fields cooler.T_air, cooler.T_case and cooler.Q_case; these three fields are scalar for N = 1. cooler.T_exit is (N, 1) for every node count and gives each slice's outlet temperature. The case sees the midpoint T_air,k = (T_exit,k-1 + T_exit,k) / 2, with T_exit,0 = T_inlet. Each slice stores (air_mass / N) * air_heat_capacity * T_air,k and receives Q_case,k + air_flow * air_heat_capacity * (T_exit,k-1 - T_exit,k). With positive air mass the midpoint temperatures cooler.T_air are the states, so cooler.T_air in u0 sets the initial air temperature. cooler.T_out is the final outlet temperature and cooler.Q_out is the net heat carried out relative to the inlet. Zero air mass gives an algebraic balance at every slice. For N = 1 these equations give the ADVISOR mean-air relation, including T_air = T_inlet + Q_case / (2 * air_flow * air_heat_capacity) at zero air mass and a time constant of air_mass / (2 * air_flow) at positive air mass. Uniform steady heating gives a mean case-seen rise of half the total outlet rise for any N. flow_axis = :none selects one node for any pack size.

The network's heatport_coolant is (N, 1) for a chain and scalar for one node. Its entry k exchanges with exposed faces in slice k; internal conduction between cells contributes no coolant heat. The (series, parallel) field pack.q_coolant reports this exposed-face heat input for each cell, including for a single coolant node. For a manually wired BatteryPack, select the same port geometry with coolant_axis = :series or :parallel; both require a thermal network. Invalid axis symbols are rejected for thermal and isothermal packs alike.

Per-row ports couple both ends of row k to node k under series flow; multi-node parallel flow with per-row ports is rejected because they reach only the end columns. CyclingCircuit also rejects network geometries that leave a slice without an exposed face: multi-node parallel flow needs positive coolant_conductance_lateral, or positive coolant_conductance with at most two columns, and multi-node series flow needs positive coolant_conductance, or positive coolant_conductance_lateral with at most two rows. A hand-wired BatteryPack(; thermal_network, coolant_axis) connected to PackForcedAir(; nodes) is not checked: each node receives area / N, so the case area of a slice with no exposed face is lost. A manually constructed PackForcedAir with row ports and parallel flow requires an explicit parallel dimension, even for a single column. The thermostat senses the hottest cell and controls the whole chain with one setpoint and hysteresis.

Selecting ECMDegradation.PrescribedHealth() adds the real-input arrays capacity_health and resistance_growth, both (series, parallel). They are an interface for an aging model you supply, not an aging model themselves.

The pack reports V, I, P, SOC, SOC_abs, SOH, T, Q, Q_theoretical and C_rate as real outputs, and Q_interconnect as the loss in the connection resistances.

Running it

Declare an analysis over the model and run it the way you run any other Dyad analysis:

analysis PackDischarge
  extends TransientAnalysis(stop = 600, abstol = 1m, reltol = 1m)
  model = PackOnLoad()
end

What this library does not do

Cycling protocols, stop conditions and experiment logs have no Dyad components today. A model that must follow a charge, hold and rest schedule drives the pack from a current or voltage source of your own, or uses the package's Julia CyclingCircuit directly.

Compilation scalarizes the pack. The symbolic model holds a fixed number of array equations whatever the pack's dimensions, but mtkcompile expands them per cell, so compile time and the size of the numerical problem still grow with the cell count. Keeping the array structure through compilation is tracked in ModelingToolkit issue 5139.