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 single heat port heatport_coolant. 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.

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.