Photonicat 2 SOC Calculation:修订间差异
小 C2h2移动页面How Photonicat 2 Calculates SOC至Photonicat 2 SOC Calculation:Align with the Photonicat 2 topic prefix used by sibling pages |
Point cross-links at the renamed SOC pages directly instead of via redirects |
||
| 第8行: | 第8行: | ||
For the indicator see [[Photonicat 2 LED Indicator]]; for sysfs paths see | For the indicator see [[Photonicat 2 LED Indicator]]; for sysfs paths see | ||
[[Photonicat 2 sysfs]]. 中文版见 [[ | [[Photonicat 2 sysfs]]. 中文版见 [[Photonicat 2 SOC 计算]]。 | ||
== Why voltage alone is not enough == | == Why voltage alone is not enough == | ||
2026年7月26日 (日) 17:25的版本
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 计算。
Why voltage alone is not enough
This is the first problem every fuel gauge has to solve. A lithium cell's voltage-vs-charge curve is remarkably 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 — roughly 6 mV per percent. Put a load on the battery and the IR drop across its internal resistance easily amounts to tens of millivolts. In other words, voltage alone can be off by more than 10 %, and it gets worse the heavier the load.
The two ends, by contrast, are steep: in this table 8.34 V is 100 % and 6.34 V is 0 %. So the right approach is to anchor at the ends with voltage and advance through the middle by integration — which is exactly what the system below does.
The idea in one line
charge = starting charge − ∫(discharge current)dt + ∫(charge current)dt
The integral itself is accurate, provided the current measurement is. But it has two weaknesses: where does the starting point come from, and error accumulates. So a real fuel gauge is integration plus a set of correction mechanisms. Photonicat 2 layers them like this:
| 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. Hardware: how it measures
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, which
would smear out the voltage step when a charger is plugged or unplugged — and the internal-resistance learning depends on exactly that step, so the firmware keeps unfiltered raw values 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 |
A few engineering details worth calling out:
- 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 counts as zero, so idle noise cannot
slowly skew the ledger.
2. Coulomb integration: the ledger
coulomb_counter_update() runs every 200 ms, multiplying current
by elapsed time into a 64-bit µAs (microamp-second) ledger. µAs rather than
mAh avoids fixed-point rounding error piling up 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: pulling the drift back
Integration error accumulates, so whenever the battery is near rest the firmware looks up charge from voltage and applies a gentle correction.
The entry conditions are strict, because misjudging "at rest" would drag a perfectly good integral off course:
| Condition | Threshold | Why |
|---|---|---|
| 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 |
Even then it does not overwrite — it blends:
- 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 %
So OCV nudges the integral back rather than yanking it, and the user never sees the reading 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, so roughly ±60 mV for a 2S pack). The firmware therefore 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) so it does not chatter 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: getting the ends right
The middle is carried by integration; the two ends are pinned directly:
- Full anchor: charger present, vbus ≥ 8 V, and current has fallen low →
charge = 100 %. Requiring the charger is deliberate: resting OCV at 95 % is about 8.16 V, so a high but resting pack is easy to mistake for full. A poor or under-voltage charger cannot push the pack to true full, and is refused.
- Empty anchor: discharged to the cutoff point → charge = 0 %, which also
triggers empty-point learning.
5. Self-learning: every unit is different
This is the most interesting part of the system. Even within one batch of cells and boards, unit-to-unit variation can exceed 10 %. 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 — the display still shows charge remaining when the device is about to cut out.
How it learns:
- The full anchor opens a window and zeroes the discharge accumulator
- The pack discharges all the way to the low-voltage cutoff
- Full capacity = accumulated discharge + 700 mAh
- Detecting any charging mid-window voids that attempt
That 700 mAh matters: the device never runs the pack down to a true 0 V, so at the cutoff (around 6.3–6.45 V) roughly 700 mAh is still left 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 (Apple-style gradual change), so a single measurement never makes it plunge.
5.2 Internal-resistance learning (voltage-step method)
IR compensation needs a resistance value. The firmware gets it neatly: 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Ω.
Note this measures the whole loop: cell body + wiring/connectors (20 mΩ) + protection-board MOSFET (15 mΩ). IR compensation wants exactly that series total, so nothing is subtracted; only when the host wants to display "cell internal resistance" does it subtract the 35 mΩ of board parasitics.
5.3 Empty-point learning
A problem found in the field: the device cut out while still showing 20 %. Each unit's current-sense gain and true capacity differ slightly, so the moment internal SOC reaches zero and the moment the hardware actually gives up do not line up.
The fix is not to touch the current/mAh chain — that would introduce new error — but to apply 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.
There is also downward self-correction: if voltage is still healthy (> 6.8 V) during discharge but internal SOC has already fallen below the empty point, then empty was learned too high and follows the reading down. A replaced battery, or an overshoot, heals 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
Lithium cells dislike the cold. 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 strongly with temperature, which directly affects IR compensation:
| 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 three times its room-temperature value. The learned resistance is normalised to 25 °C and multiplied by k(T) in use.
6.2 Cold-weather capacity compensation
In the cold, usable capacity shrinks — the energy is not gone, it just cannot be drawn out. The firmware handles this by raising the display empty point, so the reading falls earlier and nobody is misled by "15 % left" outdoors at −10 °C:
| 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, and why it is "Tesla-style"
The internally computed SOC is not shown directly. One more mapping sits in between:
- 4 % → displays 1 %
- 96 % → displays 100 %
- Linear in between (slope 99/92), clipped at both ends
The point is that when the display reads 0 %, roughly 4 % is still in the pack (plus the hardware margin below the empty point), giving time to save work and shut down cleanly. Likewise 96 % shows as 100 % so nobody agonises over the last few percent — both are standard consumer-electronics practice.
The reported percentage is also clamped to a minimum of 1 %: as long as the device is running, it will not show 0 %.
8. After power-off: not losing the reading
This is where many fuel gauges fall down. Photonicat 2 has 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. This is what fixed the old "10 % becomes 1 % after a quick reboot" behaviour.
- >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 the way in and, on waking, subtracts the whole-device standby draw of about 2.0 mA.
8.3 Detecting a battery swap
Remove the battery and the RTC loses power and resets, so its time runs backwards. The firmware treats that as a battery swap, voids the snapshot and re-estimates from cold. (A 10-minute tolerance is allowed; without it, a valid snapshot could be discarded after an update reboot and the SOC would visibly step.)
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, any existing Linux tool works directly — nothing needs to know 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 this through rtc_class_ops, so standard
tools just work:
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 — and it is also the mechanism behind the battery-swap detection in 8.3, since a battery removal 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 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 |
A concrete example is 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, while pcat-manager also keeps its own mirror and re-sends it if it detects an MCU restart — belt and braces.
10. FAQ
- Why does the reading occasionally jump?
- Usually OCV fusion or an anchor correcting integration drift. A single
correction is capped at 0.20 %, so you should not see large jumps in normal use; a large one generally means a stale snapshot led to a cold-start re-estimate.
- Why does charge drop faster in winter?
- Two effects together: the pack genuinely has less usable capacity when cold, and
the firmware deliberately raises the display empty point (about +8 % at −10 °C) so you learn early that it needs charging. It recovers as things warm up.
- Why does 96 % show as 100 %?
- The display curve is designed that way — see section 7. By the same token, there
is still margin left when it reads 0 %.
- Does a new battery need "calibration"?
- Not deliberately. One full discharge (from full down to automatic shutdown) lets
the firmware learn true capacity and the empty point; ordinary use gets there gradually anyway.
- How often does health update?
- It needs a complete discharge window (full anchor → cutoff), and any charging
mid-window voids it. So it updates slowly and changes gradually by EMA — that is intentional, so one bad measurement cannot make health plunge.