Add calibration-charge skill to fix EG4 SoC counter drift (improvement #1)

The everyday profile caps grid charging at 54V, so the bank can go weeks
without a full charge and the EG4 BMS coulomb counters drift (proven: pack 6
read 76% SoC while at the same 53.4V/3.337V-per-cell as packs reading 50-55%
— all paralleled, so physically equal charge; the spread is pure drift).

- profiles/eg4-lp4-v2-calibration.yaml: temporary profile, identical to
  canonical except stop_charge_voltage 54.0 -> 0 (Full), so grid can finish a
  full charge to the 56.4V absorption hold that re-anchors every pack to 100%.
- calibration-charge skill: guided runbook (pre-flight safety, two methods
  solar-only / grid-assist, live monitoring with cell-voltage/temp aborts,
  re-anchor verification, mandatory revert).
- REFERENCE: scoped action-policy exception (this skill alone may flip
  stop_charge, both units, user-confirmed, must revert); corrected pack-6 /
  SoC-drift notes to the verified equal-voltage-different-SoC signature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 12:11:47 -04:00
parent aa97d65b0c
commit 56b2cc2bf1
3 changed files with 172 additions and 2 deletions

View File

@@ -114,9 +114,13 @@ MQTT — compute them yourself from the raw entities when working off the Pi.
untrustworthy and use the `lifepower4_*` pack entities for any battery math; if it
reads ~10 V right now, a powermon (or inverter) restart may clear it — worth testing.
- **Pack 6 is an oddball**: Modbus addr `0x01` @ 115200 (packs 15 are `0x40` @
9600); ran 65 % SoC while 15 sat 4044 %. Treat as a distinct member.
9600). It reads SoC high (76 % on 2026-06-24 vs 5055 % on packs 15) — but at the
SAME pack_voltage (53.4 V) and cell voltage (3.337 V), so that's **counter drift,
not real imbalance**: all packs are paralleled and physically at the same charge.
- **EG4 SoC never re-anchors** (drifts because packs rarely hit 100 % to reset the
coulomb counter). See memory `project_eg4_soc_drift_remediation`.
coulomb counter). Verified live via the equal-voltage/different-SoC signature above.
Fix = the `calibration-charge` skill (periodic full charge). See memory
`project_eg4_soc_drift_remediation`.
- **RS485 daisy-chain silences slave packs** — each pack needs its own FTDI; an
inter-pack chain demotes slaves. See memory `project_eg4_daisy_chain_silences_slaves`.
- **No per-day inverter energy** — PI18 only gives `ET` (lifetime Wh); ED/EM/EY NAK.
@@ -143,3 +147,9 @@ MQTT — compute them yourself from the raw entities when working off the Pi.
When a fix is outside the allowed set, report the finding and hand the user the
precise command(s) to run.
**Scoped exception — `calibration-charge` skill only:** that one skill may change
exactly one setting (`stop_charge_voltage` → Full and back) via the prepared
`eg4-lp4-v2-calibration.yaml` profile, on BOTH inverters, and ONLY after explicit
in-session user confirmation, and it must REVERT afterward. No other skill and no
other setting. This does not loosen the policy above for anything else.

View File

