Physics-Informed TinyML on an ESP32: Estimating Battery SOC and SOH

An offline ESP32 battery monitor from our IEEE paper: a physics-informed neural network (R² 99.70%) compressed to INT8 with Edge Impulse EON, plus no-start prediction.

Satyam KesharwaniSatyam Kesharwani 7 min read
  • Machine Learning
  • IoT
  • Embedded
  • Research

TL;DR. For our IEEE NE-IECCE 2026 paper, SOC and SOH Estimation of Lead-Acid Battery using IoT and Residual-Physics Neural Network (I am the second author), we built a low-cost, fully offline battery monitor on an ESP32. A Residual-Physics Neural Network (RPNN), trained with a loss that penalises violations of the Coulomb-counting equation, estimates state of charge with R² = 99.70% (MAE 0.6815, MSE 0.6408), ahead of XGBoost (98.46%), Random Forest (98.45%) and linear regression (73.57%) on a 19,948-sample dataset. The model was compressed into an INT8-quantized C++ library with Edge Impulse's EON Compiler and runs from the ESP32's flash. Two physics-based features sit on top of it: Virtual Cranking, which predicts a no-start before it happens, and Vampire Drain alerts.

Paper: IEEE Xplore · Code: GitHub

The problem: voltage lies

Most cars still rely on a 12 V lead-acid battery to start the engine and run their low-voltage electronics. The cheap way to judge such a battery is its voltage, and that is unreliable: voltage sags under load, recovers at rest and shifts with temperature, and an aged battery can show a healthy resting voltage until the morning it fails to start the engine.

Our goals were to estimate three quantities continuously on cheap hardware, without depending on the cloud:

  • State of charge (SOC): how full the battery is right now.
  • State of health (SOH): how much capacity it has left compared with when it was new.
  • Time to empty: how long it can keep supplying the current load.

And to turn those into two warnings a driver actually cares about: "your car may not start" and "something is draining your battery".

Collecting the data

The sensing rig is an ESP32 with three cheap sensors:

  • a resistive voltage divider for battery voltage,
  • an ACS712 Hall-effect sensor for current,
  • a DHT11 for temperature (and humidity).

I collected the dataset during a controlled discharge of a 12 V, 7 Ah lead-acid battery from 12.5 V down to 10.5 V, sampling at 1 Hz for about 5.5 hours, which gave 19,948 samples.

The ESP32's ADC reads at most 3.3 V, so the divider scales the battery voltage down by a factor of five and the firmware scales it back up. The ACS712 outputs a voltage proportional to current, centred on a zero-current offset:

const float R1 = 30000.0;                      // 30 kΩ
const float R2 = 7500.0;                       // 7.5 kΩ
const float SCALING_FACTOR = (R1 + R2) / R2;   // = 5.0
// Calibration factor based on comparison with multimeter reading
const float CALIBRATION_FACTOR = 1.122;
const float ACS712_OFFSET_VOLTAGE = 2.335;     // sensor output at zero current
const float ACS712_SENSITIVITY = 0.100;        // 100 mV per amp

// battery voltage (12-bit ADC)
float vOut = (adcValue * ADC_VOLTAGE_REF) / ADC_RESOLUTION;
float measuredVoltage = vOut * SCALING_FACTOR * CALIBRATION_FACTOR;

// current: the average of 100 ADC readings, converted around the offset
float curr = abs((voltagee - ACS712_OFFSET_VOLTAGE) / ACS712_SENSITIVITY) - .025;

Cheap sensors are noisy, so each current reading averages 100 ADC samples, and the voltage carries a calibration factor fitted against a multimeter. The same firmware also contains the naive approach we set out to replace: a linear map from voltage (10.5 V to 12.6 V) to a percentage.

Ground truth: Coulomb counting

To train a model you need true labels. Coulomb counting provides them by integrating current over time: the charge that left the battery, divided by its capacity.

SOC(t) = SOC(t₀) − (1 / Cₙ) · ∫ I(τ) dτ          (Cₙ = rated capacity, I = discharge current)

which, as a differential equation, says:   dSOC/dt = −I(t) / Cₙ

During a controlled discharge with a known starting point, this is accurate. In a real car it is not enough on its own: small current-sensor errors accumulate without bound, and you rarely know the starting SOC. That is why we wanted a model that predicts SOC from what it can measure right now, while still respecting the physics.

The Residual-Physics Neural Network

A plain neural network learns whatever mapping fits the training data, including relationships that are physically impossible, and nothing stops its predictions from drifting in directions the physics forbids. A physics-informed network adds a second term to the loss:

loss = data loss (predicted SOC vs true SOC)
     + λ · physics residual (how far the predicted dSOC/dt is from −I / Cₙ)

