Skip to main content
emobility.academy
B.Tech / BEBattery & BMSIntermediate About 6 weeksKit + add-ons

Coulomb-Counting State-of-Charge (SOC) Estimator

Build a state-of-charge estimator that integrates cell current over time, measured with an INA219 add-on, and corrects its starting point from the rested cell voltage. SOC, charge used and estimated error are shown on the LCD and logged to a PC over UART.

Uses: ADC · UART · GPIO

The problem

Drivers read state of charge as the fuel gauge of an EV, and BMS functions such as range estimation and charge limits depend on it. Coulomb counting is the most widely used basis for SOC, but it drifts because of current-sensor offset, unknown starting SOC and capacity uncertainty. This project builds a coulomb counter and makes those error sources visible and measurable.

Objectives

  • Measure charge and discharge current with an INA219 current monitor on the I2C bus.
  • Integrate current at a fixed rate to track ampere-hours in and out of the cell.
  • Initialise SOC from a rested open-circuit voltage and a simple OCV–SOC table.
  • Quantify drift caused by current offset and sampling errors over a full discharge.
  • Display SOC on the LCD and stream logs over UART for analysis in Python.

What you need

From the EV Embedded Development Kit

  • STM32F103C8T6 microcontroller unit (Arm Cortex-M3)
  • 1S battery with connector and T-sense input
  • Load section: three switchable resistive loads with LEDs
  • Battery charging unit with its own switch
  • I2C and SPI buses to the onboard devices
  • Serial UART interface
  • 16×2 LCD with contrast control

Software

  • STM32CubeIDE
  • Embedded C with STM32 HAL
  • Python with pyserial, pandas and matplotlib

Extra parts

  • INA219 I2C current and power monitor breakout — The kit does not list a current sensor, and coulomb counting needs current during both charge and discharge. The INA219 measures the voltage across a shunt in either direction and reports it over I2C.

This project uses the kit's lithium cell. Respect polarity, never short the terminals, and don't leave the cell charging unattended.

Key concepts

Coulomb counting
SOC(t) = SOC(0) − (1 / capacity) × the integral of current over time. It is accurate over short periods but accumulates any constant current error.
Current-sensor offset
A small reading when no current flows. Integrated for hours it becomes a growing SOC error, so it must be measured with zero current and subtracted.
Open-circuit voltage (OCV)
The voltage of a cell that has rested with no current. It relates to SOC, so it can set or correct the starting point of the counter.
Fixed-rate integration
Sampling from a timer interrupt at a known period lets the integral be a simple sum: charge += current × period. Integer milliamp-milliseconds avoid software floating point.

