Photonicat 2 SOC Calculation

来自Photonicat Wiki
C2h2留言 | 贡献2026年7月26日 (日) 20:03的版本 (Tighten prose: state how each stage works rather than motivating it; note k(T) is a piecewise-linear NCA approximation clamped outside -20..40C; link the MCU protocol page)
(差异) ←上一版本 | 最后版本 (差异) | 下一版本→ (差异)
跳转到导航 跳转到搜索

Photonicat 2 does not guess the battery level from voltage. It runs a proper coulomb counter (also called a fuel gauge): think of it as a water meter on the battery, accounting for every milliamp-second in and out.

This page explains how that system works — from the measuring hardware, through the firmware's integration, self-learning and temperature compensation, to the Linux kernel driver that turns it all into ordinary sysfs files.

For the indicator see Photonicat 2 LED Indicator; for sysfs paths see Photonicat 2 sysfs. 中文版见 Photonicat 2 SOC 计算

Voltage and charge level

A lithium cell's voltage-vs-charge curve is very flat in the middle. The figures below come from the firmware's discharge OCV table (IR-compensated pack voltage):

Pack OCV (open-circuit voltage) Charge level
7.58 V 70 %
7.50 V 60 %
7.44 V 50 %
7.36 V 40 %
7.28 V 30 %

Going from 70 % down to 30 % moves the voltage by just 0.30 V, about 6 mV per percent. Under load the IR drop across the internal resistance reaches tens of millivolts, so voltage alone can be off by more than 10 %, and the error grows with load.

The two ends are steep: in this table 8.34 V is 100 % and 6.34 V is 0 %. The firmware therefore anchors at the ends with voltage and advances through the middle by current integration.

Overall structure

 charge = starting charge − ∫(discharge current)dt + ∫(charge current)dt

Integration needs a starting point, and its error accumulates over time, so it is paired with a set of correction mechanisms. Photonicat 2 layers them as follows:

Layer Job
Measurement hardware TPA626 samples voltage/current every 100 ms
Coulomb integration Accumulates current into a µAs ledger every 200 ms
OCV fusion At rest, looks up charge from voltage and gently pulls the integral back
Anchors At full/empty, pins the value directly to 100 % / 0 %
Self-learning Capacity, resistance, empty point and charge gain — learned per unit
Temperature Cold raises resistance and shrinks usable capacity; both corrected
Display layer Maps internal charge to the 1–100 % the user sees

1. Measurement hardware

The measuring chip is a TPA626 (an I²C current/voltage monitor, in the same family as the INA226). It sits across a milliohm-class shunt resistor in the battery path, deriving current from the voltage drop while also measuring bus voltage.

  • Sampling: roughly every 100 ms (VD1_Collect)
  • Median filtering: voltage and current each pass through a median filter
 that rejects occasional I²C glitches and load spikes
  • Raw values kept separately: the median filter lags by about 3 s and would
 smear out the charger plug/unplug voltage step; internal-resistance learning
 needs that step, so unfiltered raw values are kept alongside

Current calibration

The shunt and amplifier have unit-to-unit variation, so each board is calibrated against a precision multimeter and fitted to a quadratic:

 I_true(mA) = C2·x² + C1·x + C0

Current coefficients (second calibration round, 2026-07-24; residuals ≤3 mA across four points):

Term Value Purpose
C2 −4.444×10⁻⁵ Quadratic term, corrects high-current nonlinearity
C1 1.0411 Linear term (gain)
C0 +20 mA (discharge) / 0 mA (charge) Zero offset, per direction

Implementation details:

  • Clamp before evaluating: outside the calibrated range the parabola bends
 back on itself (vertex near +11.7 A), so the input is clamped to ±3 A and
 extended linearly by the endpoint slope beyond that.
  • 64-bit arithmetic: x² reaches 9×10⁶, which overflows int32 once scaled.
  • 55 mA dead band: anything smaller is treated as zero, keeping idle noise
 out of the accumulator.

2. Coulomb integration

coulomb_counter_update() runs every 200 ms, multiplying current by elapsed time into a 64-bit µAs (microamp-second) accumulator. Using µAs rather than mAh avoids fixed-point rounding error accumulating over long runs.

  • Discharge subtracts, charge adds
  • Currents under 30 mA in magnitude are ignored (dead band, anti-drift)
  • The charge direction carries its own learned gain — charging is not 100 %
 efficient

3. OCV fusion

Whenever the battery is near rest, the firmware looks up charge from voltage and applies a small correction to the integral. All of the following must hold:

Condition Threshold Notes
Current small enough |I| ≤ 340 mA (~0.05C) IR-model error is amplified at higher current
Held long enough ≥ 30 s Lets polarisation and surface charge relax
Voltage slew ≤ 20 mV/s Still in a transient, so not really at rest
Current slew ≤ 2000 mA/s Likewise
Plug/unplug grace 10 s Voltage has not settled after a charger event