@@ -0,0 +1,109 @@
---
name: calibration-charge
description: >-
Guided runbook to re-anchor the 6 EG4 LifePower4 pack SoC counters by driving a
full charge to absorption and verifying every pack resets to 100%. Use when pack
SoC readings have drifted (e.g. one pack reads much higher/lower than the others
while all pack voltages agree), when the user asks to "calibrate / balance / fix
SoC / do a full charge", or on a monthly cadence. This is the ONE skill that
changes inverter settings — and only the grid-charge ceiling, only on explicit
user confirmation, and it reverts them. Everything else is read/monitor/verify.
---
# calibration-charge
Re-anchors drifted EG4 SoC counters. The BMS only resets SoC to 100% on a real
full-charge termination (high cell voltage + low taper current); the everyday
profile caps grid charging at 54 V, so the bank can go weeks without a full charge
and the counters drift. This runbook guarantees one full charge, then verifies.
## Action-policy exception (read this)
Unlike the troubleshoot-* skills, this one MAY change two inverter settings — but
ONLY `stop_charge_voltage` (54.0 → 0/Full) via the prepared calibration profile,
applied to BOTH units, and ONLY after the user explicitly confirms in-session. You
present the exact `flash.py` commands; the user runs them (or confirms you may). You
own pre-flight, monitoring, verification, and the REVERT. Never change anything else.
## 0. Load context
```bash
ROOT="$(git rev-parse --show-toplevel)"; SNAP="$ROOT/.claude/skills/lib/solar-snapshot"; HIST="$ROOT/.claude/skills/lib/ha-history"
FLASH="$ROOT/LVX6048/lvx-flash" # flash.py + profiles/ live here
```
Read `$ROOT/.claude/skills/REFERENCE.md`. The two profiles: `eg4-lp4-v2.yaml`
(canonical/everyday) and `eg4-lp4-v2-calibration.yaml` (temporary; identical except
`stop_charge_voltage: 0`).
## 1. Pre-flight (must pass before charging)
```bash
"$SNAP" -w 16 -g 'lifepower4_[1-6]_(soc|pack_voltage|cell_voltage_max|cell_voltage_delta_mv|temperature_pcb|protection)' 'homeassistant/sensor/+/state'
```
Record and check:
- **Capture the "before" SoC spread** — this is what we're fixing. Confirm it's *drift*
not real imbalance: if all 6 `pack_voltage` agree (±0.1 V) but SoC readings differ,
it's counter drift (the target). If voltages actually diverge, STOP — that's real
imbalance → troubleshoot-battery first.
- **Temps in charge range**: every `temperature_*` between ~5 °C and 45 °C. **Never
charge LFP below 0 °C** (BMS should block, but verify). Abort if any pack > 45 °C.
- **No protection bits set**; cells reasonably balanced (`cell_voltage_delta_mv` < ~50).
- **Forecast/grid**: solar-only needs a sunny low-load day; grid-assist works anytime.
## 2. Choose the method (ask the user)
- **Solar-only (no setting change, free):** on a sunny, low-load day the bank reaches
bulk 56.4 V on its own (the 54 V cap only gates GRID charging, not solar). Just
monitor §4 and verify §5. Best when weather cooperates; no flash needed — skip §3.
- **Grid-assist (reliable, any weather):** temporarily lift the grid-charge ceiling so
grid finishes the top-off. Needs the setter change in §3. Use this if it's cloudy or
a full charge hasn't happened in a while.
## 3. Grid-assist: apply the calibration profile (USER-CONFIRMED setter change)
Mirror to BOTH inverters (parallel cluster — mismatched settings throw fault 86).
`flash.py apply` stops powermon for exclusive USB, so MQTT telemetry pauses briefly.
```bash
cd "$FLASH"
sudo systemctl stop powermon.service powermon2.service
./flash.py apply --device /dev/lvx6048-1 --profile profiles/eg4-lp4-v2-calibration.yaml --confirm
./flash.py apply --device /dev/lvx6048-2 --profile profiles/eg4-lp4-v2-calibration.yaml --confirm
./flash.py compare --device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2 # must match
sudo systemctl start powermon.service powermon2.service
```
Confirm via MQTT the new ceiling took (both units): `battery_recharge`/`stop_charge`
readback reflects Full, others unchanged.
## 4. Drive + monitor the charge (this is the agent's job — poll periodically)
```bash
"$SNAP" -w 16 -g 'lifepower4_[1-6]_(soc|pack_voltage|pack_current|cell_voltage_max|temperature_pcb)' 'homeassistant/sensor/+/state'
"$SNAP" -w 10 -g 'lvx6048_[12]_(device_mode|mppt1_input_power|ac_output_active_power)/' 'homeassistant/sensor/+/state'
```
Watch for, and re-poll every ~1530 min as it climbs:
- `pack_voltage` rising toward ~56 V; `device_mode` should show charging/absorption.
- **SAFETY — abort and revert (§6) if:** any `cell_voltage_max` > **3.60 V** (BMS
protects ~3.65; don't ride it), any pack temp > 45 °C, or any protection bit sets.
- **Absorption/taper:** once pack voltage holds near bulk and `pack_current` tapers to
~5 A/pack (≈0.05 C, < ~30 A total), the BMS will flip SoC to 100%. This hold is the
part that actually re-anchors — let it finish, don't cut it early.
## 5. Verify the re-anchor (the whole point)
```bash
"$SNAP" -w 16 -g 'lifepower4_[1-6]_(soc|pack_voltage|cell_voltage_delta_mv)' 'homeassistant/sensor/+/state'
```
PASS = **all 6 packs read SoC 100%** (≥99) with pack voltages converged (spread
< ~0.1 V) and cell deltas still tight. The previously-drifted pack (e.g. pack 6) now
matching the others = counter re-anchored. Report before→after SoC spread.
If a pack still lags, it didn't terminate — extend the hold or investigate that pack.
## 6. REVERT (mandatory if you did §3 — do NOT leave the ceiling lifted)
```bash
cd "$FLASH"
sudo systemctl stop powermon.service powermon2.service
./flash.py apply --device /dev/lvx6048-1 --profile profiles/eg4-lp4-v2.yaml --confirm
./flash.py apply --device /dev/lvx6048-2 --profile profiles/eg4-lp4-v2.yaml --confirm
./flash.py compare --device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2
./flash.py sync-check --device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2
sudo systemctl start powermon.service powermon2.service
```
Confirm `stop_charge` is back to 54.0 on both units via MQTT.
## 7. Record
Note the date (for the ~monthly cadence) and before→after SoC spread. If drift returns
fast, consider the opt-in `soc_estimator` daemon backstop (see memory
`project_eg4_soc_drift_remediation`) as a longer-term fix.

View File

@@ -0,0 +1,51 @@
# LVX6048 settings profile — TEMPORARY calibration charge for the EG4 LP4 v2 bank.
#
# Purpose: re-anchor drifted EG4 pack SoC counters (and top-balance) by letting the
# bank reach a FULL charge with absorption hold. The EG4 BMS resets SoC to 100% only
# on a real full-charge termination (high cell voltage + low taper current); the
# conservative everyday profile stops grid charging at 54.0 V (mid-knee), so on cloudy
# / high-load stretches the bank may go weeks without a full charge and the coulomb
# counters drift (e.g. pack 6 read 76% while physically at ~53% on 2026-06-24).
#
# The ONLY change vs the canonical eg4-lp4-v2.yaml is:
# stop_charge_voltage: 54.0 -> 0 (= "Full"; remove the grid-charge ceiling so a
# full charge can complete even without strong sun)
# bulk_voltage stays 56.4 (the absorption target). Solar already charges past 54 V on a
# good day; this profile just lets GRID finish the top-off when solar can't.
#
# USE: this is a TEMPORARY profile driven by the `calibration-charge` skill. Apply to
# BOTH inverters, run the full charge, verify all 6 packs hit 100%, then REVERT to
# eg4-lp4-v2.yaml. Do not leave this profile applied — it removes the everyday
# grid-charge ceiling.
#
# sudo systemctl stop powermon.service powermon2.service
# ./flash.py apply --device /dev/lvx6048-1 --profile profiles/eg4-lp4-v2-calibration.yaml --confirm
# ./flash.py apply --device /dev/lvx6048-2 --profile profiles/eg4-lp4-v2-calibration.yaml --confirm
# ./flash.py compare --device-a /dev/lvx6048-1 --device-b /dev/lvx6048-2
# sudo systemctl start powermon.service powermon2.service
# # ... drive + verify the charge (see calibration-charge skill) ...
# # REVERT when all packs read 100%:
# sudo systemctl stop powermon.service powermon2.service
# ./flash.py apply --device /dev/lvx6048-1 --profile profiles/eg4-lp4-v2.yaml --confirm
# ./flash.py apply --device /dev/lvx6048-2 --profile profiles/eg4-lp4-v2.yaml --confirm
# sudo systemctl start powermon.service powermon2.service
battery_type: USER
cutoff_voltage: 48.0
stop_discharge_voltage: 48.0
# 0 = Full — let grid charge all the way to bulk (the calibration lever).
stop_charge_voltage: 0
bulk_voltage: 56.4
float_voltage: 54.0
max_charging_current: 60
max_utility_charging_current: 30
output_source_priority: solar_battery_utility
charger_priority: solar_first
solar_power_priority: battery_load_utility_ac
grid_tie: disabled