A battery management system simulator: PyBaMM electrochemistry for the cells, a real BMS on top (SOC estimation, protection, balancing, contactors), and a desktop studio to watch it work.
An internal short drains cell 4 on its own. The BMS sees it only as an imbalance and bleeds the eleven healthy cells down to match: the reason internal shorts are dangerous to miss.
A BMS decides when a battery may charge, how hard it may discharge, which cell needs balancing and when to open the contactor. Developing and validating that logic runs into the same walls:
| # | Problem | What it costs | How GAIA approaches it |
|---|---|---|---|
| 1 | Real cells are slow to test against. A single 1C cycle takes about two hours; an ageing or temperature study takes weeks. | Algorithm iterations are paced by the battery, not the engineer. | Packs run in simulated time: an hour of battery in a few seconds of CPU, deterministic and repeatable. |
| 2 | The interesting cases are destructive. Over-temperature, internal shorts and over-discharge can't be provoked on a bench without risking the cell, the rig or the lab. | Fault handling is the least tested part of most BMS code. | Fault injection on any cell (overheat, internal short, capacity fade) against a BMS whose protections really open the contactors, latch, and refuse to reset while the condition is still present. |
| 3 | Toy battery models hide the physics. A linear voltage curve has no rate-dependent polarisation and no chemistry-specific OCV. | Algorithms tuned on toy models break on real cells, and LFP's flat plateau defeats voltage-based SOC. | Cells come from PyBaMM's electrochemical models (SPM, SPMe, DFN) with published parameter sets; the real-time pack uses OCV tables generated from those same models. |
| 4 | "Is my SOC estimator any good?" is rarely measured honestly. Filters are usually tested against the same model they contain. | Optimistic accuracy claims that collapse on hardware. | A benchmark against electrochemical truth: a PyBaMM cell, noisy and biased sensors, a 20 % wrong starting SOC, and estimators that do not share the truth model. |
| 5 | The BMS lives apart from the rest of the toolchain. | Control engineers in Simulink, test engineers in Python, nobody sees the same pack. | One engine behind GAIA Studio, a REST API, a MATLAB class and Simulink block, and a XiLoop plant for closed-loop requirement testing. |
Who it is for: BMS and battery engineers prototyping algorithms, students learning how a BMS works, and anyone who needs realistic Li-ion behaviour without lab hardware.
A native desktop window (gaia studio) around the local REST API, with three workspaces.
System view. The pack in its circuit: charger, charge and discharge contactors, the series string with its BMS board and sense wires, inverter and motor. Everything drawn is read from the model: the contactors open and close with the BMS, current moves at a speed set by the measured pack current and in its real direction (discharge, charge, or regenerative braking), each cell is filled to its true SOC with the BMS estimate as a tick, and coloured by its temperature. Conditions set what the pack lives through: ambient temperature, and a manual, pulsed or synthetic drive-cycle load. The profiles are test inputs, not a standard cycle; above the BMS limits (100 A discharge, 50 A charge) they trip it, which is the protection doing its job.
45 minutes of pulsed, drive-like load (0 to 2C) on a PyBaMM SPMe cell. The BMS starts 20 % wrong,
voltage noise is 5 mV, current noise 2 % of C, and the current sensor reads 1 % of C high.
RMS error after a 10 minute settling window (gaia benchmark, pinned by the test suite):
| Chemistry | Coulomb counting | EKF | Adaptive EKF |
|---|---|---|---|
| NMC | 21.4 % | 1.6 % | 1.5 % |
| LFP | 21.4 % | 2.3 % | 2.2 % |
| NCA | 21.4 % | 7.4 % | 6.0 % |
Coulomb counting never recovers from a wrong start. The Kalman filters do, using only the chemistry's OCV curve and a guessed resistance. NCA is hardest here because its strong polarisation is what the filters' simple resistance model misses most; the adaptive filter's noise estimate is what narrows the gap.
| Chemistry matters | Current matters |
|---|---|
![]() |
![]() |
| LFP's flat plateau, NCA's steep knee near empty, NMC's gradual slope. | More current, more polarisation, lower voltage at the same charge delivered. |
Every row is an automated test in tests/test_bms_engine.py:
| Situation | What the BMS does |
|---|---|
| 30 min at 1C | SOC drops exactly 50 %; the estimate stays within 1 %; energy counted |
| Discharge requested without permission | No current flows: the contactor stays open |
| 150 A discharge / 80 A charge | Over-current alarm, contactor opens, state latches to FAULT |
| 0 → 95 A load step | Not a short circuit (the rise rate uses the real time step) |
| One cell at 65 °C | Emergency stop; reset refused while hot, accepted after it cools |
| Discharging past empty / charging past full | Under-voltage / over-voltage trip |
| 3 % SOC spread at rest | Passive balancing bleeds the high cells towards the lowest |
| Internal short on one cell | That cell drains on its own and shows up as imbalance |
| SOC estimate starts 20 % wrong | AEKF converges within 2 %; Coulomb counting stays wrong |
GAIA runs inside MATLAB through MATLAB's built-in Python interface: no server, and the same engine as
Studio. See matlab/README.md.
gaia_setup('C:\path\to\.venv\Scripts\python.exe') % once per session (Python 3.9-3.12 with GAIA installed)
pack = GaiaPack(12, "NMC", 50, 80); % 12s NMC pack, 50 Ah cells, 80 % SOC
pack.setCurrent(50); % A, positive = discharge
T = pack.runFor(1800); % table: time_s, pack_voltage_V, soc_estimated_pct, ...
pack.injectFault(5, "overheat"); % overheat | short | fade | healFor Simulink, GaiaBMS is a MATLAB System block (requested current in; voltage, actual current,
SOC estimate, true SOC, hottest cell, BMS state and fault flag out), and
build_gaia_simulink_demo builds a ready-to-run model around it. Replace its load profile with your
own charger or traction controller to close the loop.
| The generated model | 45 minutes of a 0 to 1.5C drive profile |
|---|---|
![]() |
![]() |
Verified on MATLAB R2025a with Simulink and Python 3.12: the GaiaPack fault scenario (trip, refused
reset, recovery) and the Simulink demo (SOC 90 % to 26 %, estimate within 0.1 %).
XiLoop verifies controllers against requirements. GAIA ships a
XiLoop plant (gaia.xiloop_plant:GaiaCellPlant: charge current in, cell voltage out), so a plain PID
with a current limit becomes a CC-CV charger that can be verified:
pip install git+https://github.com/Alifizz01/XiLoop
xiloop run examples/xiloop_cccv/testplan.yamlCampaign: CC-CV charger on a GAIA NMC cell - PASS
[PASS] charge_to_4v1 / REQ-CV-1: overshoot_pct=0.06002 (required <= 0.5)
[PASS] charge_to_4v1 / REQ-CV-2: steady_state_error=2.238e-09 (required <= 0.005)
[PASS] charge_to_4v1 / REQ-CC-1: peak_command=5 (required <= 5.0)
With aggressive gains (kp 100, ki 20) the loop goes unstable, the "charger" ends up discharging the cell, and REQ-CV-2 fails: exactly the kind of bug the requirement exists to catch.
GAIA needs Python 3.9 to 3.12 (PyBaMM does not support 3.13 yet).
git clone https://github.com/Alifizz01/GAIA && cd GAIA
py -3.12 -m venv .venv && .venv\Scripts\activate # Linux/macOS: python3.12 -m venv .venv && source .venv/bin/activate
pip install -e ".[studio]"
gaia studio # the desktop GUI
python examples/bms_pack_demo.py # discharge, overheat, trip, refused reset, recovery
gaia benchmark --chemistry LFP # SOC estimator accuracy against PyBaMMfrom gaia import SimulationEngine, SimulationHardwareInterface
pack = {"cells_in_series": 12, "nominal_capacity": 50.0, "chemistry": "NMC", "initial_soc": 90.0}
engine = SimulationEngine(SimulationHardwareInterface(pack), pack)
engine.initialize()
engine.set_pack_current(50.0) # 1C discharge
samples = engine.run_for(1800) # simulated time, one sample per second
print(samples[-1]["soc_estimated"], samples[-1]["soc_true"], samples[-1]["state"])
engine.hardware.set_cell_state(6, {"temperature": 273.15 + 65}) # overheat cell 7
print(engine.run_for(2)[-1]["faults"]) # ['overtemperature'], contactors openfrom gaia import SimulatorManager # one electrochemical cell
sim = SimulatorManager("SPMe", "LFP", 298.15, c_rate=2.0, thermal="lumped")
sim.run_battery_simulation(1700)
t, voltage, soc, temperature, current = sim.get_simulation_results()gaia serve (or an open Studio) answers on http://127.0.0.1:8780; every function in
gaia/api.py is POST /api/<name>:
curl -X POST http://127.0.0.1:8780/api/pack_command -H "Content-Type: application/json" -d '{"current": 50}'
curl -X POST http://127.0.0.1:8780/api/pack_step -H "Content-Type: application/json" -d '{"seconds": 60}'flowchart TB
subgraph CLIENTS[" "]
S[GAIA Studio] --- API[REST API]
M[MATLAB GaiaPack · Simulink GaiaBMS]
X[XiLoop plant]
P[Python]
end
API & M & X & P --> ENG
subgraph ENG["gaia"]
E[SimulationEngine<br/>simulated or real time] --> C["BMSController<br/>per-cell AEKF · protection · balancing · contactors"]
E --> H["SimulationHardwareInterface<br/>per-cell OCV + R0 + RC, lumped thermal"]
C <--> H
O["OCV tables per chemistry"] --> H & C
B["BatteryModel<br/>PyBaMM SPM · SPMe · DFN"] --> O
end
The real-time pack uses one equivalent-circuit model per cell, because PyBaMM is too slow to step a dozen cells every second; its OCV curve comes from GAIA's own PyBaMM model, so both views share the chemistry. The SOC benchmark and the Cell lab use PyBaMM directly.
Engineering evidence: 32 tests (cell physics, every protection path, SOC accuracy against PyBaMM, REST API, XiLoop integration) on every push, plus the SOC benchmark for all three chemistries in the CI summary.
Honest limits: parallel cells share current equally (no circulating currents); ageing is a
capacity-fade injection, not a degradation model; RealHardwareInterface is a stub for a future CAN or
serial connection; the lumped thermal model is one node per cell.
gaia/ the package
battery_model.py PyBaMM cell (SPM / SPMe / DFN, isothermal or lumped thermal)
ocv.py OCV tables per chemistry, generated from PyBaMM (data/)
hardware_interface.py simulated pack: per-cell equivalent circuit, contactors, balancing, faults
bms_controller.py state machine, per-cell SOC estimation, protection, balancing
soc_estimation.py Coulomb counting, EKF, adaptive EKF
soc_benchmark.py estimators against electrochemical truth
simulation_engine.py step() / run_for() in simulated time, or a real-time thread
api.py, server.py REST API; studio/ the desktop GUI
matlab_bridge.py MATLAB-friendly wrapper; xiloop_plant.py XiLoop plant
matlab/ GaiaPack, GaiaBMS (Simulink), demo builder
examples/ bms_pack_demo.py, xiloop_cccv/ test plan
tests/ pytest suite
assets/ logo, figures and screenshots, with the scripts that regenerate them
MIT · © Muhamad Alif Izzuwan Bin Ibrahim (github.com/Alifizz01)