The correction is blended rather than written over the integral:

  • Weight capped at α = 0.07 (OCV contributes at most 7 %), ramping linearly
 from 0 over 8 s
  • Any single correction is limited to 0.20 %

OCV therefore corrects the integral gradually, and the displayed reading does not jump.

Three OCV tables

Lithium cells show hysteresis: at the same charge level, terminal voltage reads higher while charging and lower while discharging (about 30 mV per cell, roughly ±60 mV for a 2S pack). The firmware carries three tables:

  • Discharge table — used in discharge mode (|I| > 900 mA)
  • Charge table — used in charge mode
  • Rest table — the average of the two, used at low current (|I| ≤ 600 mA)

Mode switching is hysteretic (enter at 900 mA, leave at 600 mA) to avoid chattering around the threshold.

Before any lookup the reading is IR-compensated: OCV = terminal voltage − I×R_pack, using the learned resistance described below.

4. Anchors

The middle is carried by integration; the two ends are set directly by anchors:

  • Full anchor: charger present, vbus ≥ 8 V, and current has fallen low →
 charge = 100 %. The charger requirement is necessary because resting OCV at
 95 % is about 8.16 V, so a high resting pack is easily mistaken for full; a
 poor or under-voltage charger cannot reach true full and is refused.
  • Empty anchor: discharged to the cutoff point → charge = 0 %, which also
 triggers empty-point learning.

5. Self-learning

Unit-to-unit variation can exceed 10 % even within one batch of cells and boards, so the firmware learns four quantities per unit and stores them in data flash, where they survive power loss.

5.1 Full-capacity learning (ageing)

A cell rated 6800 mAh may be down to 5500 mAh after two years. With capacity hard-coded, an aged pack reads optimistically. Learning procedure:

  1. The full anchor opens a window and zeroes the discharge accumulator
  2. The pack discharges all the way to the low-voltage cutoff
  3. Full capacity = accumulated discharge + 700 mAh
  4. Detecting any charging mid-window voids that attempt

The 700 mAh is the cutoff reserve: the device never runs the pack down to 0 V, so at the cutoff (around 6.3–6.45 V) roughly 700 mAh remains inside and must be added back.

The learned value also yields health:

 health = learned capacity / 6800 mAh

Health is folded in slowly via an EMA, so a single measurement does not move it sharply.

5.2 Internal-resistance learning (voltage-step method)

Resistance is measured from the voltage step at the instant a charger is plugged or unplugged: current steps by ΔI and voltage steps by ΔV, so

 R = ΔV / ΔI

Conditions: voltage > 7.8 V (near full, otherwise OCV itself drifts with charge and spoils the measurement), current step ≥ 300 mA, voltage step ≥ 15 mV. Results fold in by EMA at 3/10 weight, clamped to 10–200 mΩ, defaulting to 50 mΩ.

This is the whole-loop resistance: cell body + wiring/connectors (20 mΩ) + protection-board MOSFET (15 mΩ). IR compensation uses that series total with nothing subtracted; the 35 mΩ of board parasitics is subtracted only when the host displays "cell internal resistance".

5.3 Empty-point learning

Each unit's current-sense gain and true capacity differ slightly, so the moment internal SOC reaches zero and the moment the hardware actually cuts out do not line up (observed as the device cutting out while still showing 20 %). Rather than altering the current/mAh chain, the firmware applies a per-unit learned linear map at the display layer:

 displayed = (internal SOC − empty) × 100 / (100 − empty)

Empty is the internal SOC recorded the last time the battery genuinely ran out. Learning events: a low-voltage shutdown command, an unexpected host death while discharging at low voltage, or the empty anchor.

It also applies downward self-correction: if voltage is still healthy (> 6.8 V) during discharge but internal SOC has already fallen below the empty point, empty was learned too high and follows the reading down. A replaced battery, or an overshoot, recovers within one discharge cycle.

5.4 Charge-gain learning

Charging is not 100 % efficient, so charge pushed in differs from charge actually stored. This gain is learned at the full anchor and written to flash when it moves by ≥1 %.

6. Temperature compensation

An on-board NTC feeds temperature to the coulomb counter every 200 ms, driving two corrections.

6.1 Resistance temperature factor k(T)

Internal resistance varies with temperature, which directly affects IR compensation. The factor is a piecewise-linear approximation of typical NCA temperature behaviour:

Temperature Resistance factor k(T)
−20 °C ×3.00
−10 °C ×2.30
0 °C ×1.80
10 °C ×1.35
25 °C ×1.00 (reference)
40 °C ×0.85

At −20 °C the resistance is about three times its room-temperature value. The learned resistance is normalised to 25 °C and multiplied by k(T) in use. Values outside −20…40 °C are clamped to the endpoints.

6.2 Cold-weather capacity compensation

Usable capacity falls in the cold (the energy is not gone, it just cannot be drawn out). The firmware compensates by raising the display empty point so the reading falls earlier:

Temperature Empty point raised by
≥ 15 °C 0 (no compensation)
0 °C about +4 %
−10 °C about +8 %
≤ −20 °C +12 % (capped)