The physics residual penalises predictions whose rate of change disagrees with the Coulomb-counting equation for the measured current; λ sets how much that matters relative to fitting the labels. The network is pushed towards solutions that are both accurate and physically consistent, which helps most where the data is thin.

On the 19,948-sample dataset:

ModelR²
RPNN (physics residual in the loss)99.70%
XGBoost98.46%
Random Forest98.45%
Linear regression73.57%

The RPNN's mean absolute error was 0.6815 and its mean squared error 0.6408. Linear regression's 73.57% shows how non-linear the voltage-to-SOC relationship is. The tree ensembles do well, but the physics-informed network does better again, and it is the one that stays consistent with the battery's discharge behaviour.

Fitting it on an ESP32: INT8 and the EON Compiler

I trained the RPNN in PyTorch and then compressed it with Edge Impulse's EON (Edge Optimized Neural) Compiler into an INT8-quantized C++ library. Quantization replaces 32-bit floating-point weights and activations with 8-bit integers, making the model roughly four times smaller and letting it run on integer arithmetic. The EON Compiler goes a step further than a typical on-device runtime: it compiles the network into plain C++, so no interpreter has to be shipped. The library lives in the ESP32's flash, and inference runs entirely on the device. The only network traffic is optional telemetry to the dashboard.

Power matters, because a battery monitor must not become the thing that drains the battery. The device wakes up, runs inference in under 2 seconds, and then hibernates in deep sleep for 5 minutes.

Virtual Cranking: predicting a no-start

The real question a driver has is "will the engine start tomorrow?". Starting draws a huge current for a moment (we model 200 A), and a weak battery's voltage collapses under that load. The voltage drop is governed by the battery's internal resistance R₀, which rises as the battery ages:

R₀ ≈ ΔV / ΔI                       measured from natural load changes
V_crank ≈ V_rest − I_crank · R₀    with I_crank = 200 A

The system estimates R₀ from the voltage dips during ordinary load changes and uses it to simulate a 200 A engine start. If the predicted cranking voltage is too low for the starter and the electronics, the dashboard warns of a No-Crank risk before the driver is stranded.

Vampire Drain: catching a parasitic load

With the engine off, a healthy car draws only a small quiescent current. A light left on, a faulty module or a short can drain a battery overnight. The monitor flags any quiescent current above 50 mA that lasts for more than 60 seconds after engine-off, which is long enough to ignore brief spikes and short enough to save the battery.

The dashboard

The ESP32 pushes SOC, SOH, time to empty, Virtual Crank status and Vampire Drain alerts to an Arduino IoT Cloud dashboard, so the battery can be checked from a phone. Inference never depends on that connection; with a small display attached, the whole system could run with no network at all.

What the repository contains

The GitHub repository has the sensor and Arduino IoT Cloud firmware shown above, the system architecture and screenshots of the dashboard. The trained RPNN and the EON-compiled library are not published there yet; the full method and results are in the IEEE paper.

What I learned

  • Physics is a strong prior when data is limited. A single controlled discharge is not much data, and the physics term keeps the model honest where the samples are sparse.
  • Calibrate your sensors. The voltage divider needed a calibration factor fitted against a multimeter, and the current sensor's zero-current offset has to be right: small offsets turn into large errors once current is integrated over hours.
  • TinyML is a systems problem. Quantization, flash size, wake-up time and sleep current mattered as much as model accuracy.
  • Report the honest baseline. XGBoost at 98.46% is a strong result in its own right, and stating it next to the RPNN makes the comparison meaningful.

This was research I did as an IoT and machine learning research intern at NIT Kurukshetra, starting in January 2025, with R. Malhotra, S. Sharma, S. Sah and M. Malik.

Frequently asked questions

What is a Residual-Physics Neural Network (RPNN)?

A neural network trained with a physics residual added to its data loss: the residual penalises predictions whose rate of change of state of charge disagrees with the Coulomb-counting equation dSOC/dt = −I/Cₙ. In this work it estimated state of charge with R² = 99.70% (MAE 0.6815, MSE 0.6408).

How does the model run on an ESP32?

The network was trained in PyTorch and compressed with Edge Impulse's EON Compiler into an INT8-quantized C++ library stored in the ESP32's flash, so inference runs fully offline. The device runs inference in under 2 seconds and then deep-sleeps for 5 minutes.

What is Virtual Cranking?

A way to predict a no-start before it happens: the system estimates the battery's internal resistance from voltage drops during ordinary load changes, then simulates a 200 A engine-start load to see whether the voltage would fall too low.

What data was the model trained on?

19,948 samples collected at 1 Hz over about 5.5 hours from voltage, ACS712 current and DHT11 temperature sensors during a controlled discharge of a 12 V, 7 Ah lead-acid battery from 12.5 V to 10.5 V.

Source code on GitHub Read the paper Project overview