Skip to content

Multi-String Systems

Larger BESS installations — grid-scale, utility, or industrial — are typically built from several independent strings, each with its own converter, for modularity, redundancy, and staged deployment. simses has no dedicated multi-string class; instead, you compose N independent (Battery, Converter) pairs and apply a power-distribution rule on every timestep.

Basic setup

Two strings, one Battery + one Converter each. The batteries can differ in chemistry, circuit, initial SOC, or C-rate; the converters can differ in loss model or rating.

from simses.battery import Battery
from simses.converter import Converter
from simses.model.cell.sony_lfp import SonyLFP
from simses.model.converter.sinamics import SinamicsS120Fit


def make_string(start_soc, circuit=(104, 10), max_power=20_000):
    battery = Battery(
        cell=SonyLFP(),
        circuit=circuit,
        initial_states={"start_soc": start_soc, "start_T": 25.0},
    )
    converter = Converter(
        loss_model=SinamicsS120Fit(),
        max_power=max_power,
        storage=battery,
    )
    return battery, converter


strings = [
    make_string(start_soc=0.3, circuit=(104, 10), max_power=20_000),  # full-size
    make_string(start_soc=0.7, circuit=(104, 5),  max_power=10_000),  # half size, half power
]

Equal split

The simplest rule: divide the total setpoint by len(strings). Appropriate when all strings have identical ratings and SOCs track closely.

def equal_split(strings, total_power):
    power_per_string = total_power / len(strings)
    for _, converter in strings:
        converter.step(power_per_string, dt)

This ignores SOC differences — over many cycles, strings drift apart, and the first to hit soc_limits silently stops contributing while the others keep running. Use this only as a baseline.

SOC-weighted split

A better rule for strings that start at different SOCs or drift apart over time: on discharge, drain higher-SOC strings faster; on charge, fill lower-SOC strings faster. This is what the demo notebook, Part 3 uses.

import numpy as np


def soc_weighted_split(strings, total_power, eps=1e-6):
    socs = np.array([battery.state.soc for battery, _ in strings])
    if total_power < 0:                              # discharging
        weights = (socs + eps) / (socs + eps).sum()
    else:                                             # charging (or idle)
        weights = (1 - socs + eps) / (1 - socs + eps).sum()
    return total_power * weights


def step_strings(strings, total_power, dt):
    powers = soc_weighted_split(strings, total_power)
    for (_, converter), p in zip(strings, powers, strict=True):
        converter.step(p, dt)

The eps guards against the degenerate case where all strings are at the same boundary (all SOC = 0 on discharge, or all SOC = 1 on charge).

Designing your own rule

The two rules above are starting points. A distribution rule is just a function from (strings, total_power) to per-string powers, so anything can go there — weightings that minimise cumulative aging, MPC over a receding horizon, thermal-aware splits that offload from hot strings, or a centralised optimiser that treats the multi-string system as a single decision variable. simses only requires that each converter's step(power, dt) is called per timestep with whatever value your strategy produced.

Saturation caveat

Both rules above are single-pass: they compute a distribution from the pre-step state and apply it verbatim. If one string then saturates (conv.state.power < commanded because the battery hit a voltage, SOC, or thermal limit), the total AC power delivered to the system bus will be less than the commanded total_power — there is no automatic re-distribution to the non-saturated strings.

For studies where exact total power matters, either:

  • Check sum(conv.state.power for _, conv in strings) after stepping and iterate — redistribute the unmet portion among strings that still have headroom.
  • Upstream the distribution into an EMS (energy-management system) that tracks and compensates for saturation across steps.

Shared thermal environment

Multiple strings in the same container share one thermal model — register each battery as a separate node via add_component():

from simses.thermal import AmbientThermalModel

thermal = AmbientThermalModel(T_ambient=25.0)
for battery, _ in strings:
    thermal.add_component(battery)

In the simulation loop, step the thermal model after all the electrical steps have written their state.heat:

for i, load in enumerate(load_profile):
    step_strings(strings, total_power=-load, dt=dt)
    thermal.step(dt)

For richer setups (walls, HVAC, solar gain) swap AmbientThermalModel for ContainerThermalModel — the add_component() contract is the same.

Encapsulating the whole system as a single class

The patterns above — a list of (Battery, Converter) tuples, an external distribution function, an external thermal model — are the simplest way to express a multi-string system. For larger projects, you can wrap all of it (strings, split rule, thermal environment) behind a single class that exposes step(power, dt) and a state.power attribute. Because Converter duck-types its storage (see the Converter concept page), such a wrapper fits anywhere a Battery does — an upstream Converter can chain onto it, a higher-level EMS or co-simulation bridge sees one interface, and the internal multi-string composition stays fully encapsulated.

See Also