I Made a Battery Admit It Was Only 73% Healthy — On-Device, End to End
Voltage lies. Put a battery under load and its terminal voltage sags. Let it rest and the voltage springs back. A naive fuel gauge watching only voltage will happily tell you a worn-out cell is "fine" right up until it falls off a cliff. The number you actually care about — is this battery still good, or is it time to replace it? — isn't in the instantaneous voltage at all. It's in the capacity : how much charge the cell can still deliver between full and empty. That quantity fades as a cell ages. Tracking it is called State of Health (SoH) , and it's the difference between "the device says 80%" and "the device has 80% of the runtime it had when it was new." I wanted my open-source battery SDK ( ibattery-sdk , Apache-2.0) to learn SoH on the device itself — no cloud model, no floating-point, on MCUs with kilobytes of RAM. This post is the story of getting that working end to end: from a coulomb integral in firmware to a faded value showing up live on a Grafana dashboard. The idea: learn capacity from one full→empty trip You don't need a PhD-grade model to estimate usable capacity. You need two anchors and an ammeter. Full anchor — when the cell is at its full-voltage plateau, declare "this is full" and set the coulomb counter to the rated capacity. Discharge — integrate current over time (coulomb counting). Every milliamp-hour that leaves the cell ticks the counter down. Empty anchor — when the cell hits its empty-voltage threshold, look at how much charge actually flowed. A healthy cell delivers close to its rated capacity before going empty. An aged cell hits empty early — it simply has less to give. From the charge measured between those two anchors, you get the cell's real usable capacity, and SoH = measured / rated . The SDK runs it through an integer EMA (so one noisy excursion doesn't whip the estimate around) and a plausibility guard (reject anything outside 30–120% of rated — that's almost certainly a glitch, not a real measurement). The whole thing is inte