Photonicat 2 SOC Calculation:修订间差异

来自Photonicat Wiki
跳转到导航 跳转到搜索
C2h2留言 | 贡献
C2h2移动页面How Photonicat 2 Calculates SOCPhotonicat 2 SOC Calculation:​Align with the Photonicat 2 topic prefix used by sibling pages
C2h2留言 | 贡献
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
 
(未显示同一用户的1个中间版本)
第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]]. 中文版见 [[光影猫2 是如何计算电量的]]。
[[Photonicat 2 sysfs]]. 中文版见 [[Photonicat 2 SOC 计算]]。


== Why voltage alone is not enough ==
== Voltage and charge level ==


This is the first problem every fuel gauge has to solve. A lithium cell's
A lithium cell's voltage-vs-charge curve is very flat in the middle. The figures
voltage-vs-charge curve is remarkably flat in the middle. The figures below come
below come from the firmware's '''discharge OCV table''' (IR-compensated pack
from the firmware's '''discharge OCV table''' (IR-compensated pack voltage):
voltage):


{| class="wikitable"
{| class="wikitable"
第30行: 第30行:
|}
|}


Going from 70 % down to 30 % moves the voltage by just '''0.30 V''' — roughly
Going from 70 % down to 30 % moves the voltage by just '''0.30 V''', about
'''6 mV per percent'''. Put a load on the battery and the IR drop across its
'''6 mV per percent'''. Under load the IR drop across the internal resistance
internal resistance easily amounts to tens of millivolts. In other words,
reaches tens of millivolts, so voltage alone can be off by more than 10 %, and the
'''voltage alone can be off by more than 10 %''', and it gets worse the heavier
error grows with load.
the load.


The two ends, by contrast, are steep: in this table 8.34 V is 100 % and 6.34 V is
The two ends are steep: in this table 8.34 V is 100 % and 6.34 V is 0 %. The
0 %. So the right approach is to '''anchor at the ends with voltage and advance
firmware therefore '''anchors at the ends with voltage and advances through the
through the middle by integration''' — which is exactly what the system below
middle by current integration'''.
does.


== The idea in one line ==
== Overall structure ==


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


The integral itself is accurate, provided the current measurement is. But it has
Integration needs a starting point, and its error accumulates over time, so it is
two weaknesses: '''where does the starting point come from''', and '''error
paired with a set of correction mechanisms. Photonicat 2 layers them as follows:
accumulates'''. So a real fuel gauge is integration ''plus'' a set of correction
mechanisms. Photonicat 2 layers them like this:


{| class="wikitable"
{| class="wikitable"
第68行: 第64行:
|}
|}


== 1. Hardware: how it measures ==
== 1. Measurement hardware ==


The measuring chip is a '''TPA626''' (an I²C current/voltage monitor, in the same
The measuring chip is a '''TPA626''' (an I²C current/voltage monitor, in the same
第78行: 第74行:
* '''Median filtering''': voltage and current each pass through a median filter
* '''Median filtering''': voltage and current each pass through a median filter
   that rejects occasional I²C glitches and load spikes
   that rejects occasional I²C glitches and load spikes
* '''Raw values kept separately''': the median filter lags by about 3 s, which
* '''Raw values kept separately''': the median filter lags by about 3 s and would
   would smear out the voltage step when a charger is plugged or unplugged — and
   smear out the charger plug/unplug voltage step; internal-resistance learning
  the internal-resistance learning depends on exactly that step, so the firmware
  needs that step, so unfiltered raw values are kept alongside
  keeps unfiltered raw values alongside


=== Current calibration ===
=== Current calibration ===
第103行: 第98行:
|}
|}


A few engineering details worth calling out:
Implementation details:


* '''Clamp before evaluating''': outside the calibrated range the parabola bends
* '''Clamp before evaluating''': outside the calibrated range the parabola bends
第109行: 第104行:
   extended linearly by the endpoint slope beyond that.
   extended linearly by the endpoint slope beyond that.
* '''64-bit arithmetic''': x² reaches 9×10⁶, which overflows int32 once scaled.
* '''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
* '''55 mA dead band''': anything smaller is treated as zero, keeping idle noise
   slowly skew the ledger.
   out of the accumulator.


== 2. Coulomb integration: the ledger ==
== 2. Coulomb integration ==


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


