查看“︁Photonicat 2 SOC Calculation”︁的源代码
←
Photonicat 2 SOC Calculation
跳转到导航
跳转到搜索
因为以下原因,您没有权限编辑该页面:
您请求的操作仅限属于该用户组的用户执行:
用户
您可以查看和复制此页面的源代码。
'''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]]. 中文版见 [[光影猫2 是如何计算电量的]]。 == 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): {| class="wikitable" ! 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: {| class="wikitable" ! 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 (<code>VD1_Collect</code>) * '''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): {| class="wikitable" ! 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 == <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 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: {| class="wikitable" ! 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: {| class="wikitable" ! 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: {| class="wikitable" ! 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 (<code>990-photonicat-pm-add-driver.patch</code>, kernel 6.12) that attaches to the serial port, parses status frames, and registers as standard Linux devices: * '''power_supply''' class → <code>/sys/class/power_supply/battery</code> * '''RTC''' class → <code>/dev/rtc</code> Because these are '''standard subsystems''', any existing Linux tool works directly — nothing needs to know the private protocol: <syntaxhighlight lang="bash"> # 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 </syntaxhighlight> '''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 <code>rtc_class_ops</code>, so standard tools just work: <syntaxhighlight lang="bash"> hwclock -r # read the time from the MCU hwclock -w # write system time back to the MCU </syntaxhighlight> 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: {| class="wikitable" ! 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.
返回
Photonicat 2 SOC Calculation
。
导航菜单
个人工具
登录
命名空间
页面
讨论
大陆简体
查看
阅读
查看源代码
查看历史
更多
搜索
导航
首页
最近更改
随机页面
MediaWiki帮助
特殊页面
工具
链入页面
相关更改
页面信息