An NTC reading outside −30…70 °C is treated as a fault, and the last valid temperature is held (initially 25 °C).

7. The display curve

The internally computed SOC is not displayed directly. One more mapping sits in between:

  • 4 % → displays 1 %
  • 96 % → displays 100 %
  • Linear in between (slope 99/92), clipped at both ends

So when the display reads 0 %, roughly 4 % is still in the pack (plus the hardware margin below the empty point), leaving time to save work and shut down cleanly; 96 % shows as 100 %. Both are standard consumer-electronics practice.

The reported percentage is clamped to a minimum of 1 %: while the device is running it will not show 0 %.

8. Retaining charge across power-off

There are three paths.

8.1 SOC snapshot

While running, SOC is written to flash whenever it drifts by ≥5 %; shutdown, host power-down, standby entry and OTA reset all force a write and refresh the timestamp.

On boot:

  • ≤12 hours: SOC = snapshot − 2.0 mA × Δt (standby current), and OCV
 re-estimation is blocked from overriding it.
  • >12 hours: the snapshot is stale, so a cold-start OCV estimate is used.

8.2 Deep-sleep discharge estimation

In deep sleep the TPA626 is powered down and no current can be measured. The firmware records an RTC minute stamp on entry and, on waking, subtracts the whole-device standby draw of about 2.0 mA.

8.3 Detecting a battery swap

Removing the battery cuts power to the RTC, which resets, so its time runs backwards. The firmware treats this as a battery swap, voids the snapshot and re-estimates from cold. A 10-minute tolerance avoids discarding a valid snapshot after an update reboot.

9. How the data reaches Linux

Once the MCU has computed everything, it sends status frames to the host (RK3576) over UART. Two consumers sit on the host side.

9.1 Kernel driver: photonicat-pm

This is a serdev driver (990-photonicat-pm-add-driver.patch, kernel 6.12) that attaches to the serial port, parses status frames, and registers as standard Linux devices:

  • power_supply class → /sys/class/power_supply/battery
  • RTC class → /dev/rtc

Because these are standard subsystems, existing Linux tools work directly without knowing the private protocol:

# Charge level (%) — the final output of everything described above
cat /sys/class/power_supply/battery/capacity

# Voltage µV / current µA / power µW
cat /sys/class/power_supply/battery/voltage_now
cat /sys/class/power_supply/battery/current_now
cat /sys/class/power_supply/battery/power_now

# Energy µWh: energy_full is the learned capacity — compare against
# energy_full_design to see ageing
cat /sys/class/power_supply/battery/energy_now
cat /sys/class/power_supply/battery/energy_full
cat /sys/class/power_supply/battery/energy_full_design

# Charger online
cat /sys/class/power_supply/charger/online

The MCU is the hardware RTC. There is no separate RTC chip on the board; the MCU is always powered, keeps time itself, and sends it up with the status frames. The driver registers it through rtc_class_ops, so standard tools work directly:

hwclock -r          # read the time from the MCU
hwclock -w          # write system time back to the MCU

This is why the clock is still right after days unplugged and offline. It is also the mechanism behind the battery-swap detection in 8.3, since removing the battery resets the RTC and time runs backwards.

9.2 Userspace daemon: pcat-manager

The kernel driver only consumes the power-related part of the status frame. The rest of the UART protocol is handled by pcat-manager: modem management, watchdog, power on/off policy, button events, pushing the charge threshold down, MCU firmware updates and so on. The full protocol is documented at Photonicat 2 MCU 通讯协议.

The division of labour:

Component Responsibility
MCU firmware Measurement, integration, learning, compensation — all the algorithms
photonicat-pm (kernel) Power/RTC data → standard sysfs interfaces
pcat-manager (userspace) Everything else: modem, watchdog, power policy, OTA

For example, the charge limit: set 80 % in the web UI and pcat-manager sends it to the MCU over UART; the MCU stores it in data flash and drives the charging MOSFET directly. From firmware RA2E1260726005 the setting persists on the MCU side and survives resets and OTA updates, and pcat-manager keeps its own mirror, re-sending it if it detects an MCU restart.

10. FAQ

The reading occasionally moves slightly
OCV fusion or an anchor is correcting integration drift. A single correction is
capped at 0.20 %, so large jumps should not occur in normal use; a large one
generally means a stale snapshot led to a cold-start re-estimate.
Charge drops faster in winter
Usable capacity falls when cold, and the firmware also raises the display empty
point (about +8 % at −10 °C). It recovers as things warm up.
96 % shows as 100 %
This is the display curve, see section 7. Likewise there is still margin when it
reads 0 %.
Does a new battery need calibration
No specific procedure is needed. One full discharge (from full to automatic
shutdown) lets the firmware learn true capacity and the empty point; ordinary use
gets there gradually.
How often health updates
It needs a complete discharge window (full anchor → cutoff), and charging
mid-window voids that attempt, so it updates slowly and changes gradually by EMA.