* Discharge subtracts, charge adds
* Discharge subtracts, charge adds
第123行: 第118行:
   efficient
   efficient


== 3. OCV fusion: pulling the drift back ==
== 3. OCV fusion ==


Integration error accumulates, so whenever the battery is '''near rest''' the
Whenever the battery is '''near rest''', the firmware looks up charge from voltage
firmware looks up charge from voltage and applies a gentle correction.
and applies a small correction to the integral. All of the following must hold:
 
The entry conditions are strict, because misjudging "at rest" would drag a
perfectly good integral off course:


{| class="wikitable"
{| class="wikitable"
! Condition !! Threshold !! Why
! Condition !! Threshold !! Notes
|-
|-
| Current small enough || &#124;I&#124; ≤ 340 mA (~0.05C) || IR-model error is amplified at higher current
| Current small enough || &#124;I&#124; ≤ 340 mA (~0.05C) || IR-model error is amplified at higher current
第145行: 第137行:
|}
|}


Even then it does not overwrite — it '''blends''':
The correction is '''blended''' rather than written over the integral:


* Weight capped at '''α = 0.07''' (OCV contributes at most 7 %), ramping linearly
* Weight capped at '''α = 0.07''' (OCV contributes at most 7 %), ramping linearly
第151行: 第143行:
* Any single correction is limited to '''0.20 %'''
* Any single correction is limited to '''0.20 %'''


So OCV nudges the integral back rather than yanking it, and the user never sees
OCV therefore corrects the integral gradually, and the displayed reading does not
the reading jump.
jump.


=== Three OCV tables ===
=== Three OCV tables ===
第158行: 第150行:
Lithium cells show '''hysteresis''': at the same charge level, terminal voltage
Lithium cells show '''hysteresis''': at the same charge level, terminal voltage
reads higher while charging and lower while discharging (about 30 mV per cell,
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:
roughly ±60 mV for a 2S pack). The firmware carries three tables:


