Photonicat 2 SOC Calculation:修订间差异
Create: how the coulomb counter computes SOC (hardware, integration, OCV fusion, self-learning, temperature, Linux sysfs/RTC chain) |
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 |
||
| (未显示同一用户的3个中间版本) | |||
| 第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 计算]]。 | ||
== | == Voltage and charge level == | ||
A lithium cell's voltage-vs-charge curve is very flat in the middle. The figures | |||
voltage-vs-charge curve is | 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''' | Going from 70 % down to 30 % moves the voltage by just '''0.30 V''', about | ||
'''6 mV per percent'''. | '''6 mV per percent'''. Under load the IR drop across the internal resistance | ||
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 | The two ends are steep: in this table 8.34 V is 100 % and 6.34 V is 0 %. The | ||
0 %. | firmware therefore '''anchors at the ends with voltage and advances through the | ||
through the middle by integration''' | middle by current integration'''. | ||
== | == Overall structure == | ||
charge = starting charge − ∫(discharge current)dt + ∫(charge current)dt | 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: | |||
accumulates | |||
mechanisms. Photonicat 2 layers them | |||
{| class="wikitable" | {| class="wikitable" | ||
| 第68行: | 第64行: | ||
|} | |} | ||
== 1. | == 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 | * '''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 === | === Current calibration === | ||
| 第103行: | 第98行: | ||
|} | |} | ||
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 | * '''55 mA dead band''': anything smaller is treated as zero, keeping idle noise | ||
out of the accumulator. | |||
== 2. Coulomb integration | == 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)''' | by elapsed time into a 64-bit '''µAs (microamp-second)''' accumulator. Using µAs | ||
mAh avoids fixed-point rounding error | 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 | == 3. OCV fusion == | ||
Whenever the battery is '''near rest''', the firmware looks up charge from voltage | |||
firmware looks up charge from voltage and applies a | and applies a small correction to the integral. All of the following must hold: | ||
{| class="wikitable" | {| class="wikitable" | ||
! Condition !! Threshold !! | ! Condition !! Threshold !! Notes | ||
|- | |- | ||
| Current small enough || | | 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 | | Held long enough || ≥ 30 s || Lets polarisation and surface charge relax | ||
| 第145行: | 第137行: | ||
|} | |} | ||
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 %''' | ||
OCV therefore corrects the integral gradually, and the displayed reading does not | |||
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, | ||
roughly ±60 mV for a 2S pack). The firmware carries three tables: | |||
* '''Discharge table''' — used in discharge mode ( | * '''Discharge table''' — used in discharge mode (|I| > 900 mA) | ||
* '''Charge table''' — used in charge mode | * '''Charge table''' — used in charge mode | ||
* '''Rest table''' — the average of the two, used at low current ( | * '''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) | 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 − | 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 | == 4. Anchors == | ||
The middle is carried by integration; the two ends are | 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 %. | 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 | |||
or under-voltage charger cannot | 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 | == 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 | |||
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 | hard-coded, an aged pack reads optimistically. Learning procedure: | ||
# 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 | ||
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 | 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 | Health is folded in slowly via an EMA, so a single measurement does not move it | ||
measurement | sharply. | ||
=== 5.2 Internal-resistance learning (voltage-step method) === | === 5.2 Internal-resistance learning (voltage-step method) === | ||
Resistance is measured from the voltage step at the '''instant a charger is | |||
'''instant a charger is plugged or unplugged''' | 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Ω. | ||
This is the '''whole-loop''' resistance: cell body + wiring/connectors (20 mΩ) + | |||
protection-board MOSFET (15 mΩ). IR compensation | 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 | |||
resistance" | displays "cell internal resistance". | ||
=== 5.3 Empty-point learning === | === 5.3 Empty-point learning === | ||
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 | 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) | 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. | ||
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, | point, empty was learned too high and follows the reading down. A replaced battery, | ||
battery, or an overshoot, | 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 == | ||
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 | 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 === | ||
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: | |||
{| class="wikitable" | {| class="wikitable" | ||
| 第306行: | 第293行: | ||
temperature is held (initially 25 °C). | temperature is held (initially 25 °C). | ||
== 7. The display curve | == 7. The display curve == | ||
The internally computed SOC is not | 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 | ||
So '''when the display reads 0 %, roughly 4 % is still in the pack''' (plus the | |||
pack''' (plus the hardware margin below the empty point), | hardware margin below the empty point), leaving time to save work and shut down | ||
and shut down cleanly | cleanly; 96 % shows as 100 %. Both are standard consumer-electronics practice. | ||
The reported percentage is | The reported percentage is '''clamped to a minimum of 1 %''': while the device is | ||
device is running | running it will not show 0 %. | ||
== 8. | == 8. Retaining charge across power-off == | ||
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 | re-estimation is '''blocked''' from overriding it. | ||
* '''>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 | 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 === | ||
Removing the battery cuts power to the RTC, which resets, so its time runs | |||
The firmware treats | backwards. The firmware treats this as a battery swap, voids the snapshot and | ||
from cold. | re-estimates from cold. A 10-minute tolerance avoids discarding a valid snapshot | ||
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''', | Because these are '''standard subsystems''', existing Linux tools work directly | ||
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 | ||
MCU is always powered, keeps time itself, and sends it up with the status frames. | |||
frames. The driver registers | The driver registers it through <code>rtc_class_ops</code>, so standard tools work | ||
tools | directly: | ||
<syntaxhighlight lang="bash"> | <syntaxhighlight lang="bash"> | ||
| 第399行: | 第384行: | ||
</syntaxhighlight> | </syntaxhighlight> | ||
This is why '''the clock is still right after days unplugged and offline''' | 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 | 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行: | ||
|} | |} | ||
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, | side and survives resets and OTA updates, and pcat-manager keeps its own mirror, | ||
pcat-manager | re-sending it if it detects an MCU restart. | ||
restart | |||
== 10. FAQ == | == 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 | ;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 | ;How often health updates | ||
:It needs a complete discharge window (full anchor → cutoff), and | :It needs a complete discharge window (full anchor → cutoff), and charging | ||
mid-window voids | mid-window voids that attempt, so it updates slowly and changes gradually by EMA. | ||
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:
- 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
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.