Step-by-step guide

  1. 1

    Define the estimator and its error budget

    Write the SOC equation and list every error source: current offset, current gain error, sampling period error, initial SOC error and capacity uncertainty. For each, estimate the effect after a two-hour discharge; for example a 2 mA offset over two hours is 4 mAh, which is a noticeable fraction of a small cell's capacity. Decide the sample rate (10 Hz is enough for slowly switched resistive loads) and the outputs: SOC in tenths of a percent, mAh used, and an estimated uncertainty.

    Checkpoint: A table of error sources with their predicted effect on SOC after two hours.

  2. 2

    Wire the INA219 safely

    Disconnect the cell first. Insert the INA219 shunt in series with one cell lead at the battery connector so that both charge and discharge current pass through it, keeping the polarity consistent so discharge reads as positive current. Connect the breakout's VCC, GND, SDA and SCL to the kit's I2C bus. Reconnect the cell, checking polarity, and confirm with a multimeter that the board still sees the correct cell voltage. Never let the bare shunt terminals touch anything conductive.

    Checkpoint: The board powers up normally and the cell voltage at the battery-sense block matches the multimeter.

  3. 3

    Configure CubeMX

    Set the system clock to 72 MHz. Enable I2C1 at 100 kHz or 400 kHz on the pins your kit routes to the I2C bus, enable the DIP switches for the I2C, LCD, battery-sense and UART blocks, and set a USART at 115200 baud. Configure a basic timer such as TIM2 to interrupt every 100 ms; this is the integration clock. Keep the ADC channel for cell voltage from the voltage-monitor design so OCV can be read.

    Checkpoint: A scan of I2C addresses finds the INA219 (0x40 by default) and the timer callback toggles an LED at 5 Hz.

  4. 4

    Write the INA219 driver

    Write functions to read and write its 16-bit registers with HAL_I2C_Mem_Read and HAL_I2C_Mem_Write. Set the configuration register for the bus voltage range and a shunt gain that suits your expected current, then program the calibration register from the shunt resistance and your chosen current LSB. Read the current register as a signed value. Check the conversion with a known case: switch on one resistive load and compare the current with the cell voltage divided by the load resistance you measured with a multimeter.

    Checkpoint: Measured current agrees with V / R for each load within a few per cent, and reads negative during charge.

  5. 5

    Measure and remove the offset

    With the loads and charger switched off, a small current still flows into the board's own circuits through the shunt, and the sensor also has an offset. Average 100 readings in this state; this combined value is your quiescent reading. Decide whether the board's own consumption should count (it does drain the cell, so usually yes) and handle offset accordingly. Record how stable it is over ten minutes and at different temperatures.

    Checkpoint: A logged quiescent current value with its standard deviation and drift over ten minutes.

  6. 6

    Implement the integrator

    In HAL_TIM_PeriodElapsedCallback, read the current and add current_mA × 100 ms to a 64-bit accumulator in milliamp-milliseconds; convert to mAh only for display. Keep the interrupt short by setting a flag and doing the I2C read in the main loop if your read takes too long, but then use the actual elapsed time from a microsecond timer rather than assuming exactly 100 ms. Protect the accumulator from being read mid-update.

    Checkpoint: Switching one load on for exactly 10 minutes by a stopwatch gives a mAh figure within 2 per cent of current × time.

  7. 7

    Initialise SOC from rested voltage

    A coulomb counter needs a starting point. Measure a coarse OCV–SOC table for your cell (or use a typical Li-ion table and flag it as approximate). At power-up, if the cell has rested and current is near zero, read the voltage and interpolate the starting SOC. Hold the rated capacity as a constant to be refined later by a full discharge test. Show on the LCD whether the starting point came from OCV or from a stored value.

    Checkpoint: After a two-hour rest the OCV-based starting SOC is plausible and repeatable between two power-ups.

  8. 8

    Display and log

    Show SOC, mAh used and current on the 16×2 LCD. Stream a CSV line every second over UART: time, voltage, current, accumulated mAh, SOC. Write a Python script with pyserial that saves the stream to a file and pandas plus matplotlib to plot SOC and voltage against time. Keep the UART transmit non-blocking with HAL_UART_Transmit_DMA or IT so it does not disturb the integration timing.

    Checkpoint: A live plot updates on the PC while the LCD shows the same SOC value.

  9. 9

    Run a full discharge test

    Charge the cell fully under supervision, let it rest for an hour, then discharge it with a chosen load combination until the voltage reaches the cut-off you set (not below the cell's rated minimum). Log everything. The total mAh is the measured capacity; compare the estimator's SOC trajectory with the final result. Repeat with a deliberately wrong offset to show how drift grows, and plot both runs.

    Checkpoint: A plot showing SOC falling to near 0 per cent at cut-off, and a second plot quantifying drift from a known offset error.

  10. 10

    Report error and limitations

    Compare measured drift with your step 1 predictions. Explain why the estimate needs periodic correction, for example from OCV after rest or a full-charge reset at the end of CC–CV charging. Include the wiring diagram of the INA219, the calibration values, the Python scripts and the raw logs. State clearly the limits: one cell, low currents, room temperature only. Finish with a short list of improvements you would make next and the order you would make them in.

    Checkpoint: A report with predicted versus measured error, wiring diagram, code and data that a reviewer can reproduce.

Testing & validation

  • Current accuracy against V / R and a multimeter for each load.
  • Integration accuracy: mAh over a timed constant load compared with current × time.
  • Capacity measured by full discharge, repeated twice for repeatability.
  • SOC drift with a deliberately injected offset compared with the predicted drift.
  • SOC at cut-off compared with 0 per cent and SOC after full charge compared with 100 per cent.

Expected results

A coulomb-counting SOC estimator that tracks charge and discharge, a measured capacity for the kit cell, and plots that show how offset and starting-point errors cause drift.

Take it further

  • Store SOC and accumulated charge in SPI flash so it survives a power cycle.
  • Correct SOC automatically at end of charge and after long rests.
  • Compensate capacity for temperature using the T-sense reading.
  • Send SOC over CAN as a BMS status frame.

Viva and review questions

Why does coulomb counting drift?

Any small constant error in current, such as sensor offset, is integrated over time, so the SOC error grows the longer the counter runs without correction.

Why did you use an INA219 and not estimate current from the load resistance?

Coulomb counting needs current during charge as well as discharge, and the charger current is unknown. A shunt monitor measures both directions directly.

How do you choose the initial SOC?

From the open-circuit voltage after the cell has rested, using an OCV–SOC table, because under load the terminal voltage includes resistive drops.

Why integrate in integers?

The Cortex-M3 has no FPU, so integer milliamp-millisecond sums are faster and avoid rounding loss over long periods.

Related EV projects

All 100 EV projects