* '''Discharge table''' — used in discharge mode (&#124;I&#124; > 900 mA)
* '''Discharge table''' — used in discharge mode (&#124;I&#124; > 900 mA)
第164行: 第156行:
* '''Rest table''' — the average of the two, used at low current (&#124;I&#124; ≤ 600 mA)
* '''Rest table''' — the average of the two, used at low current (&#124;I&#124; ≤ 600 mA)


Mode switching is '''hysteretic''' (enter at 900 mA, leave at 600 mA) so it does
Mode switching is '''hysteretic''' (enter at 900 mA, leave at 600 mA) to avoid
not chatter around the threshold.
chattering around the threshold.


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


== 4. Anchors: getting the ends right ==
== 4. Anchors ==


The middle is carried by integration; the two ends are pinned directly:
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 →
* '''Full anchor''': charger present, vbus ≥ 8 V, and current has fallen low →
   charge = 100 %. Requiring the charger is deliberate: '''resting OCV at 95 % is
   charge = 100 %. The charger requirement is necessary because '''resting OCV at
  about 8.16 V''', so a high but resting pack is easy to mistake for full. A poor
  95 % is about 8.16 V''', so a high resting pack is easily mistaken for full; a
   or under-voltage charger cannot push the pack to true full, and is refused.
   poor or under-voltage charger cannot reach true full and is refused.
* '''Empty anchor''': discharged to the cutoff point → charge = 0 %, which also
* '''Empty anchor''': discharged to the cutoff point → charge = 0 %, which also
   triggers empty-point learning.
   triggers empty-point learning.


== 5. Self-learning: every unit is different ==
== 5. Self-learning ==


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


=== 5.1 Full-capacity learning (ageing) ===
=== 5.1 Full-capacity learning (ageing) ===


A cell rated 6800 mAh may be down to 5500 mAh after two years. With capacity
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
hard-coded, an aged pack reads optimistically. Learning procedure:
remaining when the device is about to cut out.
 
How it learns:


# The full anchor opens a window and zeroes the discharge accumulator
# The full anchor opens a window and zeroes the discharge accumulator
第201行: 第189行:
# Detecting any charging mid-window voids that attempt
# 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
The '''700 mAh''' is the cutoff reserve: the device never runs the pack down to
at the cutoff (around 6.3–6.45 V) roughly 700 mAh is still left inside and must
0 V, so at the cutoff (around 6.3–6.45 V) roughly 700 mAh remains inside and must
be added back.
be added back.


第209行: 第197行:
   health = learned capacity / 6800 mAh
   health = learned capacity / 6800 mAh


Health is folded in slowly via an EMA (Apple-style gradual change), so a single
Health is folded in slowly via an EMA, so a single measurement does not move it
measurement never makes it plunge.
sharply.


=== 5.2 Internal-resistance learning (voltage-step method) ===
=== 5.2 Internal-resistance learning (voltage-step method) ===


IR compensation needs a resistance value. The firmware gets it neatly: at the
Resistance is measured from the voltage step at the '''instant a charger is
'''instant a charger is plugged or unplugged''', current steps by ΔI and voltage
plugged or unplugged''': current steps by ΔI and voltage steps by ΔV, so
steps by ΔV, so


   R = ΔV / ΔI
   R = ΔV / ΔI
第224行: 第211行:
fold in by EMA at 3/10 weight, clamped to 10–200 mΩ, defaulting to 50 mΩ.
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Ω) +
This is the '''whole-loop''' resistance: cell body + wiring/connectors (20 mΩ) +
protection-board MOSFET (15 mΩ). IR compensation wants exactly that series total,
protection-board MOSFET (15 mΩ). IR compensation uses that series total with
so nothing is subtracted; only when the host wants to display "cell internal
nothing subtracted; the 35 mΩ of board parasitics is subtracted only when the host
resistance" does it subtract the 35 mΩ of board parasitics.
displays "cell internal resistance".


=== 5.3 Empty-point learning ===
=== 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
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
internal SOC reaches zero and the moment the hardware actually cuts out do not line
line up.
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
The fix is not to touch the current/mAh chain — that would introduce new error —
at the '''display layer''':
but to apply a per-unit learned linear map at the '''display layer''':


   displayed = (internal SOC − empty) × 100 / (100 − empty)
   displayed = (internal SOC − empty) × 100 / (100 − empty)
第245行: 第230行:
while discharging at low voltage, or the empty anchor.
while discharging at low voltage, or the empty anchor.


There is also '''downward self-correction''': if voltage is still healthy
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
(> 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
point, empty was learned too high and follows the reading down. A replaced battery,
battery, or an overshoot, heals within one discharge cycle.
or an overshoot, recovers within one discharge cycle.


=== 5.4 Charge-gain learning ===
=== 5.4 Charge-gain learning ===
第258行: 第243行:
== 6. Temperature compensation ==
== 6. Temperature compensation ==


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


=== 6.1 Resistance temperature factor k(T) ===
=== 6.1 Resistance temperature factor k(T) ===


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


{| class="wikitable"
{| class="wikitable"
第282行: 第268行:
|}
|}


At −20 °C the resistance is '''three times''' its room-temperature value. The
At −20 °C the resistance is about '''three times''' its room-temperature value.
learned resistance is normalised to 25 °C and multiplied by k(T) in use.
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 ===
=== 6.2 Cold-weather capacity compensation ===


In the cold, usable capacity shrinks — the energy is not gone, it just cannot be
Usable capacity falls in the cold (the energy is not gone, it just cannot be drawn
drawn out. The firmware handles this by '''raising the display empty point''', so
out). The firmware compensates by '''raising the display empty point''' so the
the reading falls earlier and nobody is misled by "15 % left" outdoors at −10 °C:
reading falls earlier:


{| class="wikitable"
{| class="wikitable"
第306行: 第293行:
temperature is held (initially 25 °C).
temperature is held (initially 25 °C).


== 7. The display curve, and why it is "Tesla-style" ==
== 7. The display curve ==


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


第315行: 第302行:
* Linear in between (slope 99/92), clipped at both ends
* 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
So '''when the display reads 0 %, roughly 4 % is still in the pack''' (plus the
pack''' (plus the hardware margin below the empty point), giving time to save work
hardware margin below the empty point), leaving time to save work and shut down
and shut down cleanly. Likewise 96 % shows as 100 % so nobody agonises over the
cleanly; 96 % shows as 100 %. Both are standard consumer-electronics practice.
last few percent — both are standard consumer-electronics practice.


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


== 8. After power-off: not losing the reading ==
== 8. Retaining charge across power-off ==


This is where many fuel gauges fall down. Photonicat 2 has three paths.
There are three paths.


=== 8.1 SOC snapshot ===
=== 8.1 SOC snapshot ===
第336行: 第322行:


* '''≤12 hours''': SOC = snapshot − 2.0 mA × Δt (standby current), and OCV
* '''≤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
   re-estimation is '''blocked''' from overriding it.
  "10 % becomes 1 % after a quick reboot" behaviour.
* '''>12 hours''': the snapshot is stale, so a cold-start OCV estimate is used.
* '''>12 hours''': the snapshot is stale, so a cold-start OCV estimate is used.


第343行: 第328行:


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


=== 8.3 Detecting a battery swap ===
=== 8.3 Detecting a battery swap ===


Remove the battery and the RTC loses power and resets, so its time runs backwards.
Removing the battery cuts power to the RTC, which resets, so its time runs
The firmware treats that as a battery swap, voids the snapshot and re-estimates
backwards. The firmware treats this as a battery swap, voids the snapshot and
from cold. (A 10-minute tolerance is allowed; without it, a valid snapshot could
re-estimates from cold. A 10-minute tolerance avoids discarding a valid snapshot
be discarded after an update reboot and the SOC would visibly step.)
after an update reboot.


== 9. How the data reaches Linux ==
== 9. How the data reaches Linux ==
第367行: 第352行:
* '''RTC''' class → <code>/dev/rtc</code>
* '''RTC''' class → <code>/dev/rtc</code>


Because these are '''standard subsystems''', any existing Linux tool works
Because these are '''standard subsystems''', existing Linux tools work directly
directly — nothing needs to know the private protocol:
without knowing the private protocol:


<syntaxhighlight lang="bash">
<syntaxhighlight lang="bash">
第389行: 第374行:
</syntaxhighlight>
</syntaxhighlight>


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


<syntaxhighlight lang="bash">
<syntaxhighlight lang="bash">
第399行: 第384行:
</syntaxhighlight>
</syntaxhighlight>


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


=== 9.2 Userspace daemon: pcat-manager ===
=== 9.2 Userspace daemon: pcat-manager ===
第408行: 第393行:
'''The rest of the UART protocol is handled by pcat-manager''': modem management,
'''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,
watchdog, power on/off policy, button events, pushing the charge threshold down,
MCU firmware updates and so on.
MCU firmware updates and so on. The full protocol is documented at
[[Photonicat 2 MCU 通讯协议]].


The division of labour:
The division of labour:
第422行: 第408行:
|}
|}


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


== 10. FAQ ==
== 10. FAQ ==


;Why does the reading occasionally jump?
;The reading occasionally moves slightly
:Usually OCV fusion or an anchor correcting integration drift. A single
:OCV fusion or an anchor is correcting integration drift. A single correction is
  correction is capped at 0.20 %, so you should not see large jumps in normal use;
  capped at 0.20 %, so large jumps should not occur in normal use; a large one
a large one generally means a stale snapshot led to a cold-start re-estimate.
generally means a stale snapshot led to a cold-start re-estimate.


;Why does charge drop faster in winter?
;Charge drops faster in winter
:Two effects together: the pack genuinely has less usable capacity when cold, and
:Usable capacity falls when cold, and the firmware also raises the display empty
the firmware deliberately raises the display empty point (about +8 % at −10 °C)
point (about +8 % at −10 °C). It recovers as things warm up.
so you learn early that it needs charging. It recovers as things warm up.


;Why does 96 % show as 100 %?
;96 % shows as 100 %
:The display curve is designed that way — see section 7. By the same token, there
:This is the display curve, see section 7. Likewise there is still margin when it
is still margin left when it reads 0 %.
reads 0 %.


;Does a new battery need "calibration"?
;Does a new battery need calibration
:Not deliberately. One full discharge (from full down to automatic shutdown) lets
:No specific procedure is needed. One full discharge (from full to automatic
the firmware learn true capacity and the empty point; ordinary use gets there
shutdown) lets the firmware learn true capacity and the empty point; ordinary use
gradually anyway.
gets there gradually.


;How often does health update?
;How often health updates
:It needs a complete discharge window (full anchor → cutoff), and any charging
:It needs a complete discharge window (full anchor → cutoff), and charging
  mid-window voids it. So it updates slowly and changes gradually by EMA — that is
  mid-window voids that attempt, so it updates slowly and changes gradually by EMA.
intentional, so one bad measurement cannot make health plunge.

2026年7月26日 (日) 20:03的最新版本

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.