Grid calibration: correct lever is output-priority SUB, add grid-cal-monitor
Discovered live 2026-06-25 driving an actual grid calibration: forcing a full grid charge is done via OUTPUT PRIORITY, not voltage thresholds. - SBU (everyday) won't grid-charge unless the bank is critically low; setting charger_priority=solar_and_utility alone does nothing at 52V. - SUB (output_priority=solar_utility_battery) runs loads on grid AND charges the battery to full. Combined with charger_priority=solar_and_utility, grid charging engages (device_mode->Hybrid/Line, line_dir->input, pack current jumps to ~120A). - Both POP/PCP set via lvx-control (all-mode-safe, atomic, no flash/USB). Revert POP->solar_battery_utility, PCP->solar_first when done. The re_discharge/flash.py approach is dead (firmware NAKs stop_charge>float); profile eg4-lp4-v2-calibration.yaml marked DEPRECATED. - grid-cal-monitor: supervises a SUB grid charge, safety aborts (cell>3.60V/ temp>45C), detects re-anchor (all 6 packs ->100%), auto-reverts POP+PCP (trap). - calibration-charge skill §3 rewritten to the POP lever. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,18 +53,26 @@ Record and check:
|
||||
curve to bulk 56.4 V and holds absorption on its own — exactly the clean termination
|
||||
the BMS needs to re-anchor. No flash, no risk. Just monitor §4 and verify §5; skip §3.
|
||||
This is the method to default to.
|
||||
- **Grid-assist — ADVANCED / UNVALIDATED:** the field flash.py calls
|
||||
`stop_charge_voltage` is really `battery_re_discharge_voltage`; the firmware **NAKs
|
||||
`0`/"Full"** (confirmed 2026-06-24 — both units rejected `BUCD480,000`). The corrected
|
||||
lever raises it 54.0 → 56.0 so grid charges higher, but it may band-oscillate near
|
||||
56 V instead of holding a clean absorption, so it is **not proven** to re-anchor.
|
||||
Only use as a supervised experiment when solar can't reach full and you accept it
|
||||
may not fully work. Needs §3.
|
||||
- **Grid-assist — for cloudy days (CORRECT lever, validated 2026-06-25):** force a full
|
||||
grid charge by switching **output priority to SUB**, NOT by touching voltage
|
||||
thresholds. In the everyday SBU mode the inverter won't grid-charge unless the bank is
|
||||
critically low; SUB makes it run loads on grid AND charge the battery to full. Both
|
||||
setters go through lvx-control (all-mode-safe, atomic, no flash). Needs §3.
|
||||
(Do NOT use `re_discharge`/flash.py — firmware NAKs it; see memory
|
||||
`project_lvx6048_grid_charge_lever`.)
|
||||
|
||||
## 3. Grid-assist (ADVANCED): apply the calibration profile (USER-CONFIRMED setter change)
|
||||
Run `flash.py diff` FIRST and confirm the ONLY change is `stop_charge_voltage 54.0 ->
|
||||
56.0` before applying. If any apply NAKs, the cluster is unchanged (flash.py aborts on
|
||||
first failure) — fall back to solar-only.
|
||||
## 3. Grid-assist: enable grid charging via output priority (USER-CONFIRMED)
|
||||
Publish via lvx-control (atomic to both units, no powermon stop):
|
||||
```bash
|
||||
B=... # broker creds from ~/.config/powermon/powermon.yaml
|
||||
mosquitto_pub ... -t solar/control/lvx6048/charger_priority -m solar_and_utility
|
||||
mosquitto_pub ... -t solar/control/lvx6048/output_priority -m solar_utility_battery # SUB
|
||||
```
|
||||
Confirm within ~1 min: `device_mode` -> Hybrid/Line, `line_power_direction` -> input,
|
||||
pack current jumps. Verify BOTH units match (output_source_priority) — parallel sync.
|
||||
Then run `lib/grid-cal-monitor` (detached) to drive/verify/auto-revert; it reverts
|
||||
`output_priority->solar_battery_utility` + `charger_priority->solar_first` on completion
|
||||
(trap-guaranteed). Skip §3 entirely for the solar-only method.
|
||||
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
|
||||
|
||||
91
.claude/skills/lib/grid-cal-monitor
Executable file
91
.claude/skills/lib/grid-cal-monitor
Executable file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# grid-cal-monitor — supervise a grid-assisted calibration charge to full, then
|
||||
# auto-revert the inverter back to normal (battery-priority, solar-only charging).
|
||||
#
|
||||
# Assumes the operator already set, via lvx-control:
|
||||
# output_priority = solar_utility_battery (SUB: grid powers loads + charges batt)
|
||||
# charger_priority = solar_and_utility
|
||||
# This script ONLY monitors and then REVERTS those two (POP->solar_battery_utility,
|
||||
# PCP->solar_first). It changes nothing else. Reverting is trap-guaranteed on exit.
|
||||
#
|
||||
# Done = all 6 packs report SoC>=99 (BMS re-anchored), or pack_V>=56.2 with combined
|
||||
# current tapered <20A for 2 polls. Safety: abort+revert if any cell>3.60V or temp>45C.
|
||||
# Max runtime guard then revert regardless.
|
||||
set -uo pipefail
|
||||
|
||||
HA="http://10.0.0.41:8123"; TOKEN_FILE="$HOME/.config/ha/token"
|
||||
POWERMON_CONF="$HOME/.config/powermon/powermon.yaml"
|
||||
CELL_ABORT=3.60; TEMP_ABORT=45; SOC_DONE=99
|
||||
VFULL=56.2; ITAPER=20; MAX_HOURS=8; POLL_S=300
|
||||
RUNDIR="$HOME/solar-runs"; mkdir -p "$RUNDIR"
|
||||
LOG="$RUNDIR/gridcal-$(date +%Y%m%d).log"
|
||||
REVERTED=0
|
||||
log(){ printf '%s %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG"; }
|
||||
|
||||
read -r BHOST BPORT BUSER BPASS < <(awk '/^[^[:space:]]/{i=0}/^mqttbroker:/{i=1;next} i&&/^[[:space:]]+name:/{h=$2} i&&/^[[:space:]]+port:/{p=$2} i&&/^[[:space:]]+username:/{u=$2} i&&/^[[:space:]]+password:/{w=$2} END{print h,(p?p:1883),u,w}' "$POWERMON_CONF")
|
||||
TOKEN="$(cat "$TOKEN_FILE")"
|
||||
ha(){ curl -s -H "Authorization: Bearer $TOKEN" "$HA/api/states/$1"; }
|
||||
st(){ ha "$1" | python3 -c 'import sys,json
|
||||
try:print(json.load(sys.stdin).get("state",""))
|
||||
except:print("")'; }
|
||||
tc(){ ha "$1" | python3 -c 'import sys,json
|
||||
try:
|
||||
d=json.load(sys.stdin);s=float(d["state"]);u=d["attributes"].get("unit_of_measurement","")
|
||||
print(round((s-32)*5/9,1) if "F" in u else round(s,1))
|
||||
except:print("")'; }
|
||||
P(){ echo "sensor.eg4_lifepower4_lifepower4_${1}_lifepower4_${1}_${2}"; }
|
||||
pub(){ mosquitto_pub -h "$BHOST" -p "$BPORT" -u "$BUSER" -P "$BPASS" -t "solar/control/lvx6048/$1" -m "$2"; }
|
||||
|
||||
revert(){
|
||||
[ "$REVERTED" = 1 ] && return 0
|
||||
log "REVERT: output_priority->solar_battery_utility, charger_priority->solar_first"
|
||||
pub output_priority solar_battery_utility; sleep 3
|
||||
pub charger_priority solar_first; sleep 8
|
||||
log "REVERT done. live POP=$(st sensor.lvx6048_lvx6048_1_output_source_priority)/$(st sensor.lvx6048_lvx6048_2_output_source_priority) PCP=$(st sensor.lvx6048_lvx6048_1_charger_source_priority)/$(st sensor.lvx6048_lvx6048_2_charger_source_priority)"
|
||||
REVERTED=1
|
||||
}
|
||||
trap 'revert; log "exit"' EXIT INT TERM
|
||||
|
||||
# read packs -> "minSoC maxSoC maxcell maxtemp minV maxV totI ndone"
|
||||
read_packs(){
|
||||
local socs=() cells=() temps=() vs=() is=() i s t tmax
|
||||
for i in 1 2 3 4 5 6; do
|
||||
socs+=("$(st "$(P $i soc)")"); cells+=("$(st "$(P $i cell_voltage_max)")")
|
||||
vs+=("$(st "$(P $i pack_voltage)")"); is+=("$(st "$(P $i pack_current)")")
|
||||
tmax=0
|
||||
for s in temperature_pcb temperature_01 temperature_02 temperature_03; do
|
||||
t="$(tc "$(P $i $s)")"; t=${t%.*}; [[ "$t" =~ ^-?[0-9]+$ ]] && [ "$t" -gt "$tmax" ] && tmax=$t
|
||||
done
|
||||
temps+=("$tmax")
|
||||
done
|
||||
python3 - "${socs[*]}" "${cells[*]}" "${temps[*]}" "${vs[*]}" "${is[*]}" <<'PY'
|
||||
import sys
|
||||
f=lambda a:[float(x) for x in a.split() if x]
|
||||
soc,cell,tmp,v,i=map(f,sys.argv[1:6])
|
||||
print(f"{min(soc):.0f} {max(soc):.0f} {max(cell):.3f} {max(tmp):.0f} {min(v):.2f} {max(v):.2f} {sum(i):.0f} {len([s for s in soc if s>=99])}")
|
||||
PY
|
||||
}
|
||||
|
||||
log "=== grid-cal-monitor start (auto-revert on full/abort/exit) ==="
|
||||
START=$(date +%s); taper_hits=0
|
||||
while :; do
|
||||
read MNS MXS MXCELL MXT MNV MXV TOTI NDONE <<<"$(read_packs)"
|
||||
el=$(( ($(date +%s)-START)/60 ))
|
||||
log "[+${el}m] SoC ${MNS}-${MXS}% packs@100=${NDONE}/6 | packV ${MNV}-${MXV} | totI ${TOTI}A | maxcell ${MXCELL}V maxtemp ${MXT}C"
|
||||
# SAFETY
|
||||
if (( $(python3 -c "print(1 if $MXCELL>$CELL_ABORT or $MXT>$TEMP_ABORT else 0)") )); then
|
||||
log "!!! SAFETY ABORT: maxcell ${MXCELL}V / maxtemp ${MXT}C — reverting now"; exit 2; fi
|
||||
# DONE: all re-anchored
|
||||
if [ "$NDONE" = 6 ]; then log "COMPLETE: all 6 packs >=${SOC_DONE}% — re-anchored"; break; fi
|
||||
# DONE backstop: at bulk + tapered for 2 consecutive polls
|
||||
if (( $(python3 -c "print(1 if $MXV>=$VFULL and $TOTI<$ITAPER else 0)") )); then
|
||||
taper_hits=$((taper_hits+1)); log " (at bulk + tapered, ${taper_hits}/2)"
|
||||
[ "$taper_hits" -ge 2 ] && { log "COMPLETE: bulk reached + current tapered"; break; }
|
||||
else taper_hits=0; fi
|
||||
# TIMEOUT
|
||||
if [ "$el" -ge $((MAX_HOURS*60)) ]; then log "TIMEOUT ${MAX_HOURS}h — reverting at packV ${MXV}"; break; fi
|
||||
sleep "$POLL_S"
|
||||
done
|
||||
read MNS MXS _ _ _ MXV _ NDONE <<<"$(read_packs)"
|
||||
log "RESULT: SoC ${MNS}-${MXS}%, packs@100=${NDONE}/6, packV up to ${MXV}"
|
||||
# revert runs via trap
|
||||
@@ -7,6 +7,12 @@
|
||||
# / 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).
|
||||
#
|
||||
# !!! DEPRECATED 2026-06-25 — DO NOT USE. The re_discharge lever does not work for
|
||||
# grid calibration: firmware NAKs stop_charge_voltage above float (56 > 54), and it's
|
||||
# the wrong mechanism anyway. The CORRECT grid-charge lever is output priority -> SUB
|
||||
# (solar_utility_battery) via lvx-control — see the calibration-charge skill §3 and
|
||||
# memory project_lvx6048_grid_charge_lever. Kept only as a record of the dead end.
|
||||
#
|
||||
# !!! 2026-06-24 FINDING — grid-assist lever corrected, still UNVALIDATED !!!
|
||||
# The original idea (stop_charge_voltage: 0 = "Full") was REJECTED by the firmware:
|
||||
# `flash.py apply` got an inverter NAK on `BUCD480,000` on BOTH units (no change made).
|
||||
|
||||
Reference in New Issue
